mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-11-01 03:27:52 +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 FormattedContent from 'next/message/bubbles/Text/FormattedContent.vue'; | ||||||
| import AttachmentChips from 'next/message/chips/AttachmentChips.vue'; | import AttachmentChips from 'next/message/chips/AttachmentChips.vue'; | ||||||
| import EmailMeta from './EmailMeta.vue'; | import EmailMeta from './EmailMeta.vue'; | ||||||
|  | import TranslationToggle from 'dashboard/components-next/message/TranslationToggle.vue'; | ||||||
|  |  | ||||||
| import { useMessageContext } from '../../provider.js'; | import { useMessageContext } from '../../provider.js'; | ||||||
| import { MESSAGE_TYPES } from 'next/message/constants.js'; | import { MESSAGE_TYPES } from 'next/message/constants.js'; | ||||||
|  | import { useTranslations } from 'dashboard/composables/useTranslations'; | ||||||
|  |  | ||||||
| const { content, contentAttributes, attachments, messageType } = | const { content, contentAttributes, attachments, messageType } = | ||||||
|   useMessageContext(); |   useMessageContext(); | ||||||
| @@ -19,35 +21,77 @@ const { content, contentAttributes, attachments, messageType } = | |||||||
| const isExpandable = ref(false); | const isExpandable = ref(false); | ||||||
| const isExpanded = ref(false); | const isExpanded = ref(false); | ||||||
| const showQuotedMessage = ref(false); | const showQuotedMessage = ref(false); | ||||||
|  | const renderOriginal = ref(false); | ||||||
| const contentContainer = useTemplateRef('contentContainer'); | const contentContainer = useTemplateRef('contentContainer'); | ||||||
|  |  | ||||||
| onMounted(() => { | onMounted(() => { | ||||||
|   isExpandable.value = contentContainer.value?.scrollHeight > 400; |   isExpandable.value = contentContainer.value?.scrollHeight > 400; | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const isOutgoing = computed(() => { | const isOutgoing = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING); | ||||||
|   return messageType.value === MESSAGE_TYPES.OUTGOING; |  | ||||||
| }); |  | ||||||
| const isIncoming = computed(() => !isOutgoing.value); | const isIncoming = computed(() => !isOutgoing.value); | ||||||
|  |  | ||||||
| const textToShow = computed(() => { | const { hasTranslations, translationContent } = | ||||||
|  |   useTranslations(contentAttributes); | ||||||
|  |  | ||||||
|  | const originalEmailText = computed(() => { | ||||||
|   const text = |   const text = | ||||||
|     contentAttributes?.value?.email?.textContent?.full ?? content.value; |     contentAttributes?.value?.email?.textContent?.full ?? content.value; | ||||||
|   return text?.replace(/\n/g, '<br>'); |   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(() => { | 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(() => { | const unquotedHTML = computed(() => | ||||||
|   return EmailQuoteExtractor.extractQuotes(fullHTML.value); |   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(() => { | const handleSeeOriginal = () => { | ||||||
|   return EmailQuoteExtractor.hasQuotes(fullHTML.value); |   renderOriginal.value = !renderOriginal.value; | ||||||
| }); | }; | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <template> | <template> | ||||||
| @@ -88,11 +132,12 @@ const hasQuotedMessage = computed(() => { | |||||||
|         <FormattedContent |         <FormattedContent | ||||||
|           v-if="isOutgoing && content" |           v-if="isOutgoing && content" | ||||||
|           class="text-n-slate-12" |           class="text-n-slate-12" | ||||||
|           :content="content" |           :content="messageContent" | ||||||
|         /> |         /> | ||||||
|         <template v-else> |         <template v-else> | ||||||
|           <Letter |           <Letter | ||||||
|             v-if="showQuotedMessage" |             v-if="showQuotedMessage" | ||||||
|  |             :key="`letter-quoted-${translationKeySuffix}`" | ||||||
|             class-name="prose prose-bubble !max-w-none letter-render" |             class-name="prose prose-bubble !max-w-none letter-render" | ||||||
|             :allowed-css-properties="[ |             :allowed-css-properties="[ | ||||||
|               ...allowedCssProperties, |               ...allowedCssProperties, | ||||||
| @@ -104,6 +149,7 @@ const hasQuotedMessage = computed(() => { | |||||||
|           /> |           /> | ||||||
|           <Letter |           <Letter | ||||||
|             v-else |             v-else | ||||||
|  |             :key="`letter-unquoted-${translationKeySuffix}`" | ||||||
|             class-name="prose prose-bubble !max-w-none letter-render" |             class-name="prose prose-bubble !max-w-none letter-render" | ||||||
|             :html="unquotedHTML" |             :html="unquotedHTML" | ||||||
|             :allowed-css-properties="[ |             :allowed-css-properties="[ | ||||||
| @@ -135,6 +181,12 @@ const hasQuotedMessage = computed(() => { | |||||||
|         </button> |         </button> | ||||||
|       </div> |       </div> | ||||||
|     </section> |     </section> | ||||||
|  |     <TranslationToggle | ||||||
|  |       v-if="hasTranslations" | ||||||
|  |       class="py-2 px-3" | ||||||
|  |       :showing-original="renderOriginal" | ||||||
|  |       @toggle="handleSeeOriginal" | ||||||
|  |     /> | ||||||
|     <section |     <section | ||||||
|       v-if="Array.isArray(attachments) && attachments.length" |       v-if="Array.isArray(attachments) && attachments.length" | ||||||
|       class="px-4 pb-4 space-y-2" |       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 BaseBubble from 'next/message/bubbles/Base.vue'; | ||||||
| import FormattedContent from './FormattedContent.vue'; | import FormattedContent from './FormattedContent.vue'; | ||||||
| import AttachmentChips from 'next/message/chips/AttachmentChips.vue'; | import AttachmentChips from 'next/message/chips/AttachmentChips.vue'; | ||||||
|  | import TranslationToggle from 'dashboard/components-next/message/TranslationToggle.vue'; | ||||||
| import { MESSAGE_TYPES } from '../../constants'; | import { MESSAGE_TYPES } from '../../constants'; | ||||||
| import { useMessageContext } from '../../provider.js'; | import { useMessageContext } from '../../provider.js'; | ||||||
|  | import { useTranslations } from 'dashboard/composables/useTranslations'; | ||||||
|  |  | ||||||
| const { content, attachments, contentAttributes, messageType } = | const { content, attachments, contentAttributes, messageType } = | ||||||
|   useMessageContext(); |   useMessageContext(); | ||||||
|  |  | ||||||
| const hasTranslations = computed(() => { | const { hasTranslations, translationContent } = | ||||||
|   const { translations = {} } = contentAttributes.value; |   useTranslations(contentAttributes); | ||||||
|   return Object.keys(translations || {}).length > 0; |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| const renderOriginal = ref(false); | const renderOriginal = ref(false); | ||||||
|  |  | ||||||
| @@ -22,8 +22,7 @@ const renderContent = computed(() => { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   if (hasTranslations.value) { |   if (hasTranslations.value) { | ||||||
|     const translations = contentAttributes.value.translations; |     return translationContent.value; | ||||||
|     return translations[Object.keys(translations)[0]]; |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   return content.value; |   return content.value; | ||||||
| @@ -37,12 +36,6 @@ const isEmpty = computed(() => { | |||||||
|   return !content.value && !attachments.value?.length; |   return !content.value && !attachments.value?.length; | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const viewToggleKey = computed(() => { |  | ||||||
|   return renderOriginal.value |  | ||||||
|     ? 'CONVERSATION.VIEW_TRANSLATED' |  | ||||||
|     : 'CONVERSATION.VIEW_ORIGINAL'; |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| const handleSeeOriginal = () => { | const handleSeeOriginal = () => { | ||||||
|   renderOriginal.value = !renderOriginal.value; |   renderOriginal.value = !renderOriginal.value; | ||||||
| }; | }; | ||||||
| @@ -55,15 +48,12 @@ const handleSeeOriginal = () => { | |||||||
|         {{ $t('CONVERSATION.NO_CONTENT') }} |         {{ $t('CONVERSATION.NO_CONTENT') }} | ||||||
|       </span> |       </span> | ||||||
|       <FormattedContent v-if="renderContent" :content="renderContent" /> |       <FormattedContent v-if="renderContent" :content="renderContent" /> | ||||||
|       <span class="-mt-3"> |       <TranslationToggle | ||||||
|         <span |         v-if="hasTranslations" | ||||||
|           v-if="hasTranslations" |         class="-mt-3" | ||||||
|           class="text-xs text-n-slate-11 cursor-pointer hover:underline" |         :showing-original="renderOriginal" | ||||||
|           @click="handleSeeOriginal" |         @toggle="handleSeeOriginal" | ||||||
|         > |       /> | ||||||
|           {{ $t(viewToggleKey) }} |  | ||||||
|         </span> |  | ||||||
|       </span> |  | ||||||
|       <AttachmentChips :attachments="attachments" class="gap-2" /> |       <AttachmentChips :attachments="attachments" class="gap-2" /> | ||||||
|       <template v-if="isTemplate"> |       <template v-if="isTemplate"> | ||||||
|         <div |         <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' | require 'google/cloud/translate/v3' | ||||||
|  |  | ||||||
| class Integrations::GoogleTranslate::ProcessorService | class Integrations::GoogleTranslate::ProcessorService | ||||||
|   pattr_initialize [:message!, :target_language!] |   pattr_initialize [:message!, :target_language!] | ||||||
|  |  | ||||||
|   def perform |   def perform | ||||||
|     return if message.content.blank? |  | ||||||
|     return if hook.blank? |     return if hook.blank? | ||||||
|  |  | ||||||
|  |     content = translation_content | ||||||
|  |     return if content.blank? | ||||||
|  |  | ||||||
|     response = client.translate_text( |     response = client.translate_text( | ||||||
|       contents: [message.content], |       contents: [content], | ||||||
|       target_language_code: target_language, |       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? |     return if response.translations.first.blank? | ||||||
| @@ -19,6 +23,43 @@ class Integrations::GoogleTranslate::ProcessorService | |||||||
|  |  | ||||||
|   private |   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 |   def hook | ||||||
|     @hook ||= message.account.hooks.find_by(app_id: 'google_translate') |     @hook ||= message.account.hooks.find_by(app_id: 'google_translate') | ||||||
|   end |   end | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 Sivin Varghese
					Sivin Varghese