Files
kiku-plugin/_kiku_plugin.js
2025-12-11 14:37:20 -08:00

171 lines
4.8 KiB
JavaScript

/**
* @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()];
},
};