mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-03 04:27:53 +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="text-xs text-n-slate-11 cursor-pointer hover:underline"
|
class="-mt-3"
|
||||||
@click="handleSeeOriginal"
|
:showing-original="renderOriginal"
|
||||||
>
|
@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