diff --git a/packages/app/src/components/reader/TranslationPopover.tsx b/packages/app/src/components/reader/TranslationPopover.tsx index 1101675e..b424b281 100644 --- a/packages/app/src/components/reader/TranslationPopover.tsx +++ b/packages/app/src/components/reader/TranslationPopover.tsx @@ -169,7 +169,7 @@ export function TranslationPopover({ text, position, onClose }: TranslationPopov const aiConfig = useSettingsStore((s) => s.aiConfig); const endpointId = translationConfig.provider.endpointId || aiConfig.activeEndpointId; const endpoint = aiConfig.endpoints.find((e) => e.id === endpointId); - const providerName = provider === "ai" ? endpoint?.name || "AI" : "DeepL"; + const providerName = provider === "ai" ? endpoint?.name || "AI" : translationConfig.provider.name; return (
s.translationConfig); const aiConfigFromStore = useSettingsStore((s) => s.aiConfig); const translationConfig = translationConfigOverride || translationConfigFromStore; @@ -92,6 +97,12 @@ export function useTranslator(options: UseTranslatorOptions = {}) { apiKey, translationConfig.provider.baseUrl, ); + } else if (providerId === "microsoft") { + translatedTexts = await microsoftTranslate( + needsTranslation.map((n) => n.text), + sourceLang, + targetLanguage, + ); } else { throw new Error(`Unknown translation provider: ${providerId}`); } diff --git a/packages/core/src/translation/providers.test.ts b/packages/core/src/translation/providers.test.ts index a63e9c1b..ccaf622b 100644 --- a/packages/core/src/translation/providers.test.ts +++ b/packages/core/src/translation/providers.test.ts @@ -1,5 +1,9 @@ -import { describe, expect, it } from "vitest"; -import { buildAITranslationPrompt } from "./providers"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { buildAITranslationPrompt, microsoftTranslate, toMicrosoftLangCode } from "./providers"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); describe("buildAITranslationPrompt", () => { it("asks AI to translate classical Chinese into modern vernacular Chinese", () => { @@ -20,3 +24,40 @@ describe("buildAITranslationPrompt", () => { expect(prompt).toContain("Do not add any explanation"); }); }); + +describe("Microsoft translator", () => { + it("normalizes Chinese language variants to Microsoft script codes", () => { + expect(toMicrosoftLangCode("zh-CN")).toBe("zh-Hans"); + expect(toMicrosoftLangCode("zh-cn")).toBe("zh-Hans"); + expect(toMicrosoftLangCode("zh_Hans")).toBe("zh-Hans"); + expect(toMicrosoftLangCode("zh")).toBe("zh-Hans"); + expect(toMicrosoftLangCode("zh-TW")).toBe("zh-Hant"); + expect(toMicrosoftLangCode("zh_hant")).toBe("zh-Hant"); + expect(toMicrosoftLangCode("ja")).toBe("ja"); + }); + + it("requests Simplified Chinese without an empty source language parameter", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response("token")) + .mockResolvedValueOnce( + new Response(JSON.stringify([{ translations: [{ text: "你好" }] }]), { + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await expect(microsoftTranslate(["hello"], "AUTO", "zh_Hans")).resolves.toEqual(["你好"]); + + const [url, init] = fetchMock.mock.calls[1] as [string, RequestInit]; + const requestUrl = new URL(url); + expect(requestUrl.searchParams.get("api-version")).toBe("3.0"); + expect(requestUrl.searchParams.get("to")).toBe("zh-Hans"); + expect(requestUrl.searchParams.has("from")).toBe(false); + expect(init.headers).toMatchObject({ + "Content-Type": "application/json", + Authorization: "Bearer token", + }); + expect(init.body).toBe(JSON.stringify([{ Text: "hello" }])); + }); +}); diff --git a/packages/core/src/translation/providers.ts b/packages/core/src/translation/providers.ts index 8bfe8a24..26f3a20a 100644 --- a/packages/core/src/translation/providers.ts +++ b/packages/core/src/translation/providers.ts @@ -553,12 +553,18 @@ let _msToken: string | null = null; let _msTokenExpiry = 0; /** Language code mapping: our codes → Microsoft API codes */ -function toMicrosoftLangCode(lang: string): string { - const map: Record = { - "zh-CN": "zh-Hans", - "zh-TW": "zh-Hant", - }; - return map[lang] || lang; +export function toMicrosoftLangCode(lang: string): string { + const normalized = lang.trim().replace(/_/g, "-"); + const lower = normalized.toLowerCase(); + + if (lower === "zh" || lower === "zh-cn" || lower === "zh-sg" || lower === "zh-hans") { + return "zh-Hans"; + } + if (lower === "zh-tw" || lower === "zh-hk" || lower === "zh-mo" || lower === "zh-hant") { + return "zh-Hant"; + } + + return normalized; } /** Microsoft supported source languages (subset for validation) */ @@ -601,11 +607,18 @@ export async function microsoftTranslate( // If source lang is "auto"/"AUTO", empty, or not recognized by Microsoft, omit it for auto-detection const from = (!sourceLang || sourceLang.toLowerCase() === "auto" || !MS_SUPPORTED_LANGS.has(mappedSource)) ? "" : mappedSource; const to = toMicrosoftLangCode(targetLang); + const params = new URLSearchParams({ + "api-version": "3.0", + to, + }); + if (from) { + params.set("from", from); + } const body = texts.map((t) => ({ Text: t })); const resp = await fetch( - `https://api-edge.cognitive.microsofttranslator.com/translate?from=${from}&to=${to}&api-version=3.0`, + `https://api-edge.cognitive.microsofttranslator.com/translate?${params.toString()}`, { method: "POST", headers: {