initial commit]
This commit is contained in:
170
_kiku_plugin.js
Normal file
170
_kiku_plugin.js
Normal file
@@ -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()];
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user