'use strict'; const Diff = require('diff'); const { createCoreService } = require('@strapi/strapi').factories; const { ApplicationError } = require('@strapi/utils').errors; const LANG_MAP = { fr: { field: 'francais', targetLang: 'fr', userPrompt: 'Tradui an fransé' }, en: { field: 'anglais', targetLang: 'en', userPrompt: 'Translate to English', deeplTarget: 'EN', suffix: '\n\n (Translated by DeepL)' }, es: { field: 'espagnol', targetLang: 'es', userPrompt: 'Traduce al español', deeplTarget: 'ES', suffix: '\n\n (Traducido por DeepL)' }, de: { field: 'allemand', targetLang: 'de', userPrompt: 'Übersetze auf Deutsch', deeplTarget: 'DE', suffix: '\n\n (Übersetzt von DeepL)' }, it: { field: 'italien', targetLang: 'it', userPrompt: 'Traduci in italiano', deeplTarget: 'IT', suffix: '\n\n (Tradotto da DeepL)' }, pt: { field: 'portugais', targetLang: 'pt', userPrompt: 'Traduza para o português', deeplTarget: 'PT-BR', suffix: '\n\n (Traduzido pela DeepL)' }, ja: { field: 'japonais', targetLang: 'ja', userPrompt: '日本語に翻訳して', deeplTarget: 'JA', suffix: '\n\n (DeepLによる翻訳)' }, ko: { field: 'coreen', targetLang: 'ko', userPrompt: '한국어로 번역해줘', deeplTarget: 'KO', suffix: '\n\n (DeepL 번역)' }, }; const ALL_LANGS = Object.keys(LANG_MAP); function stripMarkdown(text) { if (!text) return ''; return text .replace(/#{1,6}\s+/g, '') .replace(/\*\*(.*?)\*\*/gs, '$1') .replace(/\*(.*?)\*/gs, '$1') .replace(/__(.*?)__/gs, '$1') .replace(/_(.*?)_/gs, '$1') .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') .replace(/^[>\-*+]\s+/gm, '') .replace(/\n{3,}/g, '\n\n') .trim(); } // Détecte si une transcription est probablement en français plutôt qu'en KA. // Heuristique : si les pronoms personnels français représentent > 4 % des mots. const FR_PRONOUNS = new Set(['je', 'tu', 'il', 'elle', 'nous', 'vous', 'ils', 'elles']); function suspectFrench(text) { if (!text) return false; const words = text.toLowerCase().match(/\b[a-zàâäéèêëîïôöùûüç]+\b/g) || []; if (words.length < 10) return false; const frCount = words.filter(w => FR_PRONOUNS.has(w)).length; return frCount / words.length > 0.04; } const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); class Translator { constructor() { this.deeplApi = process.env.DEEPL_URL || 'api-free.deepl.com'; this.deeplKey = process.env.DEEPL_KEY; this.urlRequest = `https://${this.deeplApi}/v2/translate`; } async get(origin, target, text) { const response = await fetch(this.urlRequest, { method: 'POST', headers: { Authorization: `DeepL-Auth-Key ${this.deeplKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ text: Array.isArray(text) ? text : [text], source_lang: origin, target_lang: target, }), signal: AbortSignal.timeout(15_000) }); if (!response.ok) { const body = await response.text(); console.error('DeepL error:', body); throw new Error(`DeepL ${response.status}: ${body}`); } return await response.json(); } } const translator = new Translator(); module.exports = createCoreService('api::parole.parole', ({strapi}) => ({ async translate(origin, target, text) { const data = await translator.get(origin, target, text); return data.translations[0].text; }, async translateLyrics(parolesFR) { const result = { francais: parolesFR }; for (const lang of ALL_LANGS) { if (lang === 'fr') continue; const { field, deeplTarget, suffix } = LANG_MAP[lang]; try { const translated = await this.translate('FR', deeplTarget, parolesFR); result[field] = translated + suffix; } catch (err) { strapi.log.error(`DeepL (${deeplTarget}): ${err.message}`); } } return result; }, validateParoles(titre, transcription) { if (!titre || titre.trim().length === 0) { throw new ApplicationError('Champ obligatoire. Veuillez choisir un titre.'); } if (!transcription || transcription.trim().length === 0) { throw new ApplicationError('Champ obligatoire. Veuillez renseigner la transcription.'); } if (transcription.trim().length < 10) { throw new ApplicationError('La transcription doit contenir au moins 10 caractères.'); } }, async fetchAllParoles() { const pageSize = 100; let start = 0; const all = []; for (;;) { const batch = await strapi.documents('api::parole.parole').findMany({ status: 'published', populate: ['artistes', 'traductions'], fields: ['documentId', 'titre', 'slug', 'transcription', 'annee', 'langueSource'], limit: pageSize, start, }); all.push(...batch); if (batch.length < pageSize) break; start += pageSize; } return all; }, buildExport(paroles, type, langs) { const targetLangs = langs && langs.length ? langs : ALL_LANGS; const pairs = []; const missing = []; const nonKa = []; const langCounts = {}; for (const parole of paroles) { const source = stripMarkdown(parole.transcription); const sourceLang = parole.langueSource || 'ka'; const artists = (parole.artistes || []).map(a => a.alias); const paroleMeta = { title: parole.titre, artists }; if (sourceLang !== 'ka') { nonKa.push({ documentId: parole.documentId, slug: parole.slug, ...paroleMeta, suspected_lang: sourceLang }); } else if (suspectFrench(source)) { nonKa.push({ documentId: parole.documentId, slug: parole.slug, ...paroleMeta, suspected_lang: 'fr' }); } const missingLangs = ALL_LANGS.filter(lang => !parole.traductions?.[LANG_MAP[lang].field]); if (missingLangs.length > 0) { missing.push({ documentId: parole.documentId, slug: parole.slug, ...paroleMeta, missing: missingLangs }); } for (const lang of targetLangs) { const { field, targetLang, userPrompt } = LANG_MAP[lang]; if (lang === sourceLang) continue; const target = stripMarkdown(parole.traductions?.[field]); if (!target) continue; langCounts[lang] = (langCounts[lang] || 0) + 1; if (type === 'instruct') { const systemPrompt = sourceLang === 'ka' ? 'Tu es un expert en langue KA (créole guadeloupéen/martiniquais). Traduis le texte KA suivant.' : `Tu es un expert en traduction. Traduis le texte suivant (langue source : ${sourceLang}).`; pairs.push({ messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: `${userPrompt} :\n\n${source}` }, { role: 'assistant', content: target }, ], }); } else { pairs.push({ source_lang: sourceLang, target_lang: targetLang, source, target, ...paroleMeta, }); } } } const metadata = { exported_at: new Date().toISOString(), total_paroles: paroles.length, total_pairs: pairs.length, languages: langCounts, missing_translations: missing, non_ka_transcriptions: nonKa, }; return { metadata, pairs }; }, async bulkTranslateMissing() { const TARGET_LANGS = ALL_LANGS .filter(lang => lang !== 'fr') .map(lang => ({ lang, ...LANG_MAP[lang] })); const pageSize = 100; let start = 0; const all = []; for (;;) { const batch = await strapi.documents('api::parole.parole').findMany({ status: 'published', populate: ['traductions'], fields: ['documentId', 'slug', 'titre', 'transcription', 'langueSource'], limit: pageSize, start, }); all.push(...batch); if (batch.length < pageSize) break; start += pageSize; } const translated = []; const skipped = []; const errors = []; for (const parole of all) { const sourceFR = parole.traductions?.francais || (parole.langueSource === 'fr' ? parole.transcription : null); if (!sourceFR) { skipped.push(parole.slug); continue; } const missing = TARGET_LANGS.filter(({ field }) => !parole.traductions?.[field]); if (missing.length === 0) { skipped.push(parole.slug); continue; } // eslint-disable-next-line no-unused-vars -- id exclu intentionnellement du spread const { id: _id, ...tradData } = parole.traductions || {}; const updatedTrad = { ...tradData }; const addedLangs = []; for (const { lang, field, deeplTarget, suffix } of missing) { try { await sleep(700); const result = await translator.get('FR', deeplTarget, sourceFR); const text = result?.translations?.[0]?.text; if (text) { updatedTrad[field] = text + suffix; addedLangs.push(lang); } } catch (err) { errors.push({ slug: parole.slug, lang: deeplTarget, error: err.message }); } } if (addedLangs.length > 0) { await strapi.documents('api::parole.parole').update({ documentId: parole.documentId, data: { traductions: updatedTrad }, }); await strapi.documents('api::parole.parole').publish({ documentId: parole.documentId, }); translated.push({ slug: parole.slug, langs: addedLangs }); } } return { translated, skipped, errors }; }, parolesDiff(titre = '', oldString, newString) { const patch = Diff.createPatch(titre, oldString, newString, 'supprimée', 'ajoutée'); const parsePatch = Diff.parsePatch(patch); if (parsePatch[0].hunks.length > 0) { const jsonDiff = Diff.diffWords(oldString, newString); return { patch, jsonDiff }; } } }));