Merge remote-tracking branch 'origin/develop' into staging

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2025-01-09 11:17:51 +07:00
commit 5bf3fc0176
No known key found for this signature in database
GPG Key ID: BD80F68D68D8F7F2
48 changed files with 872 additions and 391 deletions

3
.vscode/launch.json vendored
View File

@ -584,7 +584,8 @@
"PASSWORD": "password",
"AVATAR_PATH": "./assets/avatar.png",
"AVATAR_CONTENT_TYPE": ".png",
"LOVE_ENDPOINT": "http://localhost:8096"
"STORAGE_CONFIG": "minio|localhost?accessKey=minioadmin&secretKey=minioadmin",
"LOVE_ENDPOINT": "http://localhost:8096",
},
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"],
"sourceMaps": true,

View File

@ -238,8 +238,8 @@
"summary": "Build docker with platform",
"description": "use to build all docker containers required for platform",
"safeForSimultaneousRushProcesses": true,
"shellCommand": "rush docker:build -p 20 --to @hcengineering/pod-server --to @hcengineering/pod-front --to @hcengineering/prod --to @hcengineering/pod-account --to @hcengineering/pod-workspace --to @hcengineering/pod-collaborator --to @hcengineering/tool --to @hcengineering/pod-print --to @hcengineering/pod-sign --to @hcengineering/pod-analytics-collector --to @hcengineering/rekoni-service --to @hcengineering/pod-ai-bot --to @hcengineering/import-tool --to @hcengineering/pod-stats --to @hcengineering/pod-fulltext"
},
"shellCommand": "rush docker:build -p 20 --to @hcengineering/pod-server --to @hcengineering/pod-front --to @hcengineering/prod --to @hcengineering/pod-account --to @hcengineering/pod-workspace --to @hcengineering/pod-collaborator --to @hcengineering/tool --to @hcengineering/pod-print --to @hcengineering/pod-sign --to @hcengineering/pod-analytics-collector --to @hcengineering/rekoni-service --to @hcengineering/pod-ai-bot --to @hcengineering/import-tool --to @hcengineering/pod-stats --to @hcengineering/pod-fulltext --to @hcengineering/pod-love"
},
{
"commandKind": "global",
"name": "docker:up",

View File

@ -1235,6 +1235,9 @@ dependencies:
'@tiptap/extension-task-list':
specifier: ^2.6.6
version: 2.6.6(@tiptap/core@2.6.6)
'@tiptap/extension-text-align':
specifier: ~2.11.0
version: 2.11.0(@tiptap/core@2.6.6)
'@tiptap/extension-typography':
specifier: ^2.6.6
version: 2.6.6(@tiptap/core@2.6.6)
@ -1634,9 +1637,6 @@ dependencies:
googleapis:
specifier: ^122.0.0
version: 122.0.0
got:
specifier: ^11.8.3
version: 11.8.6
graphql:
specifier: ^16.8.0
version: 16.9.0
@ -6252,6 +6252,14 @@ packages:
'@tiptap/core': 2.6.6(@tiptap/pm@2.6.6)
dev: false
/@tiptap/extension-text-align@2.11.0(@tiptap/core@2.6.6):
resolution: {integrity: sha512-VRXBqO17po6ddqhoWLBa2aCX/tqHdzdKPLfjnBy1fF8hjQKbidzjMWhb4CMm31ApvJjKK/DTkM3EnyYS/XDhng==}
peerDependencies:
'@tiptap/core': ^2.7.0
dependencies:
'@tiptap/core': 2.6.6(@tiptap/pm@2.6.6)
dev: false
/@tiptap/extension-text@2.6.6(@tiptap/core@2.6.6):
resolution: {integrity: sha512-e84uILnRzNzcwK1DVQNpXVmBG1Cq3BJipTOIDl1LHifOok7MBjhI/X+/NR0bd3N2t6gmDTWi63+4GuJ5EeDmsg==}
peerDependencies:
@ -24760,7 +24768,7 @@ packages:
dev: false
file:projects/importer.tgz(esbuild@0.24.2)(ts-node@10.9.2):
resolution: {integrity: sha512-2BSUFKldNxsA3oJ/xjVKMJc975re0WnY1T24b0yscLWNGfMchaPaHNLBMIpd3d4rXywTX0qqBNPt+aWkp2XVBA==, tarball: file:projects/importer.tgz}
resolution: {integrity: sha512-nd4QEoFM7LFj37X/9PCtKl2HTaQl3xnpCbJL+FBuYPJhimHzG4KTvb3E5vZ31OZxgAzYBBLZb1KsswqqlXAJ9A==, tarball: file:projects/importer.tgz}
id: file:projects/importer.tgz
name: '@rush-temp/importer'
version: 0.0.0
@ -27290,7 +27298,7 @@ packages:
dev: false
file:projects/pod-ai-bot.tgz(bufferutil@4.0.8)(utf-8-validate@6.0.4)(zod@3.23.8):
resolution: {integrity: sha512-zCUFPyseaS/JuRQ5JMtR1n8ybdRgxQzRn2aP528X1x+jOBVEH2L7VjDGBx1jsJGuUKvCJBCoOAQlCHDgfRambQ==, tarball: file:projects/pod-ai-bot.tgz}
resolution: {integrity: sha512-OPo+KhRKsPQhO1eqOIgL30ef9s/TAnEf21bAwzxLLcB70AIwMyjfPRzzJHDOIbVjCzA8t4YFQ5MWWoLUveylFg==, tarball: file:projects/pod-ai-bot.tgz}
id: file:projects/pod-ai-bot.tgz
name: '@rush-temp/pod-ai-bot'
version: 0.0.0
@ -27301,6 +27309,7 @@ packages:
'@types/jest': 29.5.12
'@types/node': 20.11.19
'@types/node-fetch': 2.6.11
'@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.3.3)
'@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.3.3)
@ -27325,6 +27334,7 @@ packages:
ts-jest: 29.1.2(esbuild@0.24.2)(jest@29.7.0)(typescript@5.3.3)
ts-node: 10.9.2(@types/node@20.11.19)(typescript@5.3.3)
typescript: 5.3.3
uuid: 8.3.2
ws: 8.18.0(bufferutil@4.0.8)(utf-8-validate@6.0.4)
transitivePeerDependencies:
- '@aws-sdk/credential-providers'
@ -27401,7 +27411,7 @@ packages:
dev: false
file:projects/pod-backup.tgz:
resolution: {integrity: sha512-f8l7TT88HfNQ8lRgFe4lA5Zbzb3nPF+9dBmaOAd1SFLWAnbp959dyN4CxGPWQDu4VeQ50vKe3wg7FxoYpgQRyg==, tarball: file:projects/pod-backup.tgz}
resolution: {integrity: sha512-Ccg90DAJu+vNRMm00Z8W768WT8M6AKzjUFktH9/RBbDGQSF+UYQNm7Ah/cMJnJ0GMOg/dQ//r7Tob7WL4kaBeg==, tarball: file:projects/pod-backup.tgz}
name: '@rush-temp/pod-backup'
version: 0.0.0
dependencies:
@ -27895,7 +27905,7 @@ packages:
dev: false
file:projects/pod-ses.tgz:
resolution: {integrity: sha512-Z+IlSWDXbE1r5gAGoFhvdyRXh8qZUlANdrO4jWmT0HuDHWeDibkjK5/YXnqztHDmMbz3QI60RotnYOdJwkqSLA==, tarball: file:projects/pod-ses.tgz}
resolution: {integrity: sha512-P+HVwQR4SO6F4EXl5ReGR6hbng8CKsl4IK6Ww6u7yhNh1x7YGKzmV7UMRjSUc7qsNu4CzOhF7AYatCB602ANjA==, tarball: file:projects/pod-ses.tgz}
name: '@rush-temp/pod-ses'
version: 0.0.0
dependencies:
@ -32508,7 +32518,7 @@ packages:
dev: false
file:projects/text-editor-resources.tgz(@types/node@20.11.19)(bufferutil@4.0.8)(esbuild@0.24.2)(highlight.js@11.8.0)(postcss-load-config@4.0.2)(postcss@8.4.35)(ts-node@10.9.2)(utf-8-validate@6.0.4):
resolution: {integrity: sha512-F0E6R31dkSA4Oa+lx7pYoKeHmMI1N0wMDKomkWOhwtChvWo3EZRj9xySEthXFV8au0htnsi+rKeywFOCcGdmGg==, tarball: file:projects/text-editor-resources.tgz}
resolution: {integrity: sha512-7dR/DxZ3NRHi1qbcIIwu94XiC5aklrmIsXSjx/Nd8lsNy4Tyc0+1nB8lM6di7SF89cG/xwk3JokDbL3tlYQKbw==, tarball: file:projects/text-editor-resources.tgz}
id: file:projects/text-editor-resources.tgz
name: '@rush-temp/text-editor-resources'
version: 0.0.0
@ -32531,6 +32541,7 @@ packages:
'@tiptap/extension-table-cell': 2.6.6(@tiptap/core@2.6.6)
'@tiptap/extension-table-header': 2.6.6(@tiptap/core@2.6.6)
'@tiptap/extension-table-row': 2.6.6(@tiptap/core@2.6.6)
'@tiptap/extension-text-align': 2.11.0(@tiptap/core@2.6.6)
'@tiptap/extension-typography': 2.6.6(@tiptap/core@2.6.6)
'@tiptap/extension-underline': 2.6.6(@tiptap/core@2.6.6)
'@tiptap/pm': 2.6.6
@ -32675,7 +32686,7 @@ packages:
dev: false
file:projects/text.tgz(@types/node@20.11.19)(bufferutil@4.0.8)(esbuild@0.24.2)(ts-node@10.9.2)(utf-8-validate@6.0.4):
resolution: {integrity: sha512-1V1MwGEZv1UxhJ4js4mIMG6U87MbA6jlzPZyE6EmtNpYbQ12DfVTp2XpiK3JlZKs95itkJcFCSTQIMsYSrw4yw==, tarball: file:projects/text.tgz}
resolution: {integrity: sha512-zmM6GDzeNuMbMA7iWwhg8hBc43fylNW7cEmk0fSWw9+G9NqD2MLv2dVb5dnAcPAioj9WBdDakWcrLAAHUBJyMw==, tarball: file:projects/text.tgz}
id: file:projects/text.tgz
name: '@rush-temp/text'
version: 0.0.0
@ -32695,6 +32706,7 @@ packages:
'@tiptap/extension-table-row': 2.6.6(@tiptap/core@2.6.6)
'@tiptap/extension-task-item': 2.6.6(@tiptap/core@2.6.6)(@tiptap/pm@2.6.6)
'@tiptap/extension-task-list': 2.6.6(@tiptap/core@2.6.6)
'@tiptap/extension-text-align': 2.11.0(@tiptap/core@2.6.6)
'@tiptap/extension-typography': 2.6.6(@tiptap/core@2.6.6)
'@tiptap/extension-underline': 2.6.6(@tiptap/core@2.6.6)
'@tiptap/html': 2.6.6(@tiptap/core@2.6.6)(@tiptap/pm@2.6.6)
@ -32893,7 +32905,7 @@ packages:
dev: false
file:projects/tool.tgz:
resolution: {integrity: sha512-nyx2osnJq6th2Ttrw5B2TIHUc2j8nTnB4tvx0jIXQLjiq/FEUxBluKLb2eEoHntQ5TxCWEC9a69jkO/QnNWfwA==, tarball: file:projects/tool.tgz}
resolution: {integrity: sha512-Cw36G6uROkPkNIJDl7WeHjhHKmbQth6wmjMu4vBQGz+DfuPf9Eg63SFKq29rOssZ4LlJB1d22Y7XnxilCuXK5g==, tarball: file:projects/tool.tgz}
name: '@rush-temp/tool'
version: 0.0.0
dependencies:

View File

@ -17,6 +17,7 @@
"bundle": "node ../../common/scripts/esbuild.js --entry=src/__start.ts --keep-names=true --sourcemap=external --define:MODEL_VERSION --define:GIT_REVISION",
"docker:build": "../../common/scripts/docker_build.sh hardcoreeng/tool",
"docker:tbuild": "docker build -t hardcoreeng/tool . --platform=linux/amd64 && ../../common/scripts/docker_tag_push.sh hardcoreeng/tool",
"docker:abuild": "docker build -t hardcoreeng/tool . --platform=linux/arm64 && ../../common/scripts/docker_tag_push.sh hardcoreeng/tool",
"docker:staging": "../../common/scripts/docker_tag.sh hardcoreeng/tool staging",
"docker:push": "../../common/scripts/docker_tag.sh hardcoreeng/tool",
"run-local": "rush bundle --to @hcengineering/tool >/dev/null && cross-env SERVER_SECRET=secret ACCOUNTS_URL=http://localhost:3000 TRANSACTOR_URL=ws://localhost:3333 MINIO_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin MINIO_ENDPOINT=localhost ACCOUNT_DB_URL=mongodb://localhost:27017 DB_URL=mongodb://localhost:27017 TELEGRAM_DATABASE=telegram-service REKONI_URL=http://localhost:4004 MODEL_VERSION=$(node ../../common/scripts/show_version.js) GIT_REVISION=$(git describe --all --long) node --expose-gc --max-old-space-size=18000 ./bundle/bundle.js",

View File

@ -347,7 +347,7 @@ export async function updateDataWorkspaceIdToUuid (
throw new Error('workspace uuid is required but not defined')
}
await client`UPDATE ${client(table)} SET "workspaceId" = ${uuid} WHERE "workspaceIdOld" = ${ws.workspace}`
await client`UPDATE ${client(table)} SET "workspaceId" = ${uuid} WHERE "workspaceIdOld" = ${ws.workspace} OR "workspaceIdOld" = ${uuid}`
}
})

