From 94806aa0497e820d41e5db6e06710e486aa43df4 Mon Sep 17 00:00:00 2001 From: muena Date: Sun, 27 Sep 2026 10:12:46 +0200 Subject: [PATCH] feat: restore current project copies and choose text mapping --- package.json | 12 ++-- src/depseudonymize-clipboard.ts | 7 --- src/depseudonymize-clipboard.tsx | 5 ++ src/depseudonymize-selected-text.ts | 15 ----- src/depseudonymize-selected-text.tsx | 5 ++ src/depseudonymize-text.tsx | 89 +++++++++++++++++++--------- src/lib/velum-project.ts | 31 +++++++++- src/restore-project-files.tsx | 79 +++++++++++++++++------- tests/velum-project.test.ts | 9 +++ 9 files changed, 171 insertions(+), 81 deletions(-) delete mode 100644 src/depseudonymize-clipboard.ts create mode 100644 src/depseudonymize-clipboard.tsx delete mode 100644 src/depseudonymize-selected-text.ts create mode 100644 src/depseudonymize-selected-text.tsx 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); });