Preserve rich formatting when restoring selected text

This commit is contained in:
muena
2026-09-27 10:23:37 +02:00
parent 94806aa049
commit ec1e2d27e0
6 changed files with 463 additions and 56 deletions

View File

@@ -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;

View File

@@ -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
View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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(
`(?:&lt;|&#0*60;|&#x0*3c;)${escapeRegExp(stripped)}(?:&gt;|&#0*62;|&#x0*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
View 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 },
},
);
}

View File

@@ -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
View 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 &amp; 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>&lt;PERSON_01&gt;</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>");
});
});