replace deepl with llm translation via openrouter
- translateText/translateTexts call OpenRouter chat-completions; glossary terms are prompt-injected from config/glossaries.ts, generated from the translation repo's TSVs by scripts/build-glossaries.ts - model resolution: per-language override in config/languages.ts → translation.model site setting (new admin field) → TRANSLATION_MODEL env → anthropic/claude-opus-5, at low reasoning effort - blank fragments are held back from the model, which drops them and breaks the one-translation-per-fragment count the caller maps results back on - OpenRouter failures map to a typed error with a human-readable message; an unrecoverable one (no credits, bad key, rejected model) stops the run instead of repeating the same call for every remaining locale - machine translations record source 'auto-translate'; 'deepl' stays readable for snapshots written before the switch
This commit is contained in:
parent
050708084b
commit
7dfad0ee07
17 changed files with 1400 additions and 216 deletions
10
.env.example
10
.env.example
|
|
@ -47,10 +47,12 @@ NUXT_PUBLIC_STATINATOR_ENABLED=true
|
|||
# Set to empty in dev to make the cookie host-only.
|
||||
NUXT_PUBLIC_STATINATOR_COOKIE_DOMAIN=.doxa.life
|
||||
|
||||
# DeepL (used by the CMS "Translate from English" workflow on /admin/pages/:id)
|
||||
# Pro endpoint: https://api.deepl.com | Free-tier endpoint: https://api-free.deepl.com
|
||||
DEEPL_API_KEY=your-deepl-api-key
|
||||
DEEPL_API_URL=https://api.deepl.com
|
||||
# OpenRouter (used by the CMS "Auto-translate" workflow on /admin/pages/:id
|
||||
# and the translate_text / translate_page MCP tools)
|
||||
OPENROUTER_API_KEY=your-openrouter-api-key
|
||||
# Optional model override. Admins can also set it at /admin/settings, which wins
|
||||
# over this. Defaults to anthropic/claude-opus-5 when both are unset.
|
||||
TRANSLATION_MODEL=
|
||||
|
||||
# Cloudflare cache purge on deploy (server/plugins/cloudflare-purge.ts).
|
||||
# On container boot the edge cache is purged so stale prerendered HTML can't
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ const editor = useEditor({
|
|||
}
|
||||
})
|
||||
|
||||
// External updates (DeepL translation refresh, locale tab switch) —
|
||||
// External updates (auto-translation refresh, locale tab switch) —
|
||||
// replace editor content when the prop drifts from the editor state.
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
<script setup lang="ts">
|
||||
// Admin: CMS page editor. Per-locale tabs, Tiptap editor, featured
|
||||
// image + OG image uploaders, meta_title / meta_description, and
|
||||
// Save / Publish / Unpublish actions. A DeepL button fan-outs English
|
||||
// into the other enabled locales.
|
||||
// Save / Publish / Unpublish actions. An auto-translate button fan-outs
|
||||
// English into the other enabled locales.
|
||||
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
|
|
@ -325,7 +325,7 @@ async function deletePage() {
|
|||
}
|
||||
}
|
||||
|
||||
// DeepL translate modal
|
||||
// Auto-translate modal
|
||||
const translateModalOpen = ref(false)
|
||||
const translateSource = ref('en')
|
||||
const translateTargets = ref<string[]>([])
|
||||
|
|
@ -397,7 +397,7 @@ interface VersionSummary {
|
|||
id: string
|
||||
created: string
|
||||
status: 'draft' | 'published'
|
||||
source: 'admin-ui' | 'mcp' | 'deepl'
|
||||
source: 'admin-ui' | 'mcp' | 'auto-translate' | 'deepl'
|
||||
title: string
|
||||
created_by: { id: string; name: string } | null
|
||||
}
|
||||
|
|
@ -495,6 +495,7 @@ function loadVersionIntoEditor() {
|
|||
const SOURCE_LABELS: Record<string, string> = {
|
||||
'admin-ui': 'Admin',
|
||||
mcp: 'MCP',
|
||||
'auto-translate': 'Auto-translate',
|
||||
deepl: 'DeepL'
|
||||
}
|
||||
|
||||
|
|
@ -879,7 +880,7 @@ function formatVersionTime(iso: string): string {
|
|||
</template>
|
||||
</USlideover>
|
||||
|
||||
<UModal v-model:open="translateModalOpen" title="Translate with DeepL">
|
||||
<UModal v-model:open="translateModalOpen" title="Auto-translate">
|
||||
<template #body>
|
||||
<div class="space-y-3">
|
||||
<UFormField label="Source locale">
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ definePageMeta({
|
|||
|
||||
interface SettingsResponse {
|
||||
'auth.public_registration_enabled'?: boolean
|
||||
'translation.model'?: string
|
||||
}
|
||||
|
||||
const { hasPermission } = usePermissions()
|
||||
|
|
@ -31,6 +32,42 @@ const registrationEnabled = computed({
|
|||
|
||||
const saving = ref(false)
|
||||
|
||||
// Empty means "no override" — the server falls back to TRANSLATION_MODEL and
|
||||
// then the code default, so the placeholder shows what an empty box resolves to.
|
||||
const DEFAULT_TRANSLATION_MODEL = 'anthropic/claude-opus-5'
|
||||
const translationModel = ref('')
|
||||
const savingModel = ref(false)
|
||||
|
||||
watch(data, (val) => {
|
||||
translationModel.value = val?.['translation.model'] ?? ''
|
||||
}, { immediate: true })
|
||||
|
||||
const handleSaveModel = async () => {
|
||||
if (!canEdit.value || savingModel.value) return
|
||||
const next = translationModel.value.trim()
|
||||
savingModel.value = true
|
||||
try {
|
||||
await $fetch('/api/admin/settings/translation.model', {
|
||||
method: 'PATCH',
|
||||
body: { value: next }
|
||||
})
|
||||
translationModel.value = next
|
||||
data.value = { ...(data.value ?? {}), 'translation.model': next }
|
||||
toast.add({
|
||||
title: next ? `Translation model set to ${next}` : 'Translation model reset to the default',
|
||||
color: 'success'
|
||||
})
|
||||
} catch (err: any) {
|
||||
toast.add({
|
||||
title: 'Update failed',
|
||||
description: err?.data?.statusMessage || err?.message || 'Failed to update setting',
|
||||
color: 'error'
|
||||
})
|
||||
} finally {
|
||||
savingModel.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleRegistration = async (next: boolean) => {
|
||||
if (!canEdit.value || saving.value) return
|
||||
const previous = registrationEnabled.value
|
||||
|
|
@ -102,6 +139,40 @@ const handleToggleRegistration = async (next: boolean) => {
|
|||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-lucide-languages" class="size-4 text-(--ui-text-muted)" />
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wide text-(--ui-text-muted)">Translation</h2>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<h3 class="font-medium">OpenRouter model</h3>
|
||||
<p class="text-sm text-(--ui-text-muted) mt-1 mb-3">
|
||||
Model used to auto-translate CMS pages. Any
|
||||
<ULink to="https://openrouter.ai/models" target="_blank" class="underline">OpenRouter model id</ULink>
|
||||
works — leave it blank to use the default.
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<UInput
|
||||
v-model="translationModel"
|
||||
:placeholder="DEFAULT_TRANSLATION_MODEL"
|
||||
:disabled="!canEdit || savingModel"
|
||||
class="flex-1 font-mono text-xs"
|
||||
@keyup.enter="handleSaveModel"
|
||||
/>
|
||||
<UButton
|
||||
color="primary"
|
||||
variant="outline"
|
||||
:disabled="!canEdit || translationModel.trim() === (data?.['translation.model'] ?? '')"
|
||||
:loading="savingModel"
|
||||
@click="handleSaveModel"
|
||||
>Save</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<p v-if="canView && !canEdit" class="text-xs text-(--ui-text-muted)">
|
||||
You can view settings but not change them. Contact an administrator if you need to make changes.
|
||||
</p>
|
||||
|
|
|
|||
882
config/glossaries.ts
Normal file
882
config/glossaries.ts
Normal file
|
|
@ -0,0 +1,882 @@
|
|||
// Generated by scripts/build-glossaries.ts from ../translation/deepl-glossaries/*.tsv
|
||||
// Do not edit by hand — update the TSVs in the translation repo and re-run:
|
||||
// bun scripts/build-glossaries.ts
|
||||
|
||||
/** English → target-language term pairs, keyed by language code. */
|
||||
export const GLOSSARIES: Record<string, ReadonlyArray<readonly [string, string]>> = {
|
||||
ar: [
|
||||
["people group", "جماعة عرقية"],
|
||||
["people groups", "جماعات عرقية"],
|
||||
["unreached people group", "جماعة عرقية غير مُبلَّغة"],
|
||||
["unreached people groups", "جماعات عرقية غير مُبلَّغة"],
|
||||
["unengaged people group", "جماعة عرقية غير مُنخرَطة"],
|
||||
["unengaged people groups", "جماعات عرقية غير مُنخرَطة"],
|
||||
["unengaged unreached people group", "جماعة عرقية غير مُبلَّغة وغير مُنخرَطة"],
|
||||
["under-engaged", "ناقصة الانخراط"],
|
||||
["frontier people group", "جماعة عرقية حدودية"],
|
||||
["frontier people groups", "جماعات عرقية حدودية"],
|
||||
["ethnolinguistic group", "جماعة إثنولغوية"],
|
||||
["unreached", "غير مُبلَّغ"],
|
||||
["unengaged", "غير مُنخرَط"],
|
||||
["engaged", "مُنخرَط"],
|
||||
["engagement", "انخراط"],
|
||||
["fruitful engagement", "انخراط مثمر"],
|
||||
["gospel access", "الوصول إلى الإنجيل"],
|
||||
["gospel engagement", "الانخراط بالإنجيل"],
|
||||
["the gospel", "الإنجيل"],
|
||||
["church-planting movement", "حركة تأسيس كنائس"],
|
||||
["church planting", "تأسيس كنائس"],
|
||||
["indigenous church", "كنيسة محلّية"],
|
||||
["indigenous churches", "كنائس محلّية"],
|
||||
["self-propagating", "ذاتي الانتشار"],
|
||||
["self-governing", "ذاتي الحكم"],
|
||||
["self-supporting", "ذاتي التمويل"],
|
||||
["self-theologizing", "ذاتي اللاهوت"],
|
||||
["self-missionizing", "ذاتي الإرسال"],
|
||||
["residential engagement", "انخراط إقامي"],
|
||||
["workers in residence", "عاملون مقيمون"],
|
||||
["cross-cultural workers", "عاملون عابرون للثقافات"],
|
||||
["apostolic effort", "جهد رسولي"],
|
||||
["pioneer work", "عمل ريادي"],
|
||||
["heart language", "اللغة الأم"],
|
||||
["movement to Jesus", "حركة نحو يسوع"],
|
||||
["nonresident partners", "شركاء غير مقيمين"],
|
||||
["broader engagement", "انخراط أوسع"],
|
||||
["diaspora", "شتات"],
|
||||
["daily prayer", "صلاة يومية"],
|
||||
["24-hour prayer coverage", "تغطية صلاة على مدار 24 ساعة"],
|
||||
["intercessor", "شفيع"],
|
||||
["intercessors", "شفعاء"],
|
||||
["intercessory prayer", "صلاة شفاعية"],
|
||||
["prayer points", "نقاط صلاة"],
|
||||
["daily prayer guide", "دليل الصلاة اليومي"],
|
||||
["prayer prepares the way", "الصلاة تُمهِّد الطريق"],
|
||||
["Lord of the harvest", "ربّ الحصاد"],
|
||||
["adopt", "تبنّي"],
|
||||
["pray, give, and send", "صلّوا وقدِّموا وأرسِلوا"],
|
||||
["mobilize", "تعبئة"],
|
||||
["mobilization", "تعبئة"],
|
||||
["sending agency", "وكالة إرسال"],
|
||||
["sending agencies", "وكالات إرسال"],
|
||||
["our gift to Jesus", "تقدمتنا ليسوع"],
|
||||
["DOXA partnership missionaries", "مُرسَلون شركاء في DOXA"],
|
||||
["every tribe, tongue, people, and nation", "كلّ قبيلة ولسان وشعب وأمّة"],
|
||||
["make disciples of all the nations", "تلمِذوا جميع الأمم"],
|
||||
["disciple", "تلميذ"],
|
||||
["disciples", "تلاميذ"],
|
||||
["baptized disciples", "تلاميذ مُعمَّدون"],
|
||||
["culturally appropriate", "ملائم ثقافيًّا"],
|
||||
["self-sustaining churches", "كنائس مكتفية ذاتيًّا"],
|
||||
["spiritual generations", "أجيال روحية"],
|
||||
["abide in Jesus", "اثبُتوا في يسوع"],
|
||||
["pray without ceasing", "صلّوا بلا انقطاع"],
|
||||
["deny yourself", "أنكِروا أنفسكم"],
|
||||
["engage the regions beyond", "ابلُغوا المناطق الأبعد"],
|
||||
["Pentecostal", "خمسيني"],
|
||||
["Statement of Faith", "بيان الإيمان"],
|
||||
["new birth", "الولادة الجديدة"],
|
||||
["regeneration", "التجديد"],
|
||||
["justification", "التبرير"],
|
||||
["atonement", "الكفّارة"],
|
||||
["sanctification", "التقديس"],
|
||||
["baptism in water", "المعمودية بالماء"],
|
||||
["Lord's Supper", "العشاء الربّاني"],
|
||||
["baptism in the Holy Spirit", "المعمودية بالروح القدس"],
|
||||
["speaking in tongues", "التكلّم بألسنة"],
|
||||
["gifts of the Holy Spirit", "مواهب الروح القدس"],
|
||||
["pre-millennial return", "الرجوع قبل الألفية"],
|
||||
["bodily resurrection", "القيامة الجسدية"],
|
||||
["pray", "صلّوا"],
|
||||
["engage", "انخرِطوا"],
|
||||
["choose a people group", "اختاروا جماعة عرقية"],
|
||||
["sign up", "التسجيل"]
|
||||
],
|
||||
de: [
|
||||
["people group", "Volksgruppe"],
|
||||
["people groups", "Volksgruppen"],
|
||||
["unreached people group", "unerreichte Volksgruppe"],
|
||||
["unreached people groups", "unerreichte Volksgruppen"],
|
||||
["unengaged people group", "nicht-engagierte Volksgruppe"],
|
||||
["unengaged people groups", "nicht-engagierte Volksgruppen"],
|
||||
["unengaged unreached people group", "unerreichte und nicht-engagierte Volksgruppe"],
|
||||
["under-engaged", "unterengagiert"],
|
||||
["frontier people group", "Frontier-Volksgruppe"],
|
||||
["frontier people groups", "Frontier-Volksgruppen"],
|
||||
["ethnolinguistic group", "ethnolinguistische Gruppe"],
|
||||
["unreached", "unerreicht"],
|
||||
["unengaged", "nicht-engagiert"],
|
||||
["engaged", "engagiert"],
|
||||
["engagement", "Engagement"],
|
||||
["fruitful engagement", "fruchtbares Engagement"],
|
||||
["gospel access", "Zugang zum Evangelium"],
|
||||
["gospel engagement", "Engagement für das Evangelium"],
|
||||
["the gospel", "das Evangelium"],
|
||||
["church-planting movement", "Gemeindegründungsbewegung"],
|
||||
["church planting", "Gemeindegründung"],
|
||||
["indigenous church", "einheimische Gemeinde"],
|
||||
["indigenous churches", "einheimische Gemeinden"],
|
||||
["self-propagating", "sich selbst ausbreitend"],
|
||||
["self-governing", "selbstverwaltend"],
|
||||
["self-supporting", "sich selbst finanzierend"],
|
||||
["self-theologizing", "selbsttheologisierend"],
|
||||
["self-missionizing", "selbstmissionierend"],
|
||||
["residential engagement", "Engagement vor Ort"],
|
||||
["workers in residence", "Mitarbeiter vor Ort"],
|
||||
["cross-cultural workers", "interkulturelle Mitarbeiter"],
|
||||
["apostolic effort", "apostolischer Einsatz"],
|
||||
["pioneer work", "Pionierarbeit"],
|
||||
["heart language", "Muttersprache"],
|
||||
["movement to Jesus", "Bewegung zu Jesus"],
|
||||
["nonresident partners", "nicht-residente Partner"],
|
||||
["broader engagement", "erweitertes Engagement"],
|
||||
["diaspora", "Diaspora"],
|
||||
["daily prayer", "tägliches Gebet"],
|
||||
["24-hour prayer coverage", "24-Stunden-Gebetsabdeckung"],
|
||||
["intercessor", "Fürbitter"],
|
||||
["intercessors", "Fürbitter"],
|
||||
["intercessory prayer", "Fürbitte"],
|
||||
["prayer points", "Gebetsanliegen"],
|
||||
["daily prayer guide", "täglicher Gebetsleitfaden"],
|
||||
["prayer prepares the way", "Gebet bereitet den Weg"],
|
||||
["Lord of the harvest", "Herr der Ernte"],
|
||||
["adopt", "adoptieren"],
|
||||
["pray, give, and send", "beten, geben und senden"],
|
||||
["mobilize", "mobilisieren"],
|
||||
["mobilization", "Mobilisierung"],
|
||||
["sending agency", "Entsendeorganisation"],
|
||||
["sending agencies", "Entsendeorganisationen"],
|
||||
["our gift to Jesus", "unsere Gabe an Jesus"],
|
||||
["DOXA partnership missionaries", "DOXA-Partnerschaftsmissionare"],
|
||||
["every tribe, tongue, people, and nation", "jeder Stamm, jede Sprache, jedes Volk und jede Nation"],
|
||||
["make disciples of all the nations", "macht zu Jüngern alle Völker"],
|
||||
["disciple", "Jünger"],
|
||||
["disciples", "Jünger"],
|
||||
["baptized disciples", "getaufte Jünger"],
|
||||
["culturally appropriate", "kulturell angemessen"],
|
||||
["self-sustaining churches", "eigenständige Gemeinden"],
|
||||
["spiritual generations", "geistliche Generationen"],
|
||||
["abide in Jesus", "bleibt in Jesus"],
|
||||
["pray without ceasing", "betet ohne Unterlass"],
|
||||
["deny yourself", "verleugnet euch selbst"],
|
||||
["engage the regions beyond", "erreicht die Gebiete darüber hinaus"],
|
||||
["Pentecostal", "pfingstlich"],
|
||||
["Statement of Faith", "Glaubensbekenntnis"],
|
||||
["new birth", "Wiedergeburt"],
|
||||
["regeneration", "Erneuerung"],
|
||||
["justification", "Rechtfertigung"],
|
||||
["atonement", "Sühne"],
|
||||
["sanctification", "Heiligung"],
|
||||
["baptism in water", "Wassertaufe"],
|
||||
["Lord's Supper", "Abendmahl"],
|
||||
["baptism in the Holy Spirit", "Taufe im Heiligen Geist"],
|
||||
["speaking in tongues", "Zungenreden"],
|
||||
["gifts of the Holy Spirit", "Gaben des Heiligen Geistes"],
|
||||
["pre-millennial return", "prämillenniale Wiederkunft"],
|
||||
["bodily resurrection", "leibliche Auferstehung"],
|
||||
["pray", "beten"],
|
||||
["engage", "engagieren"],
|
||||
["choose a people group", "eine Volksgruppe wählen"],
|
||||
["sign up", "anmelden"]
|
||||
],
|
||||
es: [
|
||||
["people group", "grupo étnico"],
|
||||
["people groups", "grupos étnicos"],
|
||||
["unreached people group", "grupo étnico no alcanzado"],
|
||||
["unreached people groups", "grupos étnicos no alcanzados"],
|
||||
["unengaged people group", "grupo étnico no comprometido"],
|
||||
["unengaged people groups", "grupos étnicos no comprometidos"],
|
||||
["unengaged unreached people group", "grupo étnico no alcanzado y no comprometido"],
|
||||
["under-engaged", "subcomprometido"],
|
||||
["frontier people group", "grupo étnico frontera"],
|
||||
["frontier people groups", "grupos étnicos frontera"],
|
||||
["ethnolinguistic group", "grupo etnolingüístico"],
|
||||
["unreached", "no alcanzado"],
|
||||
["unengaged", "no comprometido"],
|
||||
["engaged", "comprometido"],
|
||||
["engagement", "compromiso"],
|
||||
["fruitful engagement", "compromiso fructífero"],
|
||||
["gospel access", "acceso al Evangelio"],
|
||||
["gospel engagement", "compromiso con el Evangelio"],
|
||||
["the gospel", "el Evangelio"],
|
||||
["church-planting movement", "movimiento de plantación de iglesias"],
|
||||
["church planting", "plantación de iglesias"],
|
||||
["indigenous church", "iglesia autóctona"],
|
||||
["indigenous churches", "iglesias autóctonas"],
|
||||
["self-propagating", "autopropagador"],
|
||||
["self-governing", "autogobernado"],
|
||||
["self-supporting", "autofinanciado"],
|
||||
["self-theologizing", "autoteologizante"],
|
||||
["self-missionizing", "automisionero"],
|
||||
["residential engagement", "compromiso residencial"],
|
||||
["workers in residence", "obreros residentes"],
|
||||
["cross-cultural workers", "obreros transculturales"],
|
||||
["apostolic effort", "esfuerzo apostólico"],
|
||||
["pioneer work", "trabajo pionero"],
|
||||
["heart language", "lengua materna"],
|
||||
["movement to Jesus", "movimiento hacia Jesús"],
|
||||
["nonresident partners", "socios no residentes"],
|
||||
["broader engagement", "compromiso ampliado"],
|
||||
["diaspora", "diáspora"],
|
||||
["daily prayer", "oración diaria"],
|
||||
["24-hour prayer coverage", "cobertura de oración de 24 horas"],
|
||||
["intercessor", "intercesor"],
|
||||
["intercessors", "intercesores"],
|
||||
["intercessory prayer", "oración de intercesión"],
|
||||
["prayer points", "motivos de oración"],
|
||||
["daily prayer guide", "guía de oración diaria"],
|
||||
["prayer prepares the way", "la oración prepara el camino"],
|
||||
["Lord of the harvest", "Señor de la cosecha"],
|
||||
["adopt", "adoptar"],
|
||||
["pray, give, and send", "orar, dar y enviar"],
|
||||
["mobilize", "movilizar"],
|
||||
["mobilization", "movilización"],
|
||||
["sending agency", "agencia de envío"],
|
||||
["sending agencies", "agencias de envío"],
|
||||
["our gift to Jesus", "nuestra ofrenda a Jesús"],
|
||||
["DOXA partnership missionaries", "misioneros asociados de DOXA"],
|
||||
["every tribe, tongue, people, and nation", "toda tribu, lengua, pueblo y nación"],
|
||||
["make disciples of all the nations", "hagan discípulos de todas las naciones"],
|
||||
["disciple", "discípulo"],
|
||||
["disciples", "discípulos"],
|
||||
["baptized disciples", "discípulos bautizados"],
|
||||
["culturally appropriate", "culturalmente apropiado"],
|
||||
["self-sustaining churches", "iglesias autosostenibles"],
|
||||
["spiritual generations", "generaciones espirituales"],
|
||||
["abide in Jesus", "permanezcan en Jesús"],
|
||||
["pray without ceasing", "oren sin cesar"],
|
||||
["deny yourself", "niéguense a sí mismos"],
|
||||
["engage the regions beyond", "alcanzar las regiones de más allá"],
|
||||
["Pentecostal", "pentecostal"],
|
||||
["Statement of Faith", "Declaración de fe"],
|
||||
["new birth", "nuevo nacimiento"],
|
||||
["regeneration", "regeneración"],
|
||||
["justification", "justificación"],
|
||||
["atonement", "expiación"],
|
||||
["sanctification", "santificación"],
|
||||
["baptism in water", "bautismo en agua"],
|
||||
["Lord's Supper", "Santa Cena"],
|
||||
["baptism in the Holy Spirit", "bautismo en el Espíritu Santo"],
|
||||
["speaking in tongues", "hablar en lenguas"],
|
||||
["gifts of the Holy Spirit", "dones del Espíritu Santo"],
|
||||
["pre-millennial return", "retorno premilenial"],
|
||||
["bodily resurrection", "resurrección corporal"],
|
||||
["pray", "orar"],
|
||||
["engage", "comprometer"],
|
||||
["UUPG", "PNANC"],
|
||||
["World Assemblies of God Fellowship", "Fraternidad Mundial de las Asambleas de Dios"],
|
||||
["choose a people group", "elegir un grupo étnico"],
|
||||
["sign up", "registrarse"]
|
||||
],
|
||||
fr: [
|
||||
["people group", "peuple"],
|
||||
["people groups", "peuples"],
|
||||
["unreached people group", "peuple non atteint"],
|
||||
["unreached people groups", "peuples non atteints"],
|
||||
["unengaged people group", "peuple non engagé"],
|
||||
["unengaged people groups", "peuples non engagés"],
|
||||
["unengaged unreached people group", "peuple non atteint et non engagé"],
|
||||
["under-engaged", "sous-engagé"],
|
||||
["frontier people group", "peuple frontière"],
|
||||
["frontier people groups", "peuples frontières"],
|
||||
["frontier peoples", "peuples frontières"],
|
||||
["ethnolinguistic group", "groupe ethnolinguistique"],
|
||||
["unreached", "non atteint"],
|
||||
["unengaged", "non engagé"],
|
||||
["engaged", "engagé"],
|
||||
["engagement", "engagement"],
|
||||
["fruitful engagement", "engagement fructueux"],
|
||||
["gospel access", "accès à l'Évangile"],
|
||||
["gospel engagement", "engagement pour l'Évangile"],
|
||||
["the gospel", "l'Évangile"],
|
||||
["church-planting movement", "mouvement d'implantation d'églises"],
|
||||
["church planting", "implantation d'églises"],
|
||||
["indigenous church", "église autochtone"],
|
||||
["indigenous churches", "églises autochtones"],
|
||||
["self-propagating", "auto-propagateur"],
|
||||
["self-governing", "auto-gouverné"],
|
||||
["self-supporting", "auto-financé"],
|
||||
["self-theologizing", "auto-théologisant"],
|
||||
["self-missionizing", "auto-missionnaire"],
|
||||
["residential engagement", "engagement sur le terrain"],
|
||||
["workers in residence", "ouvriers résidents"],
|
||||
["cross-cultural workers", "ouvriers transculturels"],
|
||||
["apostolic effort", "effort pionnier"],
|
||||
["pioneer work", "travail de pionnier"],
|
||||
["heart language", "langue du cœur"],
|
||||
["movement to Jesus", "mouvement vers Jésus"],
|
||||
["nonresident partners", "partenaires extérieurs"],
|
||||
["broader engagement", "engagement élargi"],
|
||||
["diaspora", "diaspora"],
|
||||
["daily prayer", "prière quotidienne"],
|
||||
["24-hour prayer coverage", "couverture de prière 24h/24"],
|
||||
["intercessor", "intercesseur"],
|
||||
["intercessors", "intercesseurs"],
|
||||
["intercessory prayer", "prière d'intercession"],
|
||||
["prayer points", "sujets de prière"],
|
||||
["daily prayer guide", "guide de prière quotidien"],
|
||||
["prayer prepares the way", "la prière prépare le chemin"],
|
||||
["Lord of the harvest", "Maître de la moisson"],
|
||||
["adopt", "adopter"],
|
||||
["pray, give, and send", "prier, donner et envoyer"],
|
||||
["mobilize", "mobiliser"],
|
||||
["mobilization", "mobilisation"],
|
||||
["sending agency", "agence missionnaire"],
|
||||
["sending agencies", "agences missionnaires"],
|
||||
["our gift to Jesus", "notre offrande à Jésus"],
|
||||
["DOXA partnership missionaries", "missionnaires en partenariat DOXA"],
|
||||
["every tribe, tongue, people, and nation", "toute tribu, toute langue, tout peuple et toute nation"],
|
||||
["make disciples of all the nations", "faites de toutes les nations des disciples"],
|
||||
["disciple", "disciple"],
|
||||
["disciples", "disciples"],
|
||||
["baptized disciples", "disciples baptisés"],
|
||||
["culturally appropriate", "culturellement approprié"],
|
||||
["self-sustaining churches", "églises autonomes"],
|
||||
["spiritual generations", "générations spirituelles"],
|
||||
["abide in Jesus", "demeurez en Jésus"],
|
||||
["pray without ceasing", "priez sans cesse"],
|
||||
["deny yourself", "renoncez à vous-même"],
|
||||
["engage the regions beyond", "portez l'Évangile au-delà"],
|
||||
["Pentecostal", "pentecôtiste"],
|
||||
["Statement of Faith", "Déclaration de foi"],
|
||||
["new birth", "nouvelle naissance"],
|
||||
["regeneration", "régénération"],
|
||||
["justification", "justification"],
|
||||
["atonement", "expiation"],
|
||||
["sanctification", "sanctification"],
|
||||
["baptism in water", "baptême d'eau"],
|
||||
["Lord's Supper", "Sainte Cène"],
|
||||
["baptism in the Holy Spirit", "baptême du Saint-Esprit"],
|
||||
["speaking in tongues", "parler en langues"],
|
||||
["gifts of the Holy Spirit", "dons du Saint-Esprit"],
|
||||
["pre-millennial return", "retour prémillénaire"],
|
||||
["bodily resurrection", "résurrection corporelle"],
|
||||
["pray", "prier"],
|
||||
["engage", "engager"],
|
||||
["World Assemblies of God Fellowship", "Association mondiale des Assemblées de Dieu"],
|
||||
["choose a people group", "choisir un peuple"],
|
||||
["sign up", "s'inscrire"]
|
||||
],
|
||||
hi: [
|
||||
["people group", "जातीय समूह"],
|
||||
["people groups", "जातीय समूह"],
|
||||
["unreached people group", "अगम्य जातीय समूह"],
|
||||
["unreached people groups", "अगम्य जातीय समूह"],
|
||||
["unengaged people group", "अप्रवेशित जातीय समूह"],
|
||||
["unengaged people groups", "अप्रवेशित जातीय समूह"],
|
||||
["unengaged unreached people group", "अगम्य और अप्रवेशित जातीय समूह"],
|
||||
["under-engaged", "अल्प-प्रवेशित"],
|
||||
["frontier people group", "सीमांत जातीय समूह"],
|
||||
["frontier people groups", "सीमांत जातीय समूह"],
|
||||
["ethnolinguistic group", "भाषाई-जातीय समूह"],
|
||||
["unreached", "अगम्य"],
|
||||
["unengaged", "अप्रवेशित"],
|
||||
["engaged", "प्रवेशित"],
|
||||
["engagement", "प्रवेश"],
|
||||
["fruitful engagement", "फलदायक प्रवेश"],
|
||||
["gospel access", "सुसमाचार तक पहुँच"],
|
||||
["gospel engagement", "सुसमाचार प्रवेश"],
|
||||
["the gospel", "सुसमाचार"],
|
||||
["church-planting movement", "कलीसिया रोपण आंदोलन"],
|
||||
["church planting", "कलीसिया रोपण"],
|
||||
["indigenous church", "देशज कलीसिया"],
|
||||
["indigenous churches", "देशज कलीसियाएँ"],
|
||||
["self-propagating", "स्व-प्रचारक"],
|
||||
["self-governing", "स्व-शासित"],
|
||||
["self-supporting", "स्व-पोषित"],
|
||||
["self-theologizing", "स्व-धर्मशास्त्रीय"],
|
||||
["self-missionizing", "स्व-मिशनकारी"],
|
||||
["residential engagement", "आवासीय प्रवेश"],
|
||||
["workers in residence", "निवासी कार्यकर्ता"],
|
||||
["cross-cultural workers", "अंतर-सांस्कृतिक कार्यकर्ता"],
|
||||
["apostolic effort", "प्रेरितिक प्रयास"],
|
||||
["pioneer work", "अग्रणी कार्य"],
|
||||
["heart language", "मातृभाषा"],
|
||||
["movement to Jesus", "यीशु की ओर आंदोलन"],
|
||||
["nonresident partners", "अनिवासी सहभागी"],
|
||||
["broader engagement", "व्यापक प्रवेश"],
|
||||
["diaspora", "प्रवासी समुदाय"],
|
||||
["daily prayer", "दैनिक प्रार्थना"],
|
||||
["24-hour prayer coverage", "24 घंटे की प्रार्थना कवरेज"],
|
||||
["intercessor", "मध्यस्थ प्रार्थनाकर्ता"],
|
||||
["intercessors", "मध्यस्थ प्रार्थनाकर्ता"],
|
||||
["intercessory prayer", "मध्यस्थ प्रार्थना"],
|
||||
["prayer points", "प्रार्थना विषय"],
|
||||
["daily prayer guide", "दैनिक प्रार्थना मार्गदर्शिका"],
|
||||
["prayer prepares the way", "प्रार्थना मार्ग तैयार करती है"],
|
||||
["Lord of the harvest", "कटनी का स्वामी"],
|
||||
["adopt", "अपनाना"],
|
||||
["pray, give, and send", "प्रार्थना करो, दो और भेजो"],
|
||||
["mobilize", "गतिशील करना"],
|
||||
["mobilization", "गतिशीलता"],
|
||||
["sending agency", "भेजने वाली संस्था"],
|
||||
["sending agencies", "भेजने वाली संस्थाएँ"],
|
||||
["our gift to Jesus", "यीशु को हमारी भेंट"],
|
||||
["DOXA partnership missionaries", "DOXA साझेदारी के मिशनरी"],
|
||||
["every tribe, tongue, people, and nation", "हर एक कुल, भाषा, लोग और जाति"],
|
||||
["make disciples of all the nations", "सब जातियों को चेला बनाओ"],
|
||||
["disciple", "चेला"],
|
||||
["disciples", "चेले"],
|
||||
["baptized disciples", "बपतिस्मा प्राप्त चेले"],
|
||||
["culturally appropriate", "सांस्कृतिक रूप से उपयुक्त"],
|
||||
["self-sustaining churches", "स्वावलंबी कलीसियाएँ"],
|
||||
["spiritual generations", "आत्मिक पीढ़ियाँ"],
|
||||
["abide in Jesus", "यीशु में बने रहो"],
|
||||
["pray without ceasing", "निरन्तर प्रार्थना करो"],
|
||||
["deny yourself", "अपने आप का इन्कार करो"],
|
||||
["engage the regions beyond", "परे के क्षेत्रों में सुसमाचार ले जाओ"],
|
||||
["Pentecostal", "पेन्तेकोस्तल"],
|
||||
["Statement of Faith", "विश्वास का वक्तव्य"],
|
||||
["new birth", "नया जन्म"],
|
||||
["regeneration", "नवीनीकरण"],
|
||||
["justification", "धर्मीकरण"],
|
||||
["atonement", "प्रायश्चित्त"],
|
||||
["sanctification", "पवित्रीकरण"],
|
||||
["baptism in water", "जल बपतिस्मा"],
|
||||
["Lord's Supper", "प्रभु भोज"],
|
||||
["baptism in the Holy Spirit", "पवित्र आत्मा का बपतिस्मा"],
|
||||
["speaking in tongues", "अन्य भाषाओं में बोलना"],
|
||||
["gifts of the Holy Spirit", "पवित्र आत्मा के वरदान"],
|
||||
["pre-millennial return", "सहस्राब्दी-पूर्व पुनरागमन"],
|
||||
["bodily resurrection", "शारीरिक पुनरुत्थान"],
|
||||
["pray", "प्रार्थना करना"],
|
||||
["engage", "प्रवेश करना"],
|
||||
["choose a people group", "एक जातीय समूह चुनना"],
|
||||
["sign up", "पंजीकरण करना"]
|
||||
],
|
||||
it: [
|
||||
["people group", "gruppo etnico"],
|
||||
["people groups", "gruppi etnici"],
|
||||
["unreached people group", "gruppo etnico non raggiunto"],
|
||||
["unreached people groups", "gruppi etnici non raggiunti"],
|
||||
["unengaged people group", "gruppo etnico non coinvolto"],
|
||||
["unengaged people groups", "gruppi etnici non coinvolti"],
|
||||
["unengaged unreached people group", "gruppo etnico non raggiunto e non coinvolto"],
|
||||
["under-engaged", "sottocoinvolto"],
|
||||
["frontier people group", "gruppo etnico di frontiera"],
|
||||
["frontier people groups", "gruppi etnici di frontiera"],
|
||||
["ethnolinguistic group", "gruppo etnolinguistico"],
|
||||
["unreached", "non raggiunto"],
|
||||
["unengaged", "non coinvolto"],
|
||||
["engaged", "coinvolto"],
|
||||
["engagement", "coinvolgimento"],
|
||||
["fruitful engagement", "coinvolgimento fruttuoso"],
|
||||
["gospel access", "accesso al Vangelo"],
|
||||
["gospel engagement", "coinvolgimento per il Vangelo"],
|
||||
["the gospel", "il Vangelo"],
|
||||
["church-planting movement", "movimento di fondazione di chiese"],
|
||||
["church planting", "fondazione di chiese"],
|
||||
["indigenous church", "chiesa autoctona"],
|
||||
["indigenous churches", "chiese autoctone"],
|
||||
["self-propagating", "autopropagatrice"],
|
||||
["self-governing", "autogovernata"],
|
||||
["self-supporting", "autofinanziata"],
|
||||
["self-theologizing", "autoteologizzante"],
|
||||
["self-missionizing", "automissionaria"],
|
||||
["residential engagement", "coinvolgimento residenziale"],
|
||||
["workers in residence", "operatori residenti"],
|
||||
["cross-cultural workers", "operatori interculturali"],
|
||||
["apostolic effort", "impegno apostolico"],
|
||||
["pioneer work", "lavoro pionieristico"],
|
||||
["heart language", "lingua madre"],
|
||||
["movement to Jesus", "movimento verso Gesù"],
|
||||
["nonresident partners", "partner non residenti"],
|
||||
["broader engagement", "coinvolgimento più ampio"],
|
||||
["diaspora", "diaspora"],
|
||||
["daily prayer", "preghiera quotidiana"],
|
||||
["24-hour prayer coverage", "copertura di preghiera di 24 ore"],
|
||||
["intercessor", "intercessore"],
|
||||
["intercessors", "intercessori"],
|
||||
["intercessory prayer", "preghiera di intercessione"],
|
||||
["prayer points", "spunti di preghiera"],
|
||||
["daily prayer guide", "guida di preghiera quotidiana"],
|
||||
["prayer prepares the way", "la preghiera prepara la via"],
|
||||
["Lord of the harvest", "Signore della messe"],
|
||||
["adopt", "adottare"],
|
||||
["pray, give, and send", "pregare, dare e inviare"],
|
||||
["mobilize", "mobilitare"],
|
||||
["mobilization", "mobilitazione"],
|
||||
["sending agency", "agenzia di invio"],
|
||||
["sending agencies", "agenzie di invio"],
|
||||
["our gift to Jesus", "la nostra offerta a Gesù"],
|
||||
["DOXA partnership missionaries", "missionari partner di DOXA"],
|
||||
["every tribe, tongue, people, and nation", "ogni tribù, lingua, popolo e nazione"],
|
||||
["make disciples of all the nations", "fate discepoli di tutti i popoli"],
|
||||
["disciple", "discepolo"],
|
||||
["disciples", "discepoli"],
|
||||
["baptized disciples", "discepoli battezzati"],
|
||||
["culturally appropriate", "culturalmente adeguato"],
|
||||
["self-sustaining churches", "chiese autonome"],
|
||||
["spiritual generations", "generazioni spirituali"],
|
||||
["abide in Jesus", "dimorate in Gesù"],
|
||||
["pray without ceasing", "pregate senza cessare"],
|
||||
["deny yourself", "rinnegate voi stessi"],
|
||||
["engage the regions beyond", "portate il Vangelo oltre"],
|
||||
["Pentecostal", "pentecostale"],
|
||||
["Statement of Faith", "Dichiarazione di fede"],
|
||||
["new birth", "nuova nascita"],
|
||||
["regeneration", "rigenerazione"],
|
||||
["justification", "giustificazione"],
|
||||
["atonement", "espiazione"],
|
||||
["sanctification", "santificazione"],
|
||||
["baptism in water", "battesimo in acqua"],
|
||||
["Lord's Supper", "Santa Cena"],
|
||||
["baptism in the Holy Spirit", "battesimo nello Spirito Santo"],
|
||||
["speaking in tongues", "parlare in lingue"],
|
||||
["gifts of the Holy Spirit", "doni dello Spirito Santo"],
|
||||
["pre-millennial return", "ritorno premillenario"],
|
||||
["bodily resurrection", "risurrezione corporea"],
|
||||
["pray", "pregare"],
|
||||
["engage", "coinvolgere"],
|
||||
["choose a people group", "scegliere un gruppo etnico"],
|
||||
["sign up", "iscriversi"]
|
||||
],
|
||||
pt: [
|
||||
["people group", "grupo étnico"],
|
||||
["people groups", "grupos étnicos"],
|
||||
["unreached people group", "grupo étnico não alcançado"],
|
||||
["unreached people groups", "grupos étnicos não alcançados"],
|
||||
["unengaged people group", "grupo étnico não engajado"],
|
||||
["unengaged people groups", "grupos étnicos não engajados"],
|
||||
["unengaged unreached people group", "grupo étnico não alcançado e não engajado"],
|
||||
["under-engaged", "subengajado"],
|
||||
["frontier people group", "grupo étnico de fronteira"],
|
||||
["frontier people groups", "grupos étnicos de fronteira"],
|
||||
["ethnolinguistic group", "grupo etnolinguístico"],
|
||||
["unreached", "não alcançado"],
|
||||
["unengaged", "não engajado"],
|
||||
["engaged", "engajado"],
|
||||
["engagement", "engajamento"],
|
||||
["fruitful engagement", "engajamento frutífero"],
|
||||
["gospel access", "acesso ao Evangelho"],
|
||||
["gospel engagement", "engajamento com o Evangelho"],
|
||||
["the gospel", "o Evangelho"],
|
||||
["church-planting movement", "movimento de plantação de igrejas"],
|
||||
["church planting", "plantação de igrejas"],
|
||||
["indigenous church", "igreja autóctona"],
|
||||
["indigenous churches", "igrejas autóctones"],
|
||||
["self-propagating", "autopropagador"],
|
||||
["self-governing", "autogovernado"],
|
||||
["self-supporting", "autofinanciado"],
|
||||
["self-theologizing", "autoteologizante"],
|
||||
["self-missionizing", "automissionário"],
|
||||
["residential engagement", "engajamento residencial"],
|
||||
["workers in residence", "obreiros residentes"],
|
||||
["cross-cultural workers", "obreiros transculturais"],
|
||||
["apostolic effort", "esforço apostólico"],
|
||||
["pioneer work", "trabalho pioneiro"],
|
||||
["heart language", "língua materna"],
|
||||
["movement to Jesus", "movimento em direção a Jesus"],
|
||||
["nonresident partners", "parceiros não residentes"],
|
||||
["broader engagement", "engajamento ampliado"],
|
||||
["diaspora", "diáspora"],
|
||||
["daily prayer", "oração diária"],
|
||||
["24-hour prayer coverage", "cobertura de oração de 24 horas"],
|
||||
["intercessor", "intercessor"],
|
||||
["intercessors", "intercessores"],
|
||||
["intercessory prayer", "oração intercessória"],
|
||||
["prayer points", "motivos de oração"],
|
||||
["daily prayer guide", "guia de oração diário"],
|
||||
["prayer prepares the way", "a oração prepara o caminho"],
|
||||
["Lord of the harvest", "Senhor da seara"],
|
||||
["adopt", "adotar"],
|
||||
["pray, give, and send", "orar, contribuir e enviar"],
|
||||
["mobilize", "mobilizar"],
|
||||
["mobilization", "mobilização"],
|
||||
["sending agency", "agência de envio"],
|
||||
["sending agencies", "agências de envio"],
|
||||
["our gift to Jesus", "nossa oferta a Jesus"],
|
||||
["DOXA partnership missionaries", "missionários parceiros da DOXA"],
|
||||
["every tribe, tongue, people, and nation", "toda tribo, língua, povo e nação"],
|
||||
["make disciples of all the nations", "façam discípulos de todas as nações"],
|
||||
["disciple", "discípulo"],
|
||||
["disciples", "discípulos"],
|
||||
["baptized disciples", "discípulos batizados"],
|
||||
["culturally appropriate", "culturalmente apropriado"],
|
||||
["self-sustaining churches", "igrejas autossustentáveis"],
|
||||
["spiritual generations", "gerações espirituais"],
|
||||
["abide in Jesus", "permaneçam em Jesus"],
|
||||
["pray without ceasing", "orem sem cessar"],
|
||||
["deny yourself", "neguem a si mesmos"],
|
||||
["engage the regions beyond", "alcancem as regiões mais além"],
|
||||
["Pentecostal", "pentecostal"],
|
||||
["Statement of Faith", "Declaração de fé"],
|
||||
["new birth", "novo nascimento"],
|
||||
["regeneration", "regeneração"],
|
||||
["justification", "justificação"],
|
||||
["atonement", "expiação"],
|
||||
["sanctification", "santificação"],
|
||||
["baptism in water", "batismo nas águas"],
|
||||
["Lord's Supper", "Santa Ceia"],
|
||||
["baptism in the Holy Spirit", "batismo no Espírito Santo"],
|
||||
["speaking in tongues", "falar em línguas"],
|
||||
["gifts of the Holy Spirit", "dons do Espírito Santo"],
|
||||
["pre-millennial return", "retorno pré-milenial"],
|
||||
["bodily resurrection", "ressurreição corporal"],
|
||||
["pray", "orar"],
|
||||
["engage", "engajar"],
|
||||
["World Assemblies of God Fellowship", "Comunhão das Assembléias de Deus Mundial"],
|
||||
["choose a people group", "escolher um grupo étnico"],
|
||||
["sign up", "inscrever-se"]
|
||||
],
|
||||
ro: [
|
||||
["people group", "grup etnic"],
|
||||
["people groups", "grupuri etnice"],
|
||||
["unreached people group", "grup etnic neatins"],
|
||||
["unreached people groups", "grupuri etnice neatinse"],
|
||||
["unengaged people group", "grup etnic neangajat"],
|
||||
["unengaged people groups", "grupuri etnice neangajate"],
|
||||
["unengaged unreached people group", "grup etnic neatins și neangajat"],
|
||||
["under-engaged", "insuficient angajat"],
|
||||
["frontier people group", "grup etnic de frontieră"],
|
||||
["frontier people groups", "grupuri etnice de frontieră"],
|
||||
["ethnolinguistic group", "grup etnolingvistic"],
|
||||
["unreached", "neatins"],
|
||||
["unengaged", "neangajat"],
|
||||
["engaged", "angajat"],
|
||||
["engagement", "angajare"],
|
||||
["fruitful engagement", "angajare rodnică"],
|
||||
["gospel access", "acces la Evanghelie"],
|
||||
["gospel engagement", "angajare pentru Evanghelie"],
|
||||
["the gospel", "Evanghelia"],
|
||||
["church-planting movement", "mișcare de plantare de biserici"],
|
||||
["church planting", "plantare de biserici"],
|
||||
["indigenous church", "biserică autohtonă"],
|
||||
["indigenous churches", "biserici autohtone"],
|
||||
["self-propagating", "autopropagator"],
|
||||
["self-governing", "autoguvernat"],
|
||||
["self-supporting", "autofinanțat"],
|
||||
["self-theologizing", "autoteologizant"],
|
||||
["self-missionizing", "automisionar"],
|
||||
["residential engagement", "angajare rezidențială"],
|
||||
["workers in residence", "lucrători rezidenți"],
|
||||
["cross-cultural workers", "lucrători interculturali"],
|
||||
["apostolic effort", "efort apostolic"],
|
||||
["pioneer work", "lucrare pionieră"],
|
||||
["heart language", "limba maternă"],
|
||||
["movement to Jesus", "mișcare spre Isus"],
|
||||
["nonresident partners", "parteneri nerezidenți"],
|
||||
["broader engagement", "angajare extinsă"],
|
||||
["diaspora", "diaspora"],
|
||||
["daily prayer", "rugăciune zilnică"],
|
||||
["24-hour prayer coverage", "acoperire de rugăciune de 24 de ore"],
|
||||
["intercessor", "mijlocitor"],
|
||||
["intercessors", "mijlocitori"],
|
||||
["intercessory prayer", "rugăciune de mijlocire"],
|
||||
["prayer points", "subiecte de rugăciune"],
|
||||
["daily prayer guide", "ghid zilnic de rugăciune"],
|
||||
["prayer prepares the way", "rugăciunea pregătește calea"],
|
||||
["Lord of the harvest", "Stăpânul secerișului"],
|
||||
["adopt", "a adopta"],
|
||||
["pray, give, and send", "rugați-vă, dăruiți și trimiteți"],
|
||||
["mobilize", "a mobiliza"],
|
||||
["mobilization", "mobilizare"],
|
||||
["sending agency", "agenție de trimitere"],
|
||||
["sending agencies", "agenții de trimitere"],
|
||||
["our gift to Jesus", "darul nostru pentru Isus"],
|
||||
["DOXA partnership missionaries", "misionari parteneri DOXA"],
|
||||
["every tribe, tongue, people, and nation", "orice seminție, orice limbă, orice popor și orice națiune"],
|
||||
["make disciples of all the nations", "faceți ucenici din toate neamurile"],
|
||||
["disciple", "ucenic"],
|
||||
["disciples", "ucenici"],
|
||||
["baptized disciples", "ucenici botezați"],
|
||||
["culturally appropriate", "adaptat cultural"],
|
||||
["self-sustaining churches", "biserici autonome"],
|
||||
["spiritual generations", "generații spirituale"],
|
||||
["abide in Jesus", "rămâneți în Isus"],
|
||||
["pray without ceasing", "rugați-vă neîncetat"],
|
||||
["deny yourself", "lepădați-vă de voi înșivă"],
|
||||
["engage the regions beyond", "duceți Evanghelia dincolo de hotare"],
|
||||
["Pentecostal", "penticostal"],
|
||||
["Statement of Faith", "Declarație de credință"],
|
||||
["new birth", "nașterea din nou"],
|
||||
["regeneration", "regenerare"],
|
||||
["justification", "justificare"],
|
||||
["atonement", "ispășire"],
|
||||
["sanctification", "sfințire"],
|
||||
["baptism in water", "botezul în apă"],
|
||||
["Lord's Supper", "Cina Domnului"],
|
||||
["baptism in the Holy Spirit", "botezul în Duhul Sfânt"],
|
||||
["speaking in tongues", "vorbirea în limbi"],
|
||||
["gifts of the Holy Spirit", "darurile Duhului Sfânt"],
|
||||
["pre-millennial return", "revenirea premilenială"],
|
||||
["bodily resurrection", "învierea trupească"],
|
||||
["pray", "a se ruga"],
|
||||
["engage", "a angaja"],
|
||||
["choose a people group", "a alege un grup etnic"],
|
||||
["sign up", "a se înregistra"]
|
||||
],
|
||||
ru: [
|
||||
["people group", "этническая группа"],
|
||||
["people groups", "этнические группы"],
|
||||
["unreached people group", "недостигнутая этническая группа"],
|
||||
["unreached people groups", "недостигнутые этнические группы"],
|
||||
["unengaged people group", "невовлечённая этническая группа"],
|
||||
["unengaged people groups", "невовлечённые этнические группы"],
|
||||
["unengaged unreached people group", "недостигнутая и невовлечённая этническая группа"],
|
||||
["under-engaged", "недостаточно вовлечённая"],
|
||||
["frontier people group", "этническая группа на передовой"],
|
||||
["frontier people groups", "этнические группы на передовой"],
|
||||
["ethnolinguistic group", "этнолингвистическая группа"],
|
||||
["unreached", "недостигнутый"],
|
||||
["unengaged", "невовлечённый"],
|
||||
["engaged", "вовлечённый"],
|
||||
["engagement", "вовлечение"],
|
||||
["fruitful engagement", "плодотворное вовлечение"],
|
||||
["gospel access", "доступ к Евангелию"],
|
||||
["gospel engagement", "евангельское вовлечение"],
|
||||
["the gospel", "Евангелие"],
|
||||
["church-planting movement", "движение насаждения церквей"],
|
||||
["church planting", "насаждение церквей"],
|
||||
["indigenous church", "местная церковь"],
|
||||
["indigenous churches", "местные церкви"],
|
||||
["self-propagating", "самовоспроизводящийся"],
|
||||
["self-governing", "самоуправляемый"],
|
||||
["self-supporting", "самофинансируемый"],
|
||||
["self-theologizing", "самобогословствующий"],
|
||||
["self-missionizing", "самомиссионерский"],
|
||||
["residential engagement", "вовлечение на месте"],
|
||||
["workers in residence", "резидентные работники"],
|
||||
["cross-cultural workers", "межкультурные работники"],
|
||||
["apostolic effort", "апостольское служение"],
|
||||
["pioneer work", "пионерское служение"],
|
||||
["heart language", "родной язык"],
|
||||
["movement to Jesus", "движение к Иисусу"],
|
||||
["nonresident partners", "нерезидентные партнёры"],
|
||||
["broader engagement", "расширенное вовлечение"],
|
||||
["diaspora", "диаспора"],
|
||||
["daily prayer", "ежедневная молитва"],
|
||||
["24-hour prayer coverage", "круглосуточное молитвенное покрытие"],
|
||||
["intercessor", "ходатай"],
|
||||
["intercessors", "ходатаи"],
|
||||
["intercessory prayer", "ходатайственная молитва"],
|
||||
["prayer points", "молитвенные нужды"],
|
||||
["daily prayer guide", "ежедневный молитвенный путеводитель"],
|
||||
["prayer prepares the way", "молитва готовит путь"],
|
||||
["Lord of the harvest", "Господин жатвы"],
|
||||
["adopt", "взять попечение"],
|
||||
["pray, give, and send", "молитесь, жертвуйте и посылайте"],
|
||||
["mobilize", "мобилизовать"],
|
||||
["mobilization", "мобилизация"],
|
||||
["sending agency", "посылающая организация"],
|
||||
["sending agencies", "посылающие организации"],
|
||||
["our gift to Jesus", "наш дар Иисусу"],
|
||||
["DOXA partnership missionaries", "миссионеры-партнёры DOXA"],
|
||||
["every tribe, tongue, people, and nation", "всякое колено, и язык, и народ, и племя"],
|
||||
["make disciples of all the nations", "научите все народы"],
|
||||
["disciple", "ученик"],
|
||||
["disciples", "ученики"],
|
||||
["baptized disciples", "крещёные ученики"],
|
||||
["culturally appropriate", "культурно адаптированный"],
|
||||
["self-sustaining churches", "самостоятельные церкви"],
|
||||
["spiritual generations", "духовные поколения"],
|
||||
["abide in Jesus", "пребывайте в Иисусе"],
|
||||
["pray without ceasing", "непрестанно молитесь"],
|
||||
["deny yourself", "отвергните себя"],
|
||||
["engage the regions beyond", "несите Евангелие за пределы"],
|
||||
["Pentecostal", "пятидесятнический"],
|
||||
["Statement of Faith", "Исповедание веры"],
|
||||
["new birth", "рождение свыше"],
|
||||
["regeneration", "возрождение"],
|
||||
["justification", "оправдание"],
|
||||
["atonement", "искупление"],
|
||||
["sanctification", "освящение"],
|
||||
["baptism in water", "водное крещение"],
|
||||
["Lord's Supper", "Вечеря Господня"],
|
||||
["baptism in the Holy Spirit", "крещение Духом Святым"],
|
||||
["speaking in tongues", "говорение на иных языках"],
|
||||
["gifts of the Holy Spirit", "дары Святого Духа"],
|
||||
["pre-millennial return", "домилленниальное возвращение"],
|
||||
["bodily resurrection", "телесное воскресение"],
|
||||
["pray", "молиться"],
|
||||
["engage", "вовлекать"],
|
||||
["choose a people group", "выбрать этническую группу"],
|
||||
["sign up", "зарегистрироваться"]
|
||||
],
|
||||
zh: [
|
||||
["people group", "族群"],
|
||||
["people groups", "族群"],
|
||||
["unreached people group", "未得之民"],
|
||||
["unreached people groups", "未得之民"],
|
||||
["unengaged people group", "未接触族群"],
|
||||
["unengaged people groups", "未接触族群"],
|
||||
["unengaged unreached people group", "未接触未得之民"],
|
||||
["under-engaged", "接触不足的"],
|
||||
["frontier people group", "前沿族群"],
|
||||
["frontier people groups", "前沿族群"],
|
||||
["ethnolinguistic group", "民族语言群体"],
|
||||
["unreached", "未得的"],
|
||||
["unengaged", "未接触的"],
|
||||
["engaged", "已接触的"],
|
||||
["engagement", "接触"],
|
||||
["fruitful engagement", "有果效的接触"],
|
||||
["gospel access", "福音可及性"],
|
||||
["gospel engagement", "福音接触"],
|
||||
["the gospel", "福音"],
|
||||
["church-planting movement", "植堂运动"],
|
||||
["church planting", "植堂"],
|
||||
["indigenous church", "本土教会"],
|
||||
["indigenous churches", "本土教会"],
|
||||
["self-propagating", "自传的"],
|
||||
["self-governing", "自治的"],
|
||||
["self-supporting", "自养的"],
|
||||
["self-theologizing", "自我神学建构的"],
|
||||
["self-missionizing", "自我差传的"],
|
||||
["residential engagement", "驻地接触"],
|
||||
["workers in residence", "驻地工人"],
|
||||
["cross-cultural workers", "跨文化工人"],
|
||||
["apostolic effort", "使徒性开拓"],
|
||||
["pioneer work", "先锋事工"],
|
||||
["heart language", "母语"],
|
||||
["movement to Jesus", "归主运动"],
|
||||
["nonresident partners", "非驻地伙伴"],
|
||||
["broader engagement", "广义接触"],
|
||||
["diaspora", "散居群体"],
|
||||
["daily prayer", "每日祷告"],
|
||||
["24-hour prayer coverage", "全天候祷告覆盖"],
|
||||
["intercessor", "代祷者"],
|
||||
["intercessors", "代祷者"],
|
||||
["intercessory prayer", "代祷"],
|
||||
["prayer points", "祷告事项"],
|
||||
["daily prayer guide", "每日祷告指南"],
|
||||
["prayer prepares the way", "祷告预备道路"],
|
||||
["Lord of the harvest", "庄稼的主"],
|
||||
["adopt", "认领"],
|
||||
["pray, give, and send", "祷告、奉献、差派"],
|
||||
["mobilize", "动员"],
|
||||
["mobilization", "动员"],
|
||||
["sending agency", "差传机构"],
|
||||
["sending agencies", "差传机构"],
|
||||
["our gift to Jesus", "我们献给耶稣的礼物"],
|
||||
["DOXA partnership missionaries", "DOXA伙伴宣教士"],
|
||||
["every tribe, tongue, people, and nation", "各族、各方、各民、各国"],
|
||||
["make disciples of all the nations", "使万民作主的门徒"],
|
||||
["disciple", "门徒"],
|
||||
["disciples", "门徒"],
|
||||
["baptized disciples", "受洗门徒"],
|
||||
["culturally appropriate", "合乎文化的"],
|
||||
["self-sustaining churches", "自立教会"],
|
||||
["spiritual generations", "属灵世代"],
|
||||
["abide in Jesus", "常在主里面"],
|
||||
["pray without ceasing", "不住地祷告"],
|
||||
["deny yourself", "舍己"],
|
||||
["engage the regions beyond", "进入未及之地"],
|
||||
["Pentecostal", "五旬节派"],
|
||||
["Statement of Faith", "信仰宣言"],
|
||||
["new birth", "重生"],
|
||||
["regeneration", "更新"],
|
||||
["justification", "称义"],
|
||||
["atonement", "赎罪"],
|
||||
["sanctification", "成圣"],
|
||||
["baptism in water", "水的洗礼"],
|
||||
["Lord's Supper", "圣餐"],
|
||||
["baptism in the Holy Spirit", "圣灵的洗"],
|
||||
["speaking in tongues", "说方言"],
|
||||
["gifts of the Holy Spirit", "圣灵的恩赐"],
|
||||
["pre-millennial return", "千禧年前再来"],
|
||||
["bodily resurrection", "身体复活"],
|
||||
["pray", "祷告"],
|
||||
["engage", "接触"],
|
||||
["choose a people group", "选择一个族群"],
|
||||
["sign up", "注册"]
|
||||
]
|
||||
}
|
||||
|
|
@ -7,11 +7,10 @@ export interface Language {
|
|||
nativeName: string // Name in the language itself
|
||||
flag: string
|
||||
dir?: 'ltr' | 'rtl' // Text direction (defaults to 'ltr')
|
||||
deeplTarget: string // DeepL target language code
|
||||
deeplSource: string // DeepL source language code (sometimes different)
|
||||
bibleId?: string // Bolls.life translation ID for verse lookups
|
||||
bibleLabel?: string // Display label for the Bible translation (defaults to bibleId)
|
||||
glossaryId?: string // DeepL glossary ID for this language pair (EN → target)
|
||||
translationName?: string // Language name used in LLM translation prompts when the plain name is ambiguous
|
||||
translationModel?: string // OpenRouter model override for this language (falls back to the site-wide setting)
|
||||
enabled?: boolean // Whether the language is active in the UI (default: true)
|
||||
hideFromSwitcher?: boolean // If true, the language is routable (/de, /hi, …) but omitted from the public LanguageSwitcher dropdown
|
||||
latinHeadings?: boolean // Headings render in the Latin display font (Bebas Neue); set false for non-Latin scripts (default: true)
|
||||
|
|
@ -21,17 +20,17 @@ export interface Language {
|
|||
// but hidden from the UI language selector, translation targets, and admin content
|
||||
// find translations: https://bolls.life/static/bolls/app/views/languages.json
|
||||
export const LANGUAGES: Language[] = [
|
||||
{ code: 'en', name: 'English', nativeName: 'English', flag: '🇺🇸', deeplTarget: 'EN', deeplSource: 'EN', bibleId: 'NKJV' },
|
||||
{ code: 'es', name: 'Spanish', nativeName: 'Español', flag: '🇪🇸', deeplTarget: 'ES', deeplSource: 'ES', bibleId: 'NVI', glossaryId: 'b867e526-2eb8-426f-a178-b8a6b9d4d6ce' }, //RV1960
|
||||
{ code: 'fr', name: 'French', nativeName: 'Français', flag: '🇫🇷', deeplTarget: 'FR', deeplSource: 'FR', bibleId: 'FRLSG', bibleLabel: 'LSG', glossaryId: 'b6dd3213-1ddb-42ab-a2dc-91ff3a772fa3' }, //maybe BDS
|
||||
{ code: 'pt', name: 'Portuguese', nativeName: 'Português', flag: '🇵🇹', deeplTarget: 'PT-BR', deeplSource: 'PT', bibleId: 'NAA', glossaryId: 'bbe0697f-429a-45f7-9c30-576c7f21c78d' },
|
||||
{ code: 'de', name: 'German', nativeName: 'Deutsch', flag: '🇩🇪', deeplTarget: 'DE', deeplSource: 'DE', bibleId: 'S00', bibleLabel: 'SCH2000', glossaryId: '87ad31eb-1a58-4f91-b80e-943b3721065d', hideFromSwitcher: true },
|
||||
{ code: 'it', name: 'Italian', nativeName: 'Italiano', flag: '🇮🇹', deeplTarget: 'IT', deeplSource: 'IT', bibleId: 'NR06', glossaryId: '98dca739-468a-4c29-a0ce-ba89f60da00b', enabled: false },
|
||||
{ code: 'zh', name: 'Chinese', nativeName: '中文', flag: '🇨🇳', deeplTarget: 'ZH-HANS', deeplSource: 'ZH', bibleId: 'CUNPS', glossaryId: '532adee9-5117-4a9e-a6cb-a8c4b670232d', enabled: false, latinHeadings: false },
|
||||
{ code: 'ar', name: 'Arabic', nativeName: 'العربية', flag: '🇸🇦', dir: 'rtl', deeplTarget: 'AR', deeplSource: 'AR', bibleId: 'SVD', glossaryId: '294b6001-2831-45d4-bab5-3fd1af92dc7a', latinHeadings: false }, // NAV (New Arabic Version) would be better but not on Bolls
|
||||
{ code: 'ru', name: 'Russian', nativeName: 'Русский', flag: '🇷🇺', deeplTarget: 'RU', deeplSource: 'RU', bibleId: 'SYNOD', glossaryId: '7363b815-3cd1-4e1a-ae8b-d6695e6dbc4a', latinHeadings: false }, // NRT (New Russian Translation) is a modern alternative
|
||||
{ code: 'hi', name: 'Hindi', nativeName: 'हिन्दी', flag: '🇮🇳', deeplTarget: 'HI', deeplSource: 'HI', bibleId: 'HIOV', bibleLabel: 'OV', hideFromSwitcher: true, latinHeadings: false },
|
||||
{ code: 'ro', name: 'Romanian', nativeName: 'Română', flag: '🇷🇴', deeplTarget: 'RO', deeplSource: 'RO', bibleId: 'NTR', glossaryId: 'e50d3fed-6d0e-4f2c-a3ac-96112829055e', enabled: false },
|
||||
{ code: 'en', name: 'English', nativeName: 'English', flag: '🇺🇸', bibleId: 'NKJV' },
|
||||
{ code: 'es', name: 'Spanish', nativeName: 'Español', flag: '🇪🇸', bibleId: 'NVI' }, //RV1960
|
||||
{ code: 'fr', name: 'French', nativeName: 'Français', flag: '🇫🇷', bibleId: 'FRLSG', bibleLabel: 'LSG' }, //maybe BDS
|
||||
{ code: 'pt', name: 'Portuguese', nativeName: 'Português', flag: '🇵🇹', bibleId: 'NAA', translationName: 'Brazilian Portuguese' },
|
||||
{ code: 'de', name: 'German', nativeName: 'Deutsch', flag: '🇩🇪', bibleId: 'S00', bibleLabel: 'SCH2000', hideFromSwitcher: true },
|
||||
{ code: 'it', name: 'Italian', nativeName: 'Italiano', flag: '🇮🇹', bibleId: 'NR06', enabled: false },
|
||||
{ code: 'zh', name: 'Chinese', nativeName: '中文', flag: '🇨🇳', bibleId: 'CUNPS', translationName: 'Simplified Chinese', enabled: false, latinHeadings: false },
|
||||
{ code: 'ar', name: 'Arabic', nativeName: 'العربية', flag: '🇸🇦', dir: 'rtl', bibleId: 'SVD', translationName: 'Modern Standard Arabic', latinHeadings: false }, // NAV (New Arabic Version) would be better but not on Bolls
|
||||
{ code: 'ru', name: 'Russian', nativeName: 'Русский', flag: '🇷🇺', bibleId: 'SYNOD', latinHeadings: false }, // NRT (New Russian Translation) is a modern alternative
|
||||
{ code: 'hi', name: 'Hindi', nativeName: 'हिन्दी', flag: '🇮🇳', bibleId: 'HIOV', bibleLabel: 'OV', hideFromSwitcher: true, latinHeadings: false },
|
||||
{ code: 'ro', name: 'Romanian', nativeName: 'Română', flag: '🇷🇴', bibleId: 'NTR', enabled: false },
|
||||
]
|
||||
|
||||
// All language codes
|
||||
|
|
@ -57,18 +56,6 @@ export function getLanguage(code: string): Language | undefined {
|
|||
return LANGUAGES.find(l => l.code === code)
|
||||
}
|
||||
|
||||
export function getDeeplTargetCode(code: string): string {
|
||||
return getLanguage(code)?.deeplTarget ?? code.toUpperCase()
|
||||
}
|
||||
|
||||
export function getDeeplSourceCode(code: string): string {
|
||||
return getLanguage(code)?.deeplSource ?? code.toUpperCase()
|
||||
}
|
||||
|
||||
export function getGlossaryId(code: string): string | undefined {
|
||||
return getLanguage(code)?.glossaryId
|
||||
}
|
||||
|
||||
// Whether this language's headings render in the Latin display font (Bebas Neue).
|
||||
// Non-Latin scripts (Cyrillic, Arabic, CJK, Devanagari) fall back to other fonts,
|
||||
// so callers can skip Bebas-specific work (e.g. preloading it). Unknown codes
|
||||
|
|
|
|||
|
|
@ -264,8 +264,10 @@ export default defineNuxtConfig({
|
|||
// unset, the endpoint falls back to public.mapboxToken below so
|
||||
// existing setups keep working with no config change.
|
||||
mapboxKey: process.env.NUXT_MAPBOX_KEY || '',
|
||||
deeplApiKey: process.env.DEEPL_API_KEY || '',
|
||||
deeplApiUrl: process.env.DEEPL_API_URL || 'https://api.deepl.com',
|
||||
// OpenRouter — LLM content translation. TRANSLATION_MODEL overrides the
|
||||
// code default; the `translation.model` site setting overrides both.
|
||||
openrouterApiKey: process.env.OPENROUTER_API_KEY || '',
|
||||
translationModel: process.env.TRANSLATION_MODEL || '',
|
||||
// Cloudflare cache purge on deploy (server/plugins/cloudflare-purge.ts).
|
||||
// Token needs only Zone → Cache Purge for the doxa.life zone.
|
||||
cfApiToken: process.env.CF_API_TOKEN || '',
|
||||
|
|
|
|||
47
scripts/build-glossaries.ts
Normal file
47
scripts/build-glossaries.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/**
|
||||
* Generates config/glossaries.ts from the translation repo's glossary TSVs
|
||||
* (../translation/deepl-glossaries/*.tsv). The generated module is committed
|
||||
* so production builds don't need the translation repo checked out.
|
||||
*
|
||||
* Run after updating the TSVs: bun scripts/build-glossaries.ts
|
||||
*/
|
||||
import { readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const sourceDir = join(import.meta.dir, '../../translation/deepl-glossaries')
|
||||
const outFile = join(import.meta.dir, '../config/glossaries.ts')
|
||||
|
||||
const glossaries: Record<string, Array<[string, string]>> = {}
|
||||
|
||||
for (const file of readdirSync(sourceDir).sort()) {
|
||||
if (!file.endsWith('.tsv')) continue
|
||||
const code = file.replace(/\.tsv$/, '')
|
||||
const pairs: Array<[string, string]> = []
|
||||
for (const line of readFileSync(join(sourceDir, file), 'utf8').split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
const [source, target] = trimmed.split('\t')
|
||||
if (source && target) pairs.push([source.trim(), target.trim()])
|
||||
}
|
||||
glossaries[code] = pairs
|
||||
}
|
||||
|
||||
const entries = Object.entries(glossaries)
|
||||
.map(([code, pairs]) => {
|
||||
const rows = pairs.map(([s, t]) => ` [${JSON.stringify(s)}, ${JSON.stringify(t)}]`).join(',\n')
|
||||
return ` ${code}: [\n${rows}\n ]`
|
||||
})
|
||||
.join(',\n')
|
||||
|
||||
const output = `// Generated by scripts/build-glossaries.ts from ../translation/deepl-glossaries/*.tsv
|
||||
// Do not edit by hand — update the TSVs in the translation repo and re-run:
|
||||
// bun scripts/build-glossaries.ts
|
||||
|
||||
/** English → target-language term pairs, keyed by language code. */
|
||||
export const GLOSSARIES: Record<string, ReadonlyArray<readonly [string, string]>> = {
|
||||
${entries}
|
||||
}
|
||||
`
|
||||
|
||||
writeFileSync(outFile, output)
|
||||
console.log(`Wrote ${Object.keys(glossaries).length} glossaries (${Object.values(glossaries).reduce((n, p) => n + p.length, 0)} terms) to config/glossaries.ts`)
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
// Admin: one-click translate an EN (or other source) translation of a
|
||||
// CMS page into one or more target locales. Uses DeepL for title,
|
||||
// excerpt, meta_title, meta_description, and body_json. Each target
|
||||
// translation is upserted with the caller-chosen `status` (draft or
|
||||
// published, default draft). `overwrite` controls whether to replace
|
||||
// translations that already exist for a given locale.
|
||||
// CMS page into one or more target locales. Translates title, excerpt,
|
||||
// meta_title, meta_description, and body_json. Each target translation is
|
||||
// upserted with the caller-chosen `status` (draft or published, default
|
||||
// draft). `overwrite` controls whether to replace translations that
|
||||
// already exist for a given locale.
|
||||
//
|
||||
// All workflow logic — DeepL calls, body walking, upsert with
|
||||
// All workflow logic — translation calls, body walking, upsert with
|
||||
// validation, per-locale cache purge — lives in cmsTranslate.translatePage.
|
||||
// This route stays thin.
|
||||
|
||||
|
|
|
|||
|
|
@ -87,7 +87,9 @@ export interface PageTranslationsTable {
|
|||
updated: ColumnType<Date, Date | string | undefined, Date | string>
|
||||
}
|
||||
|
||||
export type PageTranslationVersionSource = 'admin-ui' | 'mcp' | 'deepl'
|
||||
// 'deepl' is retained for snapshots written by the previous translation
|
||||
// provider; new machine translations are recorded as 'auto-translate'.
|
||||
export type PageTranslationVersionSource = 'admin-ui' | 'mcp' | 'auto-translate' | 'deepl'
|
||||
|
||||
export interface PageTranslationVersionsTable {
|
||||
id: Generated<string>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,15 @@
|
|||
// DeepL text-translation tool. Generic primitive — Claude composes
|
||||
// LLM text-translation tool. Generic primitive — Claude composes
|
||||
// with upsert_page_translation when applying translations to pages.
|
||||
//
|
||||
// The configured DeepL glossary for each target locale is always
|
||||
// applied (the translateText service consults config/languages.ts),
|
||||
// keeping MCP-driven translations terminologically consistent with
|
||||
// admin auto-translates.
|
||||
// The vendored glossary for each target locale is always injected into
|
||||
// the translation prompt, keeping MCP-driven translations terminologically
|
||||
// consistent with admin auto-translates.
|
||||
|
||||
import { defineMcpTool, mcpLog } from '#mcp-layer'
|
||||
import { translateTextInput, translatePageInput } from '../schemas'
|
||||
import { translateText, translatePage } from '../../services/cmsTranslate'
|
||||
|
||||
const DESCRIPTION = `Translate a piece of text into one or more locales using DeepL.
|
||||
const DESCRIPTION = `Translate a piece of text into one or more locales.
|
||||
|
||||
Use this when you've authored a section in one language (typically English) and need versions in the site's other locales — for example, "translate this paragraph then add it to each page in every locale."
|
||||
|
||||
|
|
@ -35,7 +34,7 @@ export const translateTextTool = defineMcpTool({
|
|||
target_locales: input.target_locales
|
||||
})
|
||||
|
||||
// Audit the call so the activity log records when DeepL spend
|
||||
// Audit the call so the activity log records when translation spend
|
||||
// happened on Claude's behalf. Don't include the full text in
|
||||
// metadata — keep the audit row small and non-PII-leaky.
|
||||
await mcpLog('CREATE', 'translations.cache', `${result.source_locale}->${result.translations.map(t => t.locale).join(',')}`, ctx, {
|
||||
|
|
@ -59,20 +58,20 @@ export const translateTextTool = defineMcpTool({
|
|||
|
||||
// ── Page-level translate ────────────────────────────────────────────
|
||||
|
||||
const PAGE_DESCRIPTION = `Translate an entire CMS page (title, excerpt, meta_title, meta_description, body) from one locale into one or more target locales using DeepL.
|
||||
const PAGE_DESCRIPTION = `Translate an entire CMS page (title, excerpt, meta_title, meta_description, body) from one locale into one or more target locales.
|
||||
|
||||
Use this for the "make this page exist in all locales" workflow. For surgical edits — translating one new section and inserting it into existing translations — prefer translate_text composed with upsert_page_translation.
|
||||
|
||||
Inputs:
|
||||
- \`page_id\`: the CMS page id.
|
||||
- \`source_locale\`: defaults to "en". Must be an enabled locale with an existing translation row.
|
||||
- \`target_locales\`: array of enabled locale codes. Per-locale failures don't abort the batch — DeepL flaking on one locale lets the rest proceed.
|
||||
- \`overwrite\`: defaults to false. When false, locales that already have a translation row are silently skipped (no DeepL call). Set true to replace.
|
||||
- \`target_locales\`: array of enabled locale codes. Per-locale failures don't abort the batch — the model flaking on one locale lets the rest proceed. A failure no retry can fix (no credits, bad key, rejected model) does stop the run, reporting the same reason for the untried locales.
|
||||
- \`overwrite\`: defaults to false. When false, locales that already have a translation row are silently skipped (no model call). Set true to replace.
|
||||
- \`status\`: \`draft\` (default) or \`published\` for the new translation rows.
|
||||
|
||||
Output: \`{source_locale, results: [{locale, ok? | skipped? | error?}]}\`. Each result entry reports one of three outcomes; \`ok\` means a row was written and the public cache was purged for that locale.
|
||||
|
||||
Cost note: this triggers up to 5 DeepL calls per target locale (4 metadata strings batched into one call + 1 body call). A "translate to all 9 locales" run on a typical page is ~18 DeepL requests.`
|
||||
Cost note: this triggers one model call for the metadata plus one per 40 body fragments, per target locale. A "translate to all 9 locales" run on a typical page is ~20-30 model requests.`
|
||||
|
||||
export const translatePageTool = defineMcpTool({
|
||||
name: 'translate_page',
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
// DeepL text-translation service — the canonical primitive both the
|
||||
// MCP translate_text tool and any future admin batch-translate flow
|
||||
// share.
|
||||
// LLM text-translation service — the canonical primitive both the
|
||||
// MCP translate_text tool and the admin batch-translate flow share.
|
||||
//
|
||||
// Always uses the per-locale DeepL glossary configured in
|
||||
// config/languages.ts (via translateTexts()'s built-in lookup), so
|
||||
// the terminology stays consistent with admin auto-translates and
|
||||
// with the translation/ glossaries repo.
|
||||
// The per-locale glossary vendored in config/glossaries.ts is injected
|
||||
// into every translation prompt, so the terminology stays consistent
|
||||
// with admin auto-translates and with the translation/ glossaries repo.
|
||||
|
||||
import type { H3Error } from 'h3'
|
||||
import { translateTexts, translateTiptapContent, isDeepLConfigured } from '../utils/deepl'
|
||||
import { ENABLED_LANGUAGE_CODES, getLanguage } from '~~/config/languages'
|
||||
import { translateTexts, translateTiptapContent, isTranslationConfigured } from '../utils/translate'
|
||||
import { OpenRouterError, hasGlossary } from '../utils/openrouter'
|
||||
import { ENABLED_LANGUAGE_CODES } from '~~/config/languages'
|
||||
import { db } from '../utils/database'
|
||||
import {
|
||||
upsertCmsPageTranslation,
|
||||
|
|
@ -41,12 +40,12 @@ export interface TranslateTextResult {
|
|||
translations: TranslateTextResultEntry[]
|
||||
}
|
||||
|
||||
const MAX_TEXT_BYTES = 100 * 1024 // DeepL's per-request soft limit; large bodies
|
||||
// should be split client-side and translated piece by piece
|
||||
const MAX_TEXT_BYTES = 100 * 1024 // Per-request ceiling; large bodies should be
|
||||
// split client-side and translated piece by piece
|
||||
|
||||
export async function translateText(input: TranslateTextInput): Promise<TranslateTextResult> {
|
||||
if (!isDeepLConfigured()) {
|
||||
throw err(503, 'DeepL is not configured on this server')
|
||||
if (!isTranslationConfigured()) {
|
||||
throw err(503, 'Translation is not configured on this server')
|
||||
}
|
||||
|
||||
const text = input.text ?? ''
|
||||
|
|
@ -77,17 +76,15 @@ export async function translateText(input: TranslateTextInput): Promise<Translat
|
|||
// Dedupe — Claude might list the same locale twice
|
||||
const targets = Array.from(new Set(input.target_locales))
|
||||
|
||||
// One DeepL call per target locale (DeepL only translates to a single
|
||||
// target per request). The /translate endpoint applies the configured
|
||||
// glossary automatically based on the target locale.
|
||||
// One model call per target locale — each prompt is built around a single
|
||||
// target language and carries that locale's glossary.
|
||||
const results = await Promise.all(
|
||||
targets.map(async (locale) => {
|
||||
const out = await translateTexts([text], locale, source)
|
||||
const lang = getLanguage(locale)
|
||||
return {
|
||||
locale,
|
||||
text: out[0] ?? '',
|
||||
glossary_used: Boolean(lang?.glossaryId)
|
||||
glossary_used: hasGlossary(locale)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
|
@ -116,7 +113,7 @@ export interface TranslatePageInput {
|
|||
export interface TranslatePageResultEntry {
|
||||
locale: string
|
||||
// Mutually exclusive: either ok=true (translation written), skipped=true
|
||||
// (existing row preserved, no DeepL call), or error set (translation
|
||||
// (existing row preserved, no model call), or error set (translation
|
||||
// failed for this locale only — other locales still proceed).
|
||||
ok?: true
|
||||
skipped?: true
|
||||
|
|
@ -130,15 +127,17 @@ export interface TranslatePageResult {
|
|||
|
||||
// Translate a whole CMS page (title, excerpt, meta_title,
|
||||
// meta_description, body_json) from a source locale into one or more
|
||||
// targets. Per-locale failures don't abort the batch — DeepL flaking
|
||||
// on one locale shouldn't block the others.
|
||||
// targets. Per-locale failures don't abort the batch — the model flaking
|
||||
// on one locale shouldn't block the others. The exception is a failure no
|
||||
// retry can fix (no credits, bad key, rejected model): the remaining
|
||||
// locales are marked with the same reason instead of repeating the call.
|
||||
//
|
||||
// Each successful translation is written via upsertCmsPageTranslation,
|
||||
// so the standard validator + cache-invalidation path runs on every
|
||||
// translated row (consistent with hand-edited translations).
|
||||
export async function translatePage(input: TranslatePageInput): Promise<TranslatePageResult> {
|
||||
if (!isDeepLConfigured()) {
|
||||
throw err(503, 'DeepL is not configured on this server')
|
||||
if (!isTranslationConfigured()) {
|
||||
throw err(503, 'Translation is not configured on this server')
|
||||
}
|
||||
|
||||
const sourceLocale = input.source_locale ?? 'en'
|
||||
|
|
@ -170,7 +169,7 @@ export async function translatePage(input: TranslatePageInput): Promise<Translat
|
|||
|
||||
const results: TranslatePageResultEntry[] = []
|
||||
|
||||
for (const target of targets) {
|
||||
for (const [i, target] of targets.entries()) {
|
||||
if (!input.overwrite) {
|
||||
const existing = await db
|
||||
.selectFrom('page_translations')
|
||||
|
|
@ -207,7 +206,7 @@ export async function translatePage(input: TranslatePageInput): Promise<Translat
|
|||
og_image: source.og_image,
|
||||
status,
|
||||
actor_user_id: input.actor_user_id ?? null,
|
||||
source: 'deepl'
|
||||
source: 'auto-translate'
|
||||
})
|
||||
|
||||
// Per-locale cache purge after each successful upsert. This
|
||||
|
|
@ -221,6 +220,15 @@ export async function translatePage(input: TranslatePageInput): Promise<Translat
|
|||
const msg = e instanceof Error ? e.message : 'translation failed'
|
||||
console.error(`[cmsTranslate] page ${input.page_id} → ${target} failed:`, msg)
|
||||
results.push({ locale: target, error: msg })
|
||||
|
||||
if (e instanceof OpenRouterError && !e.retryable) {
|
||||
// Every remaining locale would hit the same wall, so report one
|
||||
// reason rather than burning a call per locale to rediscover it.
|
||||
for (const remaining of targets.slice(i + 1)) {
|
||||
results.push({ locale: remaining, error: msg })
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ export interface UpsertTranslationInput {
|
|||
// optional because some background paths may not have a user; the
|
||||
// snapshot still gets written, just with NULLs.
|
||||
actor_user_id?: string | null
|
||||
source?: 'admin-ui' | 'mcp' | 'deepl'
|
||||
source?: 'admin-ui' | 'mcp' | 'auto-translate' | 'deepl'
|
||||
user_agent?: string | null
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,134 +0,0 @@
|
|||
// DeepL translation utility. Slim port of
|
||||
// campaigns-sever/server/utils/deepl.ts — drops the Bible / verse-node
|
||||
// handling since marketing-rebuild has no verse content. Supports text,
|
||||
// batched text, and Tiptap JSON documents.
|
||||
|
||||
import { getDeeplTargetCode, getDeeplSourceCode, getGlossaryId } from '~~/config/languages'
|
||||
|
||||
interface DeepLTranslation {
|
||||
detected_source_language: string
|
||||
text: string
|
||||
}
|
||||
|
||||
interface DeepLResponse {
|
||||
translations: DeepLTranslation[]
|
||||
}
|
||||
|
||||
export interface TiptapNode {
|
||||
type: string
|
||||
content?: TiptapNode[]
|
||||
text?: string
|
||||
marks?: any[]
|
||||
attrs?: Record<string, any>
|
||||
}
|
||||
|
||||
export function isDeepLConfigured(): boolean {
|
||||
const config = useRuntimeConfig()
|
||||
return Boolean(config.deeplApiKey)
|
||||
}
|
||||
|
||||
export async function translateText(
|
||||
text: string,
|
||||
targetLanguage: string,
|
||||
sourceLanguage?: string
|
||||
): Promise<string> {
|
||||
const translated = await translateTexts([text], targetLanguage, sourceLanguage)
|
||||
return translated[0] ?? ''
|
||||
}
|
||||
|
||||
export async function translateTexts(
|
||||
texts: string[],
|
||||
targetLanguage: string,
|
||||
sourceLanguage?: string
|
||||
): Promise<string[]> {
|
||||
if (texts.length === 0) return []
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const apiKey = config.deeplApiKey
|
||||
if (!apiKey) throw new Error('DEEPL_API_KEY is not configured')
|
||||
|
||||
const targetLang = getDeeplTargetCode(targetLanguage)
|
||||
const sourceLang = sourceLanguage ? getDeeplSourceCode(sourceLanguage) : undefined
|
||||
const params = new URLSearchParams({ target_lang: targetLang })
|
||||
for (const text of texts) params.append('text', text)
|
||||
if (sourceLang) params.append('source_lang', sourceLang)
|
||||
|
||||
const glossaryId = getGlossaryId(targetLanguage)
|
||||
if (glossaryId) params.append('glossary_id', glossaryId)
|
||||
|
||||
params.append('model_type', 'quality_optimized')
|
||||
|
||||
const apiUrl = config.deeplApiUrl || 'https://api.deepl.com'
|
||||
console.log(`[DeepL] Translating ${texts.length} text(s) → ${targetLang}${sourceLang ? ` from ${sourceLang}` : ''}${glossaryId ? ` (glossary: ${glossaryId})` : ''}`)
|
||||
|
||||
const response = await fetch(`${apiUrl}/v2/translate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `DeepL-Auth-Key ${apiKey}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: params.toString()
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text()
|
||||
throw new Error(`DeepL API error: ${response.status} - ${body}`)
|
||||
}
|
||||
|
||||
const data = await response.json() as DeepLResponse
|
||||
if (!data.translations) throw new Error('No translations returned from DeepL')
|
||||
return data.translations.map(t => t.text)
|
||||
}
|
||||
|
||||
// Extract plain text nodes from a Tiptap JSON tree so we can send them
|
||||
// to DeepL as a batch, then write the translations back at the same
|
||||
// positions. Mirrors campaigns-sever's implementation minus the
|
||||
// verse-node skip (no verses in marketing content).
|
||||
export function extractTexts(
|
||||
node: TiptapNode,
|
||||
path: number[] = []
|
||||
): Array<{ path: number[]; text: string }> {
|
||||
const results: Array<{ path: number[]; text: string }> = []
|
||||
if (node.type === 'text' && node.text) {
|
||||
results.push({ path: [...path], text: node.text })
|
||||
}
|
||||
if (node.content && Array.isArray(node.content)) {
|
||||
node.content.forEach((child, index) => {
|
||||
results.push(...extractTexts(child, [...path, index]))
|
||||
})
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
export function setTextAtPath(node: TiptapNode, path: number[], text: string): void {
|
||||
if (path.length === 0) {
|
||||
node.text = text
|
||||
return
|
||||
}
|
||||
const [index, ...rest] = path as [number, ...number[]]
|
||||
if (node.content && node.content[index]) {
|
||||
setTextAtPath(node.content[index], rest, text)
|
||||
}
|
||||
}
|
||||
|
||||
export async function translateTiptapContent(
|
||||
contentJson: TiptapNode,
|
||||
targetLanguage: string,
|
||||
sourceLanguage?: string
|
||||
): Promise<TiptapNode> {
|
||||
const cloned: TiptapNode = JSON.parse(JSON.stringify(contentJson))
|
||||
const entries = extractTexts(cloned)
|
||||
if (entries.length === 0) return cloned
|
||||
|
||||
const CHUNK_SIZE = 100
|
||||
const allTranslated: string[] = []
|
||||
for (let i = 0; i < entries.length; i += CHUNK_SIZE) {
|
||||
if (i > 0) await new Promise(r => setTimeout(r, 200))
|
||||
const chunk = entries.slice(i, i + CHUNK_SIZE)
|
||||
const translated = await translateTexts(chunk.map(e => e.text), targetLanguage, sourceLanguage)
|
||||
allTranslated.push(...translated)
|
||||
}
|
||||
|
||||
entries.forEach((entry, i) => setTextAtPath(cloned, entry.path, allTranslated[i]!))
|
||||
return cloned
|
||||
}
|
||||
199
server/utils/openrouter.ts
Normal file
199
server/utils/openrouter.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
/**
|
||||
* OpenRouter Translation Client
|
||||
*
|
||||
* Translates text fragments with an LLM via the OpenRouter chat-completions
|
||||
* API. Each request carries the target language's glossary and every fragment
|
||||
* of the batch, and the model must return exactly one translation per fragment
|
||||
* so the caller can map results back into structured content.
|
||||
*/
|
||||
|
||||
import { getLanguage } from '~~/config/languages'
|
||||
import { GLOSSARIES } from '~~/config/glossaries'
|
||||
import { getSetting } from './site-settings'
|
||||
|
||||
const DEFAULT_TRANSLATION_MODEL = 'anthropic/claude-opus-5'
|
||||
|
||||
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions'
|
||||
|
||||
export function isOpenRouterConfigured(): boolean {
|
||||
return Boolean(useRuntimeConfig().openrouterApiKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* The OpenRouter model used to translate into a language. A per-language
|
||||
* override in config/languages.ts wins; then the admin-managed
|
||||
* `translation.model` site setting, the TRANSLATION_MODEL env var, and a code
|
||||
* default — so adopting a newly released model is a setting, not a code edit.
|
||||
*/
|
||||
export async function getTranslationModel(targetLanguage?: string): Promise<string> {
|
||||
if (targetLanguage) {
|
||||
const override = getLanguage(targetLanguage)?.translationModel
|
||||
if (override) return override
|
||||
}
|
||||
const stored = await getSetting('translation.model')
|
||||
if (stored) return stored
|
||||
return useRuntimeConfig().translationModel || DEFAULT_TRANSLATION_MODEL
|
||||
}
|
||||
|
||||
/** Language name as used in prompts — the precise variant when the plain name is ambiguous. */
|
||||
function promptLanguageName(code: string): string {
|
||||
const lang = getLanguage(code)
|
||||
return lang?.translationName || lang?.name || code
|
||||
}
|
||||
|
||||
/** Whether a curated terminology glossary is injected for this language. */
|
||||
export function hasGlossary(code: string): boolean {
|
||||
return Boolean(GLOSSARIES[code]?.length)
|
||||
}
|
||||
|
||||
function buildSystemPrompt(targetLanguage: string, sourceLanguage: string, fragmentCount: number): string {
|
||||
const source = promptLanguageName(sourceLanguage)
|
||||
const target = promptLanguageName(targetLanguage)
|
||||
|
||||
const glossary = GLOSSARIES[targetLanguage]
|
||||
const glossaryBlock = glossary?.length
|
||||
? `\nGlossary — always use these translations, inflected correctly for the surrounding grammar:\n${glossary.map(([s, t]) => `${s} → ${t}`).join('\n')}\n`
|
||||
: ''
|
||||
|
||||
return `You are a professional translator for DOXA, a Christian mission organization mobilizing prayer for unreached people groups. Translate marketing and informational website copy from ${source} into ${target}.
|
||||
|
||||
Rules:
|
||||
- Use natural, idiomatic ${target} in a warm but professional register suited to a public website.
|
||||
- The numbered fragments are consecutive pieces of one document; formatting may split a sentence across fragments. Translate each fragment so that the concatenated result reads naturally.
|
||||
- Return exactly one translation per fragment, in the same order. Never merge, split, reorder, or skip fragments, and add no commentary.
|
||||
- If a fragment begins or ends with whitespace, preserve that whitespace.
|
||||
- Preserve any markup, placeholders, or URLs exactly as they appear.
|
||||
- Render names of people, places, and Scripture references using standard ${target} conventions.
|
||||
${glossaryBlock}
|
||||
Respond with a JSON object of the form {"translations": ["...", "..."]} containing exactly ${fragmentCount} strings.`
|
||||
}
|
||||
|
||||
interface ChatCompletionResponse {
|
||||
choices?: Array<{ message?: { content?: string } }>
|
||||
error?: { message?: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* Failure of an OpenRouter request. The message is written for the admin UI,
|
||||
* and `retryable` says whether a later attempt could plausibly succeed —
|
||||
* false for anything that needs a human (no credits, bad key, rejected model).
|
||||
*/
|
||||
export class OpenRouterError extends Error {
|
||||
constructor(message: string, readonly status: number | null, readonly retryable: boolean) {
|
||||
super(message)
|
||||
this.name = 'OpenRouterError'
|
||||
}
|
||||
}
|
||||
|
||||
function providerMessage(body: string): string {
|
||||
try {
|
||||
const message = JSON.parse(body)?.error?.message
|
||||
if (typeof message === 'string') return message
|
||||
} catch {
|
||||
// error bodies are not always JSON
|
||||
}
|
||||
return body.slice(0, 200)
|
||||
}
|
||||
|
||||
function toOpenRouterError(status: number, body: string, model: string): OpenRouterError {
|
||||
console.error(`[Translate] OpenRouter ${status} for ${model}: ${body.slice(0, 500)}`)
|
||||
|
||||
if (status === 402) {
|
||||
return new OpenRouterError('OpenRouter account has no credits — add credits at https://openrouter.ai/settings/credits', status, false)
|
||||
}
|
||||
if (status === 401 || status === 403) {
|
||||
return new OpenRouterError('OpenRouter rejected the API key — check OPENROUTER_API_KEY', status, false)
|
||||
}
|
||||
if (status === 400 || status === 404 || status === 422) {
|
||||
return new OpenRouterError(`OpenRouter did not accept the request for model "${model}": ${providerMessage(body)}`, status, false)
|
||||
}
|
||||
return new OpenRouterError(`OpenRouter is temporarily unavailable (HTTP ${status}) — try again in a moment`, status, true)
|
||||
}
|
||||
|
||||
function parseTranslations(content: string, expectedCount: number): string[] {
|
||||
// Some models wrap JSON output in a markdown code fence despite json_object mode
|
||||
const raw = content.trim().replace(/^```(?:json)?\s*/, '').replace(/\s*```$/, '')
|
||||
const parsed = JSON.parse(raw)
|
||||
const translations = parsed?.translations
|
||||
if (!Array.isArray(translations) || translations.length !== expectedCount || !translations.every((t: unknown) => typeof t === 'string')) {
|
||||
throw new Error(`Expected ${expectedCount} translated fragments, got ${Array.isArray(translations) ? translations.length : 'invalid output'}`)
|
||||
}
|
||||
return translations
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate an array of text fragments in a single model request.
|
||||
* Returns translations in the same order as the input.
|
||||
*/
|
||||
export async function openrouterTranslateTexts(
|
||||
texts: string[],
|
||||
targetLanguage: string,
|
||||
sourceLanguage: string = 'en'
|
||||
): Promise<string[]> {
|
||||
if (texts.length === 0) return []
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const apiKey = config.openrouterApiKey
|
||||
|
||||
if (!apiKey) {
|
||||
throw new OpenRouterError('OpenRouter is not configured — set OPENROUTER_API_KEY', null, false)
|
||||
}
|
||||
|
||||
const model = await getTranslationModel(targetLanguage)
|
||||
|
||||
const body = JSON.stringify({
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: buildSystemPrompt(targetLanguage, sourceLanguage, texts.length) },
|
||||
{ role: 'user', content: JSON.stringify({ fragments: texts }) }
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
// Translation is mechanical — the glossary and the output contract are both
|
||||
// in the system prompt, so deep reasoning adds cost without adding accuracy.
|
||||
// Low rather than off: with thinking disabled, Claude models can leak
|
||||
// <thinking> tags into the response, which here would corrupt a fragment.
|
||||
reasoning: { effort: 'low' }
|
||||
})
|
||||
|
||||
console.log(`[Translate] ${model}: ${texts.length} fragments → ${targetLanguage}`)
|
||||
|
||||
// The fragment-count contract occasionally fails on a first attempt; one
|
||||
// retry recovers those cases before surfacing an error to the caller.
|
||||
let lastError: Error | undefined
|
||||
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(OPENROUTER_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'X-Title': 'DOXA Marketing'
|
||||
},
|
||||
body
|
||||
})
|
||||
} catch (e: any) {
|
||||
console.error(`[Translate] could not reach OpenRouter: ${e?.message}`)
|
||||
throw new OpenRouterError('Could not reach OpenRouter — try again in a moment', null, true)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw toOpenRouterError(response.status, await response.text(), model)
|
||||
}
|
||||
|
||||
const data = await response.json() as ChatCompletionResponse
|
||||
const content = data.choices?.[0]?.message?.content
|
||||
if (!content) {
|
||||
throw new OpenRouterError(`OpenRouter returned no translation${data.error?.message ? `: ${data.error.message}` : ''}`, response.status, true)
|
||||
}
|
||||
|
||||
try {
|
||||
return parseTranslations(content, texts.length)
|
||||
} catch (e: any) {
|
||||
lastError = e
|
||||
console.warn(`[Translate] attempt ${attempt} failed for ${targetLanguage}: ${e?.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
throw new OpenRouterError(`Translation into "${targetLanguage}" failed: ${lastError?.message}`, null, true)
|
||||
}
|
||||
|
|
@ -10,6 +10,16 @@ const settingDefs = {
|
|||
default: true,
|
||||
public: true,
|
||||
},
|
||||
// OpenRouter model id used for CMS auto-translation. Empty means "no
|
||||
// override" — resolution then falls through to TRANSLATION_MODEL and the
|
||||
// code default in server/utils/openrouter.ts. Free-text on purpose: the
|
||||
// value is passed straight to the OpenRouter API, so a newly released model
|
||||
// can be adopted by typing its id.
|
||||
'translation.model': {
|
||||
schema: z.string(),
|
||||
default: '',
|
||||
public: false,
|
||||
},
|
||||
} as const
|
||||
|
||||
export type SettingKey = keyof typeof settingDefs
|
||||
|
|
|
|||
108
server/utils/translate.ts
Normal file
108
server/utils/translate.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// Translation utility — translates plain text, batched text, and Tiptap JSON
|
||||
// documents with an LLM via OpenRouter. Slim counterpart of
|
||||
// campaigns-sever/server/utils/translate.ts, without the Bible / verse-node
|
||||
// handling since marketing-rebuild has no verse content.
|
||||
|
||||
import { openrouterTranslateTexts, isOpenRouterConfigured } from './openrouter'
|
||||
|
||||
export interface TiptapNode {
|
||||
type: string
|
||||
content?: TiptapNode[]
|
||||
text?: string
|
||||
marks?: any[]
|
||||
attrs?: Record<string, any>
|
||||
}
|
||||
|
||||
export function isTranslationConfigured(): boolean {
|
||||
return isOpenRouterConfigured()
|
||||
}
|
||||
|
||||
export async function translateText(
|
||||
text: string,
|
||||
targetLanguage: string,
|
||||
sourceLanguage?: string
|
||||
): Promise<string> {
|
||||
const translated = await translateTexts([text], targetLanguage, sourceLanguage)
|
||||
return translated[0] ?? ''
|
||||
}
|
||||
|
||||
export async function translateTexts(
|
||||
texts: string[],
|
||||
targetLanguage: string,
|
||||
sourceLanguage?: string
|
||||
): Promise<string[]> {
|
||||
if (texts.length === 0) return []
|
||||
|
||||
// Blank fragments never reach the model: it has nothing to translate and
|
||||
// tends to drop them, which would break the one-translation-per-fragment
|
||||
// count the caller relies on to map results back.
|
||||
const indexes: number[] = []
|
||||
const translatable: string[] = []
|
||||
texts.forEach((text, i) => {
|
||||
if (text.trim().length > 0) {
|
||||
indexes.push(i)
|
||||
translatable.push(text)
|
||||
}
|
||||
})
|
||||
if (translatable.length === 0) return [...texts]
|
||||
|
||||
const translated = await openrouterTranslateTexts(translatable, targetLanguage, sourceLanguage)
|
||||
|
||||
const out = [...texts]
|
||||
indexes.forEach((target, i) => { out[target] = translated[i]! })
|
||||
return out
|
||||
}
|
||||
|
||||
// Extract plain text nodes from a Tiptap JSON tree so we can send them
|
||||
// to the translator as a batch, then write the translations back at the
|
||||
// same positions.
|
||||
export function extractTexts(
|
||||
node: TiptapNode,
|
||||
path: number[] = []
|
||||
): Array<{ path: number[]; text: string }> {
|
||||
const results: Array<{ path: number[]; text: string }> = []
|
||||
if (node.type === 'text' && node.text) {
|
||||
results.push({ path: [...path], text: node.text })
|
||||
}
|
||||
if (node.content && Array.isArray(node.content)) {
|
||||
node.content.forEach((child, index) => {
|
||||
results.push(...extractTexts(child, [...path, index]))
|
||||
})
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
export function setTextAtPath(node: TiptapNode, path: number[], text: string): void {
|
||||
if (path.length === 0) {
|
||||
node.text = text
|
||||
return
|
||||
}
|
||||
const [index, ...rest] = path as [number, ...number[]]
|
||||
if (node.content && node.content[index]) {
|
||||
setTextAtPath(node.content[index], rest, text)
|
||||
}
|
||||
}
|
||||
|
||||
export async function translateTiptapContent(
|
||||
contentJson: TiptapNode,
|
||||
targetLanguage: string,
|
||||
sourceLanguage?: string
|
||||
): Promise<TiptapNode> {
|
||||
const cloned: TiptapNode = JSON.parse(JSON.stringify(contentJson))
|
||||
const entries = extractTexts(cloned)
|
||||
if (entries.length === 0) return cloned
|
||||
|
||||
// Chunk size balances request count against the fragment-alignment
|
||||
// contract, which gets harder for the model to honor on long batches
|
||||
const CHUNK_SIZE = 40
|
||||
const allTranslated: string[] = []
|
||||
for (let i = 0; i < entries.length; i += CHUNK_SIZE) {
|
||||
if (i > 0) await new Promise(r => setTimeout(r, 200))
|
||||
const chunk = entries.slice(i, i + CHUNK_SIZE)
|
||||
const translated = await translateTexts(chunk.map(e => e.text), targetLanguage, sourceLanguage)
|
||||
allTranslated.push(...translated)
|
||||
}
|
||||
|
||||
entries.forEach((entry, i) => setTextAtPath(cloned, entry.path, allTranslated[i]!))
|
||||
return cloned
|
||||
}
|
||||
Loading…
Reference in a new issue