diff --git a/src/ai-views.tsx b/src/ai-views.tsx index 2c5d030..1460feb 100644 --- a/src/ai-views.tsx +++ b/src/ai-views.tsx @@ -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 { if (getPreferences().closeAfterAction) { @@ -29,33 +28,6 @@ export async function maybeCloseRaycast(): Promise { } } -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 { - 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; diff --git a/src/depseudonymize-text.tsx b/src/depseudonymize-text.tsx index 4ff9200..fbd0957 100644 --- a/src/depseudonymize-text.tsx +++ b/src/depseudonymize-text.tsx @@ -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(); const [sessions, setSessions] = useState([]); const [activeSessionId, setActiveSessionId] = useState(); - const [projects, setProjects] = useState>([]); + 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(); @@ -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(); + push( + , + ); } 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} > - + {projects.map((project) => ( - + ))} {sessions.map((session) => ( + {richHtml && ( + <> + { + 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), + }); + } + }} + /> + + Clipboard.paste({ + html: richHtml, + text: result.original_text, + }) + } + /> + + )} Clipboard.paste(result.original_text)} /> diff --git a/src/lib/rich-text.ts b/src/lib/rich-text.ts new file mode 100644 index 0000000..f9ba0b2 --- /dev/null +++ b/src/lib/rich-text.ts @@ -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, "'"); +} + +function decodeEntities(value: string): string { + const named: Record = { + 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 + 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) + : `(? 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; +} diff --git a/src/rich-clipboard.ts b/src/rich-clipboard.ts new file mode 100644 index 0000000..03d0803 --- /dev/null +++ b/src/rich-clipboard.ts @@ -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 { + await execFileAsync( + "osascript", + ["-l", "JavaScript", "-e", RICH_COPY_SCRIPT], + { + env: { ...process.env, VELUM_HTML: html, VELUM_TEXT: text }, + }, + ); +} diff --git a/src/summarize-email.tsx b/src/summarize-email.tsx index ee65ca6..c69a463 100644 --- a/src/summarize-email.tsx +++ b/src/summarize-email.tsx @@ -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 { diff --git a/tests/rich-text.test.ts b/tests/rich-text.test.ts new file mode 100644 index 0000000..7ad5dbf --- /dev/null +++ b/tests/rich-text.test.ts @@ -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 = "

Hallo PERSON_01 bei ORG_01.

"; + expect( + restoreRichHtml( + html, + "Hallo PERSON_01 bei ORG_01.", + "Hallo Max Mustermann bei Müller & Söhne.", + mapping, + ), + ).toBe( + "

Hallo Max Mustermann bei Müller & Söhne.

", + ); + }); + + it("ändert weder Attribute noch unsichtbare Inhalte", () => { + const html = + '
PERSON_01
'; + expect(restoreRichHtml(html, "PERSON_01", "Max Mustermann", mapping)).toBe( + '
Max Mustermann
', + ); + }); + + it("übernimmt kodierte Sitzungsplatzhalter", () => { + const html = "<PERSON_01>"; + expect( + restoreRichHtml( + html, + "", + "Max Mustermann", + mapping, + undefined, + true, + ), + ).toBe("Max Mustermann"); + }); + + it("verwirft nicht passende oder über mehrere Textknoten verteilte HTML-Daten", () => { + expect( + restoreRichHtml( + "PERSON_01", + "anderer Text", + "Max Mustermann", + mapping, + ), + ).toBeUndefined(); + expect( + restoreRichHtml( + "PERSON_01", + "PERSON_01", + "Max Mustermann", + mapping, + ), + ).toBeUndefined(); + }); + + it("beachtet projektbezogene Ersetzung innerhalb zusammengesetzter Begriffe", () => { + const html = "abcPERSON_01xyz"; + expect( + restoreRichHtml( + html, + "abcPERSON_01xyz", + "abcMax Mustermannxyz", + mapping, + { PERSON_01: "anywhere" }, + ), + ).toBe("abcMax Mustermannxyz"); + }); +});