Preserve rich formatting when restoring selected text
This commit is contained in:
@@ -13,8 +13,6 @@ import {
|
|||||||
Toast,
|
Toast,
|
||||||
useNavigation,
|
useNavigation,
|
||||||
} from "@raycast/api";
|
} from "@raycast/api";
|
||||||
import { execFile } from "node:child_process";
|
|
||||||
import { promisify } from "node:util";
|
|
||||||
import { marked } from "marked";
|
import { marked } from "marked";
|
||||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||||
import type { Creativity } from "./ai";
|
import type { Creativity } from "./ai";
|
||||||
@@ -22,6 +20,7 @@ import { getPreferences } from "./preferences";
|
|||||||
import type { VelumSession } from "./types";
|
import type { VelumSession } from "./types";
|
||||||
import { markdownCodeBlock, mappingDetailTable, sessionSubtitle } from "./ui";
|
import { markdownCodeBlock, mappingDetailTable, sessionSubtitle } from "./ui";
|
||||||
import { localDepseudonymize } from "./velum";
|
import { localDepseudonymize } from "./velum";
|
||||||
|
import { copyRichText } from "./rich-clipboard";
|
||||||
|
|
||||||
export async function maybeCloseRaycast(): Promise<void> {
|
export async function maybeCloseRaycast(): Promise<void> {
|
||||||
if (getPreferences().closeAfterAction) {
|
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 = {
|
export type AiCommandLabels = {
|
||||||
pageHeading: string;
|
pageHeading: string;
|
||||||
runningLabel: string;
|
runningLabel: string;
|
||||||
|
|||||||
@@ -11,11 +11,18 @@ import {
|
|||||||
} from "@raycast/api";
|
} from "@raycast/api";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { getSelectedTextSafely } from "./selection";
|
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 { depseudonymize } from "./velum";
|
||||||
import { getActiveSessionId, getSession, listSessions } from "./sessions";
|
import { getActiveSessionId, getSession, listSessions } from "./sessions";
|
||||||
import type { DepseudonymizeResponse, VelumSession } from "./types";
|
import type { DepseudonymizeResponse, VelumSession } from "./types";
|
||||||
import { markdownCodeBlock, sessionSubtitle } from "./ui";
|
import { markdownCodeBlock, sessionSubtitle } from "./ui";
|
||||||
|
import { restoreRichHtml } from "./lib/rich-text";
|
||||||
|
import { copyRichText } from "./rich-clipboard";
|
||||||
|
|
||||||
type FormValues = {
|
type FormValues = {
|
||||||
text: string;
|
text: string;
|
||||||
@@ -23,26 +30,56 @@ type FormValues = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const CHOOSE_TARGET = "choose-target";
|
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 [text, setText] = useState("");
|
||||||
|
const [richSource, setRichSource] = useState<RichSource>();
|
||||||
const [sessions, setSessions] = useState<VelumSession[]>([]);
|
const [sessions, setSessions] = useState<VelumSession[]>([]);
|
||||||
const [activeSessionId, setActiveSessionId] = useState<string>();
|
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 [targetId, setTargetId] = useState(CHOOSE_TARGET);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const { push } = useNavigation();
|
const { push } = useNavigation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
const initialText = props.initialSource === "selection" ? getSelectedTextSafely()
|
const initialText = (async (): Promise<{
|
||||||
: props.initialSource === "clipboard" ? Clipboard.readText() : Promise.resolve(undefined);
|
text?: string | null;
|
||||||
const [loadedSessions, activeId, savedRuns, selected] = await Promise.all([
|
rich?: RichSource;
|
||||||
listSessions(),
|
}> => {
|
||||||
getActiveSessionId(),
|
if (props.initialSource === "selection") {
|
||||||
listAllRuns(),
|
const selected = await getSelectedTextSafely();
|
||||||
initialText,
|
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);
|
setSessions(loadedSessions);
|
||||||
setActiveSessionId(activeId);
|
setActiveSessionId(activeId);
|
||||||
const unique = new Map<string, string>();
|
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);
|
if (!unique.has(path)) unique.set(path, run.projectName);
|
||||||
}
|
}
|
||||||
setProjects([...unique].map(([path, name]) => ({ path, name })));
|
setProjects([...unique].map(([path, name]) => ({ path, name })));
|
||||||
if (selected) setText(selected);
|
if (selected.text) {
|
||||||
else if (props.initialSource) await showToast({ style: Toast.Style.Failure,
|
setText(selected.text);
|
||||||
title: props.initialSource === "selection" ? "Kein markierter Text gefunden" : "Zwischenablage enthält keinen 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);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
load().catch(async (error: Error) => {
|
load().catch(async (error: Error) => {
|
||||||
setIsLoading(false);
|
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() {
|
async function loadClipboardText() {
|
||||||
const clipboardText = await Clipboard.readText();
|
const clipboard = await Clipboard.read();
|
||||||
|
const clipboardText = clipboard.text;
|
||||||
if (!clipboardText) {
|
if (!clipboardText) {
|
||||||
await showToast({
|
await showToast({
|
||||||
style: Toast.Style.Failure,
|
style: Toast.Style.Failure,
|
||||||
@@ -72,11 +122,20 @@ export default function Command(props: { initialSource?: "selection" | "clipboar
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setText(clipboardText);
|
setText(clipboardText);
|
||||||
|
setRichSource(
|
||||||
|
clipboard.html
|
||||||
|
? { text: clipboardText, html: clipboard.html }
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(values: FormValues) {
|
async function handleSubmit(values: FormValues) {
|
||||||
const input = values.text.trim();
|
const input = values.text;
|
||||||
if (!input || !values.sessionId || values.sessionId === CHOOSE_TARGET) {
|
if (
|
||||||
|
!input.trim() ||
|
||||||
|
!values.sessionId ||
|
||||||
|
values.sessionId === CHOOSE_TARGET
|
||||||
|
) {
|
||||||
await showToast({
|
await showToast({
|
||||||
style: Toast.Style.Failure,
|
style: Toast.Style.Failure,
|
||||||
title: "Projekt oder Sitzung wählen und Text eingeben",
|
title: "Projekt oder Sitzung wählen und Text eingeben",
|
||||||
@@ -90,27 +149,62 @@ export default function Command(props: { initialSource?: "selection" | "clipboar
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
let result: DepseudonymizeResponse;
|
let result: DepseudonymizeResponse;
|
||||||
|
let richHtml: string | undefined;
|
||||||
let targetName: string;
|
let targetName: string;
|
||||||
let targetSubtitle: string;
|
let targetSubtitle: string;
|
||||||
if (values.sessionId.startsWith("project:")) {
|
if (values.sessionId.startsWith("project:")) {
|
||||||
const projectPath = values.sessionId.slice("project:".length);
|
const projectPath = values.sessionId.slice("project:".length);
|
||||||
const saved = (await listRuns(projectPath))[0];
|
const saved = (await listRuns(projectPath))[0];
|
||||||
if (!saved) throw new Error("Projektstand nicht gefunden.");
|
if (!saved) throw new Error("Projektstand nicht gefunden.");
|
||||||
const restored = restoreTextWithCount(input, saved.run.mapping, saved.run.matchModes);
|
const restored = restoreTextWithCount(
|
||||||
result = { original_text: restored.text, replacements_made: restored.replacements };
|
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}`;
|
targetName = `Projekt ${saved.run.projectName}`;
|
||||||
targetSubtitle = saved.run.projectPath;
|
targetSubtitle = saved.run.projectPath;
|
||||||
} else {
|
} else {
|
||||||
const session = await getSession(values.sessionId);
|
const session = await getSession(values.sessionId);
|
||||||
if (!session) throw new Error("Sitzung nicht gefunden.");
|
if (!session) throw new Error("Sitzung nicht gefunden.");
|
||||||
result = await depseudonymize(input, session.mapping);
|
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;
|
targetName = session.name;
|
||||||
targetSubtitle = sessionSubtitle(session);
|
targetSubtitle = sessionSubtitle(session);
|
||||||
}
|
}
|
||||||
toast.style = Toast.Style.Success;
|
toast.style = Toast.Style.Success;
|
||||||
toast.title = "Wiederhergestellt";
|
toast.title = "Wiederhergestellt";
|
||||||
toast.message = `${result.replacements_made} Ersetzungen`;
|
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) {
|
} catch (error) {
|
||||||
toast.style = Toast.Style.Failure;
|
toast.style = Toast.Style.Failure;
|
||||||
toast.title = "Wiederherstellung fehlgeschlagen";
|
toast.title = "Wiederherstellung fehlgeschlagen";
|
||||||
@@ -149,10 +243,18 @@ export default function Command(props: { initialSource?: "selection" | "clipboar
|
|||||||
value={targetId}
|
value={targetId}
|
||||||
onChange={setTargetId}
|
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) => (
|
{projects.map((project) => (
|
||||||
<Form.Dropdown.Item key={project.path} value={`project:${project.path}`}
|
<Form.Dropdown.Item
|
||||||
title={`Projekt: ${project.name} · ${project.path}`} icon={Icon.Folder} />
|
key={project.path}
|
||||||
|
value={`project:${project.path}`}
|
||||||
|
title={`Projekt: ${project.name} · ${project.path}`}
|
||||||
|
icon={Icon.Folder}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
{sessions.map((session) => (
|
{sessions.map((session) => (
|
||||||
<Form.Dropdown.Item
|
<Form.Dropdown.Item
|
||||||
@@ -173,10 +275,14 @@ function DepseudonymizeResult({
|
|||||||
result,
|
result,
|
||||||
targetName,
|
targetName,
|
||||||
targetSubtitle,
|
targetSubtitle,
|
||||||
|
richHtml,
|
||||||
|
showFormatStatus,
|
||||||
}: {
|
}: {
|
||||||
result: DepseudonymizeResponse;
|
result: DepseudonymizeResponse;
|
||||||
targetName: string;
|
targetName: string;
|
||||||
targetSubtitle: string;
|
targetSubtitle: string;
|
||||||
|
richHtml?: string;
|
||||||
|
showFormatStatus: boolean;
|
||||||
}) {
|
}) {
|
||||||
const markdown = [
|
const markdown = [
|
||||||
`# ${targetName}`,
|
`# ${targetName}`,
|
||||||
@@ -186,6 +292,11 @@ function DepseudonymizeResult({
|
|||||||
markdownCodeBlock(result.original_text),
|
markdownCodeBlock(result.original_text),
|
||||||
"",
|
"",
|
||||||
`Ersetzungen: ${result.replacements_made}`,
|
`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");
|
].join("\n");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -193,12 +304,46 @@ function DepseudonymizeResult({
|
|||||||
markdown={markdown}
|
markdown={markdown}
|
||||||
actions={
|
actions={
|
||||||
<ActionPanel>
|
<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
|
<Action.CopyToClipboard
|
||||||
title="Wiederhergestellten Text kopieren"
|
title="Als Klartext kopieren"
|
||||||
content={result.original_text}
|
content={result.original_text}
|
||||||
/>
|
/>
|
||||||
<Action
|
<Action
|
||||||
title="Wiederhergestellten Text einfügen"
|
title="Als Klartext einfügen"
|
||||||
icon={Icon.TextCursor}
|
icon={Icon.TextCursor}
|
||||||
onAction={() => Clipboard.paste(result.original_text)}
|
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,
|
Toast,
|
||||||
useNavigation,
|
useNavigation,
|
||||||
} from "@raycast/api";
|
} 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 { getSelectedTextSafely } from "./selection";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
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