299 lines
11 KiB
TypeScript
299 lines
11 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
|
|
// Host-side proxy backing the "Translate" plugin (manifest apiPostPaths:
|
|
// ["/api/translate"]). The plugin slot iframe POSTs { text, target, source,
|
|
// provider } here via api.http.post; we forward to a free translation backend
|
|
// and return { translatedText, detectedSource } in a stable shape.
|
|
//
|
|
// Two providers:
|
|
// - "mymemory" — public MyMemory API, no configuration required. Its
|
|
// langpair needs an explicit source language, so when the
|
|
// plugin asks for "auto" we detect it locally first.
|
|
// - "libretranslate" — only available when the host sets LIBRETRANSLATE_URL
|
|
// (and optionally LIBRETRANSLATE_API_KEY). Supports native
|
|
// source auto-detection.
|
|
|
|
export const runtime = 'nodejs';
|
|
|
|
const MAX_CHARS = 5000;
|
|
// Long bodies are split into ~480-char chunks for MyMemory and translated
|
|
// sequentially, so allow enough headroom for ~10 round-trips.
|
|
const TIMEOUT_MS = 25000;
|
|
|
|
type Provider = 'mymemory' | 'libretranslate';
|
|
|
|
interface TranslateBody {
|
|
text?: unknown;
|
|
target?: unknown;
|
|
source?: unknown;
|
|
provider?: unknown;
|
|
}
|
|
|
|
interface TranslateResult {
|
|
translatedText: string;
|
|
detectedSource?: string;
|
|
}
|
|
|
|
// ─── Lightweight language detection ───────────────────────────
|
|
//
|
|
// MyMemory has no auto-detect, so we infer a source language from the text.
|
|
// Non-Latin scripts are decided by Unicode range; Latin-script European
|
|
// languages are scored by stop-word frequency. Detection only needs to be good
|
|
// enough to (a) pick a sensible langpair and (b) let the plugin skip messages
|
|
// already in the target language.
|
|
|
|
const SCRIPT_RANGES: ReadonlyArray<[RegExp, string]> = [
|
|
[/[-ヿ]/, 'ja'], // Hiragana / Katakana
|
|
[/[가-]/, 'ko'], // Hangul
|
|
[/[一-鿿]/, 'zh'], // CJK ideographs (after JP/KR checks)
|
|
[/[Ѐ-ӿ]/, 'ru'], // Cyrillic (ru vs uk refined below)
|
|
[/[Ͱ-Ͽ]/, 'el'], // Greek
|
|
[/[-ۿ]/, 'ar'], // Arabic
|
|
[/[-]/, 'he'], // Hebrew
|
|
[/[ऀ-ॿ]/, 'hi'], // Devanagari
|
|
];
|
|
|
|
// Distinctive stop words per Latin-script language from the manifest's option
|
|
// list. Kept small and high-signal to avoid cross-language collisions.
|
|
const LATIN_STOPWORDS: Record<string, readonly string[]> = {
|
|
en: ['the', 'and', 'you', 'that', 'with', 'for', 'this', 'have', 'are'],
|
|
de: ['der', 'die', 'und', 'das', 'ist', 'nicht', 'mit', 'sie', 'ein', 'auch'],
|
|
fr: ['les', 'des', 'une', 'est', 'pour', 'que', 'vous', 'dans', 'avec', 'pas'],
|
|
es: ['que', 'los', 'una', 'por', 'con', 'para', 'como', 'pero', 'más', 'esta'],
|
|
it: ['che', 'non', 'per', 'una', 'sono', 'con', 'come', 'questo', 'anche', 'della'],
|
|
pt: ['que', 'não', 'uma', 'com', 'para', 'como', 'mais', 'você', 'está', 'isso'],
|
|
nl: ['het', 'een', 'van', 'dat', 'niet', 'met', 'voor', 'aan', 'zijn', 'maar'],
|
|
pl: ['nie', 'jest', 'się', 'ale', 'oraz', 'tego', 'jak', 'tym', 'przez', 'dla'],
|
|
sv: ['och', 'att', 'det', 'som', 'för', 'med', 'inte', 'den', 'till', 'har'],
|
|
no: ['og', 'det', 'som', 'for', 'med', 'ikke', 'har', 'til', 'denne', 'jeg'],
|
|
da: ['og', 'det', 'som', 'for', 'med', 'ikke', 'har', 'til', 'denne', 'jeg'],
|
|
fi: ['että', 'olen', 'tämä', 'kanssa', 'mutta', 'sekä', 'ei', 'on', 'ja', 'jotta'],
|
|
cs: ['není', 'jsem', 'pro', 'ale', 'jako', 'tento', 'také', 'přes', 'jsou', 'své'],
|
|
ro: ['este', 'pentru', 'care', 'dar', 'sunt', 'această', 'mai', 'din', 'sau', 'nu'],
|
|
hu: ['hogy', 'nem', 'egy', 'van', 'ezt', 'vagy', 'mint', 'csak', 'ezzel', 'így'],
|
|
tr: ['bir', 'için', 'değil', 'bu', 'çok', 'daha', 'ama', 'gibi', 've', 'ile'],
|
|
};
|
|
|
|
function detectLanguage(text: string): string {
|
|
const sample = text.slice(0, 1000);
|
|
for (const [range, lang] of SCRIPT_RANGES) {
|
|
if (range.test(sample)) {
|
|
// Ukrainian shares Cyrillic with Russian; its unique glyphs decide it.
|
|
if (lang === 'ru' && /[єіїґ]/.test(sample)) return 'uk';
|
|
return lang;
|
|
}
|
|
}
|
|
|
|
const words = sample.toLowerCase().match(/[a-zà-ÿčśžłńęąółżźć]+/gi) || [];
|
|
if (words.length === 0) return 'en';
|
|
const counts: Record<string, number> = {};
|
|
const wordSet = new Set(words);
|
|
for (const [lang, stops] of Object.entries(LATIN_STOPWORDS)) {
|
|
let score = 0;
|
|
for (const stop of stops) if (wordSet.has(stop)) score += 1;
|
|
counts[lang] = score;
|
|
}
|
|
let best = 'en';
|
|
let bestScore = -1;
|
|
for (const [lang, score] of Object.entries(counts)) {
|
|
if (score > bestScore) {
|
|
best = lang;
|
|
bestScore = score;
|
|
}
|
|
}
|
|
return bestScore > 0 ? best : 'en';
|
|
}
|
|
|
|
function baseLang(code: string): string {
|
|
return String(code || '').toLowerCase().split('-')[0];
|
|
}
|
|
|
|
// ─── Chunking ─────────────────────────────────────────────────
|
|
//
|
|
// MyMemory's free endpoint caps each request's `q` at 500 characters and
|
|
// silently returns only the translated prefix beyond that — which is why long
|
|
// emails came back truncated ("…about Sel…"). We split the text into
|
|
// line-aware chunks under the limit, translate each, then rejoin so the whole
|
|
// body is covered.
|
|
|
|
const MYMEMORY_CHUNK = 480;
|
|
|
|
function chunkText(text: string, max: number): string[] {
|
|
const chunks: string[] = [];
|
|
let cur = '';
|
|
const flush = () => {
|
|
if (cur) {
|
|
chunks.push(cur);
|
|
cur = '';
|
|
}
|
|
};
|
|
for (const line of text.split('\n')) {
|
|
if (line.length > max) {
|
|
flush();
|
|
// A single over-long line (e.g. a long URL list): split on words, and
|
|
// hard-cut any word that is itself longer than the limit.
|
|
let seg = '';
|
|
for (const word of line.split(' ')) {
|
|
const piece = seg ? seg + ' ' + word : word;
|
|
if (piece.length > max) {
|
|
if (seg) {
|
|
chunks.push(seg);
|
|
seg = '';
|
|
}
|
|
if (word.length > max) {
|
|
for (let i = 0; i < word.length; i += max) chunks.push(word.slice(i, i + max));
|
|
} else {
|
|
seg = word;
|
|
}
|
|
} else {
|
|
seg = piece;
|
|
}
|
|
}
|
|
if (seg) chunks.push(seg);
|
|
continue;
|
|
}
|
|
if (cur && cur.length + 1 + line.length > max) flush();
|
|
cur = cur ? cur + '\n' + line : line;
|
|
}
|
|
flush();
|
|
return chunks;
|
|
}
|
|
|
|
// ─── Providers ────────────────────────────────────────────────
|
|
|
|
async function mymemoryRequest(
|
|
q: string,
|
|
langpair: string,
|
|
signal: AbortSignal,
|
|
): Promise<string> {
|
|
const url = new URL('https://api.mymemory.translated.net/get');
|
|
url.searchParams.set('q', q);
|
|
url.searchParams.set('langpair', langpair);
|
|
|
|
const res = await fetch(url.toString(), {
|
|
signal,
|
|
headers: { 'User-Agent': 'JMAP-Webmail/1.0 Translate-Plugin' },
|
|
});
|
|
const data = (await res.json().catch(() => null)) as
|
|
| { responseStatus?: number | string; responseData?: { translatedText?: string }; responseDetails?: string }
|
|
| null;
|
|
if (!res.ok || !data) {
|
|
throw new Error(`MyMemory returned ${res.status}`);
|
|
}
|
|
const status = Number(data.responseStatus);
|
|
if (status && status !== 200) {
|
|
throw new Error(data.responseDetails || `MyMemory error ${status}`);
|
|
}
|
|
const translatedText = data.responseData?.translatedText || '';
|
|
if (!translatedText) {
|
|
throw new Error('MyMemory returned no translation');
|
|
}
|
|
return translatedText;
|
|
}
|
|
|
|
async function translateMyMemory(
|
|
text: string,
|
|
source: string,
|
|
target: string,
|
|
signal: AbortSignal,
|
|
): Promise<TranslateResult> {
|
|
const detected = source === 'auto' || !source ? detectLanguage(text) : baseLang(source);
|
|
const tgt = baseLang(target);
|
|
// Nothing to do if already in the target language; the plugin skips display.
|
|
if (detected === tgt) {
|
|
return { translatedText: text, detectedSource: detected };
|
|
}
|
|
const langpair = `${detected}|${tgt}`;
|
|
const chunks = chunkText(text, MYMEMORY_CHUNK);
|
|
// Sequential to stay friendly to MyMemory's free-tier rate limits; emails are
|
|
// usually one or two chunks.
|
|
const translated: string[] = [];
|
|
for (const chunk of chunks) {
|
|
translated.push(await mymemoryRequest(chunk, langpair, signal));
|
|
}
|
|
return { translatedText: translated.join('\n'), detectedSource: detected };
|
|
}
|
|
|
|
async function translateLibre(
|
|
text: string,
|
|
source: string,
|
|
target: string,
|
|
signal: AbortSignal,
|
|
): Promise<TranslateResult> {
|
|
const endpoint = process.env.LIBRETRANSLATE_URL;
|
|
if (!endpoint) {
|
|
throw new Error('LibreTranslate is not configured on this server');
|
|
}
|
|
const apiKey = process.env.LIBRETRANSLATE_API_KEY;
|
|
const url = endpoint.replace(/\/+$/, '') + '/translate';
|
|
const res = await fetch(url, {
|
|
method: 'POST',
|
|
signal,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
q: text,
|
|
source: source || 'auto',
|
|
target: baseLang(target),
|
|
format: 'text',
|
|
...(apiKey ? { api_key: apiKey } : {}),
|
|
}),
|
|
});
|
|
const data = (await res.json().catch(() => null)) as
|
|
| { translatedText?: string; detectedLanguage?: { language?: string }; error?: string }
|
|
| null;
|
|
if (!res.ok || !data) {
|
|
throw new Error(data?.error || `LibreTranslate returned ${res.status}`);
|
|
}
|
|
if (!data.translatedText) {
|
|
throw new Error(data.error || 'LibreTranslate returned no translation');
|
|
}
|
|
return {
|
|
translatedText: data.translatedText,
|
|
detectedSource: data.detectedLanguage?.language || (source !== 'auto' ? baseLang(source) : undefined),
|
|
};
|
|
}
|
|
|
|
// ─── Route ────────────────────────────────────────────────────
|
|
|
|
export async function POST(request: NextRequest) {
|
|
let body: TranslateBody;
|
|
try {
|
|
body = (await request.json()) as TranslateBody;
|
|
} catch {
|
|
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
|
}
|
|
|
|
const text = typeof body.text === 'string' ? body.text.trim() : '';
|
|
const target = typeof body.target === 'string' && body.target.trim() ? body.target.trim() : 'en';
|
|
const source = typeof body.source === 'string' && body.source.trim() ? body.source.trim() : 'auto';
|
|
const provider: Provider = body.provider === 'libretranslate' ? 'libretranslate' : 'mymemory';
|
|
|
|
if (!text) {
|
|
return NextResponse.json({ error: 'No text to translate' }, { status: 400 });
|
|
}
|
|
if (text.length > MAX_CHARS) {
|
|
return NextResponse.json(
|
|
{ error: `Text exceeds the ${MAX_CHARS}-character limit` },
|
|
{ status: 413 },
|
|
);
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
try {
|
|
const result =
|
|
provider === 'libretranslate'
|
|
? await translateLibre(text, source, target, controller.signal)
|
|
: await translateMyMemory(text, source, target, controller.signal);
|
|
return NextResponse.json(result, { status: 200 });
|
|
} catch (error: unknown) {
|
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
return NextResponse.json({ error: 'Translation timed out' }, { status: 504 });
|
|
}
|
|
const message = error instanceof Error ? error.message : 'Translation failed';
|
|
return NextResponse.json({ error: message }, { status: 502 });
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|