Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/app/src/components/reader/TranslationPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div
Expand Down
17 changes: 14 additions & 3 deletions packages/core/src/hooks/useTranslator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
*/

import { useCallback, useState } from "react";
import type { AIConfig } from "../types";
import { useSettingsStore } from "../stores/settings-store";
import { getFromCache, storeInCache } from "../translation/cache";
import { aiTranslate, deeplTranslate } from "../translation/providers";
import { aiTranslate, deeplTranslate, microsoftTranslate } from "../translation/providers";
import type { AIConfig } from "../types";
import type { TranslationConfig, TranslationTargetLang } from "../types/translation";
import { providerRequiresApiKey } from "../utils";

Expand All @@ -20,7 +20,12 @@ export interface UseTranslatorOptions {
}

export function useTranslator(options: UseTranslatorOptions = {}) {
const { sourceLang = "AUTO", targetLang, aiConfig: aiConfigOverride, translationConfig: translationConfigOverride } = options;
const {
sourceLang = "AUTO",
targetLang,
aiConfig: aiConfigOverride,
translationConfig: translationConfigOverride,
} = options;
const translationConfigFromStore = useSettingsStore((s) => s.translationConfig);
const aiConfigFromStore = useSettingsStore((s) => s.aiConfig);
const translationConfig = translationConfigOverride || translationConfigFromStore;
Expand Down Expand Up @@ -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}`);
}
Expand Down
45 changes: 43 additions & 2 deletions packages/core/src/translation/providers.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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" }]));
});
});
27 changes: 20 additions & 7 deletions packages/core/src/translation/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
"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) */
Expand Down Expand Up @@ -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: {
Expand Down