mirror of
https://github.com/hcengineering/platform.git
synced 2026-08-17 18:05:42 +02:00
Auto translate service (#9978)
Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
This commit is contained in:
Generated
+77
-8
@@ -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:
|
||||
|
||||
+1
-1
Submodule communication updated: bbccecb9db...047325b57f
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,11 @@
|
||||
"HereDescription": "この{title}のすべてに通知する",
|
||||
"Guest": "ゲスト",
|
||||
"Deleted": "削除済み",
|
||||
"CannotMerge": "エラー:グローバル個人は統合できません"
|
||||
"CannotMerge": "エラー:グローバル個人は統合できません",
|
||||
"AutoTranslation": "自動翻訳",
|
||||
"TranslateTo": "翻訳先",
|
||||
"DontTranslate": "翻訳しない",
|
||||
"SelectLanguage": "言語を選択",
|
||||
"SelectLanguages": "言語を選択"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,11 @@
|
||||
"HereDescription": "Уведомить всех в этом {title}",
|
||||
"Guest": "Гость",
|
||||
"Deleted": "Удалён",
|
||||
"CannotMerge": "Ошибка: Невозможно объединить глобальных персон"
|
||||
"CannotMerge": "Ошибка: Невозможно объединить глобальных персон",
|
||||
"AutoTranslation": "Автоматический перевод",
|
||||
"TranslateTo": "Переводить на",
|
||||
"DontTranslate": "Не переводить",
|
||||
"SelectLanguage": "Выберите язык",
|
||||
"SelectLanguages": "Выберите языки"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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 (
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2600,6 +2600,11 @@
|
||||
"packageName": "@hcengineering/hulylake-client",
|
||||
"projectFolder": "packages/hulylake-client",
|
||||
"shouldPublish": false
|
||||
},
|
||||
{
|
||||
"packageName": "@hcengineering/pod-translate",
|
||||
"projectFolder": "services/translate",
|
||||
"shouldPublish": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'],
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
project: './tsconfig.json'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
*
|
||||
!/lib/**
|
||||
!CHANGELOG.md
|
||||
/lib/**/__tests__/
|
||||
@@ -0,0 +1,6 @@
|
||||
FROM hardcoreeng/base-slim:v20250916
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY bundle/bundle.js ./
|
||||
|
||||
CMD [ "node", "bundle.js" ]
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
|
||||
"rigPackageName": "@hcengineering/platform-rig"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
|
||||
roots: ["./src"],
|
||||
coverageReporters: ["text-summary", "html"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 ?? ''
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user