From ba9c9661968c3c178bcc2d68fec8601c8f654838 Mon Sep 17 00:00:00 2001 From: sudacode Date: Thu, 11 Dec 2025 14:37:20 -0800 Subject: [PATCH] initial commit] --- _kiku_back.html | 178 ++++++++++++++++++++++++++++++++++++++++++++++++ _kiku_plugin.js | 170 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 348 insertions(+) create mode 100644 _kiku_back.html create mode 100644 _kiku_plugin.js diff --git a/_kiku_back.html b/_kiku_back.html new file mode 100644 index 0000000..92dcef2 --- /dev/null +++ b/_kiku_back.html @@ -0,0 +1,178 @@ +
+ + + +
+ +
{{#ExpressionFurigana}}{{furigana:ExpressionFurigana}}{{/ExpressionFurigana}}{{^ExpressionFurigana}}{{Expression}}{{/ExpressionFurigana}}
{{#PitchPosition}} {{/PitchPosition}}
{{Picture}}
{{Picture}}
+
+ + +
+ + + + + + + + + + + + + + + + + + + + +
+ + + + + + + diff --git a/_kiku_plugin.js b/_kiku_plugin.js new file mode 100644 index 0000000..e03183c --- /dev/null +++ b/_kiku_plugin.js @@ -0,0 +1,170 @@ +/** + * @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()]; + }, +};