fix: complete Finder project workflow and add in-place output
This commit is contained in:
@@ -4,7 +4,7 @@ Raycast-Extension für PII-sichere Text- und Projekt-Workflows mit Velum: Inhalt
|
||||
|
||||
## Projektdateien
|
||||
|
||||
`Projektdateien pseudonymisieren` übernimmt einen im Finder ausgewählten Ordner. Ohne Auswahl versucht der Befehl, den geöffneten Finder-Ordner zu übernehmen; andernfalls steht die Ordnerauswahl bereit. Danach werden geeignete Text-, DOCX-, XLSX-, PPTX- und PDF-Dateien ausgewählt. Alle erkannten Treffer einschließlich Datei- und Ordnernamen erscheinen mit Kontext und müssen vor dem Erstellen der Kopien geprüft werden. Personen, Orte und Organisationen sind zunächst abgewählt; explizite Projektbegriffe und strukturierte Treffer sind vorausgewählt. Erst `Kopien erstellen` schreibt Dateien in den Raycast-Support-Bereich außerhalb des Projektordners. PDF wird dabei als DOCX ausgegeben.
|
||||
`Projektdateien pseudonymisieren` übernimmt zuerst den geöffneten Finder-Ordner; bei Bedarf kann er im Formular erneut übernommen oder manuell gewählt werden. Danach werden geeignete Text-, DOCX-, XLSX-, PPTX- und PDF-Dateien ausgewählt. Enter führt in der Dateiliste zur Trefferprüfung. Alle erkannten Treffer einschließlich Datei- und Ordnernamen erscheinen mit Kontext und müssen vor dem Abschluss geprüft werden. Personen, Orte und Organisationen sind zunächst abgewählt; explizite Projektbegriffe und strukturierte Treffer sind vorausgewählt. `Kopien erstellen` schreibt Dateien in den Raycast-Support-Bereich außerhalb des Projektordners. Alternativ ersetzt `Originaldateien ersetzen` die gewählten Dateien und gegebenenfalls ihre Namen vor Ort; zuvor legt Velum eine private Sicherung der Originale im Raycast-Support-Bereich an. PDF wird dabei als DOCX ausgegeben.
|
||||
|
||||
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. Eine neue Projektbearbeitung übernimmt die Zuordnung des letzten Projektstands, sodass Platzhalter konsistent bleiben.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { constants as fsConstants, promises as fs } from "node:fs";
|
||||
import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
|
||||
import { environment } from "@raycast/api";
|
||||
import type { PlaceholderMapping } from "../types";
|
||||
@@ -18,6 +18,7 @@ export type ProjectRun = {
|
||||
/** Generic replacement mode for placeholders embedded in code identifiers. */
|
||||
matchModes?: Record<string, "token" | "anywhere">;
|
||||
files: RunFile[];
|
||||
inPlace?: boolean;
|
||||
};
|
||||
|
||||
const DOCUMENTS = new Set([".docx", ".xlsx", ".pptx", ".pdf"]);
|
||||
@@ -278,3 +279,74 @@ export function resolveWithin(root: string, relPath: string): string {
|
||||
if (!target.startsWith(resolve(root) + sep)) throw new Error("Ausgabepfad verlässt den Zielordner.");
|
||||
return target;
|
||||
}
|
||||
|
||||
async function assertNoSymlinkParents(root: string, relPath: string): Promise<void> {
|
||||
const parts = safeRelativePath(relPath).split("/");
|
||||
let parent = resolve(root);
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
parent = join(parent, part);
|
||||
try {
|
||||
if ((await fs.lstat(parent)).isSymbolicLink()) throw new Error(`Symbolischer Link im Ausgabepfad: ${relPath}`);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Replaces selected originals only after every output exists and a private backup is complete. */
|
||||
export async function replaceProjectFilesInPlace(projectPath: string, runDirectory: string, files: RunFile[]): Promise<void> {
|
||||
const root = resolve(projectPath);
|
||||
const sourcePaths = new Set(files.map((file) => resolveWithin(root, file.source)));
|
||||
const targetPaths = new Set<string>();
|
||||
const sourceModes = new Map<string, number>();
|
||||
for (const file of files) {
|
||||
const source = resolveWithin(root, file.source);
|
||||
const target = resolveWithin(root, file.output);
|
||||
if (targetPaths.has(target)) throw new Error(`Doppelter Zielname: ${file.output}`);
|
||||
targetPaths.add(target);
|
||||
await assertNoSymlinkParents(root, file.output);
|
||||
const sourceInfo = await fs.lstat(source);
|
||||
if (!sourceInfo.isFile()) throw new Error(`Quelldatei ist keine reguläre Datei: ${file.source}`);
|
||||
sourceModes.set(source, sourceInfo.mode & 0o777);
|
||||
try {
|
||||
await fs.lstat(target);
|
||||
if (!sourcePaths.has(target)) throw new Error(`Zielname bereits vorhanden: ${file.output}`);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
await fs.access(resolveWithin(join(runDirectory, "files"), file.output));
|
||||
}
|
||||
|
||||
const backupRoot = join(runDirectory, "backup");
|
||||
for (const file of files) {
|
||||
const backup = resolveWithin(backupRoot, file.source);
|
||||
await fs.mkdir(dirname(backup), { recursive: true, mode: 0o700 });
|
||||
await fs.copyFile(resolveWithin(root, file.source), backup, fsConstants.COPYFILE_EXCL);
|
||||
await fs.chmod(backup, 0o600);
|
||||
}
|
||||
|
||||
const written: string[] = [];
|
||||
try {
|
||||
for (const file of files) await fs.unlink(resolveWithin(root, file.source));
|
||||
for (const file of files) {
|
||||
const target = resolveWithin(root, file.output);
|
||||
await fs.mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
||||
await fs.copyFile(resolveWithin(join(runDirectory, "files"), file.output), target, fsConstants.COPYFILE_EXCL);
|
||||
written.push(target);
|
||||
await fs.chmod(target, sourceModes.get(resolveWithin(root, file.source)) ?? 0o600);
|
||||
}
|
||||
} catch (error) {
|
||||
for (const target of written) await fs.rm(target, { force: true });
|
||||
for (const file of files) {
|
||||
const source = resolveWithin(root, file.source);
|
||||
try { await fs.lstat(source); }
|
||||
catch (missing) {
|
||||
if ((missing as NodeJS.ErrnoException).code !== "ENOENT") throw missing;
|
||||
await fs.mkdir(dirname(source), { recursive: true, mode: 0o700 });
|
||||
await fs.copyFile(resolveWithin(backupRoot, file.source), source, fsConstants.COPYFILE_EXCL);
|
||||
await fs.chmod(source, sourceModes.get(source) ?? 0o600);
|
||||
}
|
||||
}
|
||||
throw new Error(`Ersetzen fehlgeschlagen; Originale wurden wiederhergestellt. Sicherung: ${backupRoot}. ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Action, ActionPanel, Color, Form, getSelectedFinderItems, Icon, List, showInFinder, showToast, Toast, useNavigation } from "@raycast/api";
|
||||
import { Action, ActionPanel, Alert, Color, confirmAlert, Form, getSelectedFinderItems, Icon, List, showInFinder, showToast, Toast, useNavigation } from "@raycast/api";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { basename, dirname, extname, join } from "node:path";
|
||||
import { execFile } from "node:child_process";
|
||||
@@ -9,7 +9,7 @@ import { pseudonymize, pseudonymizeDocument, requireProjectCapabilities } from "
|
||||
import type { DocumentHit } from "./velum";
|
||||
import {
|
||||
createRun, digest, forceMasks, listRuns, loadProjectRules, parseProjectRules, replaceSpans,
|
||||
resolveWithin, safeRelativePath, saveProjectRules, saveRun, scanProjectFiles, toUtf16Offsets,
|
||||
replaceProjectFilesInPlace, resolveWithin, safeRelativePath, saveProjectRules, saveRun, scanProjectFiles, toUtf16Offsets,
|
||||
} from "./lib/velum-project";
|
||||
import type { ProjectFile, ProjectRule } from "./lib/velum-project";
|
||||
|
||||
@@ -30,6 +30,14 @@ function defaultAccepted(hit: Hit, rules: ProjectRule[]): boolean {
|
||||
}
|
||||
|
||||
async function finderFolder(): Promise<string | undefined> {
|
||||
try {
|
||||
// The user asked for the open Finder folder, even when a file inside it is selected.
|
||||
const { stdout } = await run("/usr/bin/osascript", ["-e",
|
||||
'tell application "Finder" to get POSIX path of (target of front window as alias)',
|
||||
]);
|
||||
const path = stdout.trim();
|
||||
if (path && (await fs.stat(path)).isDirectory()) return path;
|
||||
} catch { /* Finder may have no open window or Automation permission */ }
|
||||
try {
|
||||
const items = await getSelectedFinderItems();
|
||||
if (items.length) {
|
||||
@@ -37,13 +45,7 @@ async function finderFolder(): Promise<string | undefined> {
|
||||
return (await fs.stat(first)).isDirectory() ? first : dirname(first);
|
||||
}
|
||||
} catch { /* Finder selection may be empty or unavailable */ }
|
||||
try {
|
||||
const { stdout } = await run("/usr/bin/osascript", ["-e",
|
||||
'tell application "Finder" to get POSIX path of (target of front Finder window as alias)',
|
||||
]);
|
||||
const path = stdout.trim();
|
||||
return path && (await fs.stat(path)).isDirectory() ? path : undefined;
|
||||
} catch { return undefined; }
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function textFromBytes(data: Buffer): string {
|
||||
@@ -146,8 +148,8 @@ function FileSelection(props: { root: string; rules: ProjectRule[] }) {
|
||||
setFiles(scan.files);
|
||||
setSkipped(scan.skipped);
|
||||
setSelected(new Set(scan.files.map((file) => file.relPath)));
|
||||
setBusy(false);
|
||||
}).catch((error: Error) => showToast({ style: Toast.Style.Failure, title: "Projekt nicht lesbar", message: error.message }));
|
||||
}).catch((error: Error) => showToast({ style: Toast.Style.Failure, title: "Projekt nicht lesbar", message: error.message }))
|
||||
.finally(() => setBusy(false));
|
||||
}, [props.root]);
|
||||
|
||||
function toggle(path: string) {
|
||||
@@ -161,7 +163,10 @@ function FileSelection(props: { root: string; rules: ProjectRule[] }) {
|
||||
|
||||
async function review() {
|
||||
const chosen = files.filter((file) => selected.has(file.relPath));
|
||||
if (!chosen.length) return;
|
||||
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 {
|
||||
@@ -177,13 +182,14 @@ function FileSelection(props: { root: string; rules: ProjectRule[] }) {
|
||||
}
|
||||
|
||||
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={selected.has(file.relPath) ? "Datei abwählen" : "Datei auswählen"} onAction={() => toggle(file.relPath)} />
|
||||
<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>} />)}
|
||||
@@ -207,10 +213,17 @@ function HitReview(props: { root: string; rules: ProjectRule[]; preview: Preview
|
||||
return next;
|
||||
});
|
||||
}
|
||||
async function apply() {
|
||||
async function apply(inPlace = false) {
|
||||
if (inPlace && !(await confirmAlert({
|
||||
title: "Originaldateien ersetzen?",
|
||||
message: "Die gewählten Dateien und gegebenenfalls ihre Namen werden im Projektordner ersetzt. Die Originale werden vorher außerhalb des Projekts gesichert. PDF-Dateien werden als DOCX ausgegeben.",
|
||||
primaryAction: { title: "Originaldateien ersetzen", style: Alert.ActionStyle.Destructive },
|
||||
}))) return;
|
||||
setBusy(true);
|
||||
const toast = await showToast({ style: Toast.Style.Animated, title: "Pseudonymisierte Kopien werden erstellt" });
|
||||
const toast = await showToast({ style: Toast.Style.Animated,
|
||||
title: inPlace ? "Originaldateien werden ersetzt" : "Pseudonymisierte Kopien werden erstellt" });
|
||||
let directory: string | undefined;
|
||||
let persisted = false;
|
||||
try {
|
||||
const created = await createRun(props.root);
|
||||
directory = created.directory;
|
||||
@@ -240,14 +253,20 @@ function HitReview(props: { root: string; rules: ProjectRule[]; preview: Preview
|
||||
run.files.push({ source: draft.file.relPath, output: relPath, kind: draft.file.kind });
|
||||
}
|
||||
await saveRun(directory, run);
|
||||
persisted = true;
|
||||
if (inPlace) {
|
||||
await replaceProjectFilesInPlace(props.root, directory, run.files);
|
||||
run.inPlace = true;
|
||||
await saveRun(directory, run);
|
||||
}
|
||||
toast.style = Toast.Style.Success;
|
||||
toast.title = `${run.files.length} Kopien erstellt`;
|
||||
await showInFinder(join(directory, "files"));
|
||||
toast.title = inPlace ? `${run.files.length} Originaldateien ersetzt` : `${run.files.length} Kopien erstellt`;
|
||||
await showInFinder(inPlace ? props.root : join(directory, "files")).catch(() => undefined);
|
||||
} catch (error) {
|
||||
if (directory) await fs.rm(directory, { recursive: true, force: true });
|
||||
if (directory && !persisted) await fs.rm(directory, { recursive: true, force: true });
|
||||
toast.style = Toast.Style.Failure;
|
||||
toast.title = "Pseudonymisierung fehlgeschlagen";
|
||||
toast.message = (error as Error).message;
|
||||
toast.message = `${(error as Error).message}${persisted && directory ? ` Kopien/Sicherung: ${directory}` : ""}`;
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
@@ -256,7 +275,10 @@ function HitReview(props: { root: string; rules: ProjectRule[]; preview: Preview
|
||||
return <List isLoading={busy} isShowingDetail navigationTitle="Treffer prüfen"
|
||||
searchBarPlaceholder="Treffer, Typ oder Datei suchen">
|
||||
<List.EmptyView title="Keine Treffer" description="Die gewählten Dateien können unverändert kopiert werden."
|
||||
actions={<ActionPanel><Action title="Kopien erstellen" onAction={apply} /></ActionPanel>} />
|
||||
actions={<ActionPanel>
|
||||
<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 }}
|
||||
@@ -264,11 +286,12 @@ function HitReview(props: { root: string; rules: ProjectRule[]; preview: Preview
|
||||
subtitle={`${hit.type} · ${hit.source}`}
|
||||
detail={<List.Item.Detail markdown={`**${hit.type}** · ${hit.source}\n\n${hit.context.replace(/</g, "<")}`} />}
|
||||
actions={<ActionPanel>
|
||||
<Action title="Pseudonymisierte Kopien erstellen" icon={Icon.Document} onAction={() => apply()} />
|
||||
<Action title="Originaldateien ersetzen" icon={Icon.Pencil} onAction={() => apply(true)} />
|
||||
<Action title={accepted.has(hit.id) ? "Treffer ausnehmen" : "Treffer übernehmen"} onAction={() => toggle(hit.id)} />
|
||||
<Action title="Gleichen Begriff übernehmen" onAction={() => setAccepted((current) => new Set([
|
||||
...current, ...props.preview.hits.filter((other) => other.type === hit.type && other.original === hit.original).map((other) => other.id),
|
||||
]))} />
|
||||
<Action title="Kopien erstellen" icon={Icon.Document} onAction={apply} />
|
||||
</ActionPanel>} />)}
|
||||
</List.Section>)}
|
||||
</List>;
|
||||
@@ -279,11 +302,14 @@ export default function Command() {
|
||||
const [root, setRoot] = useState("");
|
||||
const [rules, setRules] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
useEffect(() => { finderFolder().then((path) => { if (path) setRoot(path); }); }, []);
|
||||
useEffect(() => { finderFolder().then((path) => { if (path) setRoot((current) => current || path); }); }, []);
|
||||
useEffect(() => {
|
||||
if (!root) return;
|
||||
loadProjectRules(root).then((found) => setRules(found.map((rule) => `${rule.type}: ${rule.value}`).join("\n")))
|
||||
.catch(() => setRules(""));
|
||||
let active = true;
|
||||
loadProjectRules(root).then((found) => {
|
||||
if (active) setRules(found.map((rule) => `${rule.type}: ${rule.value}`).join("\n"));
|
||||
}).catch(() => { if (active) setRules(""); });
|
||||
return () => { active = false; };
|
||||
}, [root]);
|
||||
async function next() {
|
||||
setBusy(true);
|
||||
@@ -296,7 +322,16 @@ export default function Command() {
|
||||
await showToast({ style: Toast.Style.Failure, title: "Projekt nicht bereit", message: (error as Error).message });
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
return <Form isLoading={busy} actions={<ActionPanel><Action title="Dateien auswählen" onAction={next} /></ActionPanel>}>
|
||||
async function useFinderFolder() {
|
||||
const path = await finderFolder();
|
||||
if (path) setRoot(path);
|
||||
else await showToast({ style: Toast.Style.Failure, title: "Kein Finder-Ordner gefunden",
|
||||
message: "Bitte den Ordner im Finder öffnen oder unten manuell auswählen." });
|
||||
}
|
||||
return <Form isLoading={busy} actions={<ActionPanel>
|
||||
<Action title="Dateien auswählen" onAction={next} />
|
||||
<Action title="Geöffneten Finder-Ordner übernehmen" icon={Icon.Folder} onAction={useFinderFolder} />
|
||||
</ActionPanel>}>
|
||||
<Form.FilePicker id="projectPath" title="Projektordner" allowMultipleSelection={false}
|
||||
canChooseDirectories canChooseFiles={false} value={root ? [root] : []}
|
||||
onChange={(paths) => setRoot(paths[0] ?? "")} />
|
||||
|
||||
@@ -2,6 +2,7 @@ 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";
|
||||
import type { RunFile } from "../src/lib/velum-project";
|
||||
|
||||
mock.module("@raycast/api", () => ({ environment: { supportPath: tmpdir() } }));
|
||||
const project = await import("../src/lib/velum-project");
|
||||
@@ -44,4 +45,41 @@ describe("Velum-Projektstände", () => {
|
||||
const spans = project.toUtf16Offsets(text, [{ id: "max", start: 2, end: 5, placeholder: "PERSON_01" }]);
|
||||
expect(project.replaceSpans(text, spans, new Set(["max"]))).toBe("😀 PERSON_01");
|
||||
});
|
||||
|
||||
it("ersetzt ausgewählte Projektdateien vor Ort und sichert die Originale außerhalb", async () => {
|
||||
const root = await fs.mkdtemp(join(tmpdir(), "velum-in-place-test-"));
|
||||
roots.push(root);
|
||||
const source = join(root, "Quelle");
|
||||
const support = join(root, "Raycast");
|
||||
await fs.mkdir(source);
|
||||
await fs.writeFile(join(source, "Bericht.txt"), "Max Mustermann");
|
||||
await fs.chmod(join(source, "Bericht.txt"), 0o640);
|
||||
await fs.writeFile(join(source, "Unberührt.txt"), "bleibt");
|
||||
const created = await project.createRun(source, support);
|
||||
await fs.writeFile(join(created.directory, "files", "PERSON_01.txt"), "PERSON_01");
|
||||
const files: RunFile[] = [{ source: "Bericht.txt", output: "PERSON_01.txt", kind: "text" }];
|
||||
await project.replaceProjectFilesInPlace(source, created.directory, files);
|
||||
expect(await fs.readFile(join(source, "PERSON_01.txt"), "utf8")).toBe("PERSON_01");
|
||||
expect((await fs.stat(join(source, "PERSON_01.txt"))).mode & 0o777).toBe(0o640);
|
||||
expect(await fs.readFile(join(created.directory, "backup", "Bericht.txt"), "utf8")).toBe("Max Mustermann");
|
||||
expect((await fs.stat(join(created.directory, "backup", "Bericht.txt"))).mode & 0o777).toBe(0o600);
|
||||
expect(await fs.readFile(join(source, "Unberührt.txt"), "utf8")).toBe("bleibt");
|
||||
await expect(fs.access(join(source, "Bericht.txt"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("verhindert Namenskollisionen vor dem Ersetzen", async () => {
|
||||
const root = await fs.mkdtemp(join(tmpdir(), "velum-in-place-collision-test-"));
|
||||
roots.push(root);
|
||||
const source = join(root, "Quelle");
|
||||
const support = join(root, "Raycast");
|
||||
await fs.mkdir(source);
|
||||
await fs.writeFile(join(source, "Bericht.txt"), "Original");
|
||||
await fs.writeFile(join(source, "PERSON_01.txt"), "Andere Datei");
|
||||
const created = await project.createRun(source, support);
|
||||
await fs.writeFile(join(created.directory, "files", "PERSON_01.txt"), "Pseudonym");
|
||||
await expect(project.replaceProjectFilesInPlace(source, created.directory,
|
||||
[{ source: "Bericht.txt", output: "PERSON_01.txt", kind: "text" }])).rejects.toThrow("Zielname bereits vorhanden");
|
||||
expect(await fs.readFile(join(source, "Bericht.txt"), "utf8")).toBe("Original");
|
||||
expect(await fs.readFile(join(source, "PERSON_01.txt"), "utf8")).toBe("Andere Datei");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user