diff --git a/package.json b/package.json
index b03734f..5172669 100644
--- a/package.json
+++ b/package.json
@@ -80,14 +80,14 @@
{
"name": "depseudonymize-selected-text",
"title": "Markierten Text wiederherstellen",
- "description": "Aktuell markierten Text mit der aktiven Sitzung wiederherstellen und das Ergebnis kopieren oder einfügen.",
- "mode": "no-view"
+ "description": "Markierten Text übernehmen, Projekt oder Sitzung wählen und wiederherstellen.",
+ "mode": "view"
},
{
"name": "depseudonymize-clipboard",
"title": "Zwischenablage wiederherstellen",
- "description": "Aktuellen Zwischenablage-Text mit der aktiven Sitzung wiederherstellen und das Ergebnis kopieren oder einfügen.",
- "mode": "no-view"
+ "description": "Zwischenablage übernehmen, Projekt oder Sitzung wählen und wiederherstellen.",
+ "mode": "view"
},
{
"name": "manage-sessions",
@@ -104,8 +104,8 @@
},
{
"name": "restore-project-files",
- "title": "Projektdateien nachbearbeiten",
- "description": "Zuordnungen freigeben, Personen zusammenführen oder Projektdateien als Klartext exportieren.",
+ "title": "Projektdateien wiederherstellen",
+ "description": "Aktuelle Dateien aus dem Kopienordner als Klartext exportieren, Zuordnungen nachbearbeiten und frühere Stände wiederherstellen.",
"mode": "view",
"icon": "extension-icon.png"
},
diff --git a/src/depseudonymize-clipboard.ts b/src/depseudonymize-clipboard.ts
deleted file mode 100644
index d779224..0000000
--- a/src/depseudonymize-clipboard.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { Clipboard } from "@raycast/api";
-import { depseudonymizeQuickText } from "./quick";
-
-export default async function Command() {
- const clipboardText = await Clipboard.readText();
- await depseudonymizeQuickText(clipboardText ?? "", "Zwischenablage");
-}
diff --git a/src/depseudonymize-clipboard.tsx b/src/depseudonymize-clipboard.tsx
new file mode 100644
index 0000000..cb2718e
--- /dev/null
+++ b/src/depseudonymize-clipboard.tsx
@@ -0,0 +1,5 @@
+import RestoreTextForm from "./depseudonymize-text";
+
+export default function Command() {
+ return ;
+}
diff --git a/src/depseudonymize-selected-text.ts b/src/depseudonymize-selected-text.ts
deleted file mode 100644
index 4a9143f..0000000
--- a/src/depseudonymize-selected-text.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { showToast, Toast } from "@raycast/api";
-import { depseudonymizeQuickText } from "./quick";
-import { getSelectedTextSafely } from "./selection";
-
-export default async function Command() {
- const selectedText = await getSelectedTextSafely();
- if (!selectedText) {
- await showToast({
- style: Toast.Style.Failure,
- title: "Kein markierter Text gefunden",
- });
- return;
- }
- await depseudonymizeQuickText(selectedText, "Selektion");
-}
diff --git a/src/depseudonymize-selected-text.tsx b/src/depseudonymize-selected-text.tsx
new file mode 100644
index 0000000..27ca937
--- /dev/null
+++ b/src/depseudonymize-selected-text.tsx
@@ -0,0 +1,5 @@
+import RestoreTextForm from "./depseudonymize-text";
+
+export default function Command() {
+ return ;
+}
diff --git a/src/depseudonymize-text.tsx b/src/depseudonymize-text.tsx
index bfb969b..4ff9200 100644
--- a/src/depseudonymize-text.tsx
+++ b/src/depseudonymize-text.tsx
@@ -9,7 +9,9 @@ import {
Toast,
useNavigation,
} from "@raycast/api";
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useState } from "react";
+import { getSelectedTextSafely } from "./selection";
+import { canonicalProjectPath, listAllRuns, listRuns, restoreTextWithCount } from "./lib/velum-project";
import { depseudonymize } from "./velum";
import { getActiveSessionId, getSession, listSessions } from "./sessions";
import type { DepseudonymizeResponse, VelumSession } from "./types";
@@ -20,31 +22,46 @@ type FormValues = {
sessionId: string;
};
-export default function Command() {
+const CHOOSE_TARGET = "choose-target";
+
+export default function Command(props: { initialSource?: "selection" | "clipboard" } = {}) {
const [text, setText] = useState("");
const [sessions, setSessions] = useState([]);
const [activeSessionId, setActiveSessionId] = useState();
+ const [projects, setProjects] = useState>([]);
+ const [targetId, setTargetId] = useState(CHOOSE_TARGET);
const [isLoading, setIsLoading] = useState(true);
const { push } = useNavigation();
useEffect(() => {
async function load() {
- const [loadedSessions, activeId] = await Promise.all([
+ const initialText = props.initialSource === "selection" ? getSelectedTextSafely()
+ : props.initialSource === "clipboard" ? Clipboard.readText() : Promise.resolve(undefined);
+ const [loadedSessions, activeId, savedRuns, selected] = await Promise.all([
listSessions(),
getActiveSessionId(),
+ listAllRuns(),
+ initialText,
]);
setSessions(loadedSessions);
setActiveSessionId(activeId);
+ const unique = new Map();
+ for (const { run } of savedRuns) {
+ const path = canonicalProjectPath(run.projectPath);
+ if (!unique.has(path)) unique.set(path, run.projectName);
+ }
+ setProjects([...unique].map(([path, name]) => ({ path, name })));
+ if (selected) setText(selected);
+ else if (props.initialSource) await showToast({ style: Toast.Style.Failure,
+ title: props.initialSource === "selection" ? "Kein markierter Text gefunden" : "Zwischenablage enthält keinen Text" });
setIsLoading(false);
}
- load();
+ load().catch(async (error: Error) => {
+ setIsLoading(false);
+ await showToast({ style: Toast.Style.Failure, title: "Zuordnungen nicht lesbar", message: error.message });
+ });
}, []);
- const defaultSessionId = useMemo(
- () => activeSessionId || sessions[0]?.id,
- [activeSessionId, sessions],
- );
-
async function loadClipboardText() {
const clipboardText = await Clipboard.readText();
if (!clipboardText) {
@@ -59,19 +76,10 @@ export default function Command() {
async function handleSubmit(values: FormValues) {
const input = values.text.trim();
- if (!input || !values.sessionId) {
+ if (!input || !values.sessionId || values.sessionId === CHOOSE_TARGET) {
await showToast({
style: Toast.Style.Failure,
- title: "Sitzung wählen und Text eingeben",
- });
- return;
- }
-
- const session = await getSession(values.sessionId);
- if (!session) {
- await showToast({
- style: Toast.Style.Failure,
- title: "Sitzung nicht gefunden",
+ title: "Projekt oder Sitzung wählen und Text eingeben",
});
return;
}
@@ -81,11 +89,28 @@ export default function Command() {
title: "Stelle wieder her…",
});
try {
- const result = await depseudonymize(input, session.mapping);
+ let result: DepseudonymizeResponse;
+ let targetName: string;
+ let targetSubtitle: string;
+ if (values.sessionId.startsWith("project:")) {
+ const projectPath = values.sessionId.slice("project:".length);
+ const saved = (await listRuns(projectPath))[0];
+ if (!saved) throw new Error("Projektstand nicht gefunden.");
+ const restored = restoreTextWithCount(input, saved.run.mapping, saved.run.matchModes);
+ result = { original_text: restored.text, replacements_made: restored.replacements };
+ targetName = `Projekt ${saved.run.projectName}`;
+ targetSubtitle = saved.run.projectPath;
+ } else {
+ const session = await getSession(values.sessionId);
+ if (!session) throw new Error("Sitzung nicht gefunden.");
+ result = await depseudonymize(input, session.mapping);
+ targetName = session.name;
+ targetSubtitle = sessionSubtitle(session);
+ }
toast.style = Toast.Style.Success;
toast.title = "Wiederhergestellt";
toast.message = `${result.replacements_made} Ersetzungen`;
- push();
+ push();
} catch (error) {
toast.style = Toast.Style.Failure;
toast.title = "Wiederherstellung fehlgeschlagen";
@@ -120,9 +145,15 @@ export default function Command() {
/>
+
+ {projects.map((project) => (
+
+ ))}
{sessions.map((session) => (
mapping[key].original) : source;
+ let replacements = 0;
+ const text = pattern ? source.replace(pattern, (key) => {
+ replacements++;
+ return mapping[key].original;
+ }) : source;
+ return { text, replacements };
+}
+
+export function restoreText(source: string, mapping: PlaceholderMapping, matchModes?: ProjectRun["matchModes"]): string {
+ return restoreTextWithCount(source, mapping, matchModes).text;
}
export function restorePath(relPath: string, mapping: PlaceholderMapping, matchModes?: ProjectRun["matchModes"]): string {
@@ -305,6 +315,23 @@ export function runCopiesPath(run: ProjectRun, directory: string): string {
return run.copyPath ?? join(directory, "files");
}
+export async function scanRestorableCopyFiles(run: ProjectRun, directory: string, current = true): Promise<{
+ root: string; files: ProjectFile[]; skipped: number;
+}> {
+ let root = current ? runCopiesPath(run, directory) : join(directory, "files");
+ if (current && run.copyPath) {
+ try {
+ if (!(await fs.lstat(root)).isDirectory()) throw new Error("Kopienpfad ist kein regulärer Ordner.");
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
+ root = join(directory, "files");
+ }
+ }
+ const scan = await scanProjectFiles(root);
+ const files = scan.files.filter((file) => file.kind === "text" || isRestorableDocument(file.relPath));
+ return { root, files, skipped: scan.skipped.length + scan.files.length - files.length };
+}
+
/** Publish only output files next to the project, without the private mapping. */
export async function publishProjectCopies(run: ProjectRun, directory: string): Promise {
const projectPath = canonicalProjectPath(run.projectPath);
diff --git a/src/restore-project-files.tsx b/src/restore-project-files.tsx
index 9512d00..d9fb4a5 100644
--- a/src/restore-project-files.tsx
+++ b/src/restore-project-files.tsx
@@ -4,8 +4,8 @@ import { homedir } from "node:os";
import { basename, dirname, join } from "node:path";
import { useEffect, useState } from "react";
import { depseudonymizeDocument } from "./velum";
-import { canonicalProjectPath, isRestorableDocument, listAllRuns, resolveWithin, restorePath, restoreText, runCopiesPath } from "./lib/velum-project";
-import type { ProjectRun } from "./lib/velum-project";
+import { canonicalProjectPath, listAllRuns, resolveWithin, restorePath, restoreText, runCopiesPath, scanRestorableCopyFiles } from "./lib/velum-project";
+import type { ProjectFile, ProjectRun } from "./lib/velum-project";
import { canonicalPerson, mergePeople } from "./lib/project-review";
import { planProjectRevision, reviseProjectRun } from "./lib/project-revision";
@@ -131,9 +131,26 @@ function RevisionReview(props: { saved: SavedRun; onSaved: () => Promise }
;
}
-function FilePicker(props: { run: ProjectRun; directory: string }) {
- const [selected, setSelected] = useState>(new Set(props.run.files.map((file) => file.output)));
+function FilePicker(props: { run: ProjectRun; directory: string; historical?: boolean }) {
+ const [files, setFiles] = useState([]);
+ const [sourceRoot, setSourceRoot] = useState("");
+ const [selected, setSelected] = useState>(new Set());
+ const [skipped, setSkipped] = useState(0);
const [busy, setBusy] = useState(false);
+ useEffect(() => {
+ let active = true;
+ async function load() {
+ const scan = await scanRestorableCopyFiles(props.run, props.directory, !props.historical);
+ if (!active) return;
+ setSourceRoot(scan.root);
+ setFiles(scan.files);
+ setSelected(new Set(scan.files.map((file) => file.relPath)));
+ setSkipped(scan.skipped);
+ }
+ load().catch((error: Error) => showToast({ style: Toast.Style.Failure,
+ title: "Kopienordner nicht lesbar", message: error.message }));
+ return () => { active = false; };
+ }, [props.run, props.directory, props.historical]);
function toggle(path: string) {
setSelected((current) => {
const next = new Set(current);
@@ -143,23 +160,30 @@ function FilePicker(props: { run: ProjectRun; directory: string }) {
});
}
async function exportFiles() {
- if (!selected.size) return;
+ if (!selected.size || !sourceRoot) return;
setBusy(true);
const toast = await showToast({ style: Toast.Style.Animated, title: "Klartext-Kopien werden erstellt" });
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
- const targetRoot = join(homedir(), "Downloads", "Velum", `${basename(props.run.projectName)}-${stamp}`);
+ const parent = join(homedir(), "Downloads", "Velum");
+ let targetRoot: string | undefined;
try {
- await fs.mkdir(targetRoot, { recursive: true, mode: 0o700 });
const names = new Set();
- for (const file of props.run.files.filter((item) => selected.has(item.output))) {
- const restoredName = restorePath(file.output, props.run.mapping, props.run.matchModes);
+ const planned = files.filter((item) => selected.has(item.relPath)).map((file) => ({
+ file, restoredName: restorePath(file.relPath, props.run.mapping, props.run.matchModes),
+ }));
+ for (const { restoredName } of planned) {
if (names.has(restoredName)) throw new Error(`Mehrdeutiger Ausgabename: ${restoredName}`);
names.add(restoredName);
- const source = resolveWithin(join(props.directory, "files"), file.output);
+ }
+ await fs.mkdir(parent, { recursive: true, mode: 0o700 });
+ targetRoot = await fs.mkdtemp(join(parent, `${basename(props.run.projectName)}-${stamp}-`));
+ for (const { file, restoredName } of planned) {
+ const source = resolveWithin(sourceRoot, file.relPath);
const target = resolveWithin(targetRoot, restoredName);
+ if (!(await fs.lstat(source)).isFile()) throw new Error(`Keine reguläre Datei: ${file.relPath}`);
const data = await fs.readFile(source);
- const result = file.kind === "document" && isRestorableDocument(file.output)
- ? await depseudonymizeDocument(basename(file.output), data, props.run.mapping)
+ const result = file.kind === "document"
+ ? await depseudonymizeDocument(basename(file.relPath), data, props.run.mapping)
: Buffer.from(restoreText(data.toString("utf8"), props.run.mapping, props.run.matchModes), "utf8");
await fs.mkdir(dirname(target), { recursive: true, mode: 0o700 });
await fs.writeFile(target, result, { flag: "wx", mode: 0o600 });
@@ -168,21 +192,28 @@ function FilePicker(props: { run: ProjectRun; directory: string }) {
toast.title = `${selected.size} Klartext-Dateien in Downloads`;
await showInFinder(targetRoot);
} catch (error) {
+ if (targetRoot) await fs.rm(targetRoot, { recursive: true, force: true });
toast.style = Toast.Style.Failure;
toast.title = "Klartext-Export fehlgeschlagen";
toast.message = (error as Error).message;
} finally { setBusy(false); }
}
- return
-
- {props.run.files.map((file) =>
+ } />
+ showInFinder(sourceRoot)} />} />
+ {files.map((file) =>
- toggle(file.output)} />
+ toggle(file.relPath)} />
- setSelected(new Set(props.run.files.map((item) => item.output)))} />
+ setSelected(new Set(files.map((item) => item.relPath)))} />
setSelected(new Set())} />
} />)}
@@ -201,8 +232,10 @@ function ProjectHistory(props: { projectName: string; entries: SavedRun[] }) {
showInFinder(runCopiesPath(run, directory))} />
- push()} />
+ {run.copyPath && push()} />}
+ push()} />
} />)}
;
@@ -232,13 +265,13 @@ export default function Command() {
accessories={[{ text: `${run.files.length} Dateien` }, { text: new Date(run.createdAt).toLocaleString("de-AT") },
...(entries.length > 1 ? [{ text: `${entries.length} Stände` }] : [])]}
actions={
+ push()} />
showInFinder(runCopiesPath(run, directory))} />
push()} />
- push()} />
{entries.length > 1 && push()} />}
} />;
diff --git a/tests/velum-project.test.ts b/tests/velum-project.test.ts
index 8dc40e1..96b8556 100644
--- a/tests/velum-project.test.ts
+++ b/tests/velum-project.test.ts
@@ -72,6 +72,15 @@ describe("Velum-Projektstände", () => {
expect(await fs.readFile(join(copyPath, "Unterordner", "PERSON_01.txt"), "utf8")).toBe("PERSON_01");
expect(await fs.readdir(copyPath)).toEqual(["Unterordner"]);
expect(await fs.readFile(join(created.directory, "run.json"), "utf8")).toContain("Max Mustermann");
+ await fs.writeFile(join(copyPath, "Antwort.txt"), "PERSON_01 antwortet");
+ const current = await project.scanRestorableCopyFiles(created.run, created.directory);
+ expect(current.root).toBe(copyPath);
+ expect(current.files.map((file) => file.relPath)).toEqual(["Antwort.txt", "Unterordner/PERSON_01.txt"]);
+ const history = await project.scanRestorableCopyFiles(created.run, created.directory, false);
+ expect(history.root).toBe(join(created.directory, "files"));
+ expect(history.files.map((file) => file.relPath)).toEqual(["Unterordner/PERSON_01.txt"]);
+ expect(project.restoreTextWithCount("PERSON_01 antwortet", created.run.mapping))
+ .toEqual({ text: "Max Mustermann antwortet", replacements: 1 });
await expect(project.publishProjectCopies(created.run, created.directory)).rejects.toThrow("bereits vorhanden");
expect((await project.listRuns(source, support))[0].run.copyPath).toBe(copyPath);
});