import { renderHighlightedString } from '@modrinth/utils' import { configuredXss } from '@modrinth/utils/parse' import { invoke } from '@tauri-apps/api/core' import { fetch as tauriFetch } from '@tauri-apps/plugin-http' import { createTranslationBatches, translateInBatches as executeTranslationBatches, type TranslationRequest, type TranslationResponse, type TranslationSegment, } from './translation-batching' export { createTranslationBatches, DEFAULT_TRANSLATION_BATCH_CHARACTERS, DEFAULT_TRANSLATION_BATCH_ITEMS, DEFAULT_TRANSLATION_CONCURRENCY, prepareTranslationText, type TranslationRequest, type TranslationResponse, type TranslationSegment, type TranslationTextFormat, } from './translation-batching' /** Minimal shape of a search hit object that has a translatable title and description. */ export interface TranslatableHit { /** Unique identifier — `project_id` on search hits, `id` on SearchResult. */ project_id?: string id?: string title?: string description?: string /** Server search hits use `name` / `summary` instead of `title` / `description`. */ name?: string summary?: string provider?: 'modrinth' | 'curseforge' provider_project_id?: string } export type TranslationProvider = 'google' | 'deepl' | 'ai' export type TranslationMode = 'bilingual' | 'translation-only' export type TranslationStyle = | 'default' | 'blur' | 'blockquote' | 'weakened' | 'dashed-line' | 'border' | 'text-color' | 'background' export type DescriptionSourceFormat = 'markdown' | 'html' export interface TranslationSettings { provider: TranslationProvider target_language: string mode: TranslationMode auto_translate: boolean style: TranslationStyle ai_provider_id: string ai_model_id: string ai_system_prompt: string deepl_api_endpoint: string deepl_api_key: string | null } export async function translateInBatches( request: TranslationRequest, onBatch?: (response: TranslationResponse) => void, execute: (request: TranslationRequest) => Promise = translate, ): Promise { return executeTranslationBatches(request, onBatch, execute) } interface ProtectedElement { tagName: string attributes: Array<[string, string]> innerHtml?: string outerHtml?: string } export interface PreparedDescriptionBlock { id: string originalHtml: string translatable: boolean protectedElements: Record } export interface PreparedDescription { blocks: PreparedDescriptionBlock[] segments: TranslationSegment[] } export async function getTranslationSettings(): Promise { return await invoke('plugin:translation|translation_get_settings') } export async function updateTranslationSettings(settings: TranslationSettings): Promise { await invoke('plugin:translation|translation_update_settings', { settings }) } export async function testTranslationProvider(provider: TranslationProvider): Promise { return await invoke('plugin:translation|translation_test_provider', { provider }) } export async function translate(request: TranslationRequest): Promise { return await invoke('plugin:translation|translation_translate', { request }) } export async function clearTranslationCache(): Promise { await invoke('plugin:translation|translation_clear_cache') } export async function getGoogleIpPoolSize(): Promise { return await invoke('plugin:translation|translation_google_ip_pool_size') } export type TranslationErrorKind = | 'rate-limited' | 'authentication' | 'content-too-long' | 'network' | 'provider' function translationErrorMessage(error: unknown): string { if (error instanceof Error) return error.message if (typeof error === 'string') return error if ( typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string' ) { return error.message } return String(error) } export function getTranslationErrorKind(error: unknown): TranslationErrorKind { const message = translationErrorMessage(error) if (message.includes('TRANSLATION_RATE_LIMITED') || message.includes('AI_RATE_LIMITED')) { return 'rate-limited' } if ( message.includes('TRANSLATION_AUTHENTICATION_FAILED') || message.includes('AI_AUTHENTICATION_FAILED') ) { return 'authentication' } if (message.includes('TRANSLATION_CONTENT_TOO_LONG')) return 'content-too-long' if (message.includes('TRANSLATION_NETWORK_FAILED') || message.includes('AI_NETWORK_FAILED')) { return 'network' } return 'provider' } function containsReadableText(element: Element): boolean { if ( element.matches('pre, script, style, img, picture, source, video, audio, iframe, canvas, svg') ) { return false } const clone = element.cloneNode(true) as Element clone.querySelectorAll('pre, code, script, style').forEach((node) => node.remove()) clone.querySelectorAll('a').forEach((node) => { if (isUrlOnlyText(node.textContent ?? '')) node.remove() }) return (clone.textContent ?? '').trim().length > 0 } function isUrlOnlyText(value: string): boolean { return /^(?:https?:\/\/|www\.|mailto:)[^\s]+$/i.test(value.trim()) } const NON_TRANSLATABLE_MEDIA_SELECTOR = 'img, picture, source, video, audio, iframe, canvas, svg' function protectElementAttributes( element: Element, blockIndex: number, ): Record { const protectedElements: Record = {} const elements = [element, ...Array.from(element.querySelectorAll('*'))] elements.forEach((current, elementIndex) => { if (current !== element && !element.contains(current)) return const marker = `${blockIndex}-${elementIndex}` if (current.matches(NON_TRANSLATABLE_MEDIA_SELECTOR)) { protectedElements[marker] = { tagName: 'SPAN', attributes: [], outerHtml: current.outerHTML, } const placeholder = current.ownerDocument.createElement('span') placeholder.setAttribute('data-ax-translation-attr', marker) placeholder.setAttribute('translate', 'no') current.replaceWith(placeholder) return } const attributes = Array.from(current.attributes).map( (attribute) => [attribute.name, attribute.value] as [string, string], ) protectedElements[marker] = { tagName: current.tagName, attributes, ...(current.matches('code, pre') || (current.matches('a') && isUrlOnlyText(current.textContent ?? '')) ? { innerHtml: current.innerHTML } : {}), } Array.from(current.attributes).forEach((attribute) => current.removeAttribute(attribute.name)) current.setAttribute('data-ax-translation-attr', marker) if (protectedElements[marker].innerHtml !== undefined) { current.setAttribute('translate', 'no') current.innerHTML = '' } }) return protectedElements } export function prepareDescription( description: string, sourceFormat: DescriptionSourceFormat = 'markdown', ): PreparedDescription { const renderedDescription = sourceFormat === 'html' ? configuredXss.process(description ?? '') : renderHighlightedString(description ?? '') const document = new DOMParser().parseFromString( `${renderedDescription}`, 'text/html', ) const blocks: PreparedDescriptionBlock[] = [] const segments: TranslationSegment[] = [] Array.from(document.body.children).forEach((source, index) => { const id = `body-${index}` const originalHtml = configuredXss.process(source.outerHTML) const translatable = containsReadableText(source) const clone = source.cloneNode(true) as Element const protectedElements = translatable ? protectElementAttributes(clone, index) : {} blocks.push({ id, originalHtml, translatable, protectedElements }) if (translatable) { segments.push({ id, text: clone.outerHTML, format: 'html' }) } }) return { blocks, segments } } function restoreTranslatedBlock(block: PreparedDescriptionBlock, translatedHtml: string): string { const document = new DOMParser().parseFromString(`${translatedHtml}`, 'text/html') const root = document.body.firstElementChild const translatedElements = document.body.querySelectorAll('*') if ( !root || document.body.children.length !== 1 || translatedElements.length !== Object.keys(block.protectedElements).length || Array.from(translatedElements).some( (element) => !element.hasAttribute('data-ax-translation-attr'), ) ) { throw new Error(`Translation markup changed for block ${block.id}`) } for (const [marker, protectedElement] of Object.entries(block.protectedElements)) { const matches = document.body.querySelectorAll(`[data-ax-translation-attr="${marker}"]`) if (matches.length !== 1 || matches[0].tagName !== protectedElement.tagName) { throw new Error(`Translation markup changed for block ${block.id}`) } const element = matches[0] if (protectedElement.outerHtml !== undefined) { const mediaDocument = new DOMParser().parseFromString( `${protectedElement.outerHtml}`, 'text/html', ) const media = mediaDocument.body.firstElementChild if (!media) throw new Error(`Translation media changed for block ${block.id}`) element.replaceWith(media) continue } Array.from(element.attributes).forEach((attribute) => element.removeAttribute(attribute.name)) protectedElement.attributes.forEach(([name, value]) => element.setAttribute(name, value)) if (protectedElement.innerHtml !== undefined) element.innerHTML = protectedElement.innerHtml } return configuredXss.process(root.outerHTML) } function translationStyleClass(style: TranslationStyle): string { return `ax-translation-style-${style}` } function restorePreparedDescription( prepared: PreparedDescription, translations: Record, ): Map { const restored = new Map() for (const block of prepared.blocks) { if (!block.translatable) continue const translated = translations[block.id] if (!translated) throw new Error(`Missing translated block ${block.id}`) restored.set(block.id, restoreTranslatedBlock(block, translated)) } return restored } export function validateTranslatedDescription( prepared: PreparedDescription, translations: Record, ): void { restorePreparedDescription(prepared, translations) } export function renderTranslatedDescription( prepared: PreparedDescription, translations: Record, mode: TranslationMode, style: TranslationStyle, ): string { let restored: Map try { restored = restorePreparedDescription(prepared, translations) } catch { return prepared.blocks.map((block) => block.originalHtml).join('') } return prepared.blocks .map((block) => { if (!block.translatable) return block.originalHtml const translated = restored.get(block.id) ?? block.originalHtml if (mode === 'translation-only') return translated return `${block.originalHtml}
${translated}
` }) .join('') } const MIRROR_API_BASE = 'https://mod.mcimirror.top/translate' /** Cache: key → translated description string. Key format: `cf:{provider_project_id}` or `mr:{project_id}`. */ const descriptionCache = new Map() function mirrorCacheKey(hit: TranslatableHit): string | null { if (hit.provider === 'curseforge') { const id = hit.provider_project_id if (!id) return null return `cf:${id}` } if (hit.provider === 'modrinth') { const id = hit.project_id if (!id) return null return `mr:${id}` } return null } interface MirrorTranslationResponse { modid?: number project_id?: string translated: string original: string translated_at: string } /** Fetch translated description for a single project from the mcimirror API. */ async function fetchMirrorDescription(hit: TranslatableHit): Promise { const cacheKey = mirrorCacheKey(hit) if (!cacheKey) return null const cached = descriptionCache.get(cacheKey) if (cached !== undefined) return cached || null const id = hit.provider === 'curseforge' ? hit.provider_project_id : hit.project_id if (!id || !hit.provider) return null const url = `${MIRROR_API_BASE}/${hit.provider}/${encodeURIComponent(id)}` try { const response = await tauriFetch(url) if (!response.ok) return null const data = (await response.json()) as MirrorTranslationResponse const translated = data.translated?.trim() || null descriptionCache.set(cacheKey, translated ?? '') return translated } catch { descriptionCache.set(cacheKey, '') return null } } /** * Rewrites search hit descriptions to Chinese. Project hits use the mcimirror * translation API; server hits use the configured backend provider. * * @param hits Search result hits that carry at least provider + ID and * description fields. * @param locale Fallback target locale when no target language is configured. * @param force - When true, skip the `auto_translate` setting check; * always translate. * @param useServer - If true, sends the description via the Rust backend; * otherwise, uses mcimirror API. */ export async function translateSearchDescriptions( hits: T[], locale: string, _force = false, useServer = false, ): Promise { if (hits.length === 0) return hits let targetLanguage = locale const settings = await getTranslationSettings() if (!_force && !settings.auto_translate) return hits targetLanguage = settings.target_language?.trim() || locale if (!targetLanguage || targetLanguage === 'en-US') return hits // mcimirror 镜像只缓存中文翻译,非中文目标改走服务端路径 if (!useServer && targetLanguage !== 'zh-CN' && targetLanguage !== 'zh') { useServer = true } if (useServer) { const segments: TranslationSegment[] = hits.map((hit) => ({ text: hit.description ?? hit.summary ?? '', id: hit.project_id ?? hit.provider_project_id ?? '', format: 'html', })) const request: TranslationRequest = { target_language: targetLanguage, source_language: 'auto', segments, context: { title: hits[0]?.title ?? '', description: hits[0]?.description ?? hits[0]?.summary ?? '', }, } // 等所有批次结束(含失败批次)再决定:保留成功批次,全部失败才抛错 const translated: TranslationResponse['segments'] = [] const results = await Promise.allSettled( createTranslationBatches(request.segments).map((batch) => translateInBatches({ ...request, segments: batch }, (batchResponse) => translated.push(...batchResponse.segments), ), ), ) if (translated.length === 0) { const failed = results.find((result) => result.status === 'rejected') throw failed ? failed.reason : new Error('搜索描述翻译失败') } const response: TranslationResponse = { segments: translated } const translatedHits = hits.map((hit) => { const segment = response.segments.find( (s) => s.id === (hit.project_id ?? hit.provider_project_id ?? ''), ) if (!segment) return hit return { ...hit, description: segment.text, summary: segment.text, } }) return translatedHits as T[] } const entries = hits.map((hit) => ({ hit, index: hits.indexOf(hit) })) const results = await Promise.allSettled( entries.map(async ({ hit, index }) => { const originalDesc = hit.description ?? hit.summary ?? '' if (!originalDesc) return { index, hit } const translation = await fetchMirrorDescription(hit) if (!translation) return { index, hit } return { index, hit: { ...hit, description: translation, summary: translation, } as T, } }), ) const translatedHits = [...hits] let translatedAny = false for (const result of results) { if (result.status === 'rejected') continue if (result.value.hit === hits[result.value.index]) continue translatedHits[result.value.index] = result.value.hit translatedAny = true } return translatedAny ? translatedHits : hits }