/** * @import { KikuPlugin } from "#/plugins/pluginTypes"; */ /** * @type { KikuPlugin } */ export const plugin = { Sentence: (props) => { const h = props.ctx.h; function getPlainTextFromHtml(html) { const el = document.createElement("div"); el.innerHTML = html ?? ""; return (el.textContent ?? "").trim(); } async function openRouterTranslate({ sentenceText, targetLang }) { const apiKey = "OPENROUTER_API_KEY"; const res = await fetch("https://openrouter.ai/api/v1/chat/completions", { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", }, body: JSON.stringify({ model: "openai/gpt-oss-120b:free", temperature: 0.2, messages: [ { role: "system", content: "You are a translation engine. Translate faithfully and naturally. Return only the translation.", }, { role: "user", content: `Translate the following sentence into ${targetLang}:\n\n${sentenceText}`, }, ], }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { const msg = data?.error?.message || data?.message || `OpenRouter request failed (${res.status})`; throw new Error(msg); } const content = data?.choices?.[0]?.message?.content; if (typeof content !== "string" || !content.trim()) { throw new Error("OpenRouter returned an empty response"); } return content.trim(); } function createSpinner() { return h("span", { class: "loading loading-spinner loading-xs text-base-content-faint align-middle", "aria-label": "Loading", })(); } function SentenceTranslation() { const sentenceHtml = "Sentence" in (props.ctx.ankiFields ?? {}) ? props.ctx.ankiFields?.Sentence : document.getElementById("Sentence")?.innerHTML; const sentenceText = getPlainTextFromHtml(sentenceHtml); if (!sentenceText) return null; const translationHtml = "SentenceTranslation" in (props.ctx.ankiFields ?? {}) ? props.ctx.ankiFields?.SentenceTranslation : document.getElementById("SentenceTranslation")?.innerHTML; // Treat placeholder as empty const existingTranslationText = getPlainTextFromHtml(translationHtml); const hasExistingTranslation = !!existingTranslationText && existingTranslationText !== "Click to translate"; const targetLang = ( localStorage.getItem("translation_target_lang") || sessionStorage.getItem("translation_target_lang") || "English" )?.trim() || "English"; const root = h("div", { class: "SentenceTranslation mt-2", })(); const output = h("div", { class: "text-lg text-base-content-calm whitespace-pre-wrap", })(); const button = h("button", { class: "btn btn-sm btn-ghost mt-1", type: "button", innerHTML: "Translate", })(); const status = h("div", { class: "text-xs text-base-content-faint mt-1 min-h-4", })(); const fieldDiv = document.getElementById("SentenceTranslation"); let isLoading = false; const spinner = createSpinner(); async function run() { if (isLoading) return; isLoading = true; button.disabled = true; status.textContent = ""; output.textContent = ""; status.appendChild(spinner); try { const translationText = await openRouterTranslate({ sentenceText, targetLang, }); // Display result output.textContent = translationText; // Also update the field div if it exists on the back template if (fieldDiv) fieldDiv.textContent = translationText; } catch (e) { const msg = e instanceof Error ? e.message : "Translation failed unexpectedly"; status.textContent = msg; } finally { isLoading = false; button.disabled = false; if (spinner.parentNode) spinner.parentNode.removeChild(spinner); } } button.addEventListener("click", run); if (hasExistingTranslation) { output.innerHTML = translationHtml; root.appendChild(output); } else { root.appendChild(button); root.appendChild(status); root.appendChild(output); } return root; } // You can inline the CSS here const style = h( "style", ` .SentenceTranslation .btn[disabled] { opacity: 0.6; cursor: not-allowed; } `, ); return [props.DefaultSentence(), SentenceTranslation(), style()]; }, };