mirror of
https://github.com/lingble/chatwoot.git
synced 2025-10-30 10:42:38 +00:00
chore: Improve translation service with HTML and plain text support (#11305)
# Pull Request Template ## Description This PR changes to translation to properly handle different content types during translation. ### Changes 1. **Email translation with HTML support** - Properly detects and preserves HTML content from emails - Sets `mime_type` to 'text/html' when HTML content is present 2. **Email translation with plain text support** - Falls back to email text content when HTML is not available - Sets `mime_type` to 'text/plain' when HTML is not available and content type includes 'text/plain' 3. **Plain message with plain text support (Non email channels)** - Sets `mime_type` to 'text/plain' for non-email channels - Fixes an issue where Markdown formatting was being lost due to incorrect `mime_type` **Note**: Translation for very long emails is not currently supported. Fixes https://linear.app/chatwoot/issue/CW-4244/translate-button-doesnt-work-in-email-channels ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? **Loom video** https://www.loom.com/share/8f8428ed2cfe415ea5cb6c547c070f00?sid=eab9fa11-05f8-4838-9181-334bee1023c4 ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
<script setup>
|
||||
import { defineProps, defineEmits } from 'vue';
|
||||
|
||||
defineProps({
|
||||
showingOriginal: Boolean,
|
||||
});
|
||||
|
||||
defineEmits(['toggle']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span>
|
||||
<span
|
||||
class="text-xs text-n-slate-11 cursor-pointer hover:underline select-none"
|
||||
@click="$emit('toggle')"
|
||||
>
|
||||
{{
|
||||
showingOriginal
|
||||
? $t('CONVERSATION.VIEW_TRANSLATED')
|
||||
: $t('CONVERSATION.VIEW_ORIGINAL')
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -9,9 +9,11 @@ import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
import FormattedContent from 'next/message/bubbles/Text/FormattedContent.vue';
|
||||
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
|
||||
import EmailMeta from './EmailMeta.vue';
|
||||
import TranslationToggle from 'dashboard/components-next/message/TranslationToggle.vue';
|
||||
|
||||
import { useMessageContext } from '../../provider.js';
|
||||
import { MESSAGE_TYPES } from 'next/message/constants.js';
|
||||
import { useTranslations } from 'dashboard/composables/useTranslations';
|
||||
|
||||
const { content, contentAttributes, attachments, messageType } =
|
||||
useMessageContext();
|
||||
@@ -19,35 +21,77 @@ const { content, contentAttributes, attachments, messageType } =
|
||||
const isExpandable = ref(false);
|
||||
const isExpanded = ref(false);
|
||||
const showQuotedMessage = ref(false);
|
||||
const renderOriginal = ref(false);
|
||||
const contentContainer = useTemplateRef('contentContainer');
|
||||
|
||||
onMounted(() => {
|
||||
isExpandable.value = contentContainer.value?.scrollHeight > 400;
|
||||
});
|
||||
|
||||
const isOutgoing = computed(() => {
|
||||
return messageType.value === MESSAGE_TYPES.OUTGOING;
|
||||
});
|
||||
const isOutgoing = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
|
||||
const isIncoming = computed(() => !isOutgoing.value);
|
||||
|
||||
const textToShow = computed(() => {
|
||||
const { hasTranslations, translationContent } =
|
||||
useTranslations(contentAttributes);
|
||||
|
||||
const originalEmailText = computed(() => {
|
||||
const text =
|
||||
contentAttributes?.value?.email?.textContent?.full ?? content.value;
|
||||
return text?.replace(/\n/g, '<br>');
|
||||
});
|
||||
|
||||
// Use TextContent as the default to fullHTML
|
||||
const originalEmailHtml = computed(
|
||||
() =>
|
||||
contentAttributes?.value?.email?.htmlContent?.full ??
|
||||
originalEmailText.value
|
||||
);
|
||||
|
||||
const messageContent = computed(() => {
|
||||
// If translations exist and we're showing translations (not original)
|
||||
if (hasTranslations.value && !renderOriginal.value) {
|
||||
return translationContent.value;
|
||||
}
|
||||
// Otherwise show original content
|
||||
return content.value;
|
||||
});
|
||||
|
||||
const textToShow = computed(() => {
|
||||
// If translations exist and we're showing translations (not original)
|
||||
if (hasTranslations.value && !renderOriginal.value) {
|
||||
return translationContent.value;
|
||||
}
|
||||
// Otherwise show original text
|
||||
return originalEmailText.value;
|
||||
});
|
||||
|
||||
const fullHTML = computed(() => {
|
||||
return contentAttributes?.value?.email?.htmlContent?.full ?? textToShow.value;
|
||||
// If translations exist and we're showing translations (not original)
|
||||
if (hasTranslations.value && !renderOriginal.value) {
|
||||
return translationContent.value;
|
||||
}
|
||||
// Otherwise show original HTML
|
||||
return originalEmailHtml.value;
|
||||
});
|
||||
|
||||
const unquotedHTML = computed(() => {
|
||||
return EmailQuoteExtractor.extractQuotes(fullHTML.value);
|
||||
const unquotedHTML = computed(() =>
|
||||
EmailQuoteExtractor.extractQuotes(fullHTML.value)
|
||||
);
|
||||
|
||||
const hasQuotedMessage = computed(() =>
|
||||
EmailQuoteExtractor.hasQuotes(fullHTML.value)
|
||||
);
|
||||
|
||||
// Ensure unique keys for <Letter> when toggling between original and translated views.
|
||||
// This forces Vue to re-render the component and update content correctly.
|
||||
const translationKeySuffix = computed(() => {
|
||||
if (renderOriginal.value) return 'original';
|
||||
if (hasTranslations.value) return 'translated';
|
||||
return 'original';
|
||||
});
|
||||
|
||||
const hasQuotedMessage = computed(() => {
|
||||
return EmailQuoteExtractor.hasQuotes(fullHTML.value);
|
||||
});
|
||||
const handleSeeOriginal = () => {
|
||||
renderOriginal.value = !renderOriginal.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -88,11 +132,12 @@ const hasQuotedMessage = computed(() => {
|
||||
<FormattedContent
|
||||
v-if="isOutgoing && content"
|
||||
class="text-n-slate-12"
|
||||
:content="content"
|
||||
:content="messageContent"
|
||||
/>
|
||||
<template v-else>
|
||||
<Letter
|
||||
v-if="showQuotedMessage"
|
||||
:key="`letter-quoted-${translationKeySuffix}`"
|
||||
class-name="prose prose-bubble !max-w-none letter-render"
|
||||
:allowed-css-properties="[
|
||||
...allowedCssProperties,
|
||||
@@ -104,6 +149,7 @@ const hasQuotedMessage = computed(() => {
|
||||
/>
|
||||
<Letter
|
||||
v-else
|
||||
:key="`letter-unquoted-${translationKeySuffix}`"
|
||||
class-name="prose prose-bubble !max-w-none letter-render"
|
||||
:html="unquotedHTML"
|
||||
:allowed-css-properties="[
|
||||
@@ -135,6 +181,12 @@ const hasQuotedMessage = computed(() => {
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<TranslationToggle
|
||||
v-if="hasTranslations"
|
||||
class="py-2 px-3"
|
||||
:showing-original="renderOriginal"
|
||||
@toggle="handleSeeOriginal"
|
||||
/>
|
||||
<section
|
||||
v-if="Array.isArray(attachments) && attachments.length"
|
||||
class="px-4 pb-4 space-y-2"
|
||||
|
||||
@@ -3,16 +3,16 @@ import { computed, ref } from 'vue';
|
||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
import FormattedContent from './FormattedContent.vue';
|
||||
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
|
||||
import TranslationToggle from 'dashboard/components-next/message/TranslationToggle.vue';
|
||||
import { MESSAGE_TYPES } from '../../constants';
|
||||
import { useMessageContext } from '../../provider.js';
|
||||
import { useTranslations } from 'dashboard/composables/useTranslations';
|
||||
|
||||
const { content, attachments, contentAttributes, messageType } =
|
||||
useMessageContext();
|
||||
|
||||
const hasTranslations = computed(() => {
|
||||
const { translations = {} } = contentAttributes.value;
|
||||
return Object.keys(translations || {}).length > 0;
|
||||
});
|
||||
const { hasTranslations, translationContent } =
|
||||
useTranslations(contentAttributes);
|
||||
|
||||
const renderOriginal = ref(false);
|
||||
|
||||
@@ -22,8 +22,7 @@ const renderContent = computed(() => {
|
||||
}
|
||||
|
||||
if (hasTranslations.value) {
|
||||
const translations = contentAttributes.value.translations;
|
||||
return translations[Object.keys(translations)[0]];
|
||||
return translationContent.value;
|
||||
}
|
||||
|
||||
return content.value;
|
||||
@@ -37,12 +36,6 @@ const isEmpty = computed(() => {
|
||||
return !content.value && !attachments.value?.length;
|
||||
});
|
||||
|
||||
const viewToggleKey = computed(() => {
|
||||
return renderOriginal.value
|
||||
? 'CONVERSATION.VIEW_TRANSLATED'
|
||||
: 'CONVERSATION.VIEW_ORIGINAL';
|
||||
});
|
||||
|
||||
const handleSeeOriginal = () => {
|
||||
renderOriginal.value = !renderOriginal.value;
|
||||
};
|
||||
@@ -55,15 +48,12 @@ const handleSeeOriginal = () => {
|
||||
{{ $t('CONVERSATION.NO_CONTENT') }}
|
||||
</span>
|
||||
<FormattedContent v-if="renderContent" :content="renderContent" />
|
||||
<span class="-mt-3">
|
||||
<span
|
||||
v-if="hasTranslations"
|
||||
class="text-xs text-n-slate-11 cursor-pointer hover:underline"
|
||||
@click="handleSeeOriginal"
|
||||
>
|
||||
{{ $t(viewToggleKey) }}
|
||||
</span>
|
||||
</span>
|
||||
<TranslationToggle
|
||||
v-if="hasTranslations"
|
||||
class="-mt-3"
|
||||
:showing-original="renderOriginal"
|
||||
@toggle="handleSeeOriginal"
|
||||
/>
|
||||
<AttachmentChips :attachments="attachments" class="gap-2" />
|
||||
<template v-if="isTemplate">
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ref } from 'vue';
|
||||
import { useTranslations } from '../useTranslations';
|
||||
|
||||
describe('useTranslations', () => {
|
||||
it('returns false and null when contentAttributes is null', () => {
|
||||
const contentAttributes = ref(null);
|
||||
const { hasTranslations, translationContent } =
|
||||
useTranslations(contentAttributes);
|
||||
expect(hasTranslations.value).toBe(false);
|
||||
expect(translationContent.value).toBeNull();
|
||||
});
|
||||
|
||||
it('returns false and null when translations are missing', () => {
|
||||
const contentAttributes = ref({});
|
||||
const { hasTranslations, translationContent } =
|
||||
useTranslations(contentAttributes);
|
||||
expect(hasTranslations.value).toBe(false);
|
||||
expect(translationContent.value).toBeNull();
|
||||
});
|
||||
|
||||
it('returns false and null when translations is an empty object', () => {
|
||||
const contentAttributes = ref({ translations: {} });
|
||||
const { hasTranslations, translationContent } =
|
||||
useTranslations(contentAttributes);
|
||||
expect(hasTranslations.value).toBe(false);
|
||||
expect(translationContent.value).toBeNull();
|
||||
});
|
||||
|
||||
it('returns true and correct translation content when translations exist', () => {
|
||||
const contentAttributes = ref({
|
||||
translations: { en: 'Hello' },
|
||||
});
|
||||
const { hasTranslations, translationContent } =
|
||||
useTranslations(contentAttributes);
|
||||
expect(hasTranslations.value).toBe(true);
|
||||
// Should return the first translation (en: 'Hello')
|
||||
expect(translationContent.value).toBe('Hello');
|
||||
});
|
||||
});
|
||||
22
app/javascript/dashboard/composables/useTranslations.js
Normal file
22
app/javascript/dashboard/composables/useTranslations.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { computed } from 'vue';
|
||||
|
||||
/**
|
||||
* Composable to extract translation state/content from contentAttributes.
|
||||
* @param {Ref|Reactive} contentAttributes - Ref or reactive object containing `translations` property
|
||||
* @returns {Object} { hasTranslations, translationContent }
|
||||
*/
|
||||
export function useTranslations(contentAttributes) {
|
||||
const hasTranslations = computed(() => {
|
||||
if (!contentAttributes.value) return false;
|
||||
const { translations = {} } = contentAttributes.value;
|
||||
return Object.keys(translations || {}).length > 0;
|
||||
});
|
||||
|
||||
const translationContent = computed(() => {
|
||||
if (!hasTranslations.value) return null;
|
||||
const translations = contentAttributes.value.translations;
|
||||
return translations[Object.keys(translations)[0]];
|
||||
});
|
||||
|
||||
return { hasTranslations, translationContent };
|
||||
}
|
||||
@@ -1,15 +1,19 @@
|
||||
require 'google/cloud/translate/v3'
|
||||
|
||||
class Integrations::GoogleTranslate::ProcessorService
|
||||
pattr_initialize [:message!, :target_language!]
|
||||
|
||||
def perform
|
||||
return if message.content.blank?
|
||||
return if hook.blank?
|
||||
|
||||
content = translation_content
|
||||
return if content.blank?
|
||||
|
||||
response = client.translate_text(
|
||||
contents: [message.content],
|
||||
contents: [content],
|
||||
target_language_code: target_language,
|
||||
parent: "projects/#{hook.settings['project_id']}"
|
||||
parent: "projects/#{hook.settings['project_id']}",
|
||||
mime_type: mime_type
|
||||
)
|
||||
|
||||
return if response.translations.first.blank?
|
||||
@@ -19,6 +23,43 @@ class Integrations::GoogleTranslate::ProcessorService
|
||||
|
||||
private
|
||||
|
||||
def email_channel?
|
||||
message&.inbox&.email?
|
||||
end
|
||||
|
||||
def email_content
|
||||
@email_content ||= {
|
||||
html: message.content_attributes.dig('email', 'html_content', 'full'),
|
||||
text: message.content_attributes.dig('email', 'text_content', 'full'),
|
||||
content_type: message.content_attributes.dig('email', 'content_type')
|
||||
}
|
||||
end
|
||||
|
||||
def html_content_available?
|
||||
email_content[:html].present?
|
||||
end
|
||||
|
||||
def plain_text_content_available?
|
||||
email_content[:content_type]&.include?('text/plain') &&
|
||||
email_content[:text].present?
|
||||
end
|
||||
|
||||
def translation_content
|
||||
return message.content unless email_channel?
|
||||
return email_content[:html] if html_content_available?
|
||||
return email_content[:text] if plain_text_content_available?
|
||||
|
||||
message.content
|
||||
end
|
||||
|
||||
def mime_type
|
||||
if email_channel? && html_content_available?
|
||||
'text/html'
|
||||
else
|
||||
'text/plain'
|
||||
end
|
||||
end
|
||||
|
||||
def hook
|
||||
@hook ||= message.account.hooks.find_by(app_id: 'google_translate')
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user