Auto translate service (#9978)

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
This commit is contained in:
Kristina
2025-10-05 22:45:51 +07:00
committed by GitHub
parent baa42a1bf9
commit 43d3fef71f
48 changed files with 1553 additions and 82 deletions
+77 -8
View File
@@ -34,9 +34,6 @@ importers:
'@faker-js/faker':
specifier: ^8.4.1
version: 8.4.1
'@hcengineering/measurements':
specifier: ^0.7.7
version: 0.7.7
'@hcengineering/measurements-otlp':
specifier: ^0.7.7
version: 0.7.7(encoding@0.1.13)
@@ -913,6 +910,9 @@ importers:
'@rush-temp/pod-telegram-bot':
specifier: file:./projects/pod-telegram-bot.tgz
version: file:projects/pod-telegram-bot.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@swc/core@1.13.5)(babel-jest@29.7.0(@babel/core@7.23.9))(encoding@0.1.13)(gcp-metadata@5.3.0(encoding@0.1.13))(snappy@7.2.2)(socks@2.8.3)
'@rush-temp/pod-translate':
specifier: file:./projects/pod-translate.tgz
version: file:projects/pod-translate.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@swc/core@1.13.5)(babel-jest@29.7.0(@babel/core@7.23.9))(encoding@0.1.13)(zod@3.24.2)
'@rush-temp/pod-worker':
specifier: file:./projects/pod-worker.tgz
version: file:projects/pod-worker.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@swc/core@1.13.5)(babel-jest@29.7.0(@babel/core@7.23.9))(webpack-cli@5.1.4)
@@ -1963,6 +1963,9 @@ importers:
form-data:
specifier: ^4.0.0
version: 4.0.0
franc-min:
specifier: ^6.2.0
version: 6.2.0
gaxios:
specifier: ^5.0.1
version: 5.1.3(encoding@0.1.13)
@@ -4556,7 +4559,7 @@ packages:
version: 0.0.0
'@rush-temp/communication-server@file:projects/communication-server.tgz':
resolution: {integrity: sha512-NqgsYYbjEFDp0w3T3g9pjDaYwAfMfqE5PkZoZ2dY/YTudJcRfnYV4uHuyIDKw6HceDKfLZSd3zScMXjR5m3uNA==, tarball: file:projects/communication-server.tgz}
resolution: {integrity: sha512-o3A6bkNKoPybTtuDaNrpgzglNb4Z/YaKOO9XoMdGkbtnVpVBwehnffKiphB9GKZ0SNzUYXRCXbYbBgPnTIoC5g==, tarball: file:projects/communication-server.tgz}
version: 0.0.0
'@rush-temp/communication-shared@file:projects/communication-shared.tgz':
@@ -4580,7 +4583,7 @@ packages:
version: 0.0.0
'@rush-temp/contact@file:projects/contact.tgz':
resolution: {integrity: sha512-J2aOTd+H6SyUEBDB5+3eOs3OiWnqvYy0cGRs/Cpz6+Oqbunn4Hvj+laQqIps3c2t8bQH3u2IBiI3e0jzaPK/Fw==, tarball: file:projects/contact.tgz}
resolution: {integrity: sha512-R1RbX6FWTeUs0TqyJYDUZUiMV7W4mwZ3lWCWFIJfFSGcWE+5Sk+aww2jyVIYh8eiunUo/uSBwXIbSaYBcoyrmQ==, tarball: file:projects/contact.tgz}
version: 0.0.0
'@rush-temp/controlled-documents-assets@file:projects/controlled-documents-assets.tgz':
@@ -4940,7 +4943,7 @@ packages:
version: 0.0.0
'@rush-temp/model-calendar@file:projects/model-calendar.tgz':
resolution: {integrity: sha512-KIpD8iFaZEadGlgNoSsM7/34DiKaZ8PAH7iFYy6e/sijyGvgwtTvDardwNZ6xQfACmBu8CH6zNzn3XcGrYBx5A==, tarball: file:projects/model-calendar.tgz}
resolution: {integrity: sha512-bo+cfLn56htDm9GYZ4sFPx/YFVk6hnoTq0eBHvUoGi9QQTalHRlREiv+c4gZniVpx2sH/OaUMWAXDIfkcTPXkQ==, tarball: file:projects/model-calendar.tgz}
version: 0.0.0
'@rush-temp/model-card@file:projects/model-card.tgz':
@@ -4960,7 +4963,7 @@ packages:
version: 0.0.0
'@rush-temp/model-contact@file:projects/model-contact.tgz':
resolution: {integrity: sha512-F8VPbPQ/7oKCWNW/sqc7ul2CZJ8pRUTW2Tu7wpUU9pCo6AJOMPwB4mBI+M35pWYpoj7FLiojZmwATERl3JH/0g==, tarball: file:projects/model-contact.tgz}
resolution: {integrity: sha512-2SQj04no5IbxGV5MxaO7k3CmOt1jt9Os0/s3yeRbbgHHFNl6uJ6yaOo9Qfq6eaquBfV9nPzJuzmBH2VsTkKA9w==, tarball: file:projects/model-contact.tgz}
version: 0.0.0
'@rush-temp/model-controlled-documents@file:projects/model-controlled-documents.tgz':
@@ -5408,7 +5411,7 @@ packages:
version: 0.0.0
'@rush-temp/pod-server@file:projects/pod-server.tgz':
resolution: {integrity: sha512-z4m5kjmQbBtpRI+5G9R5oylh5Z6IuWrTpC82zkrShOHVYLnLwKwDciVN5bbfAlISdb59ThXyiOzIA5Dr/gfl8g==, tarball: file:projects/pod-server.tgz}
resolution: {integrity: sha512-3n0/hgmREmz0totaUlNjkuPuS+kGz5ekL8OO3z/HsQyI/2vDx+p3O0DsDga2m1fGt296awP3MP/iqQIWwFPcNw==, tarball: file:projects/pod-server.tgz}
version: 0.0.0
'@rush-temp/pod-sign@file:projects/pod-sign.tgz':
@@ -5427,6 +5430,10 @@ packages:
resolution: {integrity: sha512-JxdvigAw/njFUV7DoAoepnhVtYX3vvIuL4cvHy4ZpK5LYx6PjxP5AOcUD3dTb/WZXDAnZAc6APKpAKgmHA5UXQ==, tarball: file:projects/pod-telegram.tgz}
version: 0.0.0
'@rush-temp/pod-translate@file:projects/pod-translate.tgz':
resolution: {integrity: sha512-n2WumWwe2S5s0LBStlOuDVU/99zH1tTOvejx65KReHGy8c/8GzsfbcPapYs2zkLeKnInC0d99iC02Dl32Bnhjw==, tarball: file:projects/pod-translate.tgz}
version: 0.0.0
'@rush-temp/pod-worker@file:projects/pod-worker.tgz':
resolution: {integrity: sha512-6sP8dB+Kv5PnITQ4XVHj9ZHrGXy/7Xbp3VbhdJhVZqvYusL19i2iRQ+RBmuVKeKH/94YIFoRoHn/Y7LDAYCNgQ==, tarball: file:projects/pod-worker.tgz}
version: 0.0.0
@@ -8221,6 +8228,9 @@ packages:
code-red@1.0.4:
resolution: {integrity: sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==}
collapse-white-space@2.1.0:
resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==}
collect-v8-coverage@1.0.2:
resolution: {integrity: sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==}
@@ -9829,6 +9839,9 @@ packages:
fraction.js@4.3.7:
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
franc-min@6.2.0:
resolution: {integrity: sha512-1uDIEUSlUZgvJa2AKYR/dmJC66v/PvGQ9mWfI9nOr/kPpMFyvswK0gPXOwpYJYiYD008PpHLkGfG58SPjQJFxw==}
fresh@0.5.2:
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
engines: {node: '>= 0.6'}
@@ -11697,6 +11710,9 @@ packages:
mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
n-gram@2.0.2:
resolution: {integrity: sha512-S24aGsn+HLBxUGVAUFOwGpKs7LBcG4RudKU//eWzt/mQ97/NMKQxDWHyHx63UNWk/OOdihgmzoETn1tf5nQDzQ==}
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -13716,6 +13732,9 @@ packages:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
trigram-utils@2.0.1:
resolution: {integrity: sha512-nfWIXHEaB+HdyslAfMxSqWKDdmqY9I32jS7GnqpdWQnLH89r6A5sdk3fDVYqGAZ0CrT8ovAFSAo6HRiWcWNIGQ==}
triple-beam@1.4.1:
resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==}
engines: {node: '>= 14.0.0'}
@@ -19188,6 +19207,7 @@ snapshots:
eslint-plugin-import: 2.29.1(eslint@8.56.0)
eslint-plugin-n: 15.7.0(eslint@8.56.0)
eslint-plugin-promise: 6.1.1(eslint@8.56.0)
franc-min: 6.2.0
jest: 29.7.0(@types/node@22.15.29)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3))
prettier: 3.2.5
typescript: 5.8.3
@@ -25691,6 +25711,42 @@ snapshots:
- ts-node
- utf-8-validate
'@rush-temp/pod-translate@file:projects/pod-translate.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@swc/core@1.13.5)(babel-jest@29.7.0(@babel/core@7.23.9))(encoding@0.1.13)(zod@3.24.2)':
dependencies:
'@hcengineering/measurements': 0.7.7
'@tsconfig/node16': 1.0.4
'@types/jest': 29.5.12
'@types/node': 22.15.29
'@types/uuid': 8.3.4
'@types/ws': 8.5.11
'@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.8.3))(eslint@8.56.0)(typescript@5.8.3)
'@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.8.3)
dotenv: 16.0.3
esbuild: 0.25.9
eslint: 8.56.0
eslint-config-standard-with-typescript: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.8.3))(eslint@8.56.0)(typescript@5.8.3))(eslint-plugin-import@2.29.1(eslint@8.56.0))(eslint-plugin-n@15.7.0(eslint@8.56.0))(eslint-plugin-promise@6.1.1(eslint@8.56.0))(eslint@8.56.0)(typescript@5.8.3)
eslint-plugin-import: 2.29.1(eslint@8.56.0)
eslint-plugin-n: 15.7.0(eslint@8.56.0)
eslint-plugin-node: 11.1.0(eslint@8.56.0)
eslint-plugin-promise: 6.1.1(eslint@8.56.0)
jest: 29.7.0(@types/node@22.15.29)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3))
openai: 4.56.0(encoding@0.1.13)(zod@3.24.2)
prettier: 3.2.5
ts-jest: 29.1.2(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.25.9)(jest@29.7.0(@types/node@22.15.29)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3)))(typescript@5.8.3)
ts-node: 10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3)
typescript: 5.8.3
transitivePeerDependencies:
- '@babel/core'
- '@jest/types'
- '@swc/core'
- '@swc/wasm'
- babel-jest
- babel-plugin-macros
- encoding
- node-notifier
- supports-color
- zod
'@rush-temp/pod-worker@file:projects/pod-worker.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@swc/core@1.13.5)(babel-jest@29.7.0(@babel/core@7.23.9))(webpack-cli@5.1.4)':
dependencies:
'@temporalio/worker': 1.12.3(esbuild@0.25.9)(webpack-cli@5.1.4)
@@ -33317,6 +33373,8 @@ snapshots:
estree-walker: 3.0.3
periscopic: 3.1.0
collapse-white-space@2.1.0: {}
collect-v8-coverage@1.0.2: {}
color-convert@1.9.3:
@@ -35231,6 +35289,10 @@ snapshots:
fraction.js@4.3.7: {}
franc-min@6.2.0:
dependencies:
trigram-utils: 2.0.1
fresh@0.5.2: {}
fs-extra@10.1.0:
@@ -37483,6 +37545,8 @@ snapshots:
object-assign: 4.1.1
thenify-all: 1.6.0
n-gram@2.0.2: {}
nanoid@3.3.11: {}
napi-macros@2.0.0:
@@ -39745,6 +39809,11 @@ snapshots:
tree-kill@1.2.2: {}
trigram-utils@2.0.1:
dependencies:
collapse-white-space: 2.1.0
n-gram: 2.0.2
triple-beam@1.4.1: {}
truncate-utf8-bytes@1.0.2:
+16
View File
@@ -562,6 +562,22 @@ services:
- QUEUE_CONFIG=${QUEUE_CONFIG}
- QUEUE_REGION=cockroach
restart: unless-stopped
translate:
image: hardcoreeng/translate
extra_hosts:
- 'huly.local:host-gateway'
depends_on:
redpanda:
condition: service_started
account:
condition: service_started
environment:
- SECRET=secret
- ACCOUNTS_URL=http://huly.local:3000
- HULYLAKE_URL=http://huly.local:8096
- QUEUE_CONFIG=${QUEUE_CONFIG}
- QUEUE_REGION=cockroach
restart: unless-stopped
backup-cockroach:
image: hardcoreeng/backup
extra_hosts:
+1
View File
@@ -50,6 +50,7 @@
"@hcengineering/model-view": "^0.6.0",
"@hcengineering/model-workbench": "^0.6.1",
"@hcengineering/model-card": "^0.6.0",
"@hcengineering/model-preference": "^0.6.0",
"@hcengineering/notification": "^0.6.23",
"@hcengineering/platform": "^0.6.11",
"@hcengineering/setting": "^0.6.17",
+23 -2
View File
@@ -33,7 +33,8 @@ import {
type PersonSpace,
type SocialIdentity,
type Status,
type SocialIdentityProvider
type SocialIdentityProvider,
type Translation
} from '@hcengineering/contact'
import {
AccountRole,
@@ -90,6 +91,7 @@ import { type AnyComponent } from '@hcengineering/ui/src/types'
import { type Action } from '@hcengineering/view'
import contact from './plugin'
import { PaletteColorIndexes } from '@hcengineering/ui/src/colors'
import preference, { TPreference } from '@hcengineering/model-preference'
export { contactId } from '@hcengineering/contact'
export { contactOperation } from './migration'
@@ -291,6 +293,14 @@ export class TUserRole extends TDoc implements UserRole {
role!: Ref<Role>
}
@Model(contact.class.Translation, preference.class.Preference)
export class TTranslation extends TPreference implements Translation {
declare attachedTo: Ref<Employee>
enabled!: boolean
translateTo?: string
dontTranslate!: string[]
}
export function createModel (builder: Builder): void {
builder.createModel(
TAvatarProvider,
@@ -306,7 +316,8 @@ export function createModel (builder: Builder): void {
TMember,
TContactsTab,
TPersonSpace,
TUserRole
TUserRole,
TTranslation
)
builder.mixin(contact.class.PersonSpace, core.class.Class, core.mixin.TxAccessLevel, {
@@ -1405,4 +1416,14 @@ export function createModel (builder: Builder): void {
isCustom: true,
readonly: true
})
builder.createDoc(setting.class.SettingsCategory, core.space.Model, {
name: 'translation',
label: contact.string.AutoTranslation,
icon: view.icon.Translate,
component: contact.component.TranslationSettings,
group: 'settings-account',
role: AccountRole.Guest,
order: 1600
})
}
+2 -1
View File
@@ -58,7 +58,8 @@ export default mergeIds(contactId, contact, {
EmployeeFilter: '' as AnyComponent,
EmployeeFilterValuePresenter: '' as AnyComponent,
ChannelIcon: '' as AnyComponent,
PersonPreviewPresenter: '' as AnyComponent
PersonPreviewPresenter: '' as AnyComponent,
TranslationSettings: '' as AnyComponent
},
string: {
SearchEmployee: '' as IntlString,
+30 -18
View File
@@ -42,7 +42,13 @@ import aiBot from '@hcengineering/ai-bot'
import CreateCardFromMessagePopup from './components/CreateCardFromMessagePopup.svelte'
import { isCardAllowedForCommunications, showForbidden, toggleReaction, toMarkup } from './utils'
import { isMessageTranslating, messageEditingStore, threadCreateMessageStore, translateMessagesStore } from './stores'
import {
isMessageTranslating,
messageEditingStore,
showOriginalMessagesStore,
threadCreateMessageStore,
translateMessagesStore
} from './stores'
export const addReaction: MessageActionFunction = async (message, card: Card, evt, onOpen, onClose) => {
if (!isCardAllowedForCommunications(card)) {
@@ -149,34 +155,41 @@ export const canReplyInThread: MessageActionVisibilityTester = (message: Message
}
export const translateMessage: MessageActionFunction = async (message: Message): Promise<void> => {
if (isMessageTranslating(message.id)) return
const result = get(translateMessagesStore).get(message.id)
if (isMessageTranslating(message.cardId, message.id)) return
const result = get(translateMessagesStore).find((it) => it.cardId === message.cardId && it.messageId === message.id)
if (result?.result != null) {
translateMessagesStore.update((store) => {
store.set(message.id, { ...result, shown: true })
return store
})
if (result != null) {
showOriginalMessagesStore.update((store) =>
store.filter(([cId, mId]) => cId !== message.cardId || mId !== message.id)
)
return
}
translateMessagesStore.update((store) => {
store.set(message.id, { inProgress: true, shown: false })
store.push({ inProgress: true, messageId: message.id, cardId: message.cardId })
return store
})
const markup = toMarkup(message.content)
const response = await aiTranslate(markup, get(languageStore))
const lang = get(languageStore)
const response = message?.translates?.[lang] ?? (await aiTranslate(markup, lang))?.text
if (response !== undefined) {
translateMessagesStore.update((store) => {
store.set(message.id, { inProgress: false, result: response.text, shown: true })
return store
return store.map((it) => {
if (it.cardId === message.cardId && it.messageId === message.id) {
return {
...it,
inProgress: false,
result: response
}
}
return it
})
})
} else {
translateMessagesStore.update((store) => {
store.delete(message.id)
return store
return store.filter((it) => it.cardId !== message.cardId || it.messageId !== message.id)
})
}
}
@@ -189,10 +202,9 @@ export const canTranslateMessage: MessageActionVisibilityTester = (message: Mess
export const showOriginalMessage: MessageActionFunction = async (message: Message): Promise<void> => {
const messageId = message.id
translateMessagesStore.update((store) => {
const status = store.get(messageId)
if (status == null) return store
store.set(messageId, { ...status, shown: false })
showOriginalMessagesStore.update((store) => {
store.push([message.cardId, messageId])
return store
})
}
@@ -31,6 +31,7 @@
import { createEventDispatcher, onDestroy, onMount, tick } from 'svelte'
import { MessagesNavigationAnchors } from '@hcengineering/communication'
import { deviceOptionsStore as deviceInfo, isAppFocusedStore } from '@hcengineering/ui'
import { translationStore } from '@hcengineering/contact-resources'
import { createMessagesObserver, getGroupDay, groupMessagesByDay, MessagesGroup } from '../messages'
import MessagesGroupPresenter from './message/MessagesGroupPresenter.svelte'
@@ -101,6 +102,8 @@
readNotifications(new Date())
}
$: translation = $translationStore
$: reinit(position)
$: query.query(
@@ -123,7 +126,8 @@
autoExpand: true,
threads: true,
attachments: true,
reactions: true
reactions: true,
language: translation?.enabled === true ? translation?.translateTo : undefined
}
)
@@ -14,9 +14,9 @@
-->
<script lang="ts">
import { PersonPreviewProvider, Avatar } from '@hcengineering/contact-resources'
import { PersonPreviewProvider, Avatar, translationStore } from '@hcengineering/contact-resources'
import { formatName, Person } from '@hcengineering/contact'
import { Message } from '@hcengineering/communication-types'
import { CardID, Message, MessageID } from '@hcengineering/communication-types'
import { Card } from '@hcengineering/card'
import { Label } from '@hcengineering/ui'
@@ -24,7 +24,12 @@
import MessageInput from './MessageInput.svelte'
import MessageContentViewer from './MessageContentViewer.svelte'
import MessageFooter from './MessageFooter.svelte'
import { translateMessagesStore, messageEditingStore } from '../../stores'
import {
translateMessagesStore,
messageEditingStore,
TranslateMessagesStatus,
showOriginalMessagesStore
} from '../../stores'
import { showOriginalMessage } from '../../actions'
export let card: Card
@@ -46,6 +51,36 @@
minute: 'numeric'
})
}
let isTranslating = false
let translateStatus: TranslateMessagesStatus | undefined = undefined
let translateShown = false
$: language = $translationStore?.enabled === true ? $translationStore?.translateTo : undefined
$: dontTranslate = $translationStore?.enabled === true ? $translationStore?.dontTranslate ?? [] : []
$: translateStatus = $translateMessagesStore.find((it) => it.cardId === card._id && it.messageId === message.id)
$: isTranslating = translateStatus?.inProgress === true
$: translateShown = isTranslateShown(message, translateStatus, language, $showOriginalMessagesStore, dontTranslate)
function isTranslateShown (
message: Message,
translateStatus?: TranslateMessagesStatus,
language?: string,
showOriginalMessages: Array<[CardID, MessageID]> = [],
dontTranslate: string[] = []
): boolean {
const showOriginal = showOriginalMessages.some(([cId, mId]) => cId === card._id && mId === message.id)
if (showOriginal) return false
if (translateStatus?.result != null) return true
if (language == null || message.language === language) return false
if (message.language != null && dontTranslate.includes(message.language)) return false
const translate = message.translates?.[language]
return translate != null
}
</script>
{#if compact || hideHeader}
@@ -106,12 +141,12 @@
(<Label label={communication.string.Edited} />)
</div>
{/if}
{#if $translateMessagesStore.get(message.id)?.inProgress === true}
{#if isTranslating}
<div class="message__translating">
<Label label={communication.string.Translating} />
</div>
{/if}
{#if $translateMessagesStore.get(message.id)?.shown === true}
{#if translateShown}
<div class="message__show-original" on:click={() => showOriginalMessage(message, card)}>
<Label label={communication.string.ShowOriginal} />
</div>
@@ -15,16 +15,22 @@
<script lang="ts">
import { MessageViewer as MarkupMessageViewer } from '@hcengineering/presentation'
import { Markdown, Message, MessageID } from '@hcengineering/communication-types'
import { CardID, Markdown, Message, MessageID } from '@hcengineering/communication-types'
import { Card } from '@hcengineering/card'
import { Person } from '@hcengineering/contact'
import { Markup } from '@hcengineering/core'
import { translationStore } from '@hcengineering/contact-resources'
import { ShowMore } from '@hcengineering/ui'
import ActivityMessageViewer from './ActivityMessageViewer.svelte'
import { toMarkup } from '../../utils'
import { isActivityMessage } from '../../activity'
import { isShownTranslatedMessage, TranslateMessagesStatus, translateMessagesStore } from '../../stores'
import {
isShownTranslatedMessage,
TranslateMessagesStatus,
translateMessagesStore,
showOriginalMessagesStore
} from '../../stores'
import { translateMessage } from '../../actions'
export let card: Card
@@ -37,12 +43,29 @@
let displayMarkup: Markup = toMarkup(message.content)
let prevContent: Markdown | undefined = undefined
$: updateDisplayMarkup(message, $translateMessagesStore)
$: language = $translationStore?.enabled === true ? $translationStore?.translateTo : undefined
$: dontTranslate = $translationStore?.enabled === true ? $translationStore?.dontTranslate : []
$: updateDisplayMarkup(message, $translateMessagesStore, $showOriginalMessagesStore, language, dontTranslate)
function updateDisplayMarkup (message: Message, translateMessages: Map<MessageID, TranslateMessagesStatus>): void {
const translateResult = translateMessages.get(message.id)
if (translateResult?.shown === true && translateResult?.result != null) {
function updateDisplayMarkup (
message: Message,
translateMessages: TranslateMessagesStatus[],
showOriginalMessages: Array<[CardID, MessageID]>,
language?: string,
dontTranslate: string[] = []
): void {
const translateResult = translateMessages.find((it) => it.cardId === card._id && it.messageId === message.id)
const showOriginal = showOriginalMessages.some(([cId, mId]) => cId === card._id && mId === message.id)
if (!showOriginal && translateResult?.result != null) {
displayMarkup = translateResult.result
} else if (
language != null &&
message.translates?.[language] != null &&
!showOriginal &&
!dontTranslate?.includes(language) &&
message.language !== language
) {
displayMarkup = toMarkup(message.translates[language])
} else {
displayMarkup = toMarkup(message.content)
}
@@ -50,12 +73,11 @@
$: if (prevContent !== message.content) {
prevContent = message.content
if (isShownTranslatedMessage(message.id)) {
if (isShownTranslatedMessage(message.cardId, message.id)) {
void translateMessage(message, card)
} else {
translateMessagesStore.update((store) => {
store.delete(message.id)
return store
return store.filter((it) => it.cardId !== message.cardId || it.messageId !== message.id)
})
}
}
@@ -15,10 +15,10 @@
<script lang="ts">
import { Person } from '@hcengineering/contact'
import { employeeByPersonIdStore, getPersonByPersonId } from '@hcengineering/contact-resources'
import { employeeByPersonIdStore, getPersonByPersonId, translationStore } from '@hcengineering/contact-resources'
import { Card } from '@hcengineering/card'
import { getEventPositionElement, showPopup, Action, Menu } from '@hcengineering/ui'
import type { MessageID, SocialID } from '@hcengineering/communication-types'
import type { CardID, MessageID, SocialID } from '@hcengineering/communication-types'
import { Message, MessageType } from '@hcengineering/communication-types'
import { getResource } from '@hcengineering/platform'
import { MessageAction } from '@hcengineering/communication'
@@ -31,7 +31,8 @@
messageEditingStore,
TranslateMessagesStatus,
translateMessagesStore,
threadCreateMessageStore
threadCreateMessageStore,
showOriginalMessagesStore
} from '../../stores'
import { getMessageActions } from '../../actions'
import communication from '../../plugin'
@@ -94,11 +95,20 @@
return false
}
$: language = $translationStore?.enabled === true ? $translationStore?.translateTo : undefined
$: dontTranslate = $translationStore?.enabled === true ? $translationStore?.dontTranslate : undefined
let allActions: MessageAction[] = []
let excludedActions: Ref<MessageAction>[] = []
let actions: MessageAction[] = []
$: excludedActions = getExcludedActions($translateMessagesStore)
$: excludedActions = getExcludedActions(
$translateMessagesStore,
$showOriginalMessagesStore,
message,
language,
dontTranslate
)
$: void getMessageActions(message).then((res) => {
allActions = res
@@ -106,11 +116,23 @@
$: actions = allActions.filter((it) => !excludedActions.includes(it._id))
function getExcludedActions (translatedMessages: Map<MessageID, TranslateMessagesStatus>): Ref<MessageAction>[] {
const result: TranslateMessagesStatus | undefined = translatedMessages.get(message.id)
if (result === undefined || result.inProgress || !result.shown) {
function getExcludedActions (
translatedMessages: TranslateMessagesStatus[],
showOriginalMessage: Array<[CardID, MessageID]>,
message: Message,
lang?: string,
dontTranslate: string[] = []
): Ref<MessageAction>[] {
const result: TranslateMessagesStatus | undefined = translatedMessages.find(
(it) => it.cardId === message.cardId && it.messageId === message.id
)
const showOriginal = showOriginalMessage.some(([cId, mId]) => cId === message.cardId && mId === message.id)
const isTranslated =
lang != null && message?.translates?.[lang] != null && !dontTranslate.includes(message.language ?? '')
if ((result === undefined && !isTranslated) || result?.inProgress || showOriginal) {
return [communication.messageAction.ShowOriginalMessage]
} else if (result.shown) {
} else if ((result?.result != null || isTranslated) && !showOriginal) {
return [communication.messageAction.TranslateMessage]
}
+16 -15
View File
@@ -13,7 +13,7 @@
import { get, writable } from 'svelte/store'
import { createLabelsQuery, createQuery, onClient, onCommunicationClient } from '@hcengineering/presentation'
import { type Label, type LabelID, type Message, type MessageID } from '@hcengineering/communication-types'
import { type CardID, type Label, type LabelID, type Message, type MessageID } from '@hcengineering/communication-types'
import core, { getCurrentAccount, type Markup, type Ref } from '@hcengineering/core'
import { languageStore } from '@hcengineering/ui'
import cardPlugin, { type Card } from '@hcengineering/card'
@@ -21,35 +21,36 @@ import communication from '@hcengineering/communication'
export const labelsStore = writable<Label[]>([])
export const messageEditingStore = writable<MessageID | undefined>(undefined)
export const translateMessagesStore = writable<Map<MessageID, TranslateMessagesStatus>>(new Map())
export const translateMessagesStore = writable<TranslateMessagesStatus[]>([])
export const showOriginalMessagesStore = writable<Array<[CardID, MessageID]>>([])
export const threadCreateMessageStore = writable<Message | undefined>(undefined)
export const guestCommunicationAllowedCards = writable<Array<Ref<Card>>>([])
export interface TranslateMessagesStatus {
cardId: CardID
messageId: MessageID
inProgress: boolean
shown: boolean
result?: Markup
}
languageStore.subscribe(() => {
translateMessagesStore.set(new Map())
translateMessagesStore.set([])
})
export function isMessageTranslated (messageId: MessageID): boolean {
return get(translateMessagesStore).get(messageId)?.result != null
export function isMessageTranslated (cardId: CardID, messageId: MessageID): boolean {
return get(translateMessagesStore).find((it) => it.cardId === cardId && messageId === it.messageId)?.result != null
}
export function isMessageTranslating (messageId: MessageID): boolean {
return get(translateMessagesStore).get(messageId)?.inProgress === true
export function isMessageTranslating (cardId: CardID, messageId: MessageID): boolean {
return (
get(translateMessagesStore).find((it) => it.cardId === cardId && messageId === it.messageId)?.inProgress === true
)
}
export function getMessageTranslatedMarkup (messageId: MessageID): Markup | undefined {
return get(translateMessagesStore).get(messageId)?.result
}
export function isShownTranslatedMessage (messageId: MessageID): boolean {
const result = get(translateMessagesStore).get(messageId)
return result?.shown === true && result?.result != null
export function isShownTranslatedMessage (cardId: CardID, messageId: MessageID): boolean {
const result = get(translateMessagesStore).find((it) => it.cardId === cardId && messageId === it.messageId)
const showOriginal = get(showOriginalMessagesStore).some(([cId, mId]) => cId === cardId && mId === messageId)
return !showOriginal && result?.result != null
}
export function isCardSubscribed (cardId: Ref<Card>): boolean {
+6 -1
View File
@@ -120,6 +120,11 @@
"HereDescription": "Upozornit všechny v tomto {title}",
"Guest": "Host",
"Deleted": "Smazáno",
"CannotMerge": "Chyba: Nelze sloučit globální osoby"
"CannotMerge": "Chyba: Nelze sloučit globální osoby",
"AutoTranslation": "Automatický překlad",
"TranslateTo": "Přeložit do",
"DontTranslate": "Nepřekládat",
"SelectLanguage": "Vyberte jazyk",
"SelectLanguages": "Vyberte jazyky"
}
}
+6 -1
View File
@@ -120,6 +120,11 @@
"HereDescription": "Benachrichtigen Sie alle in diesem {title}",
"Guest": "Gast",
"Deleted": "Gelöscht",
"CannotMerge": "Fehler: Globale Personen können nicht zusammengeführt werden"
"CannotMerge": "Fehler: Globale Personen können nicht zusammengeführt werden",
"AutoTranslation": "Automatische Übersetzung",
"TranslateTo": "Übersetzen in",
"DontTranslate": "Nicht übersetzen",
"SelectLanguage": "Sprache auswählen",
"SelectLanguages": "Sprachen auswählen"
}
}
+6 -1
View File
@@ -124,6 +124,11 @@
"HereDescription": "Notify every online member in this {title}",
"Guest": "Guest",
"Deleted": "Deleted",
"CannotMerge": "Error: Cannot merge global persons"
"CannotMerge": "Error: Cannot merge global persons",
"AutoTranslation": "Auto translation",
"TranslateTo": "Translate to",
"DontTranslate": "Don't translate",
"SelectLanguage": "Select language",
"SelectLanguages": "Select languages"
}
}
+6 -1
View File
@@ -124,6 +124,11 @@
"HereDescription": "Notificar a todos en este {title}",
"Guest": "Invitado",
"Deleted": "Eliminado",
"CannotMerge": "Error: No se pueden fusionar personas globales"
"CannotMerge": "Error: No se pueden fusionar personas globales",
"AutoTranslation": "Traducción automática",
"TranslateTo": "Traducir a",
"DontTranslate": "No traducir",
"SelectLanguage": "Seleccionar idioma",
"SelectLanguages": "Seleccionar idiomas"
}
}
+6 -1
View File
@@ -124,6 +124,11 @@
"HereDescription": "Notifier tout le monde dans ce {title}",
"Guest": "Invité",
"Deleted": "Supprimé",
"CannotMerge": "Erreur : Impossible de fusionner les personnes globales"
"CannotMerge": "Erreur : Impossible de fusionner les personnes globales",
"AutoTranslation": "Traduction automatique",
"TranslateTo": "Traduire en",
"DontTranslate": "Ne pas traduire",
"SelectLanguage": "Sélectionner la langue",
"SelectLanguages": "Sélectionner les langues"
}
}
+6 -1
View File
@@ -124,6 +124,11 @@
"HereDescription": "Notifica tutti in questo {title}",
"Guest": "Ospite",
"Deleted": "Eliminato",
"CannotMerge": "Errore: Impossibile unire persone globali"
"CannotMerge": "Errore: Impossibile unire persone globali",
"AutoTranslation": "Traduzione automatica",
"TranslateTo": "Traduci in",
"DontTranslate": "Non tradurre",
"SelectLanguage": "Seleziona lingua",
"SelectLanguages": "Seleziona lingue"
}
}
+7 -2
View File
@@ -124,6 +124,11 @@
"HereDescription": "この{title}のすべてに通知する",
"Guest": "ゲスト",
"Deleted": "削除済み",
"CannotMerge": "エラー:グローバル個人は統合できません"
"CannotMerge": "エラー:グローバル個人は統合できません",
"AutoTranslation": "自動翻訳",
"TranslateTo": "翻訳先",
"DontTranslate": "翻訳しない",
"SelectLanguage": "言語を選択",
"SelectLanguages": "言語を選択"
}
}
}
+6 -1
View File
@@ -124,6 +124,11 @@
"HereDescription": "Notificar todos neste {title}",
"Guest": "Convidado",
"Deleted": "Excluído",
"CannotMerge": "Erro: Não é possível mesclar pessoas globais"
"CannotMerge": "Erro: Não é possível mesclar pessoas globais",
"AutoTranslation": "Tradução automática",
"TranslateTo": "Traduzir para",
"DontTranslate": "Não traduzir",
"SelectLanguage": "Selecionar idioma",
"SelectLanguages": "Selecionar idiomas"
}
}
+6 -1
View File
@@ -124,6 +124,11 @@
"HereDescription": "Уведомить всех в этом {title}",
"Guest": "Гость",
"Deleted": "Удалён",
"CannotMerge": "Ошибка: Невозможно объединить глобальных персон"
"CannotMerge": "Ошибка: Невозможно объединить глобальных персон",
"AutoTranslation": "Автоматический перевод",
"TranslateTo": "Переводить на",
"DontTranslate": "Не переводить",
"SelectLanguage": "Выберите язык",
"SelectLanguages": "Выберите языки"
}
}
+6 -1
View File
@@ -124,6 +124,11 @@
"HereDescription": "通知所有人在此{title}",
"Guest": "访客",
"Deleted": "已删除",
"CannotMerge": "错误:无法合并全局人员"
"CannotMerge": "错误:无法合并全局人员",
"AutoTranslation": "自动翻译",
"TranslateTo": "翻译为",
"DontTranslate": "不翻译",
"SelectLanguage": "选择语言",
"SelectLanguages": "选择语言"
}
}
@@ -0,0 +1,73 @@
<script lang="ts">
import { getEmbeddedLabel } from '@hcengineering/platform'
import {
Button,
type ButtonKind,
type ButtonSize,
DropdownIntlItem,
DropdownLabelsPopupIntl,
eventToHTMLElement,
Label,
showPopup
} from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import LanguagePresenter from './LanguagePresenter.svelte'
import { languagesDisplayData } from '../translation'
import LanguageIcon from './LanguageIcon.svelte'
import contact from '../plugin'
export let value: string | undefined = undefined
export let disabled: boolean = false
export let kind: ButtonKind = 'no-border'
export let size: ButtonSize = 'small'
export let justify: 'left' | 'center' = 'center'
export let width: string | undefined = 'fit-content'
const dispatch = createEventDispatcher()
let shown: boolean = false
let items: DropdownIntlItem[] = []
$: items = Object.entries(languagesDisplayData).map(([lang, data]) => ({
id: lang,
label: getEmbeddedLabel(data.label),
icon: LanguageIcon,
iconProps: { lang }
}))
function showLanguagesPopup (ev: MouseEvent): void {
shown = true
showPopup(DropdownLabelsPopupIntl, { items, selected: value }, eventToHTMLElement(ev), async (result) => {
if (result != null && result !== '') {
value = result
dispatch('change', value)
shown = false
}
})
}
</script>
<Button
{kind}
{size}
{justify}
{width}
{disabled}
on:click={(ev) => {
if (!shown && !disabled) {
showLanguagesPopup(ev)
}
}}
>
<svelte:fragment slot="content">
<div class="pointer-events-none">
{#if value != null && value !== ''}
<LanguagePresenter lang={value} withLabel />
{:else}
<Label label={contact.string.SelectLanguage} />
{/if}
</div>
</svelte:fragment>
</Button>
@@ -0,0 +1,33 @@
<!-- Copyright © 2025 Hardcore Engineering Inc. -->
<!-- -->
<!-- Licensed under the Eclipse Public License, Version 2.0 (the "License"); -->
<!-- you may not use this file except in compliance with the License. You may -->
<!-- obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 -->
<!-- -->
<!-- Unless required by applicable law or agreed to in writing, software -->
<!-- distributed under the License is distributed on an "AS IS" BASIS, -->
<!-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -->
<!-- -->
<!-- See the License for the specific language governing permissions and -->
<!-- limitations under the License. -->
<script lang="ts">
import { languagesDisplayData } from '../translation'
export let lang: string
export let size: 'small' | 'medium' = 'small'
</script>
<div class={size}>
{languagesDisplayData[lang]?.emoji}
</div>
<style lang="scss">
.small {
font-size: 1rem;
}
.medium {
font-size: 2rem;
}
</style>
@@ -0,0 +1,27 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { languagesDisplayData } from '../translation'
export let lang: string
export let withLabel = false
</script>
<span title={languagesDisplayData[lang].label ?? languagesDisplayData.en.label}>
{languagesDisplayData[lang].emoji ?? languagesDisplayData.en.emoji}
{#if withLabel}
<span class="ml-1">{languagesDisplayData[lang].label ?? languagesDisplayData.en.label}</span>
{/if}
</span>
@@ -0,0 +1,74 @@
<!-- Copyright © 2025 Hardcore Engineering Inc. -->
<!-- -->
<!-- Licensed under the Eclipse Public License, Version 2.0 (the "License"); -->
<!-- you may not use this file except in compliance with the License. You may -->
<!-- obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 -->
<!-- -->
<!-- Unless required by applicable law or agreed to in writing, software -->
<!-- distributed under the License is distributed on an "AS IS" BASIS, -->
<!-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -->
<!-- -->
<!-- See the License for the specific language governing permissions and -->
<!-- limitations under the License. -->
<script lang="ts">
import { Button, ButtonKind, ButtonSize, eventToHTMLElement, Label, showPopup } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import LanguagePresenter from './LanguagePresenter.svelte'
import LanguagesPopup from './LanguagesPopup.svelte'
import contact from '../plugin'
export let disabled = false
export let selected: string[] = []
export let kind: ButtonKind = 'no-border'
export let size: ButtonSize = 'small'
export let justify: 'left' | 'center' = 'center'
export let width: string | undefined = 'fit-content'
const dispatch = createEventDispatcher()
let shown: boolean = false
const handleOpen = (event: MouseEvent): void => {
event.stopPropagation()
event.preventDefault()
if (disabled) return
showPopup(LanguagesPopup, { selected }, eventToHTMLElement(event), undefined, async (result) => {
if (result != null) {
selected = result
dispatch('change', selected)
shown = false
}
})
}
</script>
<Button
{kind}
{size}
{justify}
{width}
{disabled}
on:click={(ev) => {
if (!shown && !disabled) {
handleOpen(ev)
}
}}
>
<svelte:fragment slot="content">
<div class="pointer-events-none flex-gap-2 flex-presenter">
{#if selected.length > 0}
{#each selected as value}
<LanguagePresenter lang={value} withLabel />
{/each}
{:else}
<Label label={contact.string.SelectLanguages} />
{/if}
</div>
</svelte:fragment>
</Button>
@@ -0,0 +1,60 @@
<!-- Copyright © 2025 Hardcore Engineering Inc. -->
<!-- -->
<!-- Licensed under the Eclipse Public License, Version 2.0 (the "License"); -->
<!-- you may not use this file except in compliance with the License. You may -->
<!-- obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 -->
<!-- -->
<!-- Unless required by applicable law or agreed to in writing, software -->
<!-- distributed under the License is distributed on an "AS IS" BASIS, -->
<!-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -->
<!-- -->
<!-- See the License for the specific language governing permissions and -->
<!-- limitations under the License. -->
<script lang="ts">
import { SelectPopup, SelectPopupValueType } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import { getEmbeddedLabel } from '@hcengineering/platform'
import { languagesDisplayData } from '../translation'
import LanguageIcon from './LanguageIcon.svelte'
export let selected: string[] = []
const dispatch = createEventDispatcher()
let items: SelectPopupValueType[] = []
$: items = Object.entries(languagesDisplayData).map(([lang, data]) => ({
id: lang,
label: getEmbeddedLabel(data.label),
icon: LanguageIcon,
iconProps: { lang },
isSelected: selected.includes(lang)
}))
function onSelect (lang?: string | null | number): void {
if (lang == null) return
items = items.map((it) => {
if (it.id === lang) {
return { ...it, isSelected: !it.isSelected }
}
return it
})
if (selected.includes(lang as string)) {
selected = selected.filter((l) => l !== lang)
} else {
selected = [...selected, lang as string]
}
dispatch('update', selected)
}
</script>
<SelectPopup
value={items}
{onSelect}
on:close={() => {
dispatch('close', selected)
}}
/>
@@ -0,0 +1,82 @@
<!-- Copyright © 2025 Hardcore Engineering Inc. -->
<!-- -->
<!-- Licensed under the Eclipse Public License, Version 2.0 (the "License"); -->
<!-- you may not use this file except in compliance with the License. You may -->
<!-- obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 -->
<!-- -->
<!-- Unless required by applicable law or agreed to in writing, software -->
<!-- distributed under the License is distributed on an "AS IS" BASIS, -->
<!-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -->
<!-- -->
<!-- See the License for the specific language governing permissions and -->
<!-- limitations under the License. -->
<script lang="ts">
import core, { Data } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { Breadcrumb, Header, Label, Toggle } from '@hcengineering/ui'
import { getEmbeddedLabel } from '@hcengineering/platform'
import view from '@hcengineering/view'
import { getCurrentEmployee, Translation } from '@hcengineering/contact'
import contact from '../plugin'
import LanguageEditor from './LanguageEditor.svelte'
import LanguagesArrayEditor from './LanguagesArrayEditor.svelte'
import { translationStore } from '../translation'
const client = getClient()
const me = getCurrentEmployee()
let settings: Translation | undefined = undefined
$: settings = $translationStore
$: enabled = settings?.enabled ?? false
async function toggle (data: Partial<Data<Translation>>): Promise<void> {
if (settings != null) {
await client.update(settings, data)
} else {
await client.createDoc(contact.class.Translation, core.space.Workspace, {
attachedTo: me,
enabled: false,
dontTranslate: [],
...data
})
}
}
async function changeEnable (): Promise<void> {
enabled = !enabled
await toggle({ enabled })
}
</script>
<div class="hulyComponent">
<Header adaptive={'disabled'}>
<Breadcrumb icon={view.icon.Translate} label={contact.string.AutoTranslation} size={'large'} isCurrent />
</Header>
<div class="flex-row-stretch flex-grow p-10">
<div class="flex-grow flex-col flex-gap-4">
<div class="flex-row-center flex-gap-4">
<Label label={getEmbeddedLabel('Enabled')} />
<Toggle on={enabled} on:change={changeEnable} />
</div>
<div class="flex-row-center flex-gap-4">
<Label label={contact.string.TranslateTo} />
<LanguageEditor
disabled={!enabled}
value={settings?.translateTo}
on:change={(ev) => toggle({ translateTo: ev.detail ?? '' })}
/>
</div>
<div class="flex-row-center flex-gap-4">
<Label label={contact.string.DontTranslate} />
<LanguagesArrayEditor
disabled={!enabled}
selected={settings?.dontTranslate ?? []}
on:change={(ev) => toggle({ dontTranslate: ev.detail ?? [] })}
/>
</div>
</div>
</div>
</div>
+4 -1
View File
@@ -126,6 +126,7 @@ import ExpandRightDouble from './components/icons/ExpandRightDouble.svelte'
import IconMembers from './components/icons/Members.svelte'
import ContactNamePresenter from './components/ContactNamePresenter.svelte'
import PersonPreviewProvider from './components/person/PersonPreviewProvider.svelte'
import TranslationSettings from './components/TranslationSettings.svelte'
import { get } from 'svelte/store'
import { canMergePersons, canResendInvitation } from './visibilityTester'
@@ -156,6 +157,7 @@ import {
export * from './utils'
export { employeeByIdStore } from './utils'
export * from './assignee'
export * from './translation'
export {
PersonIdArrayEditor,
AccountArrayEditor,
@@ -399,7 +401,8 @@ export default async (): Promise<Resources> => ({
SpaceMembersEditor,
ContactNamePresenter,
PersonIdFilter,
AssigneePopup
AssigneePopup,
TranslationSettings
},
completion: {
EmployeeQuery: async (
+6 -1
View File
@@ -85,7 +85,12 @@ export default mergeIds(contactId, contact, {
DeleteEmployeeDescr: '' as IntlString,
HasMessagesIn: '' as IntlString,
HasNewMessagesIn: '' as IntlString,
CannotMerge: '' as IntlString
CannotMerge: '' as IntlString,
AutoTranslation: '' as IntlString,
TranslateTo: '' as IntlString,
DontTranslate: '' as IntlString,
SelectLanguage: '' as IntlString,
SelectLanguages: '' as IntlString
},
function: {
GetContactLink: '' as Resource<(doc: Doc, props: Record<string, any>) => Promise<Location>>,
@@ -0,0 +1,78 @@
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
import { createQuery, onClient } from '@hcengineering/presentation'
import { getCurrentEmployee, type Translation } from '@hcengineering/contact'
import { writable } from 'svelte/store'
import contact from './plugin'
export const languagesDisplayData: Record<string, { emoji: string, label: string }> = {
bg: { emoji: '🇧🇬', label: 'Български' },
ca: { emoji: '🇨🇦', label: 'Català' },
zh: { emoji: '🇨🇳', label: '简体中文' },
'zh-TW': { emoji: '🇹🇼', label: '繁體中文' },
'zh-HK': { emoji: '🇭🇰', label: '繁體中文 (香港)' },
cs: { emoji: '🇨🇿', label: 'Čeština' },
da: { emoji: '🇩🇰', label: 'Dansk' },
nl: { emoji: '🇳🇱', label: 'Nederlands' },
en: { emoji: '🇺🇸', label: 'English' },
'en-US': { emoji: '🇺🇸', label: 'English (US)' },
'en-AU': { emoji: '🇦🇺', label: 'English (Australia)' },
'en-GB': { emoji: '🇬🇧', label: 'English (UK)' },
'en-NZ': { emoji: '🇳🇿', label: 'English (New Zealand)' },
'en-IN': { emoji: '🇮🇳', label: 'English (India)' },
et: { emoji: '🇪🇪', label: 'Eesti' },
fi: { emoji: '🇫🇮', label: 'Suomi' },
'nl-BE': { emoji: '🇧🇪', label: 'Vlaams' },
fr: { emoji: '🇫🇷', label: 'Français' },
'fr-CA': { emoji: '🇨🇦', label: 'Français (Canada)' },
de: { emoji: '🇩🇪', label: 'Deutsch' },
'de-CH': { emoji: '🇨🇭', label: 'Schweizerdeutsch' },
el: { emoji: '🇬🇷', label: 'Ελληνικά' },
hi: { emoji: '🇮🇳', label: 'हिन्दी' },
hu: { emoji: '🇭🇺', label: 'Magyar' },
id: { emoji: '🇮🇩', label: 'Bahasa Indonesia' },
it: { emoji: '🇮🇹', label: 'Italiano' },
ja: { emoji: '🇯🇵', label: '日本語' },
ko: { emoji: '🇰🇷', label: '한국어' },
lv: { emoji: '🇱🇻', label: 'Latviešu' },
lt: { emoji: '🇱🇹', label: 'Lietuvių' },
ms: { emoji: '🇲🇾', label: 'Bahasa Melayu' },
no: { emoji: '🇳🇴', label: 'Norsk' },
pl: { emoji: '🇵🇱', label: 'Polski' },
pt: { emoji: '🇵🇹', label: 'Português' },
'pt-BR': { emoji: '🇧🇷', label: 'Português (Brasil)' },
'pt-PT': { emoji: '🇵🇹', label: 'Português (Portugal)' },
ro: { emoji: '🇷🇴', label: 'Română' },
ru: { emoji: '🇷🇺', label: 'Русский' },
sk: { emoji: '🇸🇰', label: 'Slovenčina' },
es: { emoji: '🇪🇸', label: 'Español' },
'es-419': { emoji: '🇲🇽', label: 'Español (Latinoamérica)' },
sv: { emoji: '🇸🇪', label: 'Svenska' },
th: { emoji: '🇹🇭', label: 'ไทย' },
tr: { emoji: '🇹🇷', label: 'Türkçe' },
uk: { emoji: '🇺🇦', label: 'Українська' },
vi: { emoji: '🇻🇳', label: 'Tiếng Việt' }
}
export const translationStore = writable<Translation | undefined>(undefined)
const translationQuery = createQuery(true)
onClient(() => {
const me = getCurrentEmployee()
translationQuery.query(contact.class.Translation, { attachedTo: me }, (res) => {
translationStore.set(res[0])
})
})
+1
View File
@@ -50,6 +50,7 @@
"@hcengineering/core": "^0.6.32",
"@hcengineering/templates": "^0.6.11",
"@hcengineering/view": "^0.6.13",
"@hcengineering/preference": "^0.6.13",
"@hcengineering/card": "^0.6.0"
},
"repository": "https://github.com/hcengineering/platform",
+11 -1
View File
@@ -40,6 +40,8 @@ import type { AnyComponent, ColorDefinition, ResolvedLocation, Location, Compone
import { Action, FilterMode, Viewlet } from '@hcengineering/view'
import type { Readable } from 'svelte/store'
import { Card, MasterTag, Role } from '@hcengineering/card'
import { Preference } from '@hcengineering/preference'
import { PermissionsStore } from './types'
/**
@@ -208,6 +210,13 @@ export interface PersonSpace extends Space {
person: Ref<Person>
}
export interface Translation extends Preference {
attachedTo: Ref<Employee>
enabled: boolean
translateTo?: string
dontTranslate: string[]
}
/**
* @public
*/
@@ -226,7 +235,8 @@ export const contactPlugin = plugin(contactId, {
PersonSpace: '' as Ref<Class<PersonSpace>>,
SocialIdentity: '' as Ref<Class<SocialIdentity>>,
UserProfile: '' as Ref<MasterTag>,
UserRole: '' as Ref<Class<UserRole>>
UserRole: '' as Ref<Class<UserRole>>,
Translation: '' as Ref<Class<Translation>>
},
mixin: {
Employee: '' as Ref<Class<Employee>>
@@ -31,6 +31,7 @@ import { createDoc, test, type TestDocument } from './minmodel'
import { dbConfig, dbUrl, elasticIndexName, model, prepare, preparePipeline } from './utils'
prepare()
jest.mock('franc-min', () => ({ franc: () => 'en' }), { virtual: true })
jest.setTimeout(500000)
+1
View File
@@ -71,6 +71,7 @@
"@hcengineering/notification": "^0.6.23",
"@hcengineering/platform": "^0.6.11",
"@hcengineering/pod-telegram-bot": "^0.6.0",
"@hcengineering/pod-translate": "^0.6.0",
"@hcengineering/postgres": "^0.6.0",
"@hcengineering/rpc": "^0.6.5",
"@hcengineering/server": "^0.6.4",
+5
View File
@@ -2600,6 +2600,11 @@
"packageName": "@hcengineering/hulylake-client",
"projectFolder": "packages/hulylake-client",
"shouldPublish": false
},
{
"packageName": "@hcengineering/pod-translate",
"projectFolder": "services/translate",
"shouldPublish": false
}
]
}
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'],
parserOptions: {
tsconfigRootDir: __dirname,
project: './tsconfig.json'
}
}
+4
View File
@@ -0,0 +1,4 @@
*
!/lib/**
!CHANGELOG.md
/lib/**/__tests__/
+6
View File
@@ -0,0 +1,6 @@
FROM hardcoreeng/base-slim:v20250916
WORKDIR /usr/src/app
COPY bundle/bundle.js ./
CMD [ "node", "bundle.js" ]
+4
View File
@@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
"rigPackageName": "@hcengineering/platform-rig"
}
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
roots: ["./src"],
coverageReporters: ["text-summary", "html"]
}
+76
View File
@@ -0,0 +1,76 @@
{
"name": "@hcengineering/pod-translate",
"version": "0.6.0",
"main": "lib/index.js",
"svelte": "src/index.ts",
"types": "types/index.d.ts",
"files": [
"lib/**/*",
"types/**/*",
"tsconfig.json"
],
"author": "Hardcore Engineering Inc.",
"scripts": {
"build": "compile",
"build:watch": "compile",
"test": "jest --passWithNoTests --silent",
"_phase:bundle": "rushx bundle",
"_phase:docker-build": "rushx docker:build",
"_phase:docker-staging": "rushx docker:staging",
"bundle": "node ../../common/scripts/esbuild.js",
"docker:build": "../../common/scripts/docker_build.sh hardcoreeng/translate .",
"docker:tbuild": "docker build -t hardcoreeng/translate . --platform=linux/amd64 && ../../common/scripts/docker_tag_push.sh hardcoreeng/translate",
"docker:staging": "../../common/scripts/docker_tag.sh hardcoreeng/translate staging",
"docker:push": "../../common/scripts/docker_tag.sh hardcoreeng/translate",
"run-local": "cross-env ts-node src/index.ts",
"format": "format src",
"_phase:build": "compile transpile src",
"_phase:test": "jest --passWithNoTests --silent",
"_phase:format": "format src",
"_phase:validate": "compile validate"
},
"devDependencies": {
"@tsconfig/node16": "^1.0.4",
"@types/node": "^22.15.29",
"@types/ws": "^8.5.11",
"@typescript-eslint/eslint-plugin": "^6.11.0",
"@hcengineering/platform-rig": "^0.6.0",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"@types/jest": "^29.5.5",
"@types/uuid": "^8.3.1",
"@typescript-eslint/parser": "^6.11.0",
"esbuild": "^0.25.9",
"eslint": "^8.54.0",
"eslint-config-standard-with-typescript": "^40.0.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-n": "^15.4.0",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^6.1.1",
"prettier": "^3.1.0",
"ts-node": "^10.8.0",
"typescript": "^5.8.3"
},
"dependencies": {
"@hcengineering/account-client": "^0.6.0",
"@hcengineering/analytics": "^0.6.0",
"@hcengineering/analytics-service": "^0.6.0",
"@hcengineering/api-client": "^0.6.0",
"@hcengineering/card": "^0.6.0",
"@hcengineering/communication-sdk-types": "^0.1.0",
"@hcengineering/communication-shared": "^0.1.0",
"@hcengineering/communication-types": "^0.1.0",
"@hcengineering/contact": "^0.6.24",
"@hcengineering/core": "^0.6.32",
"@hcengineering/hulylake-client": "^0.6.0",
"@hcengineering/kafka": "^0.6.0",
"@hcengineering/measurements": "^0.7.7",
"@hcengineering/platform": "^0.6.11",
"@hcengineering/retry": "^0.6.0",
"@hcengineering/server-client": "^0.6.0",
"@hcengineering/server-core": "^0.6.1",
"@hcengineering/server-token": "^0.6.11",
"dotenv": "~16.0.0",
"openai": "^4.56.0"
}
}
+54
View File
@@ -0,0 +1,54 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { config as dotenvConfig } from 'dotenv'
import OpenAI from 'openai'
dotenvConfig()
export interface Config {
AccountsUrl: string
OpenAIBaseUrl: string
OpenAIKey: string
OpenAIModel: OpenAI.ChatModel
QueueConfig: string
QueueRegion: string
Secret: string
ServiceId: string
HulylakeUrl: string
}
const config: Config = (() => {
const params: Partial<Config> = {
Secret: process.env.SECRET ?? 'secret',
QueueConfig: process.env.QUEUE_CONFIG ?? '',
QueueRegion: process.env.QUEUE_REGION ?? '',
AccountsUrl: process.env.ACCOUNTS_URL ?? '',
HulylakeUrl: process.env.HULYLAKE_URL ?? '',
ServiceId: process.env.SERVICE_ID ?? 'translate',
OpenAIKey: process.env.OPENAI_API_KEY,
OpenAIModel: (process.env.OPENAI_MODEL ?? 'gpt-4o-mini') as OpenAI.ChatModel,
OpenAIBaseUrl: process.env.OPENAI_BASE_URL ?? ''
}
const missingEnv = (Object.keys(params) as Array<keyof Config>).filter((key) => params[key] === undefined)
if (missingEnv.length > 0) {
throw Error(`Missing env variables: ${missingEnv.join(', ')}`)
}
return params as Config
})()
export default config
+241
View File
@@ -0,0 +1,241 @@
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
import core, {
Doc,
Domain,
generateId,
MeasureContext,
notEmpty,
systemAccountUuid,
Tx,
TxCreateDoc,
TxDomainEvent,
TxProcessor,
TxUpdateDoc,
WorkspaceUuid
} from '@hcengineering/core'
import { createRestClient, RestClient } from '@hcengineering/api-client'
import { generateToken } from '@hcengineering/server-token'
import { getTransactorEndpoint } from '@hcengineering/server-client'
import contact, { Translation } from '@hcengineering/contact'
import { BlobID, CardID, Markdown, Message, MessageID } from '@hcengineering/communication-types'
import { withRetry } from '@hcengineering/retry'
import { Analytics } from '@hcengineering/analytics'
import OpenAI from 'openai'
import { PlatformQueue } from '@hcengineering/server-core'
import { MessageEventType, TranslateMessageEvent } from '@hcengineering/communication-sdk-types'
import { Storage } from './storage'
import config from './config'
export class Controller {
private readonly languagesByWorkspace = new Map<WorkspaceUuid, string[]>()
private readonly storage: Storage
private readonly endpoints = new Map<WorkspaceUuid, string>()
constructor (
private readonly ctx: MeasureContext,
private readonly openai: OpenAI,
private readonly queue: PlatformQueue
) {
this.storage = new Storage(this.ctx)
}
public processTx (workspace: WorkspaceUuid, tx: Tx): void {
if (!this.languagesByWorkspace.has(workspace)) return
this.processTranslation(workspace, tx)
}
private processTranslation (workspace: WorkspaceUuid, tx: Tx): void {
if (tx._class === core.class.TxCreateDoc) {
const createTx = tx as TxCreateDoc<Doc>
if (createTx.objectClass !== contact.class.Translation) return
const translation = TxProcessor.createDoc2Doc(createTx as TxCreateDoc<Translation>)
if (translation.translateTo != null && translation.translateTo !== '') {
const langs = this.languagesByWorkspace.get(workspace) ?? []
if (!langs.includes(translation.translateTo)) {
langs.push(translation.translateTo)
this.languagesByWorkspace.set(workspace, langs)
}
}
}
if (tx._class === core.class.TxUpdateDoc) {
const updateTx = tx as TxUpdateDoc<Translation>
if (updateTx.objectClass !== contact.class.Translation) return
if (updateTx.operations.translateTo == null || updateTx.operations.translateTo === '') return
const langs = this.languagesByWorkspace.get(workspace) ?? []
if (!langs.includes(updateTx.operations.translateTo)) {
langs.push(updateTx.operations.translateTo)
this.languagesByWorkspace.set(workspace, langs)
}
}
}
private async getRestClient (workspace: WorkspaceUuid): Promise<RestClient> {
const token = generateToken(systemAccountUuid, workspace, { service: config.ServiceId })
const endpoint = this.endpoints.get(workspace) ?? (await getTransactorEndpoint(token))
if (endpoint != null && endpoint !== '') {
this.endpoints.set(workspace, endpoint)
}
return createRestClient(endpoint, workspace, token)
}
public async getLanguages (workspace: WorkspaceUuid): Promise<string[]> {
if (this.languagesByWorkspace.has(workspace)) {
return this.languagesByWorkspace.get(workspace) ?? []
}
const client = await this.getRestClient(workspace)
const translations = await client.findAll(contact.class.Translation, {})
const languages = new Set(
translations
.map((it) => it.translateTo)
.filter(notEmpty)
.filter((it) => it !== '')
)
const arrayLangs = Array.from(languages)
this.languagesByWorkspace.set(workspace, arrayLangs)
return arrayLangs
}
async processMessageCreate (
ctx: MeasureContext,
workspace: WorkspaceUuid,
message: Message,
blobId: BlobID
): Promise<void> {
const translateTo = (await this.getLanguages(workspace)).filter((it) => it !== message.language)
if (translateTo.length === 0) return
console.log('translateTo', translateTo)
console.log('message', message.content, message.language)
for (const lang of translateTo) {
try {
const result = await withRetry(() => this.translate(message.content, lang))
if (result !== '') {
await this.storage.insertMessage(workspace, message.cardId, blobId, lang, message, result)
const event: TranslateMessageEvent = {
type: MessageEventType.TranslateMessage,
cardId: message.cardId,
messageId: message.id,
content: result,
language: lang
}
const tx: TxDomainEvent = {
_id: generateId(),
_class: core.class.TxDomainEvent,
space: core.space.Tx,
objectSpace: core.space.Workspace,
modifiedOn: Date.now(),
modifiedBy: core.account.System,
domain: 'communication' as Domain,
event
}
const client = await this.getRestClient(workspace)
await client.tx(tx)
}
} catch (e) {
ctx.error('Failed to translate message', { error: e, messageId: message.id })
Analytics.handleError(e as Error)
}
}
}
async processMessageUpdate (
ctx: MeasureContext,
workspace: WorkspaceUuid,
cardId: CardID,
messageId: MessageID,
content: Markdown,
blobId: BlobID
): Promise<void> {
const translateTo = await this.getLanguages(workspace)
if (translateTo.length === 0) return
for (const lang of translateTo) {
try {
const result = await withRetry(() => this.translate(content, lang))
if (result !== '') {
await this.storage.updateMessage(workspace, cardId, blobId, messageId, lang, result)
const event: TranslateMessageEvent = {
type: MessageEventType.TranslateMessage,
cardId,
messageId,
content: result,
language: lang
}
const tx: TxDomainEvent = {
_id: generateId(),
_class: core.class.TxDomainEvent,
space: core.space.Tx,
objectSpace: core.space.Workspace,
modifiedOn: Date.now(),
modifiedBy: core.account.System,
domain: 'communication' as Domain,
event
}
const client = await this.getRestClient(workspace)
await client.tx(tx)
}
} catch (e) {
ctx.error('Failed to update message translation', { error: e, messageId })
Analytics.handleError(e as Error)
}
}
}
async processMessageRemove (
ctx: MeasureContext,
workspace: WorkspaceUuid,
cardId: CardID,
messageId: MessageID,
blobId: BlobID
): Promise<void> {
const translateTo = await this.getLanguages(workspace)
if (translateTo.length === 0) return
for (const lang of translateTo) {
try {
await this.storage.removeMessage(workspace, cardId, blobId, messageId, lang)
} catch (e) {
ctx.error('Failed to translate message', { error: e, messageId })
Analytics.handleError(e as Error)
}
}
}
private async translate (markdown: string, lang: string): Promise<string> {
const response = await this.openai.chat.completions.create({
model: config.OpenAIModel,
messages: [
{
role: 'system',
content: `You are a translation model.Your only task is to translate the given markdown text into the ${lang} language.
Output only the translation, nothing else. Do not add explanations, comments, or formatting.Preserve names and terms if they have no clear equivalent. Be as literal and accurate as possible while keeping the meaning natural.`
},
{
role: 'user',
content: markdown
}
]
})
return response.choices[0].message.content ?? ''
}
}
+105
View File
@@ -0,0 +1,105 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { configureAnalytics, createOpenTelemetryMetricsContext, SplitLogger } from '@hcengineering/analytics-service'
import { initStatisticsContext, QueueTopic } from '@hcengineering/server-core'
import { newMetrics } from '@hcengineering/measurements'
import { join } from 'path'
import { getPlatformQueue } from '@hcengineering/kafka'
import { Analytics } from '@hcengineering/analytics'
import { setMetadata } from '@hcengineering/platform'
import serverToken from '@hcengineering/server-token'
import { Tx } from '@hcengineering/core'
import OpenAI from 'openai'
import serverClient from '@hcengineering/server-client'
import config from './config'
import { extractCreateMessageData, extractRemoveMessageData, extractUpdateMessageData } from './utils'
import { Controller } from './conroller'
async function main (): Promise<void> {
configureAnalytics(config.ServiceId, process.env.VERSION ?? '0.7.0')
const ctx = initStatisticsContext(config.ServiceId, {
factory: () =>
createOpenTelemetryMetricsContext(
config.ServiceId,
{},
{},
newMetrics(),
new SplitLogger(config.ServiceId, {
root: join(process.cwd(), 'logs'),
enableConsole: (process.env.ENABLE_CONSOLE ?? 'true') === 'true'
})
)
})
Analytics.setTag('application', config.ServiceId)
setMetadata(serverToken.metadata.Secret, config.Secret)
setMetadata(serverToken.metadata.Service, config.ServiceId)
setMetadata(serverClient.metadata.Endpoint, config.AccountsUrl)
const openai = new OpenAI({
apiKey: config.OpenAIKey,
baseURL: config.OpenAIBaseUrl === '' ? undefined : config.OpenAIBaseUrl
})
const queue = getPlatformQueue(config.ServiceId, config.QueueRegion)
const controller = new Controller(ctx, openai, queue)
const consumer = queue.createConsumer<Tx>(ctx, QueueTopic.Tx, queue.getClientId(), async (ctx, queueMessage) => {
const ws = queueMessage.workspace
const tx = queueMessage.value
controller.processTx(ws, tx)
const createMessageData = extractCreateMessageData(tx)
const updateMessageData = extractUpdateMessageData(tx)
const removeMessageData = extractRemoveMessageData(tx)
if (createMessageData != null) {
const { message, blobId } = createMessageData
await controller.processMessageCreate(ctx.newChild('translate', {}), ws, message, blobId)
}
if (updateMessageData != null) {
const { cardId, content, blobId, messageId } = updateMessageData
await controller.processMessageUpdate(ctx.newChild('translate', {}), ws, cardId, messageId, content, blobId)
}
if (removeMessageData != null) {
const { messageId, blobId, cardId } = removeMessageData
await controller.processMessageRemove(ctx.newChild('translate', {}), ws, cardId, messageId, blobId)
}
})
const shutdown = (): void => {
void Promise.all([consumer.close()]).then(() => {
process.exit()
})
}
process.once('SIGINT', shutdown)
process.once('SIGTERM', shutdown)
process.on('uncaughtException', (error: any) => {
ctx.error('Uncaught exception', { error })
})
process.on('unhandledRejection', (error: any) => {
ctx.error('Unhandled rejection', { error })
})
}
void main().catch((err) => {
console.error(err)
})
+150
View File
@@ -0,0 +1,150 @@
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
import { MeasureContext, systemAccountUuid, WorkspaceUuid } from '@hcengineering/core'
import { getClient, type HulylakeClient, type JsonPatch } from '@hcengineering/hulylake-client'
import { generateToken } from '@hcengineering/server-token'
import {
BlobID,
CardID,
Markdown,
Message,
MessageID,
TranslatesMessageDoc,
TranslatedMessagesDoc
} from '@hcengineering/communication-types'
import config from './config'
export class Storage {
private readonly createdBlobs = new Map<WorkspaceUuid, Map<string, Set<BlobID>>>()
private readonly retryOptions = {
maxRetries: 3,
isRetryable: () => true,
delayStrategy: {
getDelay: () => 1000
}
} as const
constructor (private readonly ctx: MeasureContext) {}
private getClient (ws: WorkspaceUuid): HulylakeClient {
const token = generateToken(systemAccountUuid, ws, undefined, config.Secret)
return getClient(config.HulylakeUrl, ws, token)
}
private async createMessageGroup (ws: WorkspaceUuid, cardId: CardID, blobId: BlobID, lang: string): Promise<void> {
const created = this.createdBlobs.get(ws)?.get(lang)?.has(blobId) ?? false
if (created) return
const client = this.getClient(ws)
const current = await client.getJson<TranslatedMessagesDoc>(`${cardId}/messages/${lang}/${blobId}`)
if (current?.body == null) {
const initialJson: TranslatedMessagesDoc = {
cardId,
language: lang,
messages: {}
}
await client.putJson(`${cardId}/messages/${lang}/${blobId}`, initialJson, undefined, this.retryOptions)
}
const map = this.createdBlobs.get(ws) ?? new Map()
const set = map.get(lang) ?? new Set()
set.add(blobId)
map.set(lang, set)
this.createdBlobs.set(ws, map)
}
async insertMessage (
ws: WorkspaceUuid,
cardId: CardID,
blobId: BlobID,
lang: string,
message: Message,
translated: Markdown
): Promise<void> {
await this.createMessageGroup(ws, cardId, blobId, lang)
const serializedMessage = this.serializeMessage(message, translated)
const patches: JsonPatch[] = [
{
hop: 'add',
path: `/messages/${message.id}`,
value: serializedMessage,
safe: true
}
]
await this.patchJson(ws, cardId, blobId, lang, patches)
}
async updateMessage (
ws: WorkspaceUuid,
cardId: CardID,
blobId: BlobID,
messageId: MessageID,
lang: string,
translated: Markdown
): Promise<void> {
const patches: JsonPatch[] = []
patches.push({
op: 'replace',
path: `/messages/${messageId}/content`,
value: translated
})
await this.patchJson(ws, cardId, blobId, lang, patches)
}
async removeMessage (
ws: WorkspaceUuid,
cardId: CardID,
blobId: BlobID,
messageId: MessageID,
lang: string
): Promise<void> {
const patches: JsonPatch[] = [
{
op: 'remove',
path: `/messages/${messageId}`
} as const
]
await this.patchJson(ws, cardId, blobId, lang, patches)
}
private async patchJson (
ws: WorkspaceUuid,
cardId: CardID,
blobId: BlobID,
lang: string,
patches: JsonPatch[]
): Promise<void> {
const client = this.getClient(ws)
await client.patchJson(`${cardId}/messages/${lang}/${blobId}`, patches, undefined, this.retryOptions)
}
private serializeMessage (message: Message, translated: Markdown): TranslatesMessageDoc {
return {
id: message.id,
created: message.created.toISOString(),
creator: message.creator,
content: translated
}
}
}
+98
View File
@@ -0,0 +1,98 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import core, { Domain, Tx, TxDomainEvent } from '@hcengineering/core'
import {
CreateMessageEvent,
MessageEventType,
RemovePatchEvent,
UpdatePatchEvent
} from '@hcengineering/communication-sdk-types'
import { BlobID, CardID, Markdown, Message, MessageID, MessageType } from '@hcengineering/communication-types'
import { MessageProcessor } from '@hcengineering/communication-shared'
const COMMUNICATION_DOMAIN = 'communication' as Domain
export function extractCreateMessageData (tx: Tx):
| {
message: Message
blobId: BlobID
}
| undefined {
if (tx._class !== core.class.TxDomainEvent) return undefined
const domainTx = tx as TxDomainEvent
if (domainTx.domain !== COMMUNICATION_DOMAIN) return undefined
if (domainTx.event?.type !== MessageEventType.CreateMessage) return undefined
const event: CreateMessageEvent = domainTx.event
if (event.messageType !== MessageType.Text) return undefined
if (event.options?.noNotify === true) return undefined
const message = MessageProcessor.create(event)
const blobId = event._eventExtra?.blobId
if (message == null) return undefined
if (blobId == null) return undefined
return { message, blobId }
}
export function extractUpdateMessageData (tx: Tx):
| {
cardId: CardID
messageId: MessageID
content: Markdown
blobId: BlobID
}
| undefined {
if (tx._class !== core.class.TxDomainEvent) return undefined
const domainTx = tx as TxDomainEvent
if (domainTx.domain !== COMMUNICATION_DOMAIN) return undefined
if (domainTx.event?.type !== MessageEventType.UpdatePatch) return undefined
const event: UpdatePatchEvent = domainTx.event
const blobId = event._eventExtra?.blobId
if (event.content == null) return undefined
if (blobId == null) return undefined
return { cardId: event.cardId, content: event.content, blobId, messageId: event.messageId }
}
export function extractRemoveMessageData (tx: Tx):
| {
cardId: CardID
messageId: MessageID
blobId: BlobID
}
| undefined {
if (tx._class !== core.class.TxDomainEvent) return undefined
const domainTx = tx as TxDomainEvent
if (domainTx.domain !== COMMUNICATION_DOMAIN) return undefined
if (domainTx.event?.type !== MessageEventType.RemovePatch) return undefined
const event: RemovePatchEvent = domainTx.event
const blobId = event._eventExtra?.blobId
if (blobId == null) return undefined
return { messageId: event.messageId, blobId, cardId: event.cardId }
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./lib",
"declarationDir": "./types",
"tsBuildInfoFile": ".build/build.tsbuildinfo"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "lib", "dist", "types", "bundle"]
}