Browse Source

Fixes #5476 - Language detection mechanism.

Co-authored-by: Mantas Masalskis <mm@zammad.com>
Co-authored-by: Rolf Schmidt <rolf.schmidt@zammad.com>
Co-authored-by: Dusan Vuckovic <dv@zammad.com>
Rolf Schmidt 1 month ago
parent
commit
933a673891

+ 3 - 0
Gemfile

@@ -201,6 +201,9 @@ gem 'macaddr'
 # watch file changes (also relevant for graphql generation in context of CDs)
 gem 'listen'
 
+# language detection
+gem 'cld'
+
 # Gems used only for develop/test and not required
 # in production environments by default.
 group :development, :test do

+ 3 - 0
Gemfile.lock

@@ -167,6 +167,8 @@ GEM
       logger (~> 1.5)
     chunky_png (1.4.0)
     clavius (1.0.4)
+    cld (0.13.0)
+      ffi
     clearbit (0.3.3)
       nestful (~> 1.1.0)
     coderay (1.1.3)
@@ -823,6 +825,7 @@ DEPENDENCIES
   byk
   capybara
   chunky_png
+  cld
   clearbit
   coffee-rails
   csv

+ 1 - 0
app/assets/javascripts/app/controllers/_manage/ticket.coffee

@@ -9,6 +9,7 @@ class Ticket extends App.ControllerTabs
       { name: __('Base'),                target: 'base',                controller: App.SettingsArea, params: { area: 'Ticket::Base' } }
       { name: __('Number'),              target: 'number',              controller: App.SettingsArea, params: { area: 'Ticket::Number' } }
       { name: __('Auto Assignment'),     target: 'auto_assignment',     controller: App.SettingTicketAutoAssignment }
+      { name: __('Language Detection'),  target: 'language_detection',  controller: App.SettingsArea, params: { area: 'Ticket::LanguageDetection' } }
       { name: __('Notifications'),       target: 'notification',        controller: App.SettingTicketNotifications }
       { name: __('Duplicate Detection'), target: 'duplicate_detection', controller: App.SettingTicketDuplicateDetection }
     ]

+ 6 - 0
app/assets/javascripts/app/views/ticket_zoom/article_view.jst.eco

@@ -153,6 +153,12 @@
 <div class="article-meta-clip bottom">
   <div class="article-content-meta bottom hide">
     <div class="article-meta bottom">
+    <% if @article.detected_language && @ticket.currentView() is 'agent': %>
+      <div class="horizontal article-meta-row">
+        <div class="article-meta-key"><%- @T('Detected Language') %></div>
+        <div class="article-meta-value"><%- @P(@article, 'detected_language') %></div>
+      </div>
+    <% end %>
       <div class="horizontal article-meta-row">
         <div class="article-meta-key"><%- @T('Channel') %></div>
         <div class="article-meta-value">

+ 15 - 0
app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/ArticleBubble/__tests__/ArticleBubbleHeaderMetaFields.spec.ts

@@ -1,5 +1,6 @@
 // Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
 
+import { waitFor } from '@testing-library/vue'
 import { expect } from 'vitest'
 
 import {
@@ -120,5 +121,19 @@ describe('ArticleBubbleMetaFields', () => {
         '/api/vuejs.org/',
       )
     })
+
+    it('displays detected language name if available', async () => {
+      const wrapper = renderWrapper('web', {
+        articleData: {
+          detectedLanguage: 'de',
+        },
+      })
+
+      expect(wrapper.getByText('Detected language')).toBeInTheDocument()
+
+      await waitFor(() => {
+        expect(wrapper.getByText('German')).toBeInTheDocument()
+      })
+    })
   })
 })

+ 37 - 0
app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/ArticleMeta/ArticleMetaDetectedLanguage.vue

@@ -0,0 +1,37 @@
+<!-- Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ -->
+
+<script setup lang="ts">
+import { computed } from 'vue'
+
+import ObjectAttributeContent from '#shared/components/ObjectAttributes/ObjectAttribute.vue'
+import { useObjectAttributes } from '#shared/entities/object-attributes/composables/useObjectAttributes.ts'
+import type { TicketArticle } from '#shared/entities/ticket/types.ts'
+import { EnumObjectManagerObjects } from '#shared/graphql/types.ts'
+
+interface Props {
+  context: {
+    article: TicketArticle
+  }
+}
+
+defineProps<Props>()
+
+const { attributesLookup } = useObjectAttributes(
+  EnumObjectManagerObjects.TicketArticle,
+)
+
+const detectedLanguageAttribute = computed(() =>
+  attributesLookup.value.get('detected_language'),
+)
+</script>
+
+<template>
+  <CommonLabel class="text-black dark:text-white">
+    <ObjectAttributeContent
+      v-if="detectedLanguageAttribute"
+      :attribute="detectedLanguageAttribute"
+      :object="context.article"
+    />
+    <template v-else>{{ context.article.detectedLanguage }}</template>
+  </CommonLabel>
+</template>

+ 32 - 0
app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/ArticleMeta/__tests__/ArticleMeta.spec.ts

@@ -1,5 +1,6 @@
 // Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
 
+import { waitFor } from '@testing-library/vue'
 import { describe } from 'vitest'
 
 import { renderComponent } from '#tests/support/components/index.ts'
@@ -9,6 +10,7 @@ import { EnumSecurityStateType } from '#shared/graphql/types.ts'
 
 import { mockDetailViewSetup } from '#desktop/pages/ticket/components/TicketDetailView/__tests__/support/article-detail-view-mocks.ts'
 import ArticleMetaAddress from '#desktop/pages/ticket/components/TicketDetailView/ArticleMeta/ArticleMetaAddress.vue'
+import ArticleMetaDetectedLanguage from '#desktop/pages/ticket/components/TicketDetailView/ArticleMeta/ArticleMetaDetectedLanguage.vue'
 import ArticleMetaSecurity from '#desktop/pages/ticket/components/TicketDetailView/ArticleMeta/ArticleMetaSecurity.vue'
 import ArticleMetaWhatsappMessageStatus from '#desktop/pages/ticket/components/TicketDetailView/ArticleMeta/ArticleMetaWhatsappMessageStatus.vue'
 
@@ -59,6 +61,36 @@ describe('Article Meta', () => {
     })
   })
 
+  describe('Detected Language', () => {
+    it('shows detected language name', async () => {
+      const wrapper = renderComponent(
+        {
+          setup() {
+            const { article } = mockDetailViewSetup({
+              article: {
+                articleType: 'email',
+                detectedLanguage: 'de',
+              },
+            })
+            return { article }
+          },
+          template: `
+          <div>
+            <ArticleMetaDetectedLanguage :context="{article}" />
+          </div>`,
+          components: { ArticleMetaDetectedLanguage },
+        },
+        {
+          router: true,
+        },
+      )
+
+      await waitFor(() => {
+        expect(wrapper.getByText('German')).toBeInTheDocument()
+      })
+    })
+  })
+
   describe('Security', () => {
     it('has PGB encrypted and signed', () => {
       const wrapper = renderComponent(

+ 8 - 0
app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/ArticleMeta/useArticleMeta.ts

@@ -7,6 +7,7 @@ import type { TicketArticle } from '#shared/entities/ticket/types.ts'
 
 import { lookupArticlePlugin } from '#desktop/pages/ticket/components/TicketDetailView/article-type/index.ts'
 import ArticleMetaFieldAddress from '#desktop/pages/ticket/components/TicketDetailView/ArticleMeta/ArticleMetaAddress.vue'
+import ArticleMetaFieldDetectedLanguage from '#desktop/pages/ticket/components/TicketDetailView/ArticleMeta/ArticleMetaDetectedLanguage.vue'
 import type { ChannelMetaField } from '#desktop/pages/ticket/components/TicketDetailView/ArticleMeta/types.ts'
 
 const getNestedProperty = (article: TicketArticle, nestedKeys: string[]) => {
@@ -96,6 +97,13 @@ export const useArticleMeta = (article: Ref<TicketArticle>) => {
           !!(article.value.cc?.parsed?.[0]?.name || article.value.cc?.raw),
         order: 350,
       },
+      {
+        label: __('Detected language'),
+        name: 'detectedLanguage',
+        component: ArticleMetaFieldDetectedLanguage,
+        show: () => !!article.value.detectedLanguage?.length,
+        order: 375,
+      },
       {
         label: __('Channel'),
         name: 'channel',

+ 27 - 0
app/frontend/apps/mobile/entities/ticket/__tests__/mocks/ticket-mocks.ts

@@ -275,6 +275,33 @@ export const ticketObjectAttributes = () => ({
 
 export const ticketArticleObjectAttributes = () => ({
   attributes: [
+    {
+      name: 'detected_language',
+      display: 'Detected Language',
+      dataType: 'select',
+      dataOption: {
+        maxlength: 255,
+        nulloption: true,
+        multiple: false,
+        null: true,
+        default: '',
+        translate: false,
+        options: {
+          de: 'German',
+        },
+        historical_options: {
+          de: 'German',
+        },
+      },
+      isInternal: true,
+      screens: {
+        create_middle: {},
+        edit: {
+          null: false,
+        },
+      },
+      __typename: 'ObjectManagerFrontendAttribute',
+    },
     {
       name: 'type_id',
       display: 'Type',

+ 22 - 0
app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/ArticleMetadataDialog.vue

@@ -3,11 +3,14 @@
 <script setup lang="ts">
 import { computed, toRef } from 'vue'
 
+import ObjectAttributeContent from '#shared/components/ObjectAttributes/ObjectAttribute.vue'
 import { useArticleSecurity } from '#shared/composables/useArticleSecurity.ts'
+import { useObjectAttributes } from '#shared/entities/object-attributes/composables/useObjectAttributes.ts'
 import { useWhatsapp } from '#shared/entities/ticket/channel/composables/useWhatsapp.ts'
 import type { TicketArticle } from '#shared/entities/ticket/types.ts'
 import { getArticleChannelIcon } from '#shared/entities/ticket-article/composables/getArticleChannelIcon.ts'
 import { translateArticleSecurity } from '#shared/entities/ticket-article/composables/translateArticleSecurity.ts'
+import { EnumObjectManagerObjects } from '#shared/graphql/types.ts'
 
 import CommonDialog from '#mobile/components/CommonDialog/CommonDialog.vue'
 import CommonSectionMenu from '#mobile/components/CommonSectionMenu/CommonSectionMenu.vue'
@@ -72,6 +75,14 @@ const {
   encryptedStatusMessage,
   signedStatusMessage,
 } = useArticleSecurity(toRef(props.article))
+
+const { attributesLookup } = useObjectAttributes(
+  EnumObjectManagerObjects.TicketArticle,
+)
+
+const detectedLanguageAttribute = computed(() =>
+  attributesLookup.value.get('detected_language'),
+)
 </script>
 
 <template>
@@ -87,6 +98,17 @@ const {
       <CommonSectionMenuItem v-if="article.subject" :label="__('Subject')">
         <div>{{ article.subject }}</div>
       </CommonSectionMenuItem>
+      <CommonSectionMenuItem
+        v-if="article.detectedLanguage"
+        :label="__('Detected language')"
+      >
+        <ObjectAttributeContent
+          v-if="detectedLanguageAttribute"
+          :attribute="detectedLanguageAttribute"
+          :object="article"
+        />
+        <div v-else>{{ article.detectedLanguage }}</div>
+      </CommonSectionMenuItem>
       <CommonSectionMenuItem v-if="article.type?.name" :label="__('Channel')">
         <span class="inline-flex items-center gap-1">
           <CommonIcon

Some files were not shown because too many files changed in this diff