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.
|
# Set to empty in dev to make the cookie host-only.
|
||||||
NUXT_PUBLIC_STATINATOR_COOKIE_DOMAIN=.doxa.life
|
NUXT_PUBLIC_STATINATOR_COOKIE_DOMAIN=.doxa.life
|
||||||
|
|
||||||
# DeepL (used by the CMS "Translate from English" workflow on /admin/pages/:id)
|
# OpenRouter (used by the CMS "Auto-translate" workflow on /admin/pages/:id
|
||||||
# Pro endpoint: https://api.deepl.com | Free-tier endpoint: https://api-free.deepl.com
|
# and the translate_text / translate_page MCP tools)
|
||||||
DEEPL_API_KEY=your-deepl-api-key
|
OPENROUTER_API_KEY=your-openrouter-api-key
|
||||||
DEEPL_API_URL=https://api.deepl.com
|
# 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).
|
# Cloudflare cache purge on deploy (server/plugins/cloudflare-purge.ts).
|
||||||
# On container boot the edge cache is purged so stale prerendered HTML can't
|
# 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.
|
// replace editor content when the prop drifts from the editor state.
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// Admin: CMS page editor. Per-locale tabs, Tiptap editor, featured
|
// Admin: CMS page editor. Per-locale tabs, Tiptap editor, featured
|
||||||
// image + OG image uploaders, meta_title / meta_description, and
|
// image + OG image uploaders, meta_title / meta_description, and
|
||||||
// Save / Publish / Unpublish actions. A DeepL button fan-outs English
|
// Save / Publish / Unpublish actions. An auto-translate button fan-outs
|
||||||
// into the other enabled locales.
|
// English into the other enabled locales.
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'admin',
|
layout: 'admin',
|
||||||
|
|
@ -325,7 +325,7 @@ async function deletePage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeepL translate modal
|
// Auto-translate modal
|
||||||
const translateModalOpen = ref(false)
|
const translateModalOpen = ref(false)
|
||||||
const translateSource = ref('en')
|
const translateSource = ref('en')
|
||||||
const translateTargets = ref<string[]>([])
|
const translateTargets = ref<string[]>([])
|
||||||
|
|
@ -397,7 +397,7 @@ interface VersionSummary {
|
||||||
id: string
|
id: string
|
||||||
created: string
|
created: string
|
||||||
status: 'draft' | 'published'
|
status: 'draft' | 'published'
|
||||||
source: 'admin-ui' | 'mcp' | 'deepl'
|
source: 'admin-ui' | 'mcp' | 'auto-translate' | 'deepl'
|
||||||
title: string
|
title: string
|
||||||
created_by: { id: string; name: string } | null
|
created_by: { id: string; name: string } | null
|
||||||
}
|
}
|
||||||
|
|
@ -495,6 +495,7 @@ function loadVersionIntoEditor() {
|
||||||
const SOURCE_LABELS: Record<string, string> = {
|
const SOURCE_LABELS: Record<string, string> = {
|
||||||
'admin-ui': 'Admin',
|
'admin-ui': 'Admin',
|
||||||
mcp: 'MCP',
|
mcp: 'MCP',
|
||||||
|
'auto-translate': 'Auto-translate',
|
||||||
deepl: 'DeepL'
|
deepl: 'DeepL'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -879,7 +880,7 @@ function formatVersionTime(iso: string): string {
|
||||||
</template>
|
</template>
|
||||||
</USlideover>
|
</USlideover>
|
||||||
|
|
||||||
<UModal v-model:open="translateModalOpen" title="Translate with DeepL">
|
<UModal v-model:open="translateModalOpen" title="Auto-translate">
|
||||||
<template #body>
|
<template #body>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<UFormField label="Source locale">
|
<UFormField label="Source locale">
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ definePageMeta({
|
||||||
|
|
||||||
interface SettingsResponse {
|
interface SettingsResponse {
|
||||||
'auth.public_registration_enabled'?: boolean
|
'auth.public_registration_enabled'?: boolean
|
||||||
|
'translation.model'?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const { hasPermission } = usePermissions()
|
const { hasPermission } = usePermissions()
|
||||||
|
|
@ -31,6 +32,42 @@ const registrationEnabled = computed({
|
||||||
|
|
||||||
const saving = ref(false)
|
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) => {
|
const handleToggleRegistration = async (next: boolean) => {
|
||||||
if (!canEdit.value || saving.value) return
|
if (!canEdit.value || saving.value) return
|
||||||
const previous = registrationEnabled.value
|
const previous = registrationEnabled.value
|
||||||
|
|
@ -102,6 +139,40 @@ const handleToggleRegistration = async (next: boolean) => {
|
||||||
</div>
|
</div>
|
||||||
</UCard>
|
</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)">
|
<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.
|
You can view settings but not change them. Contact an administrator if you need to make changes.
|
||||||
</p>
|
</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
|
nativeName: string // Name in the language itself
|
||||||
flag: string
|
flag: string
|
||||||
dir?: 'ltr' | 'rtl' // Text direction (defaults to 'ltr')
|
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
|
bibleId?: string // Bolls.life translation ID for verse lookups
|
||||||
bibleLabel?: string // Display label for the Bible translation (defaults to bibleId)
|
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)
|
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
|
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)
|
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
|
// but hidden from the UI language selector, translation targets, and admin content
|
||||||
// find translations: https://bolls.life/static/bolls/app/views/languages.json
|
// find translations: https://bolls.life/static/bolls/app/views/languages.json
|
||||||
export const LANGUAGES: Language[] = [
|
export const LANGUAGES: Language[] = [
|
||||||
{ code: 'en', name: 'English', nativeName: 'English', flag: '🇺🇸', deeplTarget: 'EN', deeplSource: 'EN', bibleId: 'NKJV' },
|
{ code: 'en', name: 'English', nativeName: 'English', flag: '🇺🇸', bibleId: 'NKJV' },
|
||||||
{ code: 'es', name: 'Spanish', nativeName: 'Español', flag: '🇪🇸', deeplTarget: 'ES', deeplSource: 'ES', bibleId: 'NVI', glossaryId: 'b867e526-2eb8-426f-a178-b8a6b9d4d6ce' }, //RV1960
|
{ code: 'es', name: 'Spanish', nativeName: 'Español', flag: '🇪🇸', bibleId: 'NVI' }, //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: 'fr', name: 'French', nativeName: 'Français', flag: '🇫🇷', bibleId: 'FRLSG', bibleLabel: 'LSG' }, //maybe BDS
|
||||||
{ code: 'pt', name: 'Portuguese', nativeName: 'Português', flag: '🇵🇹', deeplTarget: 'PT-BR', deeplSource: 'PT', bibleId: 'NAA', glossaryId: 'bbe0697f-429a-45f7-9c30-576c7f21c78d' },
|
{ code: 'pt', name: 'Portuguese', nativeName: 'Português', flag: '🇵🇹', bibleId: 'NAA', translationName: 'Brazilian Portuguese' },
|
||||||
{ code: 'de', name: 'German', nativeName: 'Deutsch', flag: '🇩🇪', deeplTarget: 'DE', deeplSource: 'DE', bibleId: 'S00', bibleLabel: 'SCH2000', glossaryId: '87ad31eb-1a58-4f91-b80e-943b3721065d', hideFromSwitcher: true },
|
{ code: 'de', name: 'German', nativeName: 'Deutsch', flag: '🇩🇪', bibleId: 'S00', bibleLabel: 'SCH2000', hideFromSwitcher: true },
|
||||||
{ code: 'it', name: 'Italian', nativeName: 'Italiano', flag: '🇮🇹', deeplTarget: 'IT', deeplSource: 'IT', bibleId: 'NR06', glossaryId: '98dca739-468a-4c29-a0ce-ba89f60da00b', enabled: false },
|
{ code: 'it', name: 'Italian', nativeName: 'Italiano', flag: '🇮🇹', bibleId: 'NR06', 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: 'zh', name: 'Chinese', nativeName: '中文', flag: '🇨🇳', bibleId: 'CUNPS', translationName: 'Simplified Chinese', 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: '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: '🇷🇺', deeplTarget: 'RU', deeplSource: 'RU', bibleId: 'SYNOD', glossaryId: '7363b815-3cd1-4e1a-ae8b-d6695e6dbc4a', latinHeadings: false }, // NRT (New Russian Translation) is a modern alternative
|
{ code: 'ru', name: 'Russian', nativeName: 'Русский', flag: '🇷🇺', bibleId: 'SYNOD', 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: 'hi', name: 'Hindi', nativeName: 'हिन्दी', flag: '🇮🇳', 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: 'ro', name: 'Romanian', nativeName: 'Română', flag: '🇷🇴', bibleId: 'NTR', enabled: false },
|
||||||
]
|
]
|
||||||
|
|
||||||
// All language codes
|
// All language codes
|
||||||
|
|
@ -57,18 +56,6 @@ export function getLanguage(code: string): Language | undefined {
|
||||||
return LANGUAGES.find(l => l.code === code)
|
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).
|
// 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,
|
// 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
|
// 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
|
// unset, the endpoint falls back to public.mapboxToken below so
|
||||||
// existing setups keep working with no config change.
|
// existing setups keep working with no config change.
|
||||||
mapboxKey: process.env.NUXT_MAPBOX_KEY || '',
|
mapboxKey: process.env.NUXT_MAPBOX_KEY || '',
|
||||||
deeplApiKey: process.env.DEEPL_API_KEY || '',
|
// OpenRouter — LLM content translation. TRANSLATION_MODEL overrides the
|
||||||
deeplApiUrl: process.env.DEEPL_API_URL || 'https://api.deepl.com',
|
// 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).
|
// Cloudflare cache purge on deploy (server/plugins/cloudflare-purge.ts).
|
||||||
// Token needs only Zone → Cache Purge for the doxa.life zone.
|
// Token needs only Zone → Cache Purge for the doxa.life zone.
|
||||||
cfApiToken: process.env.CF_API_TOKEN || '',
|
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
|
// Admin: one-click translate an EN (or other source) translation of a
|
||||||
// CMS page into one or more target locales. Uses DeepL for title,
|
// CMS page into one or more target locales. Translates title, excerpt,
|
||||||
// excerpt, meta_title, meta_description, and body_json. Each target
|
// meta_title, meta_description, and body_json. Each target translation is
|
||||||
// translation is upserted with the caller-chosen `status` (draft or
|
// upserted with the caller-chosen `status` (draft or published, default
|
||||||
// published, default draft). `overwrite` controls whether to replace
|
// draft). `overwrite` controls whether to replace translations that
|
||||||
// translations that already exist for a given locale.
|
// 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.
|
// validation, per-locale cache purge — lives in cmsTranslate.translatePage.
|
||||||
// This route stays thin.
|
// This route stays thin.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,9 @@ export interface PageTranslationsTable {
|
||||||
updated: ColumnType<Date, Date | string | undefined, Date | string>
|
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 {
|
export interface PageTranslationVersionsTable {
|
||||||
id: Generated<string>
|
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.
|
// with upsert_page_translation when applying translations to pages.
|
||||||
//
|
//
|
||||||
// The configured DeepL glossary for each target locale is always
|
// The vendored glossary for each target locale is always injected into
|
||||||
// applied (the translateText service consults config/languages.ts),
|
// the translation prompt, keeping MCP-driven translations terminologically
|
||||||
// keeping MCP-driven translations terminologically consistent with
|
// consistent with admin auto-translates.
|
||||||
// admin auto-translates.
|
|
||||||
|
|
||||||
import { defineMcpTool, mcpLog } from '#mcp-layer'
|
import { defineMcpTool, mcpLog } from '#mcp-layer'
|
||||||
import { translateTextInput, translatePageInput } from '../schemas'
|
import { translateTextInput, translatePageInput } from '../schemas'
|
||||||
import { translateText, translatePage } from '../../services/cmsTranslate'
|
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."
|
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
|
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
|
// happened on Claude's behalf. Don't include the full text in
|
||||||
// metadata — keep the audit row small and non-PII-leaky.
|
// 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, {
|
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 ────────────────────────────────────────────
|
// ── 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.
|
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:
|
Inputs:
|
||||||
- \`page_id\`: the CMS page id.
|
- \`page_id\`: the CMS page id.
|
||||||
- \`source_locale\`: defaults to "en". Must be an enabled locale with an existing translation row.
|
- \`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.
|
- \`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 DeepL call). Set true to replace.
|
- \`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.
|
- \`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.
|
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({
|
export const translatePageTool = defineMcpTool({
|
||||||
name: 'translate_page',
|
name: 'translate_page',
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
// DeepL text-translation service — the canonical primitive both the
|
// LLM text-translation service — the canonical primitive both the
|
||||||
// MCP translate_text tool and any future admin batch-translate flow
|
// MCP translate_text tool and the admin batch-translate flow share.
|
||||||
// share.
|
|
||||||
//
|
//
|
||||||
// Always uses the per-locale DeepL glossary configured in
|
// The per-locale glossary vendored in config/glossaries.ts is injected
|
||||||
// config/languages.ts (via translateTexts()'s built-in lookup), so
|
// into every translation prompt, so the terminology stays consistent
|
||||||
// the terminology stays consistent with admin auto-translates and
|
// with admin auto-translates and with the translation/ glossaries repo.
|
||||||
// with the translation/ glossaries repo.
|
|
||||||
|
|
||||||
import type { H3Error } from 'h3'
|
import type { H3Error } from 'h3'
|
||||||
import { translateTexts, translateTiptapContent, isDeepLConfigured } from '../utils/deepl'
|
import { translateTexts, translateTiptapContent, isTranslationConfigured } from '../utils/translate'
|
||||||
import { ENABLED_LANGUAGE_CODES, getLanguage } from '~~/config/languages'
|
import { OpenRouterError, hasGlossary } from '../utils/openrouter'
|
||||||
|
import { ENABLED_LANGUAGE_CODES } from '~~/config/languages'
|
||||||
import { db } from '../utils/database'
|
import { db } from '../utils/database'
|
||||||
import {
|
import {
|
||||||
upsertCmsPageTranslation,
|
upsertCmsPageTranslation,
|
||||||
|
|
@ -41,12 +40,12 @@ export interface TranslateTextResult {
|
||||||
translations: TranslateTextResultEntry[]
|
translations: TranslateTextResultEntry[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_TEXT_BYTES = 100 * 1024 // DeepL's per-request soft limit; large bodies
|
const MAX_TEXT_BYTES = 100 * 1024 // Per-request ceiling; large bodies should be
|
||||||
// should be split client-side and translated piece by piece
|
// split client-side and translated piece by piece
|
||||||
|
|
||||||
export async function translateText(input: TranslateTextInput): Promise<TranslateTextResult> {
|
export async function translateText(input: TranslateTextInput): Promise<TranslateTextResult> {
|
||||||
if (!isDeepLConfigured()) {
|
if (!isTranslationConfigured()) {
|
||||||
throw err(503, 'DeepL is not configured on this server')
|
throw err(503, 'Translation is not configured on this server')
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = input.text ?? ''
|
const text = input.text ?? ''
|
||||||
|
|
@ -77,17 +76,15 @@ export async function translateText(input: TranslateTextInput): Promise<Translat
|
||||||
// Dedupe — Claude might list the same locale twice
|
// Dedupe — Claude might list the same locale twice
|
||||||
const targets = Array.from(new Set(input.target_locales))
|
const targets = Array.from(new Set(input.target_locales))
|
||||||
|
|
||||||
// One DeepL call per target locale (DeepL only translates to a single
|
// One model call per target locale — each prompt is built around a single
|
||||||
// target per request). The /translate endpoint applies the configured
|
// target language and carries that locale's glossary.
|
||||||
// glossary automatically based on the target locale.
|
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
targets.map(async (locale) => {
|
targets.map(async (locale) => {
|
||||||
const out = await translateTexts([text], locale, source)
|
const out = await translateTexts([text], locale, source)
|
||||||
const lang = getLanguage(locale)
|
|
||||||
return {
|
return {
|
||||||
locale,
|
locale,
|
||||||
text: out[0] ?? '',
|
text: out[0] ?? '',
|
||||||
glossary_used: Boolean(lang?.glossaryId)
|
glossary_used: hasGlossary(locale)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
@ -116,7 +113,7 @@ export interface TranslatePageInput {
|
||||||
export interface TranslatePageResultEntry {
|
export interface TranslatePageResultEntry {
|
||||||
locale: string
|
locale: string
|
||||||
// Mutually exclusive: either ok=true (translation written), skipped=true
|
// 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).
|
// failed for this locale only — other locales still proceed).
|
||||||
ok?: true
|
ok?: true
|
||||||
skipped?: true
|
skipped?: true
|
||||||
|
|
@ -130,15 +127,17 @@ export interface TranslatePageResult {
|
||||||
|
|
||||||
// Translate a whole CMS page (title, excerpt, meta_title,
|
// Translate a whole CMS page (title, excerpt, meta_title,
|
||||||
// meta_description, body_json) from a source locale into one or more
|
// meta_description, body_json) from a source locale into one or more
|
||||||
// targets. Per-locale failures don't abort the batch — DeepL flaking
|
// targets. Per-locale failures don't abort the batch — the model flaking
|
||||||
// on one locale shouldn't block the others.
|
// 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,
|
// Each successful translation is written via upsertCmsPageTranslation,
|
||||||
// so the standard validator + cache-invalidation path runs on every
|
// so the standard validator + cache-invalidation path runs on every
|
||||||
// translated row (consistent with hand-edited translations).
|
// translated row (consistent with hand-edited translations).
|
||||||
export async function translatePage(input: TranslatePageInput): Promise<TranslatePageResult> {
|
export async function translatePage(input: TranslatePageInput): Promise<TranslatePageResult> {
|
||||||
if (!isDeepLConfigured()) {
|
if (!isTranslationConfigured()) {
|
||||||
throw err(503, 'DeepL is not configured on this server')
|
throw err(503, 'Translation is not configured on this server')
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceLocale = input.source_locale ?? 'en'
|
const sourceLocale = input.source_locale ?? 'en'
|
||||||
|
|
@ -170,7 +169,7 @@ export async function translatePage(input: TranslatePageInput): Promise<Translat
|
||||||
|
|
||||||
const results: TranslatePageResultEntry[] = []
|
const results: TranslatePageResultEntry[] = []
|
||||||
|
|
||||||
for (const target of targets) {
|
for (const [i, target] of targets.entries()) {
|
||||||
if (!input.overwrite) {
|
if (!input.overwrite) {
|
||||||
const existing = await db
|
const existing = await db
|
||||||
.selectFrom('page_translations')
|
.selectFrom('page_translations')
|
||||||
|
|
@ -207,7 +206,7 @@ export async function translatePage(input: TranslatePageInput): Promise<Translat
|
||||||
og_image: source.og_image,
|
og_image: source.og_image,
|
||||||
status,
|
status,
|
||||||
actor_user_id: input.actor_user_id ?? null,
|
actor_user_id: input.actor_user_id ?? null,
|
||||||
source: 'deepl'
|
source: 'auto-translate'
|
||||||
})
|
})
|
||||||
|
|
||||||
// Per-locale cache purge after each successful upsert. This
|
// 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'
|
const msg = e instanceof Error ? e.message : 'translation failed'
|
||||||
console.error(`[cmsTranslate] page ${input.page_id} → ${target} failed:`, msg)
|
console.error(`[cmsTranslate] page ${input.page_id} → ${target} failed:`, msg)
|
||||||
results.push({ locale: target, error: 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
|
// optional because some background paths may not have a user; the
|
||||||
// snapshot still gets written, just with NULLs.
|
// snapshot still gets written, just with NULLs.
|
||||||
actor_user_id?: string | null
|
actor_user_id?: string | null
|
||||||
source?: 'admin-ui' | 'mcp' | 'deepl'
|
source?: 'admin-ui' | 'mcp' | 'auto-translate' | 'deepl'
|
||||||
user_agent?: string | null
|
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,
|
default: true,
|
||||||
public: 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
|
} as const
|
||||||
|
|
||||||
export type SettingKey = keyof typeof settingDefs
|
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