From 1b399f65ebf3d71a4485d0dabe7c8b9bc33c3166 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Fri, 22 May 2026 07:21:45 -0400 Subject: [PATCH] feat(campaigns): AI template translator via Gemini Flash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New "Traduire (AI)" button in the template editor toolbar. One click translates the current template's HTML to the opposite language (detected from the -fr/-en suffix), writing the translated content as the matching companion template. Backend (lib/campaigns.js): - New endpoint: POST /campaigns/templates/:name/translate-to/:targetName - Reads source .html, calls lib/ai.js aiCall() with Gemini Flash - System prompt enforces 7 strict preservation rules: 1. Byte-preserve all HTML tags/attributes/styles/Outlook conditionals 2. Don't translate Mustache {{vars}} 3. Preserve URLs/emails/phones/hex colors/CSS/brand names (TARGO, Gigafibre, Giftbit, Amazon, IGA, Tim Hortons, etc.) 4. Preserve emojis (🎁 ⚑ 🀝 πŸͺ‚ βœ… ⏭️ ⏰) 5. Keep the warm informal tone (tu in FR, you in EN) 6. Translate only visible text inside elements (paragraphs, buttons, alt attributes, link text) 7. Output full HTML doc only, no markdown wrapping - temperature=0.2 for stable output, maxTokens=32768 to fit ~35 KB HTML - Sanity validates output isn't truncated (>50% of source size) - Strips defensive markdown fences if AI ignored rule 7 - Auto-backs up existing target before overwrite - Regenerates Unlayer design JSON from the translated HTML so the editor can reload the translated template visually - Requires { override: true } in body to overwrite existing target (409 Conflict otherwise β€” protects against accidental clobber) API client (apps/ops/src/api/campaigns.js): - translateTemplate(srcName, targetName, { override }) Frontend (TemplateEditorPage.vue): - "Traduire (AI)" button (purple, icon=translate) in toolbar β€” disabled when current template has no -fr/-en suffix - aiTranslateTargetName computed: detects source lang from suffix, flips to opposite (-fr β†’ -en, -en β†’ -fr) - Confirmation dialog: β€’ Shows source β†’ target template names β€’ Info banner explaining what's preserved (HTML, vars, brands, emojis) β€’ Amber banner + toggle if target exists (must confirm override) - On success: positive notification with byte counts + "Open" action button to jump to the translated template - Refreshes templates list after translation so the new file appears in the selector dropdown UX: replaces the previous manual translation workflow (where the user or I had to maintain two parallel templates). One click now does the whole round-trip. User reviews + adjusts wording in the EN editor if the AI translation needs polish. Co-Authored-By: Claude Opus 4.7 --- apps/ops/src/api/campaigns.js | 10 ++ .../campaigns/pages/TemplateEditorPage.vue | 87 +++++++++- services/targo-hub/lib/campaigns.js | 163 ++++++++++++++++++ 3 files changed, 259 insertions(+), 1 deletion(-) diff --git a/apps/ops/src/api/campaigns.js b/apps/ops/src/api/campaigns.js index bdaae66..a52402f 100644 --- a/apps/ops/src/api/campaigns.js +++ b/apps/ops/src/api/campaigns.js @@ -127,6 +127,16 @@ export function previewTemplate (name, { html, vars } = {}) { }) } +// Translate the source template to a target language via Gemini. +// targetName must match the source's prefix (e.g. gift-email-fr β†’ gift-email-en). +// override=true required if the target already exists. +export function translateTemplate (srcName, targetName, { override = false } = {}) { + return hubFetch( + `/campaigns/templates/${encodeURIComponent(srcName)}/translate-to/${encodeURIComponent(targetName)}`, + { method: 'POST', body: { override } }, + ) +} + // Send ONE rendered email to a specific address for visual QA. // Pass { to, vars, from?, subject? } β€” defaults filled in server-side. export function testSendTemplate (name, { to, vars, from, subject } = {}) { diff --git a/apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue b/apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue index dac085a..9165777 100644 --- a/apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue +++ b/apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue @@ -24,6 +24,10 @@ Voir le HTML rendu (substituΓ©) tel que reΓ§u par le destinataire + + {{ aiTranslateTargetName ? `Traduire vers ${aiTranslateTargetName} via Gemini` : 'Disponible pour les templates avec suffixe -fr ou -en' }} + Envoyer un courriel rΓ©el Γ  une adresse de test @@ -97,6 +101,43 @@ + + + + + +
Traduire avec Gemini AI
+ + +
+ +
+ Le contenu de {{ currentName }} sera traduit vers + {{ aiTranslateTargetName }} via Gemini Flash. +
+ + + Le AI prΓ©serve la structure HTML, les variables {{ '{{...}}' }}, + les URLs, les noms de marque (TARGO, Giftbit) et les emojis. + Il traduit seulement le texte visible. + + + + Le template {{ aiTranslateTargetName }} existe dΓ©jΓ . + La traduction va l'Γ©craser (backup automatique avant). + + +
+ + + + +
+
+ @@ -142,7 +183,7 @@ import { useRoute, useRouter } from 'vue-router' import { useQuasar } from 'quasar' import { EmailEditor } from 'vue-email-editor' import { listTemplates, getTemplate, saveTemplate as saveTemplateApi, - previewTemplate, testSendTemplate } from 'src/api/campaigns' + previewTemplate, testSendTemplate, translateTemplate } from 'src/api/campaigns' const route = useRoute() const router = useRouter() @@ -231,6 +272,50 @@ async function loadAvailableTemplates () { templates.value = await listTemplates() } +// ── AI translation (Gemini via hub) ───────────────────────────────────────── +// Auto-detect source language from the template name suffix (-fr / -en) and +// compute the target name with the OPPOSITE suffix. +const aiTranslateOpen = ref(false) +const aiTranslating = ref(false) +const aiTranslateOverride = ref(false) + +const aiTranslateTargetName = computed(() => { + const m = (currentName.value || '').match(/^(.+)-(fr|en)$/) + if (!m) return '' + const opposite = m[2] === 'fr' ? 'en' : 'fr' + return `${m[1]}-${opposite}` +}) + +const targetTemplateExists = computed(() => + !!aiTranslateTargetName.value && !!templates.value.find(t => t.name === aiTranslateTargetName.value), +) + +async function doAiTranslate () { + if (!aiTranslateTargetName.value) return + aiTranslating.value = true + try { + const r = await translateTemplate(currentName.value, aiTranslateTargetName.value, { + override: aiTranslateOverride.value, + }) + aiTranslateOpen.value = false + aiTranslateOverride.value = false + await loadAvailableTemplates() + $q.notify({ + type: 'positive', + message: `Traduction terminΓ©e : ${r.source} β†’ ${r.target}`, + caption: `${r.src_bytes} β†’ ${r.out_bytes} octets (${r.from_lang} β†’ ${r.to_lang})`, + timeout: 6000, + actions: [ + { label: 'Ouvrir', color: 'white', handler: () => { onSelectTemplate(r.target) } }, + ], + }) + } catch (e) { + $q.notify({ type: 'negative', message: 'Γ‰chec traduction: ' + e.message, timeout: 6000 }) + } finally { + aiTranslating.value = false + } +} + // ── New template creation ─────────────────────────────────────────────────── const newTemplateOpen = ref(false) const creating = ref(false) diff --git a/services/targo-hub/lib/campaigns.js b/services/targo-hub/lib/campaigns.js index 09ea6b2..0c5da32 100644 --- a/services/targo-hub/lib/campaigns.js +++ b/services/targo-hub/lib/campaigns.js @@ -45,6 +45,16 @@ const sse = require('./sse') // save time so the send-worker only ever reads pre-compiled HTML. const mjml2html = require('mjml') +// AI service (Gemini Flash) β€” used by the template translation endpoint to +// convert email content between FR ↔ EN while preserving HTML structure. +// Lazy require because lib/ai.js requires AI_API_KEY which may not be set +// in dev environments β€” we only fail when the endpoint is actually called. +let aiLib = null +function getAi () { + if (!aiLib) aiLib = require('./ai') + return aiLib +} + const DATA_DIR = path.join(__dirname, '..', 'data', 'campaigns') if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }) @@ -1147,6 +1157,159 @@ async function handle (req, res, method, path) { } } + // POST /campaigns/templates/:name/translate-to/:targetName β€” translate the + // source template's HTML content via Gemini Flash and save as targetName. + // Preserves HTML structure, Mustache {{vars}}, URLs, brand names, emojis. + // Body (optional): { override: true } to overwrite an existing target. + const tplTranslate = path.match(/^\/campaigns\/templates\/([a-zA-Z0-9_-]+)\/translate-to\/([a-zA-Z0-9_-]+)$/) + if (tplTranslate && method === 'POST') { + const srcName = tplTranslate[1] + const targetName = tplTranslate[2] + if (!isValidTemplateName(srcName) || !isValidTemplateName(targetName)) { + return json(res, 400, { error: 'invalid source or target template name' }) + } + if (srcName === targetName) { + return json(res, 400, { error: 'source and target must differ' }) + } + // Detect direction from name suffix (-fr β†’ translate to en, -en β†’ fr) + // Falls back to the explicit target language inferred from target name. + const srcLang = srcName.match(/-([a-z]{2})(-|$)/)?.[1] || 'fr' + const tgtLang = targetName.match(/-([a-z]{2})(-|$)/)?.[1] || (srcLang === 'fr' ? 'en' : 'fr') + let srcHtml + try { srcHtml = fs.readFileSync(templatePath(srcName), 'utf8') } + catch { return json(res, 404, { error: 'source template not found' }) } + + const body = await parseBody(req).catch(() => ({})) + const targetHtmlPath = templatePath(targetName) + if (fs.existsSync(targetHtmlPath) && !body?.override) { + return json(res, 409, { + error: 'target template already exists', + hint: 'POST again with { "override": true } to overwrite', + }) + } + + const langNames = { fr: 'French Canadian (franΓ§ais du QuΓ©bec)', en: 'English' } + const systemPrompt = [ + `You are a professional bilingual translator for TARGO, a Quebec-based`, + `fiber Internet ISP. You translate marketing email content between`, + `French Canadian and English while strictly preserving HTML structure.`, + ``, + `STRICT RULES β€” DO NOT VIOLATE:`, + `1. PRESERVE every HTML tag, attribute, class, inline style, and Outlook`, + ` conditional comment () byte-for-byte.`, + `2. PRESERVE Mustache variables: {{firstname}}, {{amount}}, {{gift_url}},`, + ` {{commitment_months}}, {{description}}, {{expiry}}, {{year}}, etc.`, + ` DO NOT translate the content inside {{ }}.`, + `3. PRESERVE URLs (href, src), email addresses, phone numbers, hex colors,`, + ` CSS values, font names, brand names (TARGO, Gigafibre, Giftbit, Mailjet,`, + ` Amazon, IGA, Tim Hortons, etc.).`, + `4. PRESERVE all emojis as-is (🎁 ⚑ 🀝 πŸͺ‚ βœ… ⏭️ ⏰).`, + `5. KEEP the warm conversational tone β€” use "tu" (informal) in French and`, + ` "you" (informal) in English. Marketing-friendly, not corporate.`, + `6. TRANSLATE only the visible text content inside elements: paragraphs,`, + ` headings, button labels, link text, alt attributes.`, + `7. Output the COMPLETE translated HTML document (starting from or to the closing ). NO explanation, NO markdown`, + ` code fence, NO commentary β€” just the HTML.`, + ].join('\n') + + const userPrompt = `Translate the following ${langNames[srcLang]} email HTML to ${langNames[tgtLang]}. ` + + `Apply the rules from the system prompt strictly.\n\n${srcHtml}` + + let translated + try { + // Non-JSON mode (we want raw HTML back), high token limit (~35 KB output) + translated = await getAi().aiCall(systemPrompt, userPrompt, { + jsonMode: false, + maxTokens: 32768, + temperature: 0.2, // low temp = stable, less creative translation + }) + } catch (e) { + return json(res, 502, { error: 'AI translation failed', detail: e.message }) + } + + // Sanity checks β€” refuse to save garbled output + if (typeof translated !== 'string' || translated.length < srcHtml.length * 0.5) { + return json(res, 502, { + error: 'AI returned truncated or invalid output', + src_bytes: srcHtml.length, + out_bytes: typeof translated === 'string' ? translated.length : 0, + }) + } + // Strip optional markdown code fences if Gemini still added them despite the prompt + translated = translated.replace(/^```(?:html)?\s*\n?/i, '').replace(/\n?```\s*$/i, '').trim() + + // Backup existing target before overwriting + try { + if (fs.existsSync(targetHtmlPath)) { + const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) + fs.copyFileSync(targetHtmlPath, targetHtmlPath.replace(/\.html$/, `.bak-${ts}.html`)) + const jsonPath = templateJsonPath(targetName) + if (fs.existsSync(jsonPath)) fs.copyFileSync(jsonPath, jsonPath.replace(/\.json$/, `.bak-${ts}.json`)) + } + } catch (e) { log('translate backup failed:', e.message) } + + // Write translated HTML + fs.writeFileSync(targetHtmlPath, translated, 'utf8') + + // Regenerate the Unlayer design JSON via the converter so the editor + // can reload the translated template visually. Inline equivalent of + // running scripts/convert-html-to-unlayer.js for this target. + const bodyMatch = translated.match(/]*>([\s\S]*?)<\/body>/i) + const innerHtml = bodyMatch ? bodyMatch[1].trim() : translated + const preheaderMatch = innerHtml.match(/]*display:\s*none[^>]*>\s*([^<]+?)\s*<\/div>/i) + const preheader = preheaderMatch ? preheaderMatch[1].trim() : '' + const design = { + counters: { u_row: 1, u_column: 1, u_content_html: 1 }, + body: { + id: 'BODY-1', + rows: [{ + id: 'ROW-1', cells: [1], + columns: [{ + id: 'COL-1', + contents: [{ + id: 'HTML-1', type: 'html', + values: { + html: innerHtml, hideDesktop: false, displayCondition: null, + containerPadding: '0px', + _meta: { htmlID: 'u_content_html_1', htmlClassNames: 'u_content_html' }, + selectable: true, draggable: true, duplicatable: true, deletable: true, hideable: true, + }, + }], + values: { _meta: { htmlID: 'u_column_1', htmlClassNames: 'u_column' } }, + }], + values: { + displayCondition: null, columns: false, backgroundColor: '', padding: '0px', + _meta: { htmlID: 'u_row_1', htmlClassNames: 'u_row' }, + selectable: true, draggable: true, duplicatable: true, deletable: true, hideable: true, + }, + }], + values: { + contentWidth: '600px', + fontFamily: { label: 'Plus Jakarta Sans', value: "'Plus Jakarta Sans', sans-serif" }, + textColor: '#1B2E24', + backgroundColor: '#F5FAF7', + preheaderText: preheader, + linkStyle: { body: true, linkColor: '#00C853', linkUnderline: true }, + _meta: { htmlID: 'u_body', htmlClassNames: 'u_body' }, + }, + }, + schemaVersion: 12, + } + fs.writeFileSync(templateJsonPath(targetName), JSON.stringify(design, null, 2), 'utf8') + + log(`translate: ${srcName} (${srcHtml.length}b ${srcLang}) β†’ ${targetName} (${translated.length}b ${tgtLang})`) + return json(res, 200, { + source: srcName, + target: targetName, + from_lang: srcLang, + to_lang: tgtLang, + src_bytes: srcHtml.length, + out_bytes: translated.length, + saved: true, + }) + } + // POST /campaigns/templates/:name/test-send β€” send ONE rendered email to a // specific address for visual QA. Uses the campaign Mailjet sender + // throttle. NOT linked to any campaign β€” purely for template testing.