View File

@ -518,11 +518,6 @@ export function createModel (builder: Builder): void {
grouppingManager: contact.aggregation.GrouppingPersonManager
})
builder.mixin(contact.mixin.Employee, core.class.Class, view.mixin.ObjectEditor, {
editor: contact.component.EditEmployee,
pinned: true
})
builder.mixin(contact.class.Organization, core.class.Class, view.mixin.ObjectEditor, {
editor: contact.component.EditOrganization,
pinned: true

View File

@ -35,7 +35,6 @@ export default mergeIds(contactId, contact, {
ContactRefPresenter: '' as AnyComponent,
ContactPresenter: '' as AnyComponent,
EditPerson: '' as AnyComponent,
EditEmployee: '' as AnyComponent,
EditOrganization: '' as AnyComponent,
OrganizationPresenter: '' as AnyComponent,
Contacts: '' as AnyComponent,

View File

@ -147,6 +147,42 @@ function createImageAlignmentAction (builder: Builder, align: 'center' | 'left'
})
}
function createTextAlignmentAction (builder: Builder, align: 'center' | 'left' | 'right'): void {
let icon: Asset
let label: IntlString
let index: number
switch (align) {
case 'left':
icon = textEditor.icon.AlignLeft
label = textEditor.string.AlignLeft
index = 5
break
case 'center':
icon = textEditor.icon.AlignCenter
label = textEditor.string.AlignCenter
index = 10
break
case 'right':
icon = textEditor.icon.AlignRight
label = textEditor.string.AlignRight
index = 15
break
}
builder.createDoc(textEditor.class.TextEditorAction, core.space.Model, {
kind: 'text',
action: {
command: 'setTextAlign',
params: align
},
visibilityTester: textEditor.function.IsEditable,
icon,
label,
category: 45,
index
})
}
export function createModel (builder: Builder): void {
builder.createModel(TRefInputActionItem, TTextEditorExtensionFactory, TTextEditorAction)
@ -253,6 +289,11 @@ export function createModel (builder: Builder): void {
index: 10
})
// Text align category
createTextAlignmentAction(builder, 'left')
createTextAlignmentAction(builder, 'center')
createTextAlignmentAction(builder, 'right')
// Quote category
builder.createDoc(textEditor.class.TextEditorAction, core.space.Model, {
action: {

View File

@ -329,6 +329,7 @@ async function tryLoadModel (
if (conn.getLastHash !== undefined && (await conn.getLastHash(ctx)) === current.hash) {
// We have same model hash.
current.full = false // Since we load, no need to send full
return current
}
const lastTxTime = getLastTxTime(current.transactions)

View File

@ -63,7 +63,8 @@
"@tiptap/suggestion": "^2.6.6",
"prosemirror-codemark": "^0.4.2",
"markdown-it": "^14.0.0",
"fast-equals": "^5.0.1"
"fast-equals": "^5.0.1",
"@tiptap/extension-text-align": "~2.11.0"
},
"repository": "https://github.com/hcengineering/platform",
"publishConfig": {

View File

@ -33,6 +33,7 @@ import { DefaultKit, DefaultKitOptions } from './default-kit'
import { CodeExtension, codeOptions } from '../marks/code'
import { NoteBaseExtension } from '../marks/noteBase'
import { CommentNode } from '../nodes/comment'
import TextAlign from '@tiptap/extension-text-align'
const headingLevels: Level[] = [1, 2, 3, 4, 5, 6]
@ -86,6 +87,11 @@ export const ServerKit = Extension.create<ServerKitOptions>({
...taskListExtensions,
...fileExtensions,
...imageExtensions,
TextAlign.configure({
types: ['heading', 'paragraph'],
alignments: ['left', 'center', 'right'],
defaultAlignment: null
}),
TodoItemNode,
TodoListNode,
ReferenceNode,

View File

@ -61,17 +61,19 @@ describe('EmptyMarkup', () => {
describe('getMarkup', () => {
it('with empty content', async () => {
const editor = new Editor({ extensions })
expect(getMarkup(editor)).toEqual('{"type":"doc","content":[{"type":"paragraph"}]}')
expect(getMarkup(editor)).toEqual('{"type":"doc","content":[{"type":"paragraph","attrs":{"textAlign":null}}]}')
})
it('with some content', async () => {
const editor = new Editor({ extensions, content: '<p>hello</p>' })
expect(getMarkup(editor)).toEqual(
'{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}'
'{"type":"doc","content":[{"type":"paragraph","attrs":{"textAlign":null},"content":[{"type":"text","text":"hello"}]}]}'
)
})
it('with empty paragraphs as content', async () => {
const editor = new Editor({ extensions, content: '<p></p><p></p>' })
expect(getMarkup(editor)).toEqual('{"type":"doc","content":[{"type":"paragraph"},{"type":"paragraph"}]}')
expect(getMarkup(editor)).toEqual(
'{"type":"doc","content":[{"type":"paragraph","attrs":{"textAlign":null}},{"type":"paragraph","attrs":{"textAlign":null}}]}'
)
})
})
@ -250,13 +252,15 @@ describe('pmNodeToMarkup', () => {
const schema = getSchema(extensions)
const node = schema.node('paragraph', {}, [schema.text('Hello, world!')])
expect(pmNodeToMarkup(node)).toEqual('{"type":"paragraph","content":[{"type":"text","text":"Hello, world!"}]}')
expect(pmNodeToMarkup(node)).toEqual(
'{"type":"paragraph","attrs":{"textAlign":null},"content":[{"type":"text","text":"Hello, world!"}]}'
)
})
})
describe('markupToPmNode', () => {
it('converts markup to ProseMirrorNode', () => {
const markup = '{"type":"paragraph","content":[{"type":"text","text":"Hello, world!"}]}'
const markup = '{"type":"paragraph","attrs":{"textAlign":null},"content":[{"type":"text","text":"Hello, world!"}]}'
const node = markupToPmNode(markup)
expect(node.type.name).toEqual('paragraph')
@ -306,7 +310,11 @@ describe('pmNodeToJSON', () => {
const schema = getSchema(extensions)
const node = schema.node('paragraph', {}, [schema.text('Hello, world!')])
const json = nodeParagraph(nodeText('Hello, world!'))
const json: MarkupNode = {
type: MarkupNodeType.paragraph,
attrs: { textAlign: null as any },
content: [nodeText('Hello, world!')]
}
expect(pmNodeToJSON(node)).toEqual(json)
})
})

View File

@ -44,6 +44,7 @@ import core, {
TxApplyIf,
TxHandler,
TxResult,
clone,
generateId,
toFindResult,
type MeasureContext
@ -108,6 +109,8 @@ class Connection implements ClientConnection {
private helloRecieved: boolean = false
private account: Account | undefined
onConnect?: (event: ClientConnectEvent, lastTx: string | undefined, data: any) => Promise<void>
rpcHandler = new RPCHandler()
@ -303,7 +306,7 @@ class Connection implements ClientConnection {
this.websocket?.close()
return
}
this.account = helloResp.account
this.helloRecieved = true
if (this.upgrading) {
// We need to call upgrade since connection is upgraded
@ -322,8 +325,8 @@ class Connection implements ClientConnection {
}
void this.onConnect?.(
(resp as HelloResponse).reconnect === true ? ClientConnectEvent.Reconnected : ClientConnectEvent.Connected,
(resp as HelloResponse).lastTx,
helloResp.reconnect === true ? ClientConnectEvent.Reconnected : ClientConnectEvent.Connected,
helloResp.lastTx,
this.sessionId
)
this.schedulePing(socketId)
@ -635,6 +638,9 @@ class Connection implements ClientConnection {
}
getAccount (): Promise<Account> {
if (this.account !== undefined) {
return clone(this.account)
}
return this.sendRequest({ method: 'getAccount', params: [] })
}

View File

@ -1,193 +0,0 @@
<!--
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021 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 {
Channel,
Employee,
Person,
PersonAccount,
combineName,
getFirstName,
getLastName
} from '@hcengineering/contact'
import { AccountRole, Ref, getCurrentAccount, hasAccountRole } from '@hcengineering/core'
import { AttributeEditor, createQuery, getClient } from '@hcengineering/presentation'
import setting, { IntegrationType } from '@hcengineering/setting'
import { EditBox, FocusHandler, Scroller, createFocusManager } from '@hcengineering/ui'
import { createEventDispatcher, onMount } from 'svelte'
import { ChannelsDropdown } from '..'
import contact from '../plugin'
import Avatar from './Avatar.svelte'
import ChannelsEditor from './ChannelsEditor.svelte'
import EditableAvatar from './EditableAvatar.svelte'
export let object: Person
export let readonly = false
export let channels: Channel[] | undefined = undefined
const client = getClient()
const account = getCurrentAccount() as PersonAccount
let avatarEditor: EditableAvatar
$: owner = account.person === object._id
$: editable = !readonly && (hasAccountRole(account, AccountRole.Maintainer) || owner)
let firstName = getFirstName(object.name)
let lastName = getLastName(object.name)
$: setName(object)
let email: string | undefined
$: if (editable) {
client.findOne(contact.class.PersonAccount, { person: (object as Employee)._id }).then((acc) => {
email = acc?.email
})
}
function setName (object: Person) {
firstName = getFirstName(object.name)
lastName = getLastName(object.name)
}
const dispatch = createEventDispatcher()
async function firstNameChange () {
await client.update(object, {
name: combineName(firstName, getLastName(object.name))
})
}
async function lastNameChange () {
await client.update(object, {
name: combineName(getFirstName(object.name), lastName)
})
}
let integrations: Set<Ref<IntegrationType>> = new Set<Ref<IntegrationType>>()
const settingsQuery = createQuery()
$: settingsQuery.query(setting.class.Integration, { createdBy: account._id, disabled: false }, (res) => {
integrations = new Set(res.map((p) => p.type))
})
const sendOpen = () => dispatch('open', { ignoreKeys: ['comments', 'name', 'channels', 'city'] })
onMount(sendOpen)
async function onAvatarDone () {
if (object.avatar != null) {
await avatarEditor.removeAvatar(object.avatar)
}
const avatar = await avatarEditor.createAvatar()
await client.diffUpdate(object, avatar)
}
const manager = createFocusManager()
</script>
<FocusHandler {manager} />
{#if object !== undefined}
<div class="flex-row-stretch flex-grow">
<div class="flex-no-shrink mr-8">
{#key object}
{#if editable}
<EditableAvatar
person={object}
{email}
size={'x-large'}
name={object.name}
bind:this={avatarEditor}
on:done={onAvatarDone}
/>
{:else}
<Avatar person={object} size={'x-large'} name={object.name} />
{/if}
{/key}
</div>
<div class="flex-grow flex-col">
<div class="name select-text">
{#if owner}
<EditBox
placeholder={contact.string.PersonFirstNamePlaceholder}
bind:value={firstName}
disabled={!editable}
on:change={firstNameChange}
focusIndex={1}
/>
{:else}
{firstName}
{/if}
</div>
<div class="name select-text">
{#if owner}
<EditBox
placeholder={contact.string.PersonLastNamePlaceholder}
bind:value={lastName}
on:change={lastNameChange}
disabled={!editable}
focusIndex={2}
/>
{:else}
{lastName}
{/if}
</div>
<div class="location">
<AttributeEditor maxWidth="20rem" _class={contact.class.Person} {editable} {object} key="city" focusIndex={3} />
</div>
<div class="separator" />
<Scroller contentDirection={'horizontal'} padding={'.125rem .125rem .5rem'} stickedScrollBars thinScrollBars>
{#if channels === undefined}
<ChannelsEditor
attachedTo={object._id}
attachedClass={object._class}
{editable}
bind:integrations
shape={'circle'}
focusIndex={10}
/>
{:else}
<ChannelsDropdown
value={channels}
editable={false}
kind={'link-bordered'}
size={'small'}
length={'full'}
shape={'circle'}
/>
{/if}
</Scroller>
</div>
</div>
{/if}
<style lang="scss">
.name {
font-weight: 500;
font-size: 1.25rem;
color: var(--caption-color);
}
.location {
margin-top: 0.25rem;
font-size: 0.75rem;
}
.separator {
margin: 1rem 0;
height: 1px;
background-color: var(--divider-color);
}
</style>

View File

@ -14,8 +14,8 @@
// limitations under the License.
-->
<script lang="ts">
import { Person, PersonAccount, combineName, getFirstName, getLastName } from '@hcengineering/contact'
import { Ref, getCurrentAccount } from '@hcengineering/core'
import { Channel, Person, PersonAccount, combineName, getFirstName, getLastName } from '@hcengineering/contact'
import { AccountRole, Ref, getCurrentAccount, hasAccountRole } from '@hcengineering/core'
import { AttributeEditor, createQuery, getClient } from '@hcengineering/presentation'
import setting, { IntegrationType } from '@hcengineering/setting'
import { EditBox, FocusHandler, Scroller, createFocusManager } from '@hcengineering/ui'
@ -23,12 +23,25 @@
import contact from '../plugin'
import ChannelsEditor from './ChannelsEditor.svelte'
import EditableAvatar from './EditableAvatar.svelte'
import Avatar from './Avatar.svelte'
import ChannelsDropdown from './ChannelsDropdown.svelte'
export let object: Person
export let readonly: boolean = false
export let channels: Channel[] | undefined = undefined
const client = getClient()
const h = client.getHierarchy()
const account = getCurrentAccount() as PersonAccount
$: owner = account.person === object._id
function isEditable (owner: boolean, object: Person): boolean {
if (owner) return true
if (!h.hasMixin(object, contact.mixin.Employee)) return true
return hasAccountRole(account, AccountRole.Maintainer) && !h.as(object, contact.mixin.Employee).active
}
$: editable = !readonly && isEditable(owner, object)
let avatarEditor: EditableAvatar
@ -37,21 +50,21 @@
$: setName(object)
function setName (object: Person) {
function setName (object: Person): void {
firstName = getFirstName(object.name)
lastName = getLastName(object.name)
}
const dispatch = createEventDispatcher()
function firstNameChange () {
client.update(object, {
async function firstNameChange (): Promise<void> {
await client.update(object, {
name: combineName(firstName, getLastName(object.name))
})
}
function lastNameChange () {
client.update(object, {
async function lastNameChange (): Promise<void> {
await client.update(object, {
name: combineName(getFirstName(object.name), lastName)
})
}
@ -65,7 +78,7 @@
const sendOpen = () => dispatch('open', { ignoreKeys: ['comments', 'name', 'channels', 'city'] })
onMount(sendOpen)
async function onAvatarDone () {
async function onAvatarDone (): Promise<void> {
if (object.avatar != null) {
await avatarEditor.removeAvatar(object.avatar)
}
@ -82,21 +95,24 @@
<div class="flex-row-stretch flex-grow">
<div class="flex-no-shrink mr-8">
{#key object}
<EditableAvatar
disabled={readonly}
person={object}
size={'x-large'}
name={object.name}
bind:this={avatarEditor}
on:done={onAvatarDone}
/>
{#if editable}
<EditableAvatar
person={object}
size={'x-large'}
name={object.name}
bind:this={avatarEditor}
on:done={onAvatarDone}
/>
{:else}
<Avatar person={object} size={'x-large'} name={object.name} />
{/if}
{/key}
</div>
<div class="flex-grow flex-col">
<div class="name">
<EditBox
disabled={readonly}
placeholder={contact.string.PersonFirstNamePlaceholder}
disabled={!editable}
bind:value={firstName}
on:change={firstNameChange}
focusIndex={1}
@ -104,7 +120,7 @@
</div>
<div class="name">
<EditBox
disabled={readonly}
disabled={!editable}
placeholder={contact.string.PersonLastNamePlaceholder}
bind:value={lastName}
on:change={lastNameChange}
@ -112,14 +128,7 @@
/>
</div>
<div class="location">
<AttributeEditor
maxWidth="20rem"
_class={contact.class.Person}
{object}
editable={!readonly}
key="city"
focusIndex={3}
/>
<AttributeEditor maxWidth="20rem" _class={contact.class.Person} {object} {editable} key="city" focusIndex={3} />
</div>
<div class="separator" />
@ -130,14 +139,25 @@
stickedScrollBars
thinScrollBars
>
<ChannelsEditor
attachedTo={object._id}
attachedClass={object._class}
editable={!readonly}
bind:integrations
shape={'circle'}
focusIndex={10}
/>
{#if channels === undefined}
<ChannelsEditor
attachedTo={object._id}
attachedClass={object._class}
{editable}
bind:integrations
shape={'circle'}
focusIndex={10}
/>
{:else}
<ChannelsDropdown
value={channels}
editable={false}
kind={'link-bordered'}
size={'small'}
length={'full'}
shape={'circle'}
/>
{/if}
</Scroller>
</div>
</div>

View File

@ -2,6 +2,7 @@
import { Employee, Person } from '@hcengineering/contact'
import { Ref, WithLookup } from '@hcengineering/core'
import { IntlString } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import ui, { IconSize, LabelAndProps } from '@hcengineering/ui'
import { PersonLabelTooltip, employeeByIdStore, personByIdStore } from '..'
import PersonPresenter from '../components/PersonPresenter.svelte'
@ -26,9 +27,14 @@
export let compact: boolean = false
export let showStatus: boolean = false
$: employeeValue = typeof value === 'string' ? ($personByIdStore.get(value) as Employee) : (value as Employee)
const client = getClient()
const h = client.getHierarchy()
$: active = employeeValue !== undefined ? $employeeByIdStore.get(employeeValue?._id)?.active ?? false : false
$: person = typeof value === 'string' ? ($personByIdStore.get(value) as Person) : (value as Person)
$: employeeValue = person != null ? h.as(person, contact.mixin.Employee) : undefined
$: active = employeeValue?.active ?? false
function getPreviewPopup (active: boolean, value: Employee | undefined): LabelAndProps | undefined {
if (!active || value === undefined || !showPopup) {

View File

@ -34,10 +34,10 @@
import Avatar from './Avatar.svelte'
import ChannelPresenter from './ChannelPresenter.svelte'
import ChannelsDropdown from './ChannelsDropdown.svelte'
import EditEmployee from './EditEmployee.svelte'
import MergeAttributeComparer from './MergeAttributeComparer.svelte'
import MergeComparer from './MergeComparer.svelte'
import UserBox from './UserBox.svelte'
import EditPerson from './EditPerson.svelte'
export let value: Person
const dispatch = createEventDispatcher()
@ -434,7 +434,7 @@
{/each}
</div>
<div class="flex-col-center antiPopup p-4">
<EditEmployee object={result} readonly channels={resultChannels} />
<EditPerson object={result} readonly channels={resultChannels} />
</div>
{/if}
{/key}

View File

@ -77,7 +77,6 @@ import CreateGuest from './components/CreateGuest.svelte'
import CreateOrganization from './components/CreateOrganization.svelte'
import CreatePerson from './components/CreatePerson.svelte'
import DeleteConfirmationPopup from './components/DeleteConfirmationPopup.svelte'
import EditEmployee from './components/EditEmployee.svelte'
import EditMember from './components/EditMember.svelte'
import EditOrganization from './components/EditOrganization.svelte'
import EditOrganizationPanel from './components/EditOrganizationPanel.svelte'
@ -365,7 +364,6 @@ export default async (): Promise<Resources> => ({
CollaborationUserAvatar,
CreateOrganization,
EditPerson,
EditEmployee,
EditOrganization,
SocialEditor,
Contacts,

View File

@ -275,11 +275,12 @@ async function generateLocation (loc: Location, id: Ref<Contact>): Promise<Resol
console.error(`Could not find contact ${id}.`)
return undefined
}
const isEmployee = client.getHierarchy().hasMixin(doc, contact.mixin.Employee)
const appComponent = loc.path[0] ?? ''
const workspace = loc.path[1] ?? ''
const special = client.getHierarchy().isDerived(doc._class, contact.class.Organization)
? 'companies'
: client.getHierarchy().isDerived(doc._class, contact.mixin.Employee)
: isEmployee
? 'employees'
: 'persons'
@ -289,11 +290,11 @@ async function generateLocation (loc: Location, id: Ref<Contact>): Promise<Resol
return {
loc: {
path: [appComponent, workspace],
fragment: getPanelURI(component, doc._id, doc._class, 'content')
fragment: getPanelURI(component, doc._id, isEmployee ? contact.mixin.Employee : doc._class, 'content')
},
defaultLocation: {
path: [appComponent, workspace, contactId, special],
fragment: getPanelURI(component, doc._id, doc._class, 'content')
fragment: getPanelURI(component, doc._id, isEmployee ? contact.mixin.Employee : doc._class, 'content')
}
}
}

View File

@ -307,7 +307,7 @@
id={'vacancy.talant.selector'}
focusIndex={1}
readonly={preserveCandidate}
_class={recruit.mixin.Candidate}
_class={contact.class.Person}
options={{ sort: { modifiedOn: -1 } }}
excluded={existingApplicants}
label={recruit.string.Talent}

View File

@ -87,6 +87,7 @@
"mermaid": "~11.4.1",
"@hcengineering/theme": "^0.6.5",
"tippy.js": "~6.3.7",
"@hcengineering/chunter": "^0.6.20"
"@hcengineering/chunter": "^0.6.20",
"@tiptap/extension-text-align": "~2.11.0"
}
}

View File

@ -175,6 +175,7 @@
}
: false,
drawingBoard: false,
textAlign: false,
submit: supportSubmit ? { submit } : false,
toolbar: {
element: textToolbarElement,

View File

@ -60,7 +60,6 @@
const { command } = action.action
if ((editor.commands as any)[command] === undefined) {
console.error(`Command ${command} not found`)
continue
}
}

View File

@ -8,7 +8,7 @@ export const InlinePopupExtension: Extension<BubbleMenuOptions> = BubbleMenu.ext
pluginKey: 'inline-popup',
element: null,
tippyOptions: {
maxWidth: '38rem',
maxWidth: '46rem',
zIndex: 500,
appendTo: () => document.body
}

View File

@ -38,6 +38,7 @@ import { DefaultKit, type DefaultKitOptions } from './default-kit'
import { MermaidExtension, type MermaidOptions, mermaidOptions } from '../components/extension/mermaid'
import { DrawingBoardExtension, type DrawingBoardOptions } from '../components/extension/drawingBoard'
import { type IndendOptions, IndentExtension, indentExtensionOptions } from '../components/extension/indent'
import TextAlign, { type TextAlignOptions } from '@tiptap/extension-text-align'
export interface EditorKitOptions extends DefaultKitOptions {
history?: false
@ -55,6 +56,7 @@ export interface EditorKitOptions extends DefaultKitOptions {
drawingBoard?: DrawingBoardOptions | false
mermaid?: MermaidOptions | false
indent?: IndendOptions | false
textAlign?: TextAlignOptions | false
mode?: 'full' | 'compact'
note?: NoteOptions | false
submit?: SubmitOptions | false
@ -269,6 +271,19 @@ async function buildEditorKit (): Promise<Extension<EditorKitOptions, any>> {
])
}
if (this.options.textAlign !== false) {
staticKitExtensions.push([
870,
TextAlign.configure(
this.options.textAlign ?? {
types: ['heading', 'paragraph'],
alignments: ['left', 'center', 'right'],
defaultAlignment: null
}
)
])
}
if (this.options.toolbar !== false) {
staticKitExtensions.push([
900,

View File

@ -14,7 +14,7 @@
// limitations under the License.
-->
<script lang="ts">
import { Class, Doc, Mixin, Ref } from '@hcengineering/core'
import { Class, Doc, Hierarchy, Mixin, Ref } from '@hcengineering/core'
import notification from '@hcengineering/notification'
import { Panel } from '@hcengineering/panel'
import { getResource } from '@hcengineering/platform'
@ -86,7 +86,7 @@
query.query(_class, { _id }, (result) => {
object = result[0]
if (object != null) {
realObjectClass = object._class
realObjectClass = Hierarchy.mixinOrClass(object)
}
})
} else {

View File

@ -227,7 +227,7 @@ export async function connect (title: string): Promise<Client | undefined> {
return
}
try {
if (event === ClientConnectEvent.Connected) {
if (event === ClientConnectEvent.Connected || event === ClientConnectEvent.Reconnected) {
setMetadata(presentation.metadata.SessionId, data)
}
if ((_clientSet && event === ClientConnectEvent.Connected) || event === ClientConnectEvent.Refresh) {

View File

@ -64,43 +64,47 @@ export class DbAdapterManagerImpl implements DBAdapterManager {
return this.defaultAdapter
}
async registerHelper (helper: DomainHelper): Promise<void> {
async registerHelper (ctx: MeasureContext, helper: DomainHelper): Promise<void> {
this.domainHelper = helper
await this.initDomains()
await this.initDomains(ctx)
}
async initDomains (): Promise<void> {
async initDomains (ctx: MeasureContext): Promise<void> {
const adapterDomains = new Map<DbAdapter, Set<Domain>>()
for (const d of this.context.hierarchy.domains()) {
// We need to init domain info
const info = this.getDomainInfo(d)
await this.updateInfo(d, adapterDomains, info)
await ctx.with('update-info', { domain: d }, async (ctx) => {
const info = this.getDomainInfo(d)
await this.updateInfo(d, adapterDomains, info)
})
}
for (const adapter of this.adapters.values()) {
adapter.on?.((domain, event, count, helper) => {
const info = this.getDomainInfo(domain)
const oldDocuments = info.documents
switch (event) {
case 'add':
info.documents += count
break
case 'update':
break
case 'delete':
info.documents -= count
break
case 'read':
break
}
for (const [name, adapter] of this.adapters.entries()) {
await ctx.with('domain-helper', { name }, async (ctx) => {
adapter.on?.((domain, event, count, helper) => {
const info = this.getDomainInfo(domain)
const oldDocuments = info.documents
switch (event) {
case 'add':
info.documents += count
break
case 'update':
break
case 'delete':
info.documents -= count
break
case 'read':
break
}
if (oldDocuments < 50 && info.documents > 50) {
// We have more 50 documents, we need to check for indexes
void this.domainHelper?.checkDomain(this.metrics, domain, info.documents, helper)
}
if (oldDocuments > 50 && info.documents < 50) {
// We have more 50 documents, we need to check for indexes
void this.domainHelper?.checkDomain(this.metrics, domain, info.documents, helper)
}
if (oldDocuments < 50 && info.documents > 50) {
// We have more 50 documents, we need to check for indexes
void this.domainHelper?.checkDomain(this.metrics, domain, info.documents, helper)
}
if (oldDocuments > 50 && info.documents < 50) {
// We have more 50 documents, we need to check for indexes
void this.domainHelper?.checkDomain(this.metrics, domain, info.documents, helper)
}
})
})
}
}

View File

@ -16,6 +16,7 @@
import { Analytics } from '@hcengineering/analytics'
import {
toFindResult,
withContext,
type Class,
type Doc,
type DocumentQuery,
@ -66,6 +67,7 @@ class PipelineImpl implements Pipeline {
return pipeline
}
@withContext('build-chain')
private async buildChain (
ctx: MeasureContext,
constructors: MiddlewareCreator[],

View File

@ -151,7 +151,7 @@ export interface DBAdapterManager {
close: () => Promise<void>
registerHelper: (helper: DomainHelper) => Promise<void>
registerHelper: (ctx: MeasureContext, helper: DomainHelper) => Promise<void>
initAdapters: (ctx: MeasureContext) => Promise<void>

View File

@ -73,6 +73,11 @@ interface MultipartUploadPart {
etag: string
}
export interface R2UploadParams {
location: string
bucket: string
}
/** @public */
export class DatalakeClient {
constructor (private readonly endpoint: string) {}
@ -320,6 +325,8 @@ export class DatalakeClient {
return await this.signObjectComplete(ctx, workspace, objectName)
}
// S3
async uploadFromS3 (
ctx: MeasureContext,
workspace: WorkspaceId,
@ -342,6 +349,37 @@ export class DatalakeClient {
})
}
// R2
async getR2UploadParams (ctx: MeasureContext, workspace: WorkspaceId): Promise<R2UploadParams> {
const path = `/upload/r2/${workspace.name}`
const url = concatLink(this.endpoint, path)
const response = await fetchSafe(ctx, url)
const json = (await response.json()) as R2UploadParams
return json
}
async uploadFromR2 (
ctx: MeasureContext,
workspace: WorkspaceId,
objectName: string,
params: {
filename: string
}
): Promise<void> {
const path = `/upload/r2/${workspace.name}/${encodeURIComponent(objectName)}`
const url = concatLink(this.endpoint, path)
await fetchSafe(ctx, url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(params)
})
}
// Signed URL
private async signObjectSign (ctx: MeasureContext, workspace: WorkspaceId, objectName: string): Promise<string> {

View File

@ -13,7 +13,7 @@
// limitations under the License.
//
import { type MeasureContext } from '@hcengineering/core'
import { withContext, type MeasureContext } from '@hcengineering/core'
import type { Middleware, PipelineContext } from '@hcengineering/server-core'
import { BaseMiddleware, DomainIndexHelperImpl } from '@hcengineering/server-core'
@ -21,14 +21,19 @@ import { BaseMiddleware, DomainIndexHelperImpl } from '@hcengineering/server-cor
* @public
*/
export class DBAdapterInitMiddleware extends BaseMiddleware implements Middleware {
@withContext('db-adapter-init')
static async create (
ctx: MeasureContext,
context: PipelineContext,
next?: Middleware
): Promise<Middleware | undefined> {
await context.adapterManager?.initAdapters?.(ctx)
await ctx.with('init-adapters', {}, async (ctx) => {
await context.adapterManager?.initAdapters?.(ctx)
})
const domainHelper = new DomainIndexHelperImpl(ctx, context.hierarchy, context.modelDb, context.workspace)
await context.adapterManager?.registerHelper?.(domainHelper)
await ctx.with('register-helper', {}, async (ctx) => {
await context.adapterManager?.registerHelper?.(ctx, domainHelper)
})
return undefined
}
}

View File

@ -17,6 +17,7 @@ import core, {
Domain,
groupByArray,
TxProcessor,
withContext,
type Doc,
type MeasureContext,
type SessionData,
@ -41,6 +42,7 @@ import { BaseMiddleware } from '@hcengineering/server-core'
export class DomainTxMiddleware extends BaseMiddleware implements Middleware {
adapterManager!: DBAdapterManager
@withContext('domainTx-middleware')
static async create (ctx: MeasureContext, context: PipelineContext, next?: Middleware): Promise<Middleware> {
const middleware = new DomainTxMiddleware(context, next)
if (context.adapterManager == null) {

View File

@ -20,7 +20,8 @@ import core, {
type Timestamp,
type Tx,
type TxCUD,
DOMAIN_TX
DOMAIN_TX,
withContext
} from '@hcengineering/core'
import { PlatformError, unknownError } from '@hcengineering/platform'
import type {
@ -51,6 +52,7 @@ export class ModelMiddleware extends BaseMiddleware implements Middleware {
super(context, next)
}
@withContext('modelAdapter-middleware')
static async doCreate (
ctx: MeasureContext,
context: PipelineContext,

View File

@ -450,12 +450,8 @@ export class DBCollectionHelper implements DomainHelperOperations {
async create (domain: Domain): Promise<void> {}
async exists (domain: Domain): Promise<boolean> {
const exists = await this.client`
SELECT table_name
FROM information_schema.tables
WHERE table_name = '${this.client(translateDomain(domain))}'
`
return exists.length > 0
// Always exists. We don't need to check for index existence
return true
}
async listDomains (): Promise<Set<Domain>> {
@ -469,10 +465,8 @@ export class DBCollectionHelper implements DomainHelperOperations {
}
async estimatedCount (domain: Domain): Promise<number> {
const res = await this
.client`SELECT COUNT(_id) FROM ${this.client(translateDomain(domain))} WHERE "workspaceId" = ${this.workspaceId.name}`
return res.count
// We should always return 0, since no controlled index stuff is required for postgres driver
return 0
}
}

View File

@ -13,6 +13,7 @@
// limitations under the License.
//
import type { Account } from '@hcengineering/core'
import platform, { PlatformError, Severity, Status } from '@hcengineering/platform'
import { Packr } from 'msgpackr'
@ -48,6 +49,7 @@ export interface HelloResponse extends Response<any> {
serverVersion: string
lastTx?: string
lastHash?: string // Last model hash
account: Account
}
function replacer (key: string, value: any): any {

View File

@ -418,6 +418,7 @@ class TSessionManager implements SessionManager {
})
workspace = this.createWorkspace(
ctx.parent ?? ctx,
ctx,
pipelineFactory,
token,
workspaceInfo.workspaceUrl ?? workspaceInfo.workspaceId,
@ -435,7 +436,7 @@ class TSessionManager implements SessionManager {
workspace: workspaceInfo.workspaceId,
wsUrl: workspaceInfo.workspaceUrl
})
pipeline = await ctx.with('💤 wait', { workspaceName }, () => (workspace as Workspace).pipeline)
pipeline = await ctx.with('💤 wait-pipeline', {}, () => (workspace as Workspace).pipeline)
} else {
ctx.warn('reconnect workspace in upgrade switch', {
email: token.email,
@ -466,9 +467,10 @@ class TSessionManager implements SessionManager {
})
return { upgrade: true }
}
try {
if (workspace.pipeline instanceof Promise) {
pipeline = await workspace.pipeline
pipeline = await ctx.with('💤 wait-pipeline', {}, () => (workspace as Workspace).pipeline)
workspace.pipeline = pipeline
} else {
pipeline = workspace.pipeline
@ -645,6 +647,7 @@ class TSessionManager implements SessionManager {
private createWorkspace (
ctx: MeasureContext,
pipelineCtx: MeasureContext,
pipelineFactory: PipelineFactory,
token: Token,
workspaceUrl: string,
@ -655,7 +658,6 @@ class TSessionManager implements SessionManager {
const wsId = toWorkspaceString(token.workspace)
const upgrade = token.extra?.model === 'upgrade'
const context = ctx.newChild('🧲 session', {})
const pipelineCtx = context.newChild('🧲 pipeline-factory', {})
const workspace: Workspace = {
context,
id: generateId(),
@ -1106,7 +1108,8 @@ class TSessionManager implements SessionManager {
reconnect,
serverVersion: this.serverVersion,
lastTx: pipeline.context.lastTx,
lastHash: pipeline.context.lastHash
lastHash: pipeline.context.lastHash,
account: service.getRawAccount(pipeline)
}
ws.send(requestCtx, helloResponse, false, false)
}

View File

@ -46,6 +46,7 @@
"eslint-plugin-n": "^15.4.0",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^6.1.1",
"@types/uuid": "^8.3.1",
"jest": "^29.7.0",
"prettier": "^3.1.0",
"ts-jest": "^29.1.1",
@ -56,6 +57,8 @@
"@hcengineering/account": "^0.6.0",
"@hcengineering/ai-bot": "^0.6.0",
"@hcengineering/analytics-collector": "^0.6.0",
"@hcengineering/document": "^0.6.0",
"@hcengineering/attachment": "^0.6.14",
"@hcengineering/chunter": "^0.6.20",
"@hcengineering/client": "^0.6.18",
"@hcengineering/client-resources": "^0.6.27",
@ -72,6 +75,8 @@
"@hcengineering/server-token": "^0.6.11",
"@hcengineering/setting": "^0.6.17",
"@hcengineering/text": "^0.6.5",
"@hcengineering/rank": "^0.6.4",
"@hcengineering/server-storage": "^0.6.0",
"@hcengineering/workbench": "^0.6.16",
"@hcengineering/love": "^0.6.0",
"cors": "^2.8.5",
@ -80,6 +85,7 @@
"fast-equals": "^5.0.1",
"form-data": "^4.0.0",
"js-tiktoken": "^1.0.14",
"uuid": "^8.3.2",
"mongodb": "^6.12.0",
"openai": "^4.56.0",
"ws": "^8.18.0"

View File

@ -36,6 +36,7 @@ interface Config {
MaxHistoryRecords: number
Port: number
LoveEndpoint: string
DataLabApiKey: string
}
const parseNumber = (str: string | undefined): number | undefined => (str !== undefined ? Number(str) : undefined)
@ -61,7 +62,8 @@ const config: Config = (() => {
MaxContentTokens: parseNumber(process.env.MAX_CONTENT_TOKENS) ?? 128 * 100,
MaxHistoryRecords: parseNumber(process.env.MAX_HISTORY_RECORDS) ?? 500,
Port: parseNumber(process.env.PORT) ?? 4010,
LoveEndpoint: process.env.LOVE_ENDPOINT ?? ''
LoveEndpoint: process.env.LOVE_ENDPOINT ?? '',
DataLabApiKey: process.env.DATALAB_API_KEY ?? ''
}
const missingEnv = (Object.keys(params) as Array<keyof Config>).filter((key) => params[key] === undefined)

View File

@ -29,22 +29,24 @@ import {
TranslateRequest,
TranslateResponse
} from '@hcengineering/ai-bot'
import { Markup, MeasureContext, Ref, WorkspaceId } from '@hcengineering/core'
import { Room } from '@hcengineering/love'
import { WorkspaceInfoRecord } from '@hcengineering/server-ai-bot'
import { getTransactorEndpoint } from '@hcengineering/server-client'
import { generateToken } from '@hcengineering/server-token'
import OpenAI from 'openai'
import { encodingForModel } from 'js-tiktoken'
import { htmlToMarkup, markupToHTML } from '@hcengineering/text'
import { Markup, MeasureContext, Ref, WorkspaceId } from '@hcengineering/core'
import { Room } from '@hcengineering/love'
import { encodingForModel } from 'js-tiktoken'
import OpenAI from 'openai'
import { WorkspaceClient } from './workspace/workspaceClient'
import { StorageAdapter } from '@hcengineering/server-core'
import { buildStorageFromConfig, storageConfigFromEnv } from '@hcengineering/server-storage'
import config from './config'
import { DbStorage } from './storage'
import { SupportWsClient } from './workspace/supportWsClient'
import { AIReplyTransferData } from './types'
import { tryAssignToWorkspace } from './utils/account'
import { translateHtml } from './utils/openai'
import { SupportWsClient } from './workspace/supportWsClient'
import { WorkspaceClient } from './workspace/workspaceClient'
const CLOSE_INTERVAL_MS = 10 * 60 * 1000 // 10 minutes
@ -54,6 +56,7 @@ export class AIControl {
private readonly connectingWorkspaces = new Map<string, Promise<void>>()
readonly aiClient?: OpenAI
readonly storageAdapter: StorageAdapter
readonly encoding = encodingForModel(config.OpenAIModel)
supportClient: SupportWsClient | undefined = undefined
@ -70,6 +73,7 @@ export class AIControl {
})
: undefined
void this.connectSupportWorkspace()
this.storageAdapter = buildStorageFromConfig(storageConfigFromEnv())
}
async getWorkspaceRecord (workspace: string): Promise<WorkspaceInfoRecord> {
@ -125,10 +129,26 @@ export class AIControl {
this.ctx.info('Listen workspace: ', { workspace })
if (workspace === config.SupportWorkspace) {
return new SupportWsClient(endpoint, token, workspace, this, this.ctx.newChild(workspace, {}), info)
return new SupportWsClient(
this.storageAdapter,
endpoint,
token,
workspace,
this,
this.ctx.newChild(workspace, {}),
info
)
}
return new WorkspaceClient(endpoint, token, workspace, this, this.ctx.newChild(workspace, {}), info)
return new WorkspaceClient(
this.storageAdapter,
endpoint,
token,
workspace,
this,
this.ctx.newChild(workspace, {}),
info
)
}
async initWorkspaceClient (workspace: string): Promise<void> {

View File

@ -13,12 +13,13 @@
// limitations under the License.
//
import OpenAI from 'openai'
import { countTokens } from '@hcengineering/openai'
import { Tiktoken } from 'js-tiktoken'
import OpenAI from 'openai'
import config from '../config'
import { HistoryRecord } from '../types'
import { WorkspaceClient } from '../workspace/workspaceClient'
import { getTools } from './tools'
export async function translateHtml (client: OpenAI, html: string, lang: string): Promise<string | undefined> {
const response = await client.chat.completions.create({
@ -66,6 +67,58 @@ export async function createChatCompletion (
return undefined
}
export async function createChatCompletionWithTools (
workspaceClient: WorkspaceClient,
client: OpenAI,
message: OpenAI.ChatCompletionMessageParam,
user?: string,
history: OpenAI.ChatCompletionMessageParam[] = [],
skipCache = true
): Promise<
| {
completion: string | undefined
usage: number
}
| undefined
> {
const opt: OpenAI.RequestOptions = {}
if (skipCache) {
opt.headers = { 'cf-skip-cache': 'true' }
}
try {
const res = client.beta.chat.completions
.runTools(
{
messages: [
{
role: 'system',
content: 'Use tools if possible, don`t use previous information after success using tool for user request'
},
...history,
message
],
model: config.OpenAIModel,
user,
tools: getTools(workspaceClient, user)
},
opt
)
.on('message', (message) => {
console.log(message)
})
const str = await res.finalContent()
const usage = (await res.totalUsage()).completion_tokens
return {
completion: str ?? undefined,
usage
}
} catch (e) {
console.error(e)
}
return undefined
}
export async function requestSummary (
aiClient: OpenAI,
encoding: Tiktoken,

View File

@ -0,0 +1,243 @@
import { Account, MarkupBlobRef, Ref } from '@hcengineering/core'
import document, { Document, getFirstRank, Teamspace } from '@hcengineering/document'
import { makeRank } from '@hcengineering/rank'
import { parseMessageMarkdown } from '@hcengineering/text'
import {
BaseFunctionsArgs,
RunnableFunctionWithoutParse,
RunnableFunctionWithParse,
RunnableToolFunction,
RunnableToolFunctionWithoutParse,
RunnableToolFunctionWithParse,
RunnableTools
} from 'openai/lib/RunnableFunction'
import { Stream } from 'stream'
import { v4 as uuid } from 'uuid'
import config from '../config'
import { WorkspaceClient } from '../workspace/workspaceClient'
async function stream2buffer (stream: Stream): Promise<Buffer> {
return await new Promise<Buffer>((resolve, reject) => {
const _buf = Array<any>()
stream.on('data', (chunk) => {
_buf.push(chunk)
})
stream.on('end', () => {
resolve(Buffer.concat(_buf))
})
stream.on('error', (err) => {
reject(new Error(`error converting stream - ${err}`))
})
})
}
async function pdfToMarkdown (
workspaceClient: WorkspaceClient,
fileId: string,
name: string | undefined
): Promise<string | undefined> {
if (config.DataLabApiKey !== '') {
try {
const stat = await workspaceClient.storage.stat(workspaceClient.ctx, { name: workspaceClient.workspace }, fileId)
if (stat?.contentType !== 'application/pdf') {
return
}
const file = await workspaceClient.storage.get(workspaceClient.ctx, { name: workspaceClient.workspace }, fileId)
const buffer = await stream2buffer(file)
const url = 'https://www.datalab.to/api/v1/marker'
const formData = new FormData()
formData.append('file', new Blob([buffer], { type: 'application/pdf' }), name ?? 'test.pdf')
formData.append('force_ocr', 'false')
formData.append('paginate', 'false')
formData.append('output_format', 'markdown')
formData.append('use_llm', 'false')
formData.append('strip_existing_ocr', 'false')
formData.append('disable_image_extraction', 'false')
const headers = { 'X-Api-Key': config.DataLabApiKey }
const response = await fetch(url, {
method: 'POST',
body: formData,
headers
})
const data = await response.json()
console.log('data', data)
if (data.request_check_url !== undefined) {
for (let attempt = 0; attempt < 10; attempt++) {
const resp = await fetch(data.request_check_url, { headers })
const result = await resp.json()
if (result.status === 'complete' && result.markdown !== undefined) {
return result.markdown
}
await new Promise((resolve) => setTimeout(resolve, 2000))
}
}
} catch (e) {
console.error(e)
}
}
}
async function saveFile (
workspaceClient: WorkspaceClient,
user: string | undefined,
args: { fileId: string, folder: string | undefined, parent: string | undefined, name: string }
): Promise<string> {
console.log('Save file', args)
const content = await pdfToMarkdown(workspaceClient, args.fileId, args.name)
if (content === undefined) {
return 'Error while converting pdf to markdown'
}
const converted = JSON.stringify(parseMessageMarkdown(content, 'image://'))
const client = await workspaceClient.opClient
const fileId = uuid()
await workspaceClient.storage.put(
workspaceClient.ctx,
{ name: workspaceClient.workspace },
fileId,
converted,
'application/json'
)
const teamspaces = await client.findAll(document.class.Teamspace, {})
const parent = await client.findOne(document.class.Document, { _id: args.parent as Ref<Document> })
const teamspaceId = getTeamspace(args.folder, parent, teamspaces)
const parentId = parent?._id ?? document.ids.NoParent
const lastRank = await getFirstRank(client, teamspaceId, parentId)
const rank = makeRank(lastRank, undefined)
const _id = await client.createDoc(document.class.Document, teamspaceId, {
title: args.name,
parent: parentId,
content: fileId as MarkupBlobRef,
rank
})
return `File saved as ${args.name} with id ${_id}, always provide mention link as: [](ref://?_class=document%3Aclass%3ADocument&_id=${_id}&label=${args.name})`
}
function getTeamspace (
folder: string | undefined,
parent: Document | undefined,
teamspaces: Teamspace[]
): Ref<Teamspace> {
if (parent !== undefined) return parent.space
if (folder !== undefined) {
const teamspace = teamspaces.find(
(p) => p.name.trim().toLowerCase() === folder.trim().toLowerCase() || p._id === folder
)
if (teamspace !== undefined) return teamspace._id
}
return teamspaces[0]._id
}
async function getFoldersForDocuments (
workspaceClient: WorkspaceClient,
user: string | undefined,
args: Record<string, any>
): Promise<string> {
const client = await workspaceClient.opClient
const spaces = await client.findAll(
document.class.Teamspace,
user !== undefined ? { members: user as Ref<Account>, archived: false } : { archived: false }
)
let res = 'Folders:\n'
for (const space of spaces) {
res += `Id: ${space._id} Name: ${space.name}\n`
}
res += 'Parents:\n'
const parents = await client.findAll(document.class.Document, { space: { $in: spaces.map((p) => p._id) } })
for (const parent of parents) {
res += `Id: ${parent._id} Name: ${parent.title}\n`
}
return res
}
type ChangeFields<T, R> = Omit<T, keyof R> & R
type PredefinedTool<T extends object | string> = ChangeFields<
RunnableToolFunction<T>,
{
function: PredefinedToolFunction<T>
}
>
type PredefinedToolFunction<T extends object | string> = Omit<
T extends string ? RunnableFunctionWithoutParse : RunnableFunctionWithParse<any>,
'function'
>
type ToolFunc = (workspaceClient: WorkspaceClient, user: string | undefined, args: any) => Promise<string> | string
const tools: [PredefinedTool<any>, ToolFunc][] = []
export function registerTool<T extends object | string> (tool: PredefinedTool<T>, func: ToolFunc): void {
tools.push([tool, func])
}
registerTool(
{
type: 'function',
function: {
name: 'getDataBeforeImport',
parameters: {
type: 'object',
properties: {}
},
description:
'Get folders and parents for documents. This step necessery before saveFile tool. YOU MUST USE IT BEFORE import file.'
}
},
getFoldersForDocuments
)
registerTool<object>(
{
type: 'function',
function: {
name: 'saveFile',
parse: JSON.parse,
parameters: {
type: 'object',
required: ['fileId, folder, name'],
properties: {
fileId: { type: 'string', description: 'File id to parse' },
folder: {
type: 'string',
default: '',
description:
'Folder, id from getDataBeforeImport. If not provided you can guess by file name and folder name, or by another file names, if you can`t, just ask user. Don`t provide empty, this field is required. If no folders at all, you should stop pipeline execution and ask user to create teamspace'
},
parent: {
type: 'string',
default: '',
description:
'Parent document, use id from getDataBeforeImport, leave empty string if not provided, it is not necessery, please feel free to pass empty string'
},
name: {
type: 'string',
description: 'Name for file, try to recognize from user input, if not provided use attached file name'
}
}
},
description:
'Parse pdf to markdown and save it, using for import files. Use only if provide file in current message and user require to import/save, if file not provided ask user to attach it. You MUST call getDataBeforeImport tool before for get ids. Use file name as name if user not provide it, don`t use old parameters. You can ask user about folder if you have not enough data to get folder id'
}
},
saveFile
)
export function getTools (workspaceClient: WorkspaceClient, user: string | undefined): RunnableTools<BaseFunctionsArgs> {
const result: (RunnableToolFunctionWithoutParse | RunnableToolFunctionWithParse<any>)[] = []
for (const tool of tools) {
const res: RunnableToolFunctionWithoutParse | RunnableToolFunctionWithParse<any> = {
...tool[0],
function: {
...tool[0].function,
function: (args: any) => tool[1](workspaceClient, user, args)
}
}
result.push(res)
}
return result
}

View File

@ -22,6 +22,7 @@ import aiBot, {
IdentityResponse
} from '@hcengineering/ai-bot'
import analyticsCollector, { OnboardingChannel } from '@hcengineering/analytics-collector'
import attachment, { Attachment } from '@hcengineering/attachment'
import chunter, {
ChatMessage,
type ChatWidgetTab,
@ -59,7 +60,7 @@ import { Room } from '@hcengineering/love'
import { countTokens } from '@hcengineering/openai'
import { WorkspaceInfoRecord } from '@hcengineering/server-ai-bot'
import { getOrCreateOnboardingChannel } from '@hcengineering/server-analytics-collector-resources'
import { BlobClient, login } from '@hcengineering/server-client'
import { login } from '@hcengineering/server-client'
import { generateToken } from '@hcengineering/server-token'
import { jsonToMarkup, MarkdownParser, markupToText } from '@hcengineering/text'
import workbench, { SidebarEvent, TxSidebarEvent } from '@hcengineering/workbench'
@ -67,10 +68,11 @@ import fs from 'fs'
import { WithId } from 'mongodb'
import OpenAI from 'openai'
import { StorageAdapter } from '@hcengineering/server-core'
import config from '../config'
import { AIControl } from '../controller'
import { HistoryRecord } from '../types'
import { createChatCompletion, requestSummary } from '../utils/openai'
import { createChatCompletionWithTools, requestSummary } from '../utils/openai'
import { connectPlatform, getDirect } from '../utils/platform'
import { LoveController } from './love'
@ -81,8 +83,6 @@ export class WorkspaceClient {
client: Client | undefined
opClient: Promise<TxOperations> | TxOperations
blobClient: BlobClient
loginTimeout: NodeJS.Timeout | undefined
loginDelayMs = 2 * 1000
@ -103,6 +103,7 @@ export class WorkspaceClient {
love: LoveController | undefined
constructor (
readonly storage: StorageAdapter,
readonly transactorUrl: string,
readonly token: string,
readonly workspace: string,
@ -110,7 +111,6 @@ export class WorkspaceClient {
readonly ctx: MeasureContext,
readonly info: WorkspaceInfoRecord | undefined
) {
this.blobClient = new BlobClient(transactorUrl, token, { name: this.workspace })
this.opClient = this.initClient()
void this.opClient.then((opClient) => {
this.opClient = opClient
@ -154,7 +154,14 @@ export class WorkspaceClient {
if (!isAlreadyUploaded) {
const data = fs.readFileSync(config.AvatarPath)
await this.blobClient.upload(this.ctx, config.AvatarName, data.length, config.AvatarContentType, data)
await this.storage.put(
this.ctx,
{ name: this.workspace },
config.AvatarName,
data,
config.AvatarContentType,
data.length
)
await this.controller.updateAvatarInfo(this.workspace, config.AvatarPath, lastModified)
this.ctx.info('Avatar file uploaded successfully', { workspace: this.workspace, path: config.AvatarPath })
}
@ -209,9 +216,9 @@ export class WorkspaceClient {
return
}
const exist = await this.blobClient.checkFile(this.ctx, config.AvatarName)
const exist = await this.storage.stat(this.ctx, { name: this.workspace }, config.AvatarName)
if (!exist) {
if (exist === undefined) {
this.ctx.error('Cannot find file', { file: config.AvatarName, workspace: this.workspace })
return
}
@ -449,11 +456,23 @@ export class WorkspaceClient {
this.historyMap.set(objectId, currentHistory)
}
async getAttachments (client: TxOperations, objectId: Ref<Doc>): Promise<Attachment[]> {
return await client.findAll(attachment.class.Attachment, { attachedTo: objectId })
}
async processMessageEvent (event: AIMessageEventRequest): Promise<void> {
if (this.controller.aiClient === undefined) return
const { user, objectId, objectClass, messageClass } = event
const promptText = markupToText(event.message)
const client = await this.opClient
let promptText = markupToText(event.message)
const files = await this.getAttachments(client, event.messageId)
if (files.length > 0) {
promptText += '\n\nAttachments:'
for (const file of files) {
promptText += `\nName:${file.name} FileId:${file.file} Type:${file.type}`
}
}
const prompt: OpenAI.ChatCompletionMessageParam = { content: promptText, role: 'user' }
const promptTokens = countTokens([prompt], this.controller.encoding)
@ -462,7 +481,6 @@ export class WorkspaceClient {
return
}
const client = await this.opClient
const op = client.apply(undefined, 'AIMessageRequestEvent')
const hierarchy = client.getHierarchy()
@ -479,16 +497,15 @@ export class WorkspaceClient {
void this.pushHistory(promptText, prompt.role, promptTokens, user, objectId, objectClass)
const chatCompletion = await createChatCompletion(this.controller.aiClient, prompt, user, history)
const response = chatCompletion?.choices[0].message.content
const chatCompletion = await createChatCompletionWithTools(this, this.controller.aiClient, prompt, user, history)
const response = chatCompletion?.completion
if (response == null) {
await this.finishTyping(client, objectId)
return
}
const responseTokens =
chatCompletion?.usage?.completion_tokens ??
countTokens([{ content: response, role: 'assistant' }], this.controller.encoding)
chatCompletion?.usage ?? countTokens([{ content: response, role: 'assistant' }], this.controller.encoding)
void this.pushHistory(response, 'assistant', responseTokens, user, objectId, objectClass)

View File

@ -660,6 +660,7 @@ A list of closed updated issues`
content: [
{
type: MarkupNodeType.paragraph,
attrs: { textAlign: null },
content: [
{
type: MarkupNodeType.text,
@ -689,7 +690,7 @@ A list of closed updated issues`
content: [
{
type: MarkupNodeType.heading,
attrs: { level: 1 },
attrs: { level: 1, textAlign: null },
content: [
{
type: MarkupNodeType.text,
@ -699,6 +700,7 @@ A list of closed updated issues`
},
{
type: MarkupNodeType.paragraph,
attrs: { textAlign: null },
content: [
{
text: '\n',
@ -708,7 +710,7 @@ A list of closed updated issues`
},
{
type: MarkupNodeType.heading,
attrs: { level: 2 },
attrs: { level: 2, textAlign: null },
content: [
{
type: MarkupNodeType.text,

View File

@ -65,6 +65,8 @@
"@hcengineering/platform": "^0.6.11",
"@hcengineering/server-client": "^0.6.0",
"@hcengineering/server-token": "^0.6.11",
"@hcengineering/datalake": "^0.6.0",
"@hcengineering/s3": "^0.6.0",
"livekit-server-sdk": "^2.0.10",
"jwt-simple": "^0.5.6",
"uuid": "^8.3.2",

View File

@ -24,6 +24,7 @@ interface Config {
StorageConfig: string
StorageProviderName: string
S3StorageConfig: string
Secret: string
MongoUrl: string
@ -39,6 +40,7 @@ const envMap: { [key in keyof Config]: string } = {
StorageConfig: 'STORAGE_CONFIG',
StorageProviderName: 'STORAGE_PROVIDER_NAME',
S3StorageConfig: 'S3_STORAGE_CONFIG',
Secret: 'SECRET',
ServiceID: 'SERVICE_ID',
MongoUrl: 'MONGO_URL'
@ -55,12 +57,13 @@ const config: Config = (() => {
ApiSecret: process.env[envMap.ApiSecret],
StorageConfig: process.env[envMap.StorageConfig],
StorageProviderName: process.env[envMap.StorageProviderName] ?? 's3',
S3StorageConfig: process.env[envMap.S3StorageConfig],
Secret: process.env[envMap.Secret],
ServiceID: process.env[envMap.ServiceID] ?? 'love-service',
MongoUrl: process.env[envMap.MongoUrl]
}
const optional = ['StorageConfig']
const optional = ['StorageConfig', 'S3StorageConfig']
const missingEnv = (Object.keys(params) as Array<keyof Config>)
.filter((key) => !optional.includes(key))

View File

@ -13,11 +13,11 @@
// limitations under the License.
//
import { Ref, toWorkspaceString, WorkspaceId } from '@hcengineering/core'
import { MeasureContext, Ref, WorkspaceId } from '@hcengineering/core'
import { setMetadata } from '@hcengineering/platform'
import serverClient from '@hcengineering/server-client'
import { initStatisticsContext, StorageConfig, StorageConfiguration } from '@hcengineering/server-core'
import { buildStorageFromConfig, storageConfigFromEnv } from '@hcengineering/server-storage'
import { storageConfigFromEnv } from '@hcengineering/server-storage'
import serverToken, { decodeToken } from '@hcengineering/server-token'
import { RoomMetadata, TranscriptionStatus, MeetingMinutes } from '@hcengineering/love'
import cors from 'cors'
@ -32,8 +32,8 @@ import {
S3Upload,
WebhookReceiver
} from 'livekit-server-sdk'
import { v4 as uuid } from 'uuid'
import config from './config'
import { getS3UploadParams, saveFile } from './storage'
import { WorkspaceClient } from './workspaceClient'
const extractToken = (header: IncomingHttpHeaders): any => {
@ -50,11 +50,14 @@ export const main = async (): Promise<void> => {
setMetadata(serverToken.metadata.Secret, config.Secret)
const storageConfigs: StorageConfiguration = storageConfigFromEnv()
const s3StorageConfigs: StorageConfiguration | undefined =
config.S3StorageConfig !== undefined ? storageConfigFromEnv(config.S3StorageConfig) : undefined
const ctx = initStatisticsContext('love', {})
const storageConfig = storageConfigs.storages.findLast((p) => p.name === config.StorageProviderName)
const storageAdapter = buildStorageFromConfig(storageConfigs)
const s3storageConfig = s3StorageConfigs?.storages.findLast((p) => p.kind === 's3')
const app = express()
const port = config.Port
app.use(cors())
@ -81,13 +84,11 @@ export const main = async (): Promise<void> => {
if (event.event === 'egress_ended' && event.egressInfo !== undefined) {
for (const res of event.egressInfo.fileResults) {
const data = dataByUUID.get(res.filename)
if (data !== undefined) {
const prefix = rootPrefix(storageConfig, data.workspaceId)
const filename = stripPrefix(prefix, res.filename)
const storedBlob = await storageAdapter.stat(ctx, data.workspaceId, filename)
if (data !== undefined && storageConfig !== undefined) {
const storedBlob = await saveFile(ctx, data.workspaceId, storageConfig, s3storageConfig, res.filename)
if (storedBlob !== undefined) {
const client = await WorkspaceClient.create(data.workspace, ctx)
await client.saveFile(filename, data.name, storedBlob, data.meetingMinutes)
await client.saveFile(storedBlob._id, data.name, storedBlob, data.meetingMinutes)
await client.close()
}
dataByUUID.delete(res.filename)
@ -135,7 +136,7 @@ export const main = async (): Promise<void> => {
try {
const dateStr = new Date().toISOString().replace('T', '_').slice(0, 19)
const name = `${room}_${dateStr}.mp4`
const id = await startRecord(storageConfig, egressClient, roomClient, roomName, workspace)
const id = await startRecord(ctx, storageConfig, s3storageConfig, egressClient, roomClient, roomName, workspace)
dataByUUID.set(id, { name, workspace: workspace.name, workspaceId: workspace, meetingMinutes })
ctx.info('Start recording', { workspace: workspace.name, roomName, meetingMinutes })
res.send()
@ -257,50 +258,26 @@ const checkRecordAvailable = async (storageConfig: StorageConfig | undefined): P
return storageConfig !== undefined
}
function getBucket (storageConfig: any, workspaceId: WorkspaceId): string {
return storageConfig.rootBucket ?? (storageConfig.bucketPrefix ?? '') + toWorkspaceString(workspaceId)
}
function getBucketFolder (workspaceId: WorkspaceId): string {
return toWorkspaceString(workspaceId)
}
function getDocumentKey (storageConfig: any, workspace: WorkspaceId, name: string): string {
return storageConfig.rootBucket === undefined ? name : `${getBucketFolder(workspace)}/${name}`
}
function stripPrefix (prefix: string | undefined, key: string): string {
if (prefix !== undefined && key.startsWith(prefix)) {
return key.slice(prefix.length)
}
return key
}
function rootPrefix (storageConfig: any, workspaceId: WorkspaceId): string | undefined {
return storageConfig.rootBucket !== undefined ? getBucketFolder(workspaceId) + '/' : undefined
}
const startRecord = async (
ctx: MeasureContext,
storageConfig: StorageConfig | undefined,
s3StorageConfig: StorageConfig | undefined,
egressClient: EgressClient,
roomClient: RoomServiceClient,
roomName: string,
workspaceId: WorkspaceId
): Promise<string> => {
if (storageConfig === undefined) {
console.error('please provide s3 storage configuration')
throw new Error('please provide s3 storage configuration')
console.error('please provide storage configuration')
throw new Error('please provide storage configuration')
}
const endpoint = storageConfig.endpoint
const accessKey = (storageConfig as any).accessKey
const secret = (storageConfig as any).secretKey
const region = (storageConfig as any).region ?? 'auto'
const bucket = getBucket(storageConfig, workspaceId)
const name = uuid()
const filepath = getDocumentKey(storageConfig, workspaceId, `${name}.mp4`)
const uploadParams = await getS3UploadParams(ctx, workspaceId, storageConfig, s3StorageConfig)
const { filepath, endpoint, accessKey, secret, region, bucket } = uploadParams
const output = new EncodedFileOutput({
fileType: EncodedFileType.MP4,
filepath,
disableManifest: true,
output: {
case: 's3',
value: new S3Upload({

View File

@ -0,0 +1,185 @@
//
// 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.
//
import { Blob, MeasureContext, toWorkspaceString, WorkspaceId } from '@hcengineering/core'
import { DatalakeConfig, DatalakeService, createDatalakeClient } from '@hcengineering/datalake'
import { S3Config, S3Service } from '@hcengineering/s3'
import { StorageConfig } from '@hcengineering/server-core'
import { v4 as uuid } from 'uuid'
export interface S3UploadParams {
filepath: string
endpoint: string
accessKey: string
region: string
secret: string
bucket: string
}
export async function getS3UploadParams (
ctx: MeasureContext,
workspaceId: WorkspaceId,
storageConfig: StorageConfig,
s3StorageConfig: StorageConfig | undefined
): Promise<S3UploadParams> {
if (storageConfig.kind === 's3') {
if (storageConfig.kind !== 's3') {
throw new Error('Please provide S3 storage config')
}
return await getS3UploadParamsS3(ctx, workspaceId, storageConfig as S3Config)
} else if (storageConfig.kind === 'datalake') {
if (s3StorageConfig === undefined || s3StorageConfig.kind !== 's3') {
throw new Error('Please provide S3 storage config')
}
return await getS3UploadParamsDatalake(
ctx,
workspaceId,
storageConfig as DatalakeConfig,
s3StorageConfig as S3Config
)
} else {
throw new Error('Unknown storage kind: ' + storageConfig.kind)
}
}
export async function saveFile (
ctx: MeasureContext,
workspaceId: WorkspaceId,
storageConfig: StorageConfig,
s3StorageConfig: StorageConfig | undefined,
filename: string
): Promise<Blob | undefined> {
if (storageConfig.kind === 's3') {
if (storageConfig.kind !== 's3') {
throw new Error('Please provide S3 storage config')
}
return await saveFileToS3(ctx, workspaceId, storageConfig as S3Config, filename)
} else if (storageConfig.kind === 'datalake') {
if (s3StorageConfig === undefined || s3StorageConfig.kind !== 's3') {
throw new Error('Please provide S3 storage config')
}
return await saveFileToDatalake(
ctx,
workspaceId,
storageConfig as DatalakeConfig,
s3StorageConfig as S3Config,
filename
)
} else {
throw new Error('Unknown storage kind: ' + storageConfig.kind)
}
}
async function getS3UploadParamsS3 (
ctx: MeasureContext,
workspaceId: WorkspaceId,
storageConfig: S3Config
): Promise<S3UploadParams> {
const endpoint = storageConfig.endpoint
const accessKey = storageConfig.accessKey
const secret = storageConfig.secretKey
const region = storageConfig.region ?? 'auto'
const bucket = getBucket(storageConfig, workspaceId)
const name = uuid()
const filepath = getDocumentKey(storageConfig, workspaceId, `${name}.mp4`)
return {
filepath,
endpoint,
accessKey,
region,
secret,
bucket
}
}
async function getS3UploadParamsDatalake (
ctx: MeasureContext,
workspaceId: WorkspaceId,
config: DatalakeConfig,
s3config: S3Config
): Promise<S3UploadParams> {
const client = createDatalakeClient(config)
const { bucket } = await client.getR2UploadParams(ctx, workspaceId)
const endpoint = s3config.endpoint
const accessKey = s3config.accessKey
const secret = s3config.secretKey
const region = s3config.region ?? 'auto'
const name = uuid()
const filepath = getDocumentKey(s3config, workspaceId, `${name}.mp4`)
return {
filepath,
endpoint,
accessKey,
region,
secret,
bucket
}
}
async function saveFileToS3 (
ctx: MeasureContext,
workspaceId: WorkspaceId,
config: S3Config,
filename: string
): Promise<Blob | undefined> {
const storageAdapter = new S3Service(config)
const prefix = rootPrefix(config, workspaceId)
const uuid = stripPrefix(prefix, filename)
return await storageAdapter.stat(ctx, workspaceId, uuid)
}
async function saveFileToDatalake (
ctx: MeasureContext,
workspaceId: WorkspaceId,
config: DatalakeConfig,
s3config: S3Config,
filename: string
): Promise<Blob | undefined> {
const client = createDatalakeClient(config)
const storageAdapter = new DatalakeService(config)
const prefix = rootPrefix(s3config, workspaceId)
const uuid = stripPrefix(prefix, filename)
await client.uploadFromR2(ctx, workspaceId, uuid, { filename: uuid })
return await storageAdapter.stat(ctx, workspaceId, uuid)
}
function getBucket (storageConfig: S3Config, workspaceId: WorkspaceId): string {
return storageConfig.rootBucket ?? (storageConfig.bucketPrefix ?? '') + toWorkspaceString(workspaceId)
}
function getBucketFolder (workspaceId: WorkspaceId): string {
return toWorkspaceString(workspaceId)
}
function getDocumentKey (storageConfig: any, workspace: WorkspaceId, name: string): string {
return storageConfig.rootBucket === undefined ? name : `${getBucketFolder(workspace)}/${name}`
}
function stripPrefix (prefix: string | undefined, key: string): string {
if (prefix !== undefined && key.startsWith(prefix)) {
return key.slice(prefix.length)
}
return key
}
function rootPrefix (storageConfig: S3Config, workspaceId: WorkspaceId): string | undefined {
return storageConfig.rootBucket !== undefined ? getBucketFolder(workspaceId) + '/' : undefined
}