Preserve rich formatting when restoring selected text
This commit is contained in:
@@ -13,8 +13,6 @@ import {
|
||||
Toast,
|
||||
useNavigation,
|
||||
} from "@raycast/api";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { marked } from "marked";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import type { Creativity } from "./ai";
|
||||
@@ -22,6 +20,7 @@ import { getPreferences } from "./preferences";
|
||||
import type { VelumSession } from "./types";
|
||||
import { markdownCodeBlock, mappingDetailTable, sessionSubtitle } from "./ui";
|
||||
import { localDepseudonymize } from "./velum";
|
||||
import { copyRichText } from "./rich-clipboard";
|
||||
|
||||
export async function maybeCloseRaycast(): Promise<void> {
|
||||
if (getPreferences().closeAfterAction) {
|
||||
@@ -29,33 +28,6 @@ export async function maybeCloseRaycast(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// Raycast 2.0 Beta's Clipboard.copy with { html, text } only writes plain text
|
||||
// to the system pasteboard. We bypass it via osascript / JXA, which sets both
|
||||
// the HTML and plain-text pasteboard types directly on NSPasteboard. Content
|
||||
// is passed via environment variables to avoid shell-quoting hazards.
|
||||
const RICH_COPY_SCRIPT = [
|
||||
"ObjC.import('AppKit');",
|
||||
"const env = $.NSProcessInfo.processInfo.environment;",
|
||||
"const html = env.objectForKey('VELUM_HTML');",
|
||||
"const text = env.objectForKey('VELUM_TEXT');",
|
||||
"const pb = $.NSPasteboard.generalPasteboard;",
|
||||
"pb.clearContents;",
|
||||
"pb.setStringForType(html, 'public.html');",
|
||||
"pb.setStringForType(text, 'public.utf8-plain-text');",
|
||||
].join("\n");
|
||||
|
||||
export async function copyRichText(html: string, text: string): Promise<void> {
|
||||
await execFileAsync(
|
||||
"osascript",
|
||||
["-l", "JavaScript", "-e", RICH_COPY_SCRIPT],
|
||||
{
|
||||
env: { ...process.env, VELUM_HTML: html, VELUM_TEXT: text },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export type AiCommandLabels = {
|
||||
pageHeading: string;
|
||||
runningLabel: string;
|
||||
|
||||
@@ -11,11 +11,18 @@ import {
|
||||
} from "@raycast/api";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getSelectedTextSafely } from "./selection";
|
||||
import { canonicalProjectPath, listAllRuns, listRuns, restoreTextWithCount } from "./lib/velum-project";
|
||||
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";
|
||||
import { markdownCodeBlock, sessionSubtitle } from "./ui";
|
||||
import { restoreRichHtml } from "./lib/rich-text";
|
||||
import { copyRichText } from "./rich-clipboard";
|
||||
|
||||
type FormValues = {
|
||||
text: string;
|
||||
@@ -23,26 +30,56 @@ type FormValues = {
|
||||
};
|
||||
|
||||
const CHOOSE_TARGET = "choose-target";
|
||||
type RichSource = { text: string; html: string };
|
||||
|
||||
export default function Command(props: { initialSource?: "selection" | "clipboard" } = {}) {
|
||||
export default function Command(
|
||||
props: { initialSource?: "selection" | "clipboard" } = {},
|
||||
) {
|
||||
const [text, setText] = useState("");
|
||||
const [richSource, setRichSource] = useState<RichSource>();
|
||||
const [sessions, setSessions] = useState<VelumSession[]>([]);
|
||||
const [activeSessionId, setActiveSessionId] = useState<string>();
|
||||
const [projects, setProjects] = useState<Array<{ path: string; name: string }>>([]);
|
||||
const [projects, setProjects] = useState<
|
||||
Array<{ path: string; name: string }>
|
||||
>([]);
|
||||
const [targetId, setTargetId] = useState(CHOOSE_TARGET);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const { push } = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
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,
|
||||
]);
|
||||
const initialText = (async (): Promise<{
|
||||
text?: string | null;
|
||||
rich?: RichSource;
|
||||
}> => {
|
||||
if (props.initialSource === "selection") {
|
||||
const selected = await getSelectedTextSafely();
|
||||
const clipboard = selected
|
||||
? await Clipboard.read().catch(() => undefined)
|
||||
: undefined;
|
||||
return {
|
||||
text: selected,
|
||||
rich:
|
||||
clipboard?.html && clipboard.text === selected
|
||||
? { text: selected, html: clipboard.html }
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
if (props.initialSource === "clipboard") {
|
||||
const clipboard = await Clipboard.read();
|
||||
return {
|
||||
text: clipboard.text,
|
||||
rich:
|
||||
clipboard.html && clipboard.text
|
||||
? { text: clipboard.text, html: clipboard.html }
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
})();
|
||||
const [loadedSessions, activeId, savedRuns, selected] = await Promise.all(
|
||||
[listSessions(), getActiveSessionId(), listAllRuns(), initialText],
|
||||
);
|
||||
setSessions(loadedSessions);
|
||||
setActiveSessionId(activeId);
|
||||
const unique = new Map<string, string>();
|
||||
@@ -51,19 +88,32 @@ export default function Command(props: { initialSource?: "selection" | "clipboar
|
||||
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" });
|
||||
if (selected.text) {
|
||||
setText(selected.text);
|
||||
setRichSource(selected.rich);
|
||||
} 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().catch(async (error: Error) => {
|
||||
setIsLoading(false);
|
||||
await showToast({ style: Toast.Style.Failure, title: "Zuordnungen nicht lesbar", message: error.message });
|
||||
await showToast({
|
||||
style: Toast.Style.Failure,
|
||||
title: "Zuordnungen nicht lesbar",
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
async function loadClipboardText() {
|
||||
const clipboardText = await Clipboard.readText();
|
||||
const clipboard = await Clipboard.read();
|
||||
const clipboardText = clipboard.text;
|
||||
if (!clipboardText) {
|
||||
await showToast({
|
||||
style: Toast.Style.Failure,
|
||||
@@ -72,11 +122,20 @@ export default function Command(props: { initialSource?: "selection" | "clipboar
|
||||
return;
|
||||
}
|
||||
setText(clipboardText);
|
||||
setRichSource(
|
||||
clipboard.html
|
||||
? { text: clipboardText, html: clipboard.html }
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSubmit(values: FormValues) {
|
||||
const input = values.text.trim();
|
||||
if (!input || !values.sessionId || values.sessionId === CHOOSE_TARGET) {
|
||||
const input = values.text;
|
||||
if (
|
||||
!input.trim() ||
|
||||
!values.sessionId ||
|
||||
values.sessionId === CHOOSE_TARGET
|
||||
) {
|
||||
await showToast({
|
||||
style: Toast.Style.Failure,
|
||||
title: "Projekt oder Sitzung wählen und Text eingeben",
|
||||
@@ -90,27 +149,62 @@ export default function Command(props: { initialSource?: "selection" | "clipboar
|
||||
});
|
||||
try {
|
||||
let result: DepseudonymizeResponse;
|
||||
let richHtml: string | undefined;
|
||||
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 };
|
||||
const restored = restoreTextWithCount(
|
||||
input,
|
||||
saved.run.mapping,
|
||||
saved.run.matchModes,
|
||||
);
|
||||
result = {
|
||||
original_text: restored.text,
|
||||
replacements_made: restored.replacements,
|
||||
};
|
||||
if (richSource?.text === input) {
|
||||
richHtml = restoreRichHtml(
|
||||
richSource.html,
|
||||
input,
|
||||
result.original_text,
|
||||
saved.run.mapping,
|
||||
saved.run.matchModes,
|
||||
);
|
||||
}
|
||||
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);
|
||||
if (richSource?.text === input) {
|
||||
richHtml = restoreRichHtml(
|
||||
richSource.html,
|
||||
input,
|
||||
result.original_text,
|
||||
session.mapping,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
}
|
||||
targetName = session.name;
|
||||
targetSubtitle = sessionSubtitle(session);
|
||||
}
|
||||
toast.style = Toast.Style.Success;
|
||||
toast.title = "Wiederhergestellt";
|
||||
toast.message = `${result.replacements_made} Ersetzungen`;
|
||||
push(<DepseudonymizeResult result={result} targetName={targetName} targetSubtitle={targetSubtitle} />);
|
||||
push(
|
||||
<DepseudonymizeResult
|
||||
result={result}
|
||||
targetName={targetName}
|
||||
targetSubtitle={targetSubtitle}
|
||||
richHtml={richHtml}
|
||||
showFormatStatus={Boolean(props.initialSource || richSource)}
|
||||
/>,
|
||||
);
|
||||
} catch (error) {
|
||||
toast.style = Toast.Style.Failure;
|
||||
toast.title = "Wiederherstellung fehlgeschlagen";
|
||||
@@ -149,10 +243,18 @@ export default function Command(props: { initialSource?: "selection" | "clipboar
|
||||
value={targetId}
|
||||
onChange={setTargetId}
|
||||
>
|
||||
<Form.Dropdown.Item value={CHOOSE_TARGET} title="Bitte Projekt oder Sitzung wählen" icon={Icon.Circle} />
|
||||
<Form.Dropdown.Item
|
||||
value={CHOOSE_TARGET}
|
||||
title="Bitte Projekt oder Sitzung wählen"
|
||||
icon={Icon.Circle}
|
||||
/>
|
||||
{projects.map((project) => (
|
||||
<Form.Dropdown.Item key={project.path} value={`project:${project.path}`}
|
||||
title={`Projekt: ${project.name} · ${project.path}`} icon={Icon.Folder} />
|
||||
<Form.Dropdown.Item
|
||||
key={project.path}
|
||||
value={`project:${project.path}`}
|
||||
title={`Projekt: ${project.name} · ${project.path}`}
|
||||
icon={Icon.Folder}
|
||||
/>
|
||||
))}
|
||||
{sessions.map((session) => (
|
||||
<Form.Dropdown.Item
|
||||
@@ -173,10 +275,14 @@ function DepseudonymizeResult({
|
||||
result,
|
||||
targetName,
|
||||
targetSubtitle,
|
||||
richHtml,
|
||||
showFormatStatus,
|
||||
}: {
|
||||
result: DepseudonymizeResponse;
|
||||
targetName: string;
|
||||
targetSubtitle: string;
|
||||
richHtml?: string;
|
||||
showFormatStatus: boolean;
|
||||
}) {
|
||||
const markdown = [
|
||||
`# ${targetName}`,
|
||||
@@ -186,6 +292,11 @@ function DepseudonymizeResult({
|
||||
markdownCodeBlock(result.original_text),
|
||||
"",
|
||||
`Ersetzungen: ${result.replacements_made}`,
|
||||
richHtml
|
||||
? "Formatierung kann beim Kopieren oder Einfügen übernommen werden."
|
||||
: showFormatStatus
|
||||
? "Formatierung war nicht verfügbar oder konnte nicht sicher zugeordnet werden; Klartext kann weiterhin kopiert werden."
|
||||
: "",
|
||||
].join("\n");
|
||||
|
||||
return (
|
||||
@@ -193,12 +304,46 @@ function DepseudonymizeResult({
|
||||
markdown={markdown}
|
||||
actions={
|
||||
<ActionPanel>
|
||||
{richHtml && (
|
||||
<>
|
||||
<Action
|
||||
title="Mit Formatierung kopieren"
|
||||
icon={Icon.Clipboard}
|
||||
onAction={async () => {
|
||||
try {
|
||||
await copyRichText(richHtml, result.original_text);
|
||||
await showToast({
|
||||
style: Toast.Style.Success,
|
||||
title: "Mit Formatierung kopiert",
|
||||
});
|
||||
} catch (error) {
|
||||
await showToast({
|
||||
style: Toast.Style.Failure,
|
||||
title: "Formatierter Text konnte nicht kopiert werden",
|
||||
message:
|
||||
error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Action
|
||||
title="Mit Formatierung einfügen"
|
||||
icon={Icon.TextCursor}
|
||||
onAction={() =>
|
||||
Clipboard.paste({
|
||||
html: richHtml,
|
||||
text: result.original_text,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Action.CopyToClipboard
|
||||
title="Wiederhergestellten Text kopieren"
|
||||
title="Als Klartext kopieren"
|
||||
content={result.original_text}
|
||||
/>
|
||||
<Action
|
||||
title="Wiederhergestellten Text einfügen"
|
||||
title="Als Klartext einfügen"
|
||||
icon={Icon.TextCursor}
|
||||
onAction={() => Clipboard.paste(result.original_text)}
|
||||
/>
|
||||
|
||||
185
src/lib/rich-text.ts
Normal file
185
src/lib/rich-text.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import type { PlaceholderMapping } from "../types";
|
||||
import type { ProjectRun } from "./velum-project";
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function decodeEntities(value: string): string {
|
||||
const named: Record<string, string> = {
|
||||
amp: "&",
|
||||
lt: "<",
|
||||
gt: ">",
|
||||
quot: '"',
|
||||
apos: "'",
|
||||
nbsp: " ",
|
||||
ndash: "–",
|
||||
mdash: "—",
|
||||
hellip: "…",
|
||||
rsquo: "’",
|
||||
lsquo: "‘",
|
||||
rdquo: "”",
|
||||
ldquo: "“",
|
||||
};
|
||||
return value.replace(
|
||||
/&(#x[0-9a-f]+|#[0-9]+|[a-z][a-z0-9]+);/gi,
|
||||
(entity, code: string) => {
|
||||
if (code.startsWith("#")) {
|
||||
const point =
|
||||
code[1].toLowerCase() === "x"
|
||||
? Number.parseInt(code.slice(2), 16)
|
||||
: Number.parseInt(code.slice(1), 10);
|
||||
return Number.isInteger(point) && point >= 0 && point <= 0x10ffff
|
||||
? String.fromCodePoint(point)
|
||||
: entity;
|
||||
}
|
||||
return named[code.toLowerCase()] ?? entity;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function normalized(value: string): string {
|
||||
return value
|
||||
.replace(/\u00a0/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
type HtmlToken = { kind: "tag" | "text"; value: string };
|
||||
|
||||
function tokenizeHtml(html: string): HtmlToken[] | null {
|
||||
const tokens: HtmlToken[] = [];
|
||||
for (let start = 0; start < html.length; ) {
|
||||
if (html[start] !== "<") {
|
||||
const end = html.indexOf("<", start);
|
||||
const next = end < 0 ? html.length : end;
|
||||
tokens.push({ kind: "text", value: html.slice(start, next) });
|
||||
start = next;
|
||||
continue;
|
||||
}
|
||||
if (html.startsWith("<!--", start)) {
|
||||
const end = html.indexOf("-->", start + 4);
|
||||
if (end < 0) return null;
|
||||
tokens.push({ kind: "tag", value: html.slice(start, end + 3) });
|
||||
start = end + 3;
|
||||
continue;
|
||||
}
|
||||
let quote = "";
|
||||
let end = start + 1;
|
||||
for (; end < html.length; end++) {
|
||||
const character = html[end];
|
||||
if (quote) {
|
||||
if (character === quote) quote = "";
|
||||
} else if (character === '"' || character === "'") quote = character;
|
||||
else if (character === ">") break;
|
||||
}
|
||||
if (end === html.length) return null;
|
||||
tokens.push({ kind: "tag", value: html.slice(start, end + 1) });
|
||||
start = end + 1;
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
type Match = { start: number; end: number; replacement: string };
|
||||
|
||||
function replaceNode(
|
||||
value: string,
|
||||
mapping: PlaceholderMapping,
|
||||
matchModes: ProjectRun["matchModes"],
|
||||
session: boolean,
|
||||
): { value: string; count: number } {
|
||||
const candidates: Match[] = [];
|
||||
for (const [key, entry] of Object.entries(mapping)) {
|
||||
const stripped = key.replace(/[<>]/g, "");
|
||||
const bare = session ? stripped : key;
|
||||
const boundary =
|
||||
matchModes?.[key] === "anywhere"
|
||||
? escapeRegExp(bare)
|
||||
: `(?<![\\p{L}\\p{N}])${escapeRegExp(bare)}(?![\\p{L}\\p{N}])`;
|
||||
const patterns = [new RegExp(boundary, "gu")];
|
||||
if (session) {
|
||||
patterns.unshift(
|
||||
new RegExp(
|
||||
`(?:<|�*60;|�*3c;)${escapeRegExp(stripped)}(?:>|�*62;|�*3e;)`,
|
||||
"giu",
|
||||
),
|
||||
);
|
||||
}
|
||||
for (const pattern of patterns) {
|
||||
for (const match of value.matchAll(pattern)) {
|
||||
candidates.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
replacement: escapeHtml(entry.original),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates.sort((a, b) => a.start - b.start || b.end - a.end);
|
||||
let output = "";
|
||||
let cursor = 0;
|
||||
let count = 0;
|
||||
for (const match of candidates) {
|
||||
if (match.start < cursor) continue;
|
||||
output += value.slice(cursor, match.start) + match.replacement;
|
||||
cursor = match.end;
|
||||
count++;
|
||||
}
|
||||
return { value: output + value.slice(cursor), count };
|
||||
}
|
||||
|
||||
/** Keep the source markup untouched except for verified placeholder text nodes. */
|
||||
export function restoreRichHtml(
|
||||
html: string,
|
||||
sourceText: string,
|
||||
restoredText: string,
|
||||
mapping: PlaceholderMapping,
|
||||
matchModes?: ProjectRun["matchModes"],
|
||||
session = false,
|
||||
): string | undefined {
|
||||
const tokens = tokenizeHtml(html);
|
||||
if (!tokens) return undefined;
|
||||
const ignored = new Set(["head", "script", "style", "title", "noscript"]);
|
||||
const blocks = new Set(["p", "div", "li", "tr", "h1", "h2", "h3", "br"]);
|
||||
let ignoredDepth = 0;
|
||||
let before = "";
|
||||
let after = "";
|
||||
let output = "";
|
||||
for (const token of tokens) {
|
||||
if (token.kind === "tag") {
|
||||
const tag = /^<\s*(\/?)\s*([a-z][\w:-]*)/i.exec(token.value);
|
||||
if (tag) {
|
||||
const name = tag[2].toLowerCase();
|
||||
if (ignored.has(name))
|
||||
ignoredDepth = Math.max(0, ignoredDepth + (tag[1] ? -1 : 1));
|
||||
if (!ignoredDepth && blocks.has(name) && (tag[1] || name === "br")) {
|
||||
before += " ";
|
||||
after += " ";
|
||||
}
|
||||
}
|
||||
output += token.value;
|
||||
continue;
|
||||
}
|
||||
if (ignoredDepth) {
|
||||
output += token.value;
|
||||
continue;
|
||||
}
|
||||
before += decodeEntities(token.value);
|
||||
const replaced = replaceNode(token.value, mapping, matchModes, session);
|
||||
after += decodeEntities(replaced.value);
|
||||
output += replaced.value;
|
||||
}
|
||||
return normalized(before) === normalized(sourceText) &&
|
||||
normalized(after) === normalized(restoredText)
|
||||
? output
|
||||
: undefined;
|
||||
}
|
||||
27
src/rich-clipboard.ts
Normal file
27
src/rich-clipboard.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// Raycast 2.0 Beta writes only plain text for Clipboard.copy({ html, text }).
|
||||
// Set both pasteboard types directly; environment variables avoid shell quoting.
|
||||
const RICH_COPY_SCRIPT = [
|
||||
"ObjC.import('AppKit');",
|
||||
"const env = $.NSProcessInfo.processInfo.environment;",
|
||||
"const html = env.objectForKey('VELUM_HTML');",
|
||||
"const text = env.objectForKey('VELUM_TEXT');",
|
||||
"const pb = $.NSPasteboard.generalPasteboard;",
|
||||
"pb.clearContents;",
|
||||
"pb.setStringForType(html, 'public.html');",
|
||||
"pb.setStringForType(text, 'public.utf8-plain-text');",
|
||||
].join("\n");
|
||||
|
||||
export async function copyRichText(html: string, text: string): Promise<void> {
|
||||
await execFileAsync(
|
||||
"osascript",
|
||||
["-l", "JavaScript", "-e", RICH_COPY_SCRIPT],
|
||||
{
|
||||
env: { ...process.env, VELUM_HTML: html, VELUM_TEXT: text },
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
Toast,
|
||||
useNavigation,
|
||||
} from "@raycast/api";
|
||||
import { copyRichText, markdownToHtml, maybeCloseRaycast } from "./ai-views";
|
||||
import { markdownToHtml, maybeCloseRaycast } from "./ai-views";
|
||||
import { copyRichText } from "./rich-clipboard";
|
||||
import { getSelectedTextSafely } from "./selection";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
|
||||
77
tests/rich-text.test.ts
Normal file
77
tests/rich-text.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { restoreRichHtml } from "../src/lib/rich-text";
|
||||
|
||||
const mapping = {
|
||||
PERSON_01: { original: "Max Mustermann", type: "PERSON" },
|
||||
ORG_01: { original: "Müller & Söhne", type: "ORG" },
|
||||
};
|
||||
|
||||
describe("formatierte Textwiederherstellung", () => {
|
||||
it("behält Formatierung bei und maskiert Originale HTML-sicher", () => {
|
||||
const html = "<p>Hallo <strong>PERSON_01</strong> bei <em>ORG_01</em>.</p>";
|
||||
expect(
|
||||
restoreRichHtml(
|
||||
html,
|
||||
"Hallo PERSON_01 bei ORG_01.",
|
||||
"Hallo Max Mustermann bei Müller & Söhne.",
|
||||
mapping,
|
||||
),
|
||||
).toBe(
|
||||
"<p>Hallo <strong>Max Mustermann</strong> bei <em>Müller & Söhne</em>.</p>",
|
||||
);
|
||||
});
|
||||
|
||||
it("ändert weder Attribute noch unsichtbare Inhalte", () => {
|
||||
const html =
|
||||
'<div data-label="PERSON_01"><style>.PERSON_01{color:red}</style><b>PERSON_01</b></div>';
|
||||
expect(restoreRichHtml(html, "PERSON_01", "Max Mustermann", mapping)).toBe(
|
||||
'<div data-label="PERSON_01"><style>.PERSON_01{color:red}</style><b>Max Mustermann</b></div>',
|
||||
);
|
||||
});
|
||||
|
||||
it("übernimmt kodierte Sitzungsplatzhalter", () => {
|
||||
const html = "<span><PERSON_01></span>";
|
||||
expect(
|
||||
restoreRichHtml(
|
||||
html,
|
||||
"<PERSON_01>",
|
||||
"Max Mustermann",
|
||||
mapping,
|
||||
undefined,
|
||||
true,
|
||||
),
|
||||
).toBe("<span>Max Mustermann</span>");
|
||||
});
|
||||
|
||||
it("verwirft nicht passende oder über mehrere Textknoten verteilte HTML-Daten", () => {
|
||||
expect(
|
||||
restoreRichHtml(
|
||||
"<b>PERSON_01</b>",
|
||||
"anderer Text",
|
||||
"Max Mustermann",
|
||||
mapping,
|
||||
),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
restoreRichHtml(
|
||||
"<b>PERSON_</b><i>01</i>",
|
||||
"PERSON_01",
|
||||
"Max Mustermann",
|
||||
mapping,
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("beachtet projektbezogene Ersetzung innerhalb zusammengesetzter Begriffe", () => {
|
||||
const html = "<b>abcPERSON_01xyz</b>";
|
||||
expect(
|
||||
restoreRichHtml(
|
||||
html,
|
||||
"abcPERSON_01xyz",
|
||||
"abcMax Mustermannxyz",
|
||||
mapping,
|
||||
{ PERSON_01: "anywhere" },
|
||||
),
|
||||
).toBe("<b>abcMax Mustermannxyz</b>");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user