@@ -217,6 +218,10 @@
:account="account"
:envelopes="[envelope]"
@close="onCloseTagModal" />
+
@@ -299,6 +304,7 @@ import TagModal from './TagModal.vue'
import MoveModal from './MoveModal.vue'
import TaskModal from './TaskModal.vue'
import EventModal from './EventModal.vue'
+import TranslationModal from './TranslationModal.vue'
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
import { loadState } from '@nextcloud/initial-state'
@@ -307,6 +313,7 @@ import { mapStores } from 'pinia'
import moment from '@nextcloud/moment'
import { translateTagDisplayName } from '../util/tag.js'
import { FOLLOW_UP_TAG_LABEL } from '../store/constants.js'
+import { Text, toPlain } from '../util/text.js'
// Ternary loading state
const LOADING_DONE = 0
@@ -321,6 +328,7 @@ export default {
TaskModal,
MoveModal,
TagModal,
+ TranslationModal,
ConfirmModal,
Avatar,
NcActionButton,
@@ -398,6 +406,8 @@ export default {
showEventModal: false,
showTaskModal: false,
showTagModal: false,
+ showTranslationModal: false,
+ plainTextBody: '',
rawMessage: '', // Will hold the raw source of the message when requested
isInternal: true,
enabledSmartReply: loadState('mail', 'llm_freeprompt_available', false),
@@ -863,6 +873,23 @@ export default {
onCloseTagModal() {
this.showTagModal = false
},
+ onOpenTranslationModal() {
+ try {
+ if (this.message.hasHtmlBody) {
+ let text = new Text('html', this.message.body)
+ text = toPlain(text)
+ this.plainTextBody = text.value
+ } else {
+ this.plainTextBody = this.message.body
+ }
+ this.showTranslationModal = true
+ } catch (error) {
+ showError(t('mail', 'Please wait for the message to load'))
+ }
+ },
+ onCloseTranslationModal() {
+ this.showTranslationModal = false
+ },
async onShowSourceModal() {
if (this.rawMessage.length === 0) {
const resp = await axios.get(
diff --git a/src/components/TranslationModal.vue b/src/components/TranslationModal.vue
new file mode 100644
index 0000000000..5e69120ba1
--- /dev/null
+++ b/src/components/TranslationModal.vue
@@ -0,0 +1,224 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ isLoading ? t('mail', 'Translating') : t('mail', 'Translate') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('mail', 'Copy translated text') }}
+
+
+
+
+
+
+
+
diff --git a/src/main.js b/src/main.js
index dbe3fa7738..d114fd65de 100644
--- a/src/main.js
+++ b/src/main.js
@@ -21,6 +21,7 @@ import { fixAccountId } from './service/AccountService.js'
import { loadState } from '@nextcloud/initial-state'
import { createPinia, PiniaVuePlugin } from 'pinia'
import useOutboxStore from './store/outboxStore.js'
+import { fetchAvailableLanguages } from './service/translationService.js'
// eslint-disable-next-line camelcase
__webpack_nonce__ = btoa(getRequestToken())
@@ -149,6 +150,12 @@ store.commit('setFollowUpFeatureAvailable', followUpFeatureAvailable)
const smimeCertificates = loadState('mail', 'smime-certificates', [])
store.commit('setSmimeCertificates', smimeCertificates)
+const llmTranslationEnabled = loadState('mail', 'llm_translation_enabled', false)
+store.commit('enableTranslation', llmTranslationEnabled)
+if (llmTranslationEnabled) {
+ fetchAvailableLanguages()
+}
+
/* eslint-disable vue/match-component-file-name */
export default new Vue({
el: '#content',
diff --git a/src/service/translationService.js b/src/service/translationService.js
new file mode 100644
index 0000000000..153b5b2ba3
--- /dev/null
+++ b/src/service/translationService.js
@@ -0,0 +1,44 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import axios from '@nextcloud/axios'
+import { generateOcsUrl } from '@nextcloud/router'
+import store from '../store/index.js'
+
+const fetchAvailableLanguages = async function() {
+ try {
+ const response = await axios.get(generateOcsUrl('taskprocessing/tasktypes'))
+ const inputLanguages = response.data.ocs.data.types['core:text2text:translate'].inputShapeEnumValues[0]
+ const outputLanguages = response.data.ocs.data.types['core:text2text:translate'].inputShapeEnumValues[1]
+ store.commit('setTranslationInputLanguages', inputLanguages)
+ store.commit('setTranslationOutputLanguages', outputLanguages)
+ } catch (e) {
+ console.error('Failed to fetch available languages', e)
+ }
+}
+
+const translateText = async function(text, fromLanguage, toLanguage) {
+ const scheduleResponse = await axios.post(generateOcsUrl('taskprocessing/schedule'), {
+ input: {
+ origin_language: fromLanguage ?? null,
+ input: text,
+ target_language: toLanguage,
+ },
+ type: 'core:text2text:translate',
+ appId: 'mail',
+ })
+ const task = scheduleResponse.data.ocs.data.task
+ const getTaskOutput = async (task) => {
+ if (task.output) {
+ return task.output.output
+ }
+ await new Promise(resolve => setTimeout(resolve, 2000))
+ const taskResponse = await axios.get(generateOcsUrl(`taskprocessing/task/${task.id}`))
+ return getTaskOutput(taskResponse.data.ocs.data.task)
+ }
+ return await getTaskOutput(task)
+}
+
+export { fetchAvailableLanguages, translateText }
diff --git a/src/store/getters.js b/src/store/getters.js
index f0b8351b88..daa0f4d0fd 100644
--- a/src/store/getters.js
+++ b/src/store/getters.js
@@ -160,4 +160,7 @@ export const getters = {
getInternalAddresses: (state) => state.internalAddress?.filter(internalAddress => internalAddress !== undefined),
hasCurrentUserPrincipalAndCollections: (state) => state.hasCurrentUserPrincipalAndCollections,
showSettingsForAccount: (state) => (accountId) => state.showAccountSettings === accountId,
+ isTranslationEnabled: (state) => state.isTranslationEnabled,
+ getTranslationInputLanguages: (state) => state.translationInputLanguages,
+ getTranslationOutputLanguages: (state) => state.translationOutputLanguages,
}
diff --git a/src/store/index.js b/src/store/index.js
index 55bfb36d5a..5f2ae3c81e 100644
--- a/src/store/index.js
+++ b/src/store/index.js
@@ -109,6 +109,9 @@ export default new Store({
internalAddress: [],
hasCurrentUserPrincipalAndCollections: false,
showAccountSettings: null,
+ isTranslationEnabled: false,
+ translationInputLanguages: [],
+ translationOutputLanguages: [],
},
getters,
mutations,
diff --git a/src/store/mutations.js b/src/store/mutations.js
index 60214361e5..fbe127ef28 100644
--- a/src/store/mutations.js
+++ b/src/store/mutations.js
@@ -519,4 +519,13 @@ export default {
showSettingsForAccount(state, accountId) {
state.showAccountSettings = accountId
},
+ enableTranslation(state, enabled) {
+ state.isTranslationEnabled = enabled
+ },
+ setTranslationInputLanguages(state, languages) {
+ state.translationInputLanguages = languages
+ },
+ setTranslationOutputLanguages(state, languages) {
+ state.translationOutputLanguages = languages
+ },
}
diff --git a/src/util/text.js b/src/util/text.js
index 5d32878a40..fb4f86cef6 100644
--- a/src/util/text.js
+++ b/src/util/text.js
@@ -10,7 +10,7 @@ import { convert } from 'html-to-text'
/**
* @type {Text}
*/
-class Text {
+export class Text {
constructor(format, value) {
this.format = format
diff --git a/tests/Unit/Controller/PageControllerTest.php b/tests/Unit/Controller/PageControllerTest.php
index 5ea42fde9b..c5245a74d4 100644
--- a/tests/Unit/Controller/PageControllerTest.php
+++ b/tests/Unit/Controller/PageControllerTest.php
@@ -299,7 +299,7 @@ public function testIndex(): void {
->method('getLoginCredentials')
->willReturn($loginCredentials);
- $this->initialState->expects($this->exactly(19))
+ $this->initialState->expects($this->exactly(20))
->method('provideInitialState')
->withConsecutive(
['debug', true],
@@ -318,6 +318,7 @@ public function testIndex(): void {
['disable-snooze', false],
['allow-new-accounts', true],
['llm_summaries_available', false],
+ ['llm_translation_enabled', false],
['llm_freeprompt_available', false],
['llm_followup_available', false],
['smime-certificates', []],