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

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev
2024-09-27 14:25:36 +07:00
60 changed files with 1340 additions and 573 deletions
+4 -6
View File
@@ -5,21 +5,19 @@
"version": "0.2.0",
"configurations": [
{
"name": "Debug notion import",
"name": "Debug Notion import",
"type": "node",
"request": "launch",
"args": ["src/__start.ts", "import-notion-to-teamspace", "/home/anna/work/notion/natalya/Export-fad9ecb4-a1a5-4623-920d-df32dd423743", "-ws", "notion-test", "-u", "user1", "-pw", "1234", "-ts", "natalya"],
// "args": ["src/__start.ts", "import-notion-with-teamspaces", "/home/anna/work/notion/natalya/Export-fad9ecb4-a1a5-4623-920d-df32dd423743", "-ws", "ws1", "-u", "user1", "-pw", "1234"],
// "args": ["src/__start.ts", "import-notion-to-teamspace", "/home/anna/work/notion/natalya/Export-fad9ecb4-a1a5-4623-920d-df32dd423743", "-u", "user1", "-pw", "1234", "-ws", "ws1", "-ts", "notion", ],
"args": ["src/__start.ts", "import-notion-with-teamspaces", "/home/anna/work/notion/natalya/Export-fad9ecb4-a1a5-4623-920d-df32dd423743", "-u", "user1", "-pw", "1234", "-ws", "ws1"],
"env": {
"SERVER_SECRET": "secret",
"FRONT_URL": "http://localhost:8087",
"ACCOUNTS_URL": "http://localhost:3000",
},
"runtimeVersion": "20",
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"],
"sourceMaps": true,
"outputCapture": "std",
"cwd": "${workspaceRoot}/dev/tool"
"cwd": "${workspaceRoot}/dev/import-tool"
},
{
"address": "127.0.0.1",
+1 -1
View File
@@ -238,7 +238,7 @@
"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"
"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"
},
{
"commandKind": "global",
+570 -316
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
extends: ['./node_modules/@hcengineering/platform-rig/profiles/node/eslint.config.json'],
parserOptions: {
tsconfigRootDir: __dirname,
project: './tsconfig.json'
}
}
+7
View File
@@ -0,0 +1,7 @@
FROM node:20
WORKDIR /usr/src/app
COPY bundle/bundle.js ./
CMD [ "bash" ]
@@ -27,7 +27,7 @@ rushx run-local import-notion-to-teamspace {dir} \
* *dir* - path to the root of the extracted archive
* *user* - your username or email
* *password* - password
* *workspace* - workspace name where the documents should be imported to
* *workspace* - workspace url where the documents should be imported to
* *teamspace* - teamspace to be created for newly imported docs
@@ -58,11 +58,9 @@ rushx run-local import-notion-to-teamspace /home/john/extracted-notion-docs \
* To import Notion workspace with teamspaces
```
docker run \
-e SERVER_SECRET="" \
-e ACCOUNTS_URL="https://account.huly.app" \
-e FRONT_URL="https://huly.app" \
-v $(pwd):/data \
hardcoreeng/tool:latest \
hardcoreeng/import-tool:latest \
-- bundle.js import-notion-with-teamspaces /data \
--user jane.doe@gmail.com \
--password 4321qwe \
@@ -71,14 +69,12 @@ docker run \
* To import Notion workspace without teamspaces or a page with subpages.
```
docker run \
-e SERVER_SECRET="" \
-e ACCOUNTS_URL="https://account.huly.app" \
-e FRONT_URL="https://huly.app" \
-v $(pwd):/data \
hardcoreeng/tool:latest \
hardcoreeng/import-tool:latest \
-- bundle.js import-notion-to-teamspace /data \
--user jane.doe@gmail.com \
--password 4321qwe \
--workspace ws1 \
--teamspace notion
```
```
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
#
# 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.
#
rushx bundle
rushx docker:build $@
rushx docker:push
+9
View File
@@ -0,0 +1,9 @@
const esbuild = require('esbuild')
esbuild.build({
entryPoints: ['src/index.ts'],
bundle: true,
minify: true,
platform: 'node',
outfile: 'bundle/bundle.js'
})
+67
View File
@@ -0,0 +1,67 @@
{
"name": "@hcengineering/import-tool",
"version": "0.1.0",
"main": "lib/index.js",
"svelte": "src/index.ts",
"types": "types/index.d.ts",
"author": "Anticrm Platform Contributors",
"template": "@hcengineering/node-package",
"license": "EPL-2.0",
"scripts": {
"build": "compile",
"build:watch": "compile",
"start": "ts-node src/__start.ts",
"_phase:bundle": "rushx bundle",
"_phase:docker-build": "rushx docker:build",
"_phase:docker-staging": "rushx docker:staging",
"bundle": "mkdir -p bundle && esbuild src/__start.ts --bundle --keep-names --sourcemap=external --platform=node --define:process.env.MODEL_VERSION=$(node ../../common/scripts/show_version.js) --define:process.env.GIT_REVISION=$(../../common/scripts/git_version.sh) --log-level=error --outfile=bundle/bundle.js",
"docker:build": "../../common/scripts/docker_build.sh hardcoreeng/import-tool",
"docker:tbuild": "docker build -t hardcoreeng/import-tool . --platform=linux/amd64 && ../../common/scripts/docker_tag_push.sh hardcoreeng/import-tool",
"docker:staging": "../../common/scripts/docker_tag.sh hardcoreeng/import-tool staging",
"docker:push": "../../common/scripts/docker_tag.sh hardcoreeng/import-tool",
"run-local": "rush bundle --to @hcengineering/import-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 MONGO_URL=mongodb://localhost:27017 TELEGRAM_DATABASE=telegram-service ELASTIC_URL=http://localhost:9200 REKONI_URL=http://localhost:4004 MODEL_VERSION=$(node ../../common/scripts/show_version.js) GIT_REVISION=$(git describe --all --long) node --max-old-space-size=18000 ./bundle/bundle.js",
"run-local-brk": "rush bundle --to @hcengineering/import-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 MONGO_URL=mongodb://localhost:27017 TELEGRAM_DATABASE=telegram-service ELASTIC_URL=http://localhost:9200 REKONI_URL=http://localhost:4004 MODEL_VERSION=$(node ../../common/scripts/show_version.js) GIT_REVISION=$(git describe --all --long) node --inspect-brk --enable-source-maps --max-old-space-size=18000 ./bundle/bundle.js",
"run": "rush bundle --to @hcengineering/import-tool >/dev/null && cross-env node --max-old-space-size=8000 ./bundle/bundle.js",
"upgrade": "rushx run-local upgrade",
"format": "format src",
"test": "jest --passWithNoTests --silent --forceExit",
"_phase:build": "compile transpile src",
"_phase:test": "jest --passWithNoTests --silent --forceExit",
"_phase:format": "format src",
"_phase:validate": "compile validate"
},
"devDependencies": {
"cross-env": "~7.0.3",
"@hcengineering/platform-rig": "^0.6.0",
"@typescript-eslint/eslint-plugin": "^6.11.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-n": "^15.4.0",
"eslint": "^8.54.0",
"ts-node": "^10.8.0",
"esbuild": "^0.20.0",
"@types/mime-types": "~2.1.1",
"@types/node": "~20.11.16",
"@typescript-eslint/parser": "^6.11.0",
"eslint-config-standard-with-typescript": "^40.0.0",
"prettier": "^3.1.0",
"typescript": "^5.3.3",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"@types/jest": "^29.5.5"
},
"dependencies": {
"@hcengineering/attachment": "^0.6.14",
"@hcengineering/collaboration": "^0.6.0",
"@hcengineering/document": "^0.6.0",
"@hcengineering/text": "^0.6.5",
"@hcengineering/model-attachment": "^0.6.0",
"@hcengineering/model-core": "^0.6.0",
"@hcengineering/core": "^0.6.32",
"@hcengineering/platform": "^0.6.11",
"@hcengineering/server-tool": "^0.6.0",
"@hcengineering/server-client": "^0.6.0",
"commander": "^8.1.0",
"mime-types": "~2.1.34"
}
}
+17
View File
@@ -0,0 +1,17 @@
//
// 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 { importTool } from '.'
importTool()
+122
View File
@@ -0,0 +1,122 @@
//
// 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 { concatLink, TxOperations } from '@hcengineering/core'
import serverClientPlugin, {
createClient,
getUserWorkspaces,
login,
selectWorkspace
} from '@hcengineering/server-client'
import { program } from 'commander'
import { importNotion } from './notion'
import { setMetadata } from '@hcengineering/platform'
/**
* @public
*/
export function importTool (): void {
function getFrontUrl (): string {
const frontUrl = process.env.FRONT_URL
if (frontUrl === undefined) {
console.error('please provide front url')
process.exit(1)
}
return frontUrl
}
program.version('0.0.1')
// import-notion-with-teamspaces /home/anna/work/notion/pages/exported --workspace workspace
program
.command('import-notion-with-teamspaces <dir>')
.description('import extracted archive exported from Notion as "Markdown & CSV"')
.requiredOption('-u, --user <user>', 'user')
.requiredOption('-pw, --password <password>', 'password')
.requiredOption('-ws, --workspace <workspace>', 'workspace url where the documents should be imported to')
.action(async (dir: string, cmd) => {
await importFromNotion(dir, cmd.user, cmd.password, cmd.workspace)
})
// import-notion-to-teamspace /home/anna/work/notion/pages/exported --workspace workspace --teamspace notion
program
.command('import-notion-to-teamspace <dir>')
.description('import extracted archive exported from Notion as "Markdown & CSV"')
.requiredOption('-u, --user <user>', 'user')
.requiredOption('-pw, --password <password>', 'password')
.requiredOption('-ws, --workspace <workspace>', 'workspace url where the documents should be imported to')
.requiredOption('-ts, --teamspace <teamspace>', 'new teamspace name where the documents should be imported to')
.action(async (dir: string, cmd) => {
await importFromNotion(dir, cmd.user, cmd.password, cmd.workspace, cmd.teamspace)
})
async function importFromNotion (
dir: string,
user: string,
password: string,
workspaceUrl: string,
teamspace?: string
): Promise<void> {
if (workspaceUrl === '' || user === '' || password === '' || teamspace === '') {
return
}
const config = await (await fetch(concatLink(getFrontUrl(), '/config.json'))).json()
console.log('Setting up Accounts URL: ', config.ACCOUNTS_URL)
setMetadata(serverClientPlugin.metadata.Endpoint, config.ACCOUNTS_URL)
console.log('Trying to login user: ', user)
const userToken = await login(user, password, workspaceUrl)
if (userToken === undefined) {
console.log('Login failed for user: ', user)
return
}
console.log('Looking for workspace: ', workspaceUrl)
const allWorkspaces = await getUserWorkspaces(userToken)
const workspaces = allWorkspaces.filter((ws) => ws.workspaceUrl === workspaceUrl)
if (workspaces.length < 1) {
console.log('Workspace not found: ', workspaceUrl)
return
}
console.log('Workspace found')
const selectedWs = await selectWorkspace(userToken, workspaces[0].workspace)
console.log(selectedWs)
function uploader (token: string) {
return (id: string, data: any) => {
return fetch(concatLink(getFrontUrl(), config.UPLOAD_URL), {
method: 'POST',
headers: {
Authorization: 'Bearer ' + token
},
body: data
})
}
}
console.log('Connecting to Transactor URL: ', selectedWs.endpoint)
const connection = await createClient(selectedWs.endpoint, selectedWs.token)
const acc = connection.getModel().getAccountByEmail(user)
if (acc === undefined) {
console.log('Account not found for email: ', user)
return
}
const client = new TxOperations(connection, acc._id)
console.log('OK. Start the import directory: ', dir)
await importNotion(client, uploader(selectedWs.token), dir, teamspace)
await connection.close()
}
program.parse(process.argv)
}
@@ -1,3 +1,17 @@
//
// 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 {
generateId,
type AttachedData,
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "./node_modules/@hcengineering/platform-rig/profiles/node/tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./lib",
"declarationDir": "./types",
"tsBuildInfoFile": ".build/build.tsbuildinfo"
}
}
+1 -87
View File
@@ -46,14 +46,7 @@ import {
createStorageBackupStorage,
restore
} from '@hcengineering/server-backup'
import serverClientPlugin, {
BlobClient,
createClient,
getTransactorEndpoint,
getUserWorkspaces,
login,
selectWorkspace
} from '@hcengineering/server-client'
import serverClientPlugin, { BlobClient, createClient, getTransactorEndpoint } from '@hcengineering/server-client'
import { getServerPipeline } from '@hcengineering/server-pipeline'
import serverToken, { decodeToken, generateToken } from '@hcengineering/server-token'
import toolPlugin, { FileModelLogger } from '@hcengineering/server-tool'
@@ -68,13 +61,11 @@ import { diffWorkspace, recreateElastic, updateField } from './workspace'
import core, {
AccountRole,
concatLink,
generateId,
getWorkspaceId,
MeasureMetricsContext,
metricsToString,
systemAccountEmail,
TxOperations,
versionToString,
type Data,
type Doc,
@@ -115,7 +106,6 @@ import { changeConfiguration } from './configuration'
import { moveFromMongoToPG, moveWorkspaceFromMongoToPG } from './db'
import { fixJsonMarkup, migrateMarkup, restoreLostMarkup } from './markup'
import { fixMixinForeignAttributes, showMixinForeignAttributes } from './mixin'
import { importNotion } from './notion'
import { fixAccountEmails, renameAccount } from './renameAccount'
import { moveFiles, showLostFiles, syncFiles } from './storage'
@@ -172,15 +162,6 @@ export function devTool (
return elasticUrl
}
function getFrontUrl (): string {
const frontUrl = process.env.FRONT_URL
if (frontUrl === undefined) {
console.error('please provide front url')
process.exit(1)
}
return frontUrl
}
const initWS = process.env.INIT_WORKSPACE
if (initWS !== undefined) {
setMetadata(toolPlugin.metadata.InitWorkspace, initWS)
@@ -242,73 +223,6 @@ export function devTool (
})
})
// import-notion-with-teamspaces /home/anna/work/notion/pages/exported --workspace workspace
program
.command('import-notion-with-teamspaces <dir>')
.description('import extracted archive exported from Notion as "Markdown & CSV"')
.requiredOption('-u, --user <user>', 'user')
.requiredOption('-pw, --password <password>', 'password')
.requiredOption('-ws, --workspace <workspace>', 'workspace where the documents should be imported to')
.action(async (dir: string, cmd) => {
await importFromNotion(dir, cmd.user, cmd.password, cmd.workspace)
})
// import-notion-to-teamspace /home/anna/work/notion/pages/exported --workspace workspace --teamspace notion
program
.command('import-notion-to-teamspace <dir>')
.description('import extracted archive exported from Notion as "Markdown & CSV"')
.requiredOption('-u, --user <user>', 'user')
.requiredOption('-pw, --password <password>', 'password')
.requiredOption('-ws, --workspace <workspace>', 'workspace where the documents should be imported to')
.requiredOption('-ts, --teamspace <teamspace>', 'new teamspace name where the documents should be imported to')
.action(async (dir: string, cmd) => {
await importFromNotion(dir, cmd.user, cmd.password, cmd.workspace, cmd.teamspace)
})
async function importFromNotion (
dir: string,
user: string,
password: string,
workspace: string,
teamspace?: string
): Promise<void> {
if (workspace === '' || user === '' || password === '' || teamspace === '') {
return
}
const userToken = await login(user, password, workspace)
const allWorkspaces = await getUserWorkspaces(userToken)
const workspaces = allWorkspaces.filter((ws) => ws.workspace === workspace)
if (workspaces.length < 1) {
console.log('Workspace not found: ', workspace)
return
}
const selectedWs = await selectWorkspace(userToken, workspaces[0].workspace)
console.log(selectedWs)
function uploader (token: string) {
return (id: string, data: any) => {
return fetch(concatLink(getFrontUrl(), '/files'), {
method: 'POST',
headers: {
Authorization: 'Bearer ' + token
},
body: data
})
}
}
const connection = await createClient(selectedWs.endpoint, selectedWs.token)
const acc = connection.getModel().getAccountByEmail(user)
if (acc === undefined) {
console.log('Account not found for email: ', user)
return
}
const client = new TxOperations(connection, acc._id)
await importNotion(client, uploader(selectedWs.token), dir, teamspace)
await connection.close()
}
program
.command('reset-account <email>')
.description('create user and corresponding account in master database')
+2
View File
@@ -206,6 +206,8 @@ function defineChannelActions (builder: Builder): void {
query: {
archived: false
},
override: [view.action.Archive],
visibilityTester: view.function.CanArchiveSpace,
context: {
mode: 'context',
group: 'remove'
+1 -1
View File
@@ -285,7 +285,7 @@ export const coreOperation: MigrateOperation = {
func: migrateAllSpaceToTyped
},
{
state: 'add-spaces-owner',
state: 'add-spaces-owner-v1',
func: migrateSpacesOwner
},
{
+3
View File
@@ -23,6 +23,9 @@
min-width: 0;
border: 1px solid var(--theme-divider-color); // var(--global-surface-02-BorderColor);
border-radius: var(--small-focus-BorderRadius);
border-bottom-right-radius: 0;
border-top-right-radius: 0;
border-right: 0;
&:not(.modal) {
background-color: var(--theme-panel-color); // var(--global-surface-02-BackgroundColor);
+12
View File
@@ -368,6 +368,18 @@ pre.proseCodeBlock {
word-break: normal;
}
pre.proseCodeBlock {
button:not(.hovered) {
opacity: 0;
}
&:hover {
button {
opacity: 1;
}
}
}
// Fixes for MessageViewer
pre.proseCodeBlock > pre.proseCode {
padding: 0;
@@ -41,6 +41,7 @@
export let hoverable = true
export let hoverStyles: 'borderedHover' | 'filledHover' = 'borderedHover'
export let hideLink = false
export let readonly: boolean = false
export let onClick: (() => void) | undefined = undefined
$: personAccount = $personAccountByIdStore.get((value.createdBy ?? value.modifiedBy) as Ref<PersonAccount>)
@@ -74,6 +75,7 @@
{hoverable}
{hoverStyles}
viewlet={undefined}
{readonly}
{onClick}
>
<svelte:fragment slot="icon">
@@ -39,6 +39,7 @@
export let videoPreload = true
export let hideLink = false
export let compact = false
export let readonly = false
export let onClick: (() => void) | undefined = undefined
const client = getClient()
@@ -72,6 +73,7 @@
hideLink,
type,
compact,
readonly,
onClick
}}
/>
@@ -61,6 +61,7 @@
export let type: ActivityMessageViewType = 'default'
export let inlineActions: MessageInlineAction[] = []
export let excludedActions: Ref<ViewAction>[] = []
export let readonly: boolean = false
export let onClick: (() => void) | undefined = undefined
export let socialIcon: Asset | undefined = undefined
@@ -107,8 +108,7 @@
$: isHidden = !!viewlet?.onlyWithParent && parentMessage === undefined
$: withActionMenu = withActions && !embedded && (actions.length > 0 || menuActionIds.length > 0)
let readonly: boolean = false
$: readonly = $restrictionStore.disableComments
$: readonly = readonly || $restrictionStore.disableComments
function canDisplayShort (type: ActivityMessageViewType, isSaved: boolean): boolean {
return type === 'short' && !isSaved && (message.replies ?? 0) === 0
@@ -143,6 +143,7 @@
}
function handleContextMenu (event: MouseEvent): void {
if (readonly) return
const showCustomPopup = !isTextClicked(event.target as HTMLElement, event.clientX, event.clientY)
if (showCustomPopup) {
showMenu(event, { object: message, baseMenuClass: activity.class.ActivityMessage }, () => {
@@ -42,6 +42,7 @@
export let hoverStyles: 'borderedHover' | 'filledHover' = 'borderedHover'
export let hideLink = false
export let compact = false
export let readonly: boolean = false
export let onClick: (() => void) | undefined = undefined
const client = getClient()
@@ -96,6 +97,7 @@
<ActivityMessageTemplate
message={value}
{readonly}
{person}
{showNotify}
{isHighlighted}
@@ -54,6 +54,7 @@
export let hoverStyles: 'borderedHover' | 'filledHover' = 'borderedHover'
export let hideLink = false
export let type: ActivityMessageViewType = 'default'
export let readonly = false
export let space: Ref<Space> | undefined = undefined
export let onClick: (() => void) | undefined = undefined
@@ -195,6 +196,7 @@
{skipLabel}
{hoverable}
{hoverStyles}
{readonly}
type={viewlet?.label || getIsTextType(attributeModel) ? 'default' : type}
showDatePreposition={hideLink}
{onClick}
+4 -2
View File
@@ -47,7 +47,7 @@
"Settings": "Settings",
"ArchiveChannel": "Archive channel",
"UnarchiveChannel": "Unarchive channel",
"ArchiveConfirm": "Do you want to archive channel?",
"ArchiveConfirm": "When you archive a channel, its archived for everyone.<br/><br/>No one will be able to send messages, but theyll still have access to the channels history.<br/><br/>Youll still be able to find the channels contents via search.<br/>And you can always unarchive the channel in the future, if needed.\n",
"UnarchiveConfirm": "Do you want to unarchive channel?",
"AddToSaved": "Add to saved",
"RemoveFromSaved": "Remove from saved",
@@ -127,6 +127,8 @@
"Translate": "Translate",
"ShowOriginal": "Show original",
"Translating": "Translating...",
"StartConversation": "Start conversation"
"StartConversation": "Start conversation",
"ViewingThreadFromArchivedChannel": "You are viewing a thread from an archived channel",
"ViewingArchivedChannel": "You are viewing an archived channel"
}
}
+4 -2
View File
@@ -47,7 +47,7 @@
"Settings": "Ajustes",
"ArchiveChannel": "Archivar canal",
"UnarchiveChannel": "Desarchivar canal",
"ArchiveConfirm": "¿Quieres archivar el canal?",
"ArchiveConfirm": "Cuando archivas un canal, se archiva para todos.<br/><br/>Nadie podrá enviar mensajes, pero seguirán teniendo acceso al historial del canal.<br/><br/>Todavía podrás encontrar el contenido del canal a través de la búsqueda.<br/>Y siempre puedes desarchivar el canal en el futuro, si es necesario.\n",
"UnarchiveConfirm": "¿Quieres desarchivar el canal?",
"AddToSaved": "Añadir a guardados",
"RemoveFromSaved": "Eliminar de guardados",
@@ -127,6 +127,8 @@
"Translate": "Traducir",
"ShowOriginal": "Mostrar original",
"Translating": "Traduciendo...",
"StartConversation": "Iniciar conversación"
"StartConversation": "Iniciar conversación",
"ViewingThreadFromArchivedChannel": "Estás viendo un hilo de un canal archivado",
"ViewingArchivedChannel": "Estás viendo un canal archivado"
}
}
+4 -2
View File
@@ -47,7 +47,7 @@
"Settings": "Paramètres",
"ArchiveChannel": "Archiver le canal",
"UnarchiveChannel": "Désarchiver le canal",
"ArchiveConfirm": "Voulez-vous archiver le canal ?",
"ArchiveConfirm": "Lorsque vous archivez un canal, il est archivé pour tout le monde.<br/><br/>Personne ne pourra envoyer de messages, mais ils auront toujours accès à l'historique du canal.<br/><br/>Vous pourrez toujours trouver le contenu du canal via la recherche.<br/>Et vous pourrez toujours désarchiver le canal à l'avenir, si nécessaire.\n",
"UnarchiveConfirm": "Voulez-vous désarchiver le canal ?",
"AddToSaved": "Ajouter aux favoris",
"RemoveFromSaved": "Retirer des favoris",
@@ -127,6 +127,8 @@
"Translate": "Traduire",
"ShowOriginal": "Afficher l'original",
"Translating": "Traduction en cours...",
"StartConversation": "Démarrer la conversation"
"StartConversation": "Démarrer la conversation",
"ViewingThreadFromArchivedChannel": "Vous consultez un fil de discussion d'un canal archivé",
"ViewingArchivedChannel": "Vous consultez un canal archivé"
}
}
+4 -2
View File
@@ -47,7 +47,7 @@
"Settings": "Definições",
"ArchiveChannel": "Arquivar canal",
"UnarchiveChannel": "Desarquivar canal",
"ArchiveConfirm": "Deseja arquivar o canal?",
"ArchiveConfirm": "Quando arquivar um canal, ele é arquivado para todos.<br/><br/>Ninguém poderá enviar mensagens, mas ainda terão acesso ao histórico do canal.<br/><br/>Ainda poderá encontrar o conteúdo do canal através da pesquisa.<br/>E poderá sempre desarquivar o canal no futuro, se necessário.\n",
"UnarchiveConfirm": "Deseja desarquivar o canal?",
"AddToSaved": "Adicionar aos guardados",
"RemoveFromSaved": "Remover dos guardados",
@@ -127,6 +127,8 @@
"Translate": "Traduzir",
"ShowOriginal": "Mostrar original",
"Translating": "A traduzir...",
"StartConversation": "Iniciar conversa"
"StartConversation": "Iniciar conversa",
"ViewingThreadFromArchivedChannel": "Está a visualizar uma conversa em cadeia de um canal arquivado",
"ViewingArchivedChannel": "Está a visualizar um canal arquivado"
}
}
+4 -2
View File
@@ -47,7 +47,7 @@
"Settings": "Настройки",
"ArchiveChannel": "Архивировать канал",
"UnarchiveChannel": "Разархивировать канал",
"ArchiveConfirm": "Вы действительно хотите архивировать канал?",
"ArchiveConfirm": "При архивации канала он архивируется для всех.<br/><br/>Никто не сможет отправлять сообщения, но у них все равно будет доступ к истории канала.<br/><br/>Вы все равно сможете найти содержимое канала через поиск.<br/>И вы всегда сможете разархивировать канал в будущем, если это понадобится.\n",
"UnarchiveConfirm": "Вы действительно хотите разархивировать канал?",
"AddToSaved": "Добавить в сохраненные",
"RemoveFromSaved": "Удалить из сохраненных",
@@ -127,6 +127,8 @@
"Translate": "Перевести",
"ShowOriginal": "Показать оригинал",
"Translating": "Перевод...",
"StartConversation": "Начать диалог"
"StartConversation": "Начать диалог",
"ViewingThreadFromArchivedChannel": "Вы просматриваете обсуждение из архивированного канала",
"ViewingArchivedChannel": "Вы просматриваете архивированный канал"
}
}
+4 -2
View File
@@ -47,7 +47,7 @@
"Settings": "设置",
"ArchiveChannel": "归档频道",
"UnarchiveChannel": "取消归档频道",
"ArchiveConfirm": "你想要归档频道吗?",
"ArchiveConfirm": "当你归档频道时,它会对所有人进行归档。<br/><br/>没有人能够发送消息,但他们仍然可以访问频道的历史记录。<br/><br/>你仍然可以通过搜索找到频道的内容。<br/>如果需要,你可以随时取消归档频道。\n",
"UnarchiveConfirm": "你想要取消归档频道吗?",
"AddToSaved": "添加到已保存",
"RemoveFromSaved": "从已保存中移除",
@@ -127,6 +127,8 @@
"Translate": "翻译",
"ShowOriginal": "显示原文",
"Translating": "翻译中...",
"StartConversation": "开始对话"
"StartConversation": "开始对话",
"ViewingThreadFromArchivedChannel": "你正在查看已归档频道的线程",
"ViewingArchivedChannel": "你正在查看已归档频道"
}
}
@@ -103,6 +103,7 @@
{#if dataProvider}
<ChannelScrollView
{object}
channel={object}
skipLabels={!isDocChannel}
selectedFilters={filters}
startFromBottom
@@ -0,0 +1,118 @@
<!--
// 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 { ActivityExtension as ActivityExtensionComponent } from '@hcengineering/activity-resources'
import { Class, Doc, Ref } from '@hcengineering/core'
import activity, { ActivityExtension } from '@hcengineering/activity'
import { getClient } from '@hcengineering/presentation'
import { AnySvelteComponent, Icon, Label } from '@hcengineering/ui'
import { Asset, getResource, translate } from '@hcengineering/platform'
import view from '@hcengineering/view'
import { getChannelName, getObjectIcon } from '../utils'
import chunter from '../plugin'
export let object: Doc
export let readonly = false
export let boundary: HTMLElement | undefined | null = undefined
export let collection: string | undefined
export let isThread = false
const client = getClient()
const hierarchy = client.getHierarchy()
let extensions: ActivityExtension[] = []
$: extensions = client.getModel().findAllSync(activity.class.ActivityExtension, { ofClass: object._class })
let icon: Asset | AnySvelteComponent | undefined = undefined
let name: string | undefined = undefined
$: void updateIcon(object._class)
$: void updateName(object)
async function updateIcon (_class: Ref<Class<Doc>>): Promise<void> {
if (isThread) {
return
}
const iconMixin = hierarchy.classHierarchyMixin(_class, view.mixin.ObjectIcon)
let result: AnySvelteComponent | Asset | undefined = undefined
if (iconMixin?.component) {
result = await getResource(iconMixin.component)
} else {
result = getObjectIcon(_class)
}
icon = result
}
async function updateName (object: Doc): Promise<void> {
const titleIntl = client.getHierarchy().getClass(object._class).label
name = (await getChannelName(object._id, object._class, object)) ?? (await translate(titleIntl, {}))
}
</script>
{#if !readonly}
<div class="ref-input flex-col">
<ActivityExtensionComponent
kind="input"
{extensions}
props={{ object, boundary, collection, autofocus: true, withTypingInfo: true }}
/>
</div>
{:else}
<div class="message">
{#if isThread}
<Label label={chunter.string.ViewingThreadFromArchivedChannel} />
{:else}
<Label label={chunter.string.ViewingArchivedChannel} />
<span class="info">
{#if icon}
<Icon {icon} size="x-small" />
{/if}
{#if name}
{name}
{/if}
</span>
{/if}
</div>
{/if}
<style lang="scss">
.ref-input {
flex-shrink: 0;
margin: 1.25rem 1rem 0;
max-height: 18.75rem;
}
.message {
display: flex;
align-items: center;
justify-content: center;
margin: 1rem;
padding: 0.5rem 0;
color: var(--global-primary-TextColor);
background: var(--global-ui-BorderColor);
border-radius: 0.5rem;
}
.info {
display: flex;
align-items: center;
justify-content: center;
margin-left: 0.25rem;
gap: 0.125rem;
font-weight: 600;
}
</style>
@@ -23,6 +23,7 @@
export let ids: Ref<Person>[] = []
export let disableRemoveFor: Ref<Person>[] = []
export let readonly = false
const dispatch = createEventDispatcher()
@@ -36,22 +37,24 @@
</script>
<div class="root">
<div class="item" style:padding="var(--spacing-1_5)" class:withoutBorder={persons.length === 0}>
<ModernButton
label={chunter.string.AddMembers}
icon={IconAddMember}
iconSize="small"
kind="secondary"
size="small"
on:click={() => dispatch('add')}
/>
</div>
{#if !readonly}
<div class="item" style:padding="var(--spacing-1_5)" class:withoutBorder={persons.length === 0}>
<ModernButton
label={chunter.string.AddMembers}
icon={IconAddMember}
iconSize="small"
kind="secondary"
size="small"
on:click={() => dispatch('add')}
/>
</div>
{/if}
<Scroller>
{#each persons as person, index (person._id)}
<div class="item" class:withoutBorder={index === persons.length - 1}>
<div class="item__content" class:disabled={disableRemoveFor.includes(person._id)}>
<div class="item__content" class:disabled={readonly || disableRemoveFor.includes(person._id)}>
<UserDetails {person} showStatus />
{#if !disableRemoveFor.includes(person._id)}
{#if !readonly && !disableRemoveFor.includes(person._id)}
<div class="item__action">
<ButtonIcon
icon={IconDelete}
@@ -13,20 +13,14 @@
// limitations under the License.
-->
<script lang="ts">
import activity, {
ActivityExtension,
ActivityMessage,
ActivityMessagesFilter,
DisplayActivityMessage
} from '@hcengineering/activity'
import activity, { ActivityMessage, ActivityMessagesFilter, DisplayActivityMessage } from '@hcengineering/activity'
import {
ActivityExtension as ActivityExtensionComponent,
ActivityMessagePresenter,
canGroupMessages,
messageInFocus,
sortActivityMessages
} from '@hcengineering/activity-resources'
import { Doc, getCurrentAccount, getDay, Ref, Timestamp } from '@hcengineering/core'
import core, { Doc, getCurrentAccount, getDay, Ref, Space, Timestamp } from '@hcengineering/core'
import { DocNotifyContext } from '@hcengineering/notification'
import { InboxNotificationsClientImpl } from '@hcengineering/notification-resources'
import { getResource } from '@hcengineering/platform'
@@ -48,9 +42,11 @@
import ActivityMessagesSeparator from './ChannelMessagesSeparator.svelte'
import JumpToDateSelector from './JumpToDateSelector.svelte'
import HistoryLoading from './LoadingHistory.svelte'
import ChannelInput from './ChannelInput.svelte'
export let provider: ChannelDataProvider
export let object: Doc
export let channel: Doc
export let selectedMessageId: Ref<ActivityMessage> | undefined = undefined
export let scrollElement: HTMLDivElement | undefined | null = undefined
export let startFromBottom = false
@@ -74,6 +70,7 @@
const me = getCurrentAccount()
const client = getClient()
const hierarchy = client.getHierarchy()
const inboxClient = InboxNotificationsClientImpl.getClient()
const contextByDocStore = inboxClient.contextByDoc
const notificationsByContextStore = inboxClient.inboxNotificationsByContext
@@ -94,7 +91,6 @@
let messages: ActivityMessage[] = []
let displayMessages: DisplayActivityMessage[] = []
let extensions: ActivityExtension[] = []
let scroller: Scroller | undefined | null = undefined
let separatorElement: HTMLDivElement | undefined = undefined
@@ -118,9 +114,8 @@
$: messages = $messagesStore
$: isLoading = $isLoadingStore
$: extensions = client.getModel().findAllSync(activity.class.ActivityExtension, { ofClass: doc._class })
$: notifyContext = $contextByDocStore.get(doc._id)
$: readonly = hierarchy.isDerived(channel._class, core.class.Space) ? (channel as Space).archived : false
void client
.getModel()
@@ -809,7 +804,7 @@
{/if}
<slot name="header" />
{#if displayMessages.length === 0 && !embedded}
{#if displayMessages.length === 0 && !embedded && !readonly}
<BlankView
icon={chunter.icon.Thread}
header={chunter.string.NoMessagesInChannel}
@@ -840,17 +835,12 @@
attachmentImageSize="x-large"
type={canGroup ? 'short' : 'default'}
hideLink
{readonly}
/>
{/each}
{#if !fixedInput}
<div class="ref-input flex-col">
<ActivityExtensionComponent
kind="input"
{extensions}
props={{ object, boundary: scrollElement, collection, autofocus: true, withTypingInfo: true }}
/>
</div>
<ChannelInput {object} {readonly} boundary={scrollElement} {collection} isThread={embedded} />
{/if}
{#if loadMoreAllowed && $canLoadNextForwardStore}
@@ -859,7 +849,7 @@
</Scroller>
{#if !embedded && showScrollDownButton}
<div class="down-button absolute">
<div class="down-button absolute" class:readonly>
<ModernButton
label={chunter.string.LatestMessages}
shape="round"
@@ -870,14 +860,8 @@
</div>
{/if}
</div>
{#if fixedInput && object}
<div class="ref-input flex-col">
<ActivityExtensionComponent
kind="input"
{extensions}
props={{ object, boundary: scrollElement, collection, autofocus: true, withTypingInfo: true }}
/>
</div>
{#if fixedInput}
<ChannelInput {object} {readonly} boundary={scrollElement} {collection} isThread={embedded} />
{/if}
{/if}
@@ -887,13 +871,6 @@
flex-shrink: 5;
}
.ref-input {
flex-shrink: 0;
margin: 1.25rem 1rem 1rem;
margin-bottom: 0;
max-height: 18.75rem;
}
.overlay {
width: 100%;
height: 100%;
@@ -920,6 +897,10 @@
animation: 0.5s fadeIn;
animation-fill-mode: forwards;
visibility: hidden;
&.readonly {
bottom: 0.25rem;
}
}
@keyframes fadeIn {
@@ -55,6 +55,7 @@
isThreadOpened = newLocation.path[4] != null
})
$: readonly = hierarchy.isDerived(object._class, core.class.Space) ? (object as Space).archived : false
$: showJoinOverlay = shouldShowJoinOverlay(object)
$: isDocChat = !hierarchy.isDerived(object._class, chunter.class.ChunterSpace)
$: withAside =
@@ -114,7 +115,7 @@
<div class="popupPanel-body" class:asideShown={withAside && isAsideShown}>
<div class="popupPanel-body__main">
{#key object._id}
{#if shouldShowJoinOverlay(object)}
{#if !readonly && shouldShowJoinOverlay(object)}
<div class="body h-full w-full clear-mins flex-center">
<div class="joinOverlay">
<div class="an-element__label header">
@@ -52,6 +52,7 @@
export let videoPreload = true
export let hideLink = false
export let compact = false
export let readonly = false
export let type: ActivityMessageViewType = 'default'
export let onClick: (() => void) | undefined = undefined
@@ -245,6 +246,7 @@
{skipLabel}
{pending}
{stale}
{readonly}
excludedActions={$shownTranslatedMessagesStore.has(value._id)
? [chunter.action.TranslateMessage]
: [chunter.action.ShowOriginalMessage]}
@@ -115,6 +115,7 @@
}
)
}
$: readonly = object?.archived ?? false
</script>
<DocAside {_class} {object}>
@@ -146,6 +147,7 @@
<ChannelMembers
ids={Array.from(members)}
disableRemoveFor={disabledRemoveFor}
{readonly}
on:add={openSelectUsersPopup}
on:remove={removeMember}
/>
@@ -13,7 +13,7 @@
// limitations under the License.
-->
<script lang="ts">
import { Class, Doc, Mixin, Ref } from '@hcengineering/core'
import core, { Class, Doc, Mixin, Ref, Space } from '@hcengineering/core'
import {
AttributeBarEditor,
getClient,
@@ -50,6 +50,8 @@
)
return filtredKeys.filter((key) => !isCollectionAttr(hierarchy, key))
}
$: readonly = hierarchy.isDerived(_class, core.class.Space) ? (object as Space).archived : false
</script>
<Scroller>
@@ -59,7 +61,7 @@
{object}
ignoreKeys={objectChatPanel?.ignoreKeys ?? []}
showHeader={false}
readonly={false}
{readonly}
on:update
/>
<div class="popupPanel-body__aside-grid">
@@ -65,7 +65,8 @@
private: selectedVisibilityId === 'private',
archived: false,
members: [account._id],
topic: description
topic: description,
owners: [account._id]
})
openChannel(channelId, chunter.class.Channel)
@@ -39,7 +39,6 @@
const client = getClient()
const hierarchy = client.getHierarchy()
const inboxClient = InboxNotificationsClientImpl.getClient()
const contextByDocStore = inboxClient.contextByDoc
const contextsStore = inboxClient.contexts
const objectsQueryByClass = new Map<Ref<Class<Doc>>, { query: LiveQuery, limit: number }>()
@@ -70,18 +69,17 @@
sections = res
})
$: shouldPushObject =
object !== undefined && getObjectGroup(object) === model.id && !$contextByDocStore.has(object._id)
$: shouldPushObject = object !== undefined && getObjectGroup(object) === model.id
function loadObjects (contexts: DocNotifyContext[]): void {
const contextsByClass = groupByArray(contexts, ({ objectClass }) => objectClass)
for (const [_class, ctx] of contextsByClass.entries()) {
const isChunterSpace = hierarchy.isDerived(_class, chunter.class.ChunterSpace)
const isSpace = hierarchy.isDerived(_class, core.class.Space)
const ids = ctx.map(({ objectId }) => objectId)
const { query, limit } = objectsQueryByClass.get(_class) ?? {
query: createQuery(),
limit: isChunterSpace ? -1 : model.maxSectionItems ?? 5
limit: isSpace ? -1 : model.maxSectionItems ?? 5
}
objectsQueryByClass.set(_class, { query, limit: limit ?? model.maxSectionItems ?? 5 })
@@ -90,7 +88,7 @@
_class,
{
_id: { $in: limit !== -1 ? ids.slice(0, limit) : ids },
...(isChunterSpace ? { space: core.space.Space } : {})
...(isSpace ? { space: core.space.Space, archived: false } : {})
},
(res) => {
objectsByClass = objectsByClass.set(_class, { docs: res, total: res.total })
@@ -3,15 +3,31 @@
import ThreadParentMessage from './ThreadParentPresenter.svelte'
import { Label } from '@hcengineering/ui'
import ChannelScrollView from '../ChannelScrollView.svelte'
import { Ref } from '@hcengineering/core'
import core, { Doc, Ref, Space } from '@hcengineering/core'
import { createQuery, getClient } from '@hcengineering/presentation'
import { ChannelDataProvider } from '../../channelDataProvider'
import chunter from '../../plugin'
export let selectedMessageId: Ref<ActivityMessage> | undefined = undefined
export let message: ActivityMessage
const query = createQuery()
const client = getClient()
const hierarchy = client.getHierarchy()
let channel: Doc | undefined = undefined
let dataProvider: ChannelDataProvider | undefined = undefined
$: query.query(
message.attachedToClass,
{ _id: message.attachedTo },
(res) => {
channel = res[0]
},
{ limit: 1 }
)
$: if (message !== undefined && dataProvider === undefined) {
dataProvider = new ChannelDataProvider(
undefined,
@@ -24,22 +40,26 @@
}
$: messagesStore = dataProvider?.messagesStore
$: readonly = hierarchy.isDerived(message.attachedToClass, core.class.Space)
? (channel as Space)?.archived ?? false
: false
</script>
<div class="hulyComponent-content hulyComponent-content__container noShrink">
{#if dataProvider !== undefined}
{#if dataProvider !== undefined && channel !== undefined}
<ChannelScrollView
bind:selectedMessageId
embedded
skipLabels
object={message}
{channel}
provider={dataProvider}
fullHeight={false}
fixedInput={false}
>
<svelte:fragment slot="header">
<div class="mt-3">
<ThreadParentMessage {message} />
<ThreadParentMessage {message} {readonly} />
</div>
{#if (message.replies ?? $messagesStore?.length ?? 0) > 0}
@@ -39,6 +39,7 @@
export let hoverStyles: 'borderedHover' | 'filledHover' = 'borderedHover'
export let attachmentImageSize: AttachmentImageSize = 'x-large'
export let videoPreload = true
export let readonly = false
export let onClick: (() => void) | undefined = undefined
const client = getClient()
@@ -74,5 +75,6 @@
{attachmentImageSize}
{videoPreload}
{onClick}
{readonly}
/>
{/if}
@@ -17,6 +17,7 @@
import { ActivityMessage } from '@hcengineering/activity'
export let message: ActivityMessage
export let readonly = false
</script>
<ActivityMessagePresenter
@@ -26,4 +27,5 @@
withShowMore={false}
attachmentImageSize="x-large"
skipLabel
{readonly}
/>
+1
View File
@@ -92,6 +92,7 @@ export async function ArchiveChannel (channel: Channel, evt: any, props?: { afte
showPopup(MessageBox, {
label: chunter.string.ArchiveChannel,
message: chunter.string.ArchiveConfirm,
richMessage: true,
action: async () => {
const client = getClient()
+3 -1
View File
@@ -215,7 +215,9 @@ export default plugin(chunterId, {
Translate: '' as IntlString,
ShowOriginal: '' as IntlString,
Translating: '' as IntlString,
StartConversation: '' as IntlString
StartConversation: '' as IntlString,
ViewingThreadFromArchivedChannel: '' as IntlString,
ViewingArchivedChannel: '' as IntlString
},
ids: {
DMNotification: '' as Ref<NotificationType>,
@@ -141,7 +141,7 @@ function createDecorations (doc: ProseMirrorNode, options: CodeBlockLowlightOpti
button.addEventListener('click', (e) => {
e.preventDefault()
e.stopPropagation()
handleLangButtonClick(e, node, pos, view, options)
handleLangButtonClick(e, node, pos, view, button, options)
})
} else {
button.disabled = true
@@ -181,6 +181,7 @@ function handleLangButtonClick (
node: ProseMirrorNode,
pos: number,
view: EditorView,
button: HTMLButtonElement,
options: CodeBlockLowlightOptions
): void {
const language = node.attrs.language
@@ -191,6 +192,8 @@ function handleLangButtonClick (
label: language
}))
button.classList.add('hovered')
showPopup(
DropdownLabelsPopup,
{
@@ -199,6 +202,7 @@ function handleLangButtonClick (
},
getEventPositionElement(evt),
(result) => {
button.classList.remove('hovered')
if (result != null) {
const tr = view.state.tr.setNodeAttribute(pos, 'language', result)
view.dispatch(tr)
@@ -196,7 +196,13 @@
{#if isAdmin && ws.lastVisit != null && ws.lastVisit !== 0}
<div class="text-sm">
{#if ws.backupInfo != null}
{ws.backupInfo.backupSize}Mb -
{@const sz = ws.backupInfo.dataSize + ws.backupInfo.blobsSize}
{@const szGb = Math.round((sz * 100) / 1024) / 100}
{#if szGb > 0}
{Math.round((sz * 100) / 1024) / 100}Gb -
{:else}
{Math.round(sz)}Mb -
{/if}
{/if}
({lastUsageDays} days)
</div>
@@ -910,6 +910,8 @@
inset: 0;
border: 1px solid var(--theme-divider-color);
border-radius: var(--medium-BorderRadius);
border-bottom-right-radius: 0;
border-top-right-radius: 0;
pointer-events: none;
}
.antiPanel-application {
@@ -165,10 +165,7 @@
width: calc(100% - 3.5rem);
height: 100%;
background: var(--theme-panel-color);
border-right: 1px solid var(--global-ui-BorderColor);
border-top-left-radius: var(--small-focus-BorderRadius);
border-bottom-left-radius: var(--small-focus-BorderRadius);
}
.component {
@@ -23,4 +23,4 @@
export let selected: Ref<Widget> | undefined = undefined
</script>
<WidgetsBar {widgets} {preferences} {selected} roundBorder />
<WidgetsBar {widgets} {preferences} {selected} />
@@ -24,7 +24,6 @@
export let widgets: Widget[] = []
export let preferences: WidgetPreference[] = []
export let selected: Ref<Widget> | undefined = undefined
export let roundBorder = false
function handleAddWidget (): void {
showPopup(AddWidgetsPopup, { widgets })
@@ -49,7 +48,7 @@
.filter((widget): widget is Widget => widget !== undefined && widget.type === WidgetType.Configurable)
</script>
<div class="root" class:roundBorder>
<div class="root">
<div class="block">
{#each fixedWidgets as widget}
<WidgetPresenter
@@ -104,10 +103,6 @@
max-width: 3.5rem;
border-top: 1px solid var(--theme-divider-color);
overflow-y: auto;
&.roundBorder {
border-top-left-radius: var(--small-focus-BorderRadius);
}
}
.block {
+5
View File
@@ -855,6 +855,11 @@
"projectFolder": "dev/tool",
"shouldPublish": false
},
{
"packageName": "@hcengineering/import-tool",
"projectFolder": "dev/import-tool",
"shouldPublish": false
},
{
"packageName": "@hcengineering/pod-account",
"projectFolder": "pods/account",
+11 -3
View File
@@ -1017,7 +1017,8 @@ export async function listWorkspacesByAccount (db: Db, email: string): Promise<W
export async function countWorkspacesInRegion (
db: Db,
region: string = '',
upToVersion?: Data<Version>
upToVersion?: Data<Version>,
visitedSince?: number
): Promise<number> {
const regionQuery = region === '' ? { $or: [{ region: { $exists: false } }, { region: '' }] } : { region }
const query: Filter<Workspace>['$and'] = [
@@ -1039,6 +1040,10 @@ export async function countWorkspacesInRegion (
})
}
if (visitedSince !== undefined) {
query.push({ lastVisit: { $gt: visitedSince } })
}
return await db.collection<Workspace>(WORKSPACE_COLLECTION).countDocuments({
$and: query
})
@@ -1256,7 +1261,7 @@ export async function workerHandshake (
const workspacesCnt = await ctx.with(
'count-workspaces-in-region',
{},
async (ctx) => await countWorkspacesInRegion(db, region, version)
async (ctx) => await countWorkspacesInRegion(db, region, version, Date.now() - 24 * 60 * 60 * 1000)
)
await db.collection<UpgradeStatistic>(UPGRADE_COLLECTION).insertOne({
@@ -1494,7 +1499,10 @@ export async function getPendingWorkspace (
{
$or: [{ mode: 'active' }, { mode: { $exists: false } }]
},
versionQuery
versionQuery,
{
lastVisit: { $gt: Date.now() - 24 * 60 * 60 * 1000 }
}
]
},
{
+73 -16
View File
@@ -43,7 +43,7 @@ import { BlobClient, createClient } from '@hcengineering/server-client'
import { fullTextPushStagePrefix, type StorageAdapter } from '@hcengineering/server-core'
import { generateToken } from '@hcengineering/server-token'
import { connect } from '@hcengineering/server-tool'
import { createWriteStream, existsSync, mkdirSync, statSync } from 'node:fs'
import { createWriteStream, existsSync, mkdirSync } from 'node:fs'
import { dirname } from 'node:path'
import { PassThrough } from 'node:stream'
import { createGzip } from 'node:zlib'
@@ -132,6 +132,7 @@ async function loadDigest (
date?: number
): Promise<Map<Ref<Doc>, string>> {
ctx = ctx.newChild('load digest', { domain, count: snapshots.length })
ctx.info('load-digest', { domain, count: snapshots.length })
const result = new Map<Ref<Doc>, string>()
for (const s of snapshots) {
const d = s.domains[domain]
@@ -492,9 +493,9 @@ async function cleanDomain (ctx: MeasureContext, connection: CoreClient & Backup
}
}
function doTrimHash (s: string | undefined): string {
function doTrimHash (s: string | undefined): string | undefined {
if (s == null) {
return ''
return undefined
}
if (s.startsWith('"') && s.endsWith('"')) {
return s.slice(1, s.length - 1)
@@ -682,7 +683,7 @@ export async function backup (
let downloaded = 0
const printDownloaded = (msg: string, size?: number | null): void => {
if (size == null || Number.isNaN(size)) {
if (size == null || Number.isNaN(size) || !Number.isInteger(size)) {
return
}
ops++
@@ -710,6 +711,30 @@ export async function backup (
let changed: number = 0
const needRetrieveChunks: Ref<Doc>[][] = []
// Load all digest from collection.
ctx.info('processed', {
processed,
digest: digest.size,
time: Date.now() - st,
workspace: workspaceId.name
})
const oldHash = new Map<Ref<Doc>, string>()
function removeFromNeedRetrieve (needRetrieve: Ref<Doc>[], id: string): void {
const pos = needRetrieve.indexOf(id as Ref<Doc>)
if (pos !== -1) {
needRetrieve.splice(pos, 1)
processed--
changed--
}
for (const ch of needRetrieveChunks) {
const pos = ch.indexOf(id as Ref<Doc>)
if (pos !== -1) {
ch.splice(pos, 1)
processed--
changed--
}
}
}
while (true) {
try {
const currentChunk = await ctx.with('loadChunk', {}, () => connection.loadChunk(domain, idx, options.recheck))
@@ -735,17 +760,31 @@ export async function backup (
})
st = Date.now()
}
const _hash = doTrimHash(hash)
const kHash = doTrimHash(digest.get(id as Ref<Doc>))
const _hash = doTrimHash(hash) as string
const kHash = doTrimHash(digest.get(id as Ref<Doc>) ?? oldHash.get(id as Ref<Doc>))
if (kHash !== undefined) {
digest.delete(id as Ref<Doc>)
if (digest.delete(id as Ref<Doc>)) {
oldHash.set(id as Ref<Doc>, kHash)
}
if (kHash !== _hash) {
if (changes.updated.has(id as Ref<Doc>)) {
removeFromNeedRetrieve(needRetrieve, id as Ref<Doc>)
}
changes.updated.set(id as Ref<Doc>, _hash)
needRetrieve.push(id as Ref<Doc>)
currentNeedRetrieveSize += size
changed++
} else if (changes.updated.has(id as Ref<Doc>)) {
// We have same
changes.updated.delete(id as Ref<Doc>)
removeFromNeedRetrieve(needRetrieve, id as Ref<Doc>)
processed -= 1
}
} else {
if (domain === DOMAIN_BLOB && changes.added.has(id as Ref<Doc>)) {
// We need to clean old need retrieve in case of duplicates.
removeFromNeedRetrieve(needRetrieve, id)
}
changes.added.set(id as Ref<Doc>, _hash)
needRetrieve.push(id as Ref<Doc>)
changed++
@@ -753,7 +792,9 @@ export async function backup (
}
if (currentNeedRetrieveSize > retrieveChunkSize) {
needRetrieveChunks.push(needRetrieve)
if (needRetrieve.length > 0) {
needRetrieveChunks.push(needRetrieve)
}
currentNeedRetrieveSize = 0
needRetrieve = []
}
@@ -835,12 +876,17 @@ export async function backup (
const totalChunks = needRetrieveChunks.flatMap((it) => it.length).reduce((p, c) => p + c, 0)
let processed = 0
let blobs = 0
while (needRetrieveChunks.length > 0) {
if (canceled()) {
return
}
const needRetrieve = needRetrieveChunks.shift() as Ref<Doc>[]
if (needRetrieve.length === 0) {
continue
}
ctx.info('Retrieve chunk', {
needRetrieve: needRetrieveChunks.reduce((v, docs) => v + docs.length, 0),
toLoad: needRetrieve.length,
@@ -849,6 +895,10 @@ export async function backup (
let docs: Doc[] = []
try {
docs = await ctx.with('load-docs', {}, async (ctx) => await connection.loadDocs(domain, needRetrieve))
if (docs.length !== needRetrieve.length) {
const nr = new Set(docs.map((it) => it._id))
ctx.error('failed to retrieve all documents', { missing: needRetrieve.filter((it) => !nr.has(it)) })
}
ops++
} catch (err: any) {
ctx.error('error loading docs', { domain, err, workspace: workspaceId.name })
@@ -992,7 +1042,8 @@ export async function backup (
ctx.error('error packing file', { err })
}
})
if (blob.size > 1024 * 1024) {
blobs++
if (blob.size > 1024 * 1024 || blobs >= 10) {
ctx.info('download blob', {
_id: blob._id,
contentType: blob.contentType,
@@ -1000,6 +1051,9 @@ export async function backup (
provider: blob.provider,
pending: docs.length
})
if (blobs >= 10) {
blobs = 0
}
}
printDownloaded('', blob.size)
@@ -1173,15 +1227,16 @@ export async function backupDownload (storage: BackupStorage, storeIn: string):
const backupInfo: BackupInfo = JSON.parse(gunzipSync(await storage.loadFile(infoFile)).toString())
console.log('workspace:', backupInfo.workspace ?? '', backupInfo.version)
const addFileSize = async (file: string | undefined | null): Promise<void> => {
if (file != null && (await storage.exists(file))) {
const fileSize = await storage.stat(file)
const addFileSize = async (file: string | undefined | null, force: boolean = false): Promise<void> => {
if (file != null) {
const target = join(storeIn, file)
const dir = dirname(target)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
if (!existsSync(target) || fileSize !== statSync(target).size) {
if (!existsSync(target) || force) {
const fileSize = await storage.stat(file)
console.log('downloading', file, fileSize)
const readStream = await storage.load(file)
const outp = createWriteStream(target)
@@ -1194,8 +1249,10 @@ export async function backupDownload (storage: BackupStorage, storeIn: string):
resolve()
})
})
size += fileSize
} else {
console.log('file-same', file)
}
size += fileSize
}
}
@@ -1211,7 +1268,7 @@ export async function backupDownload (storage: BackupStorage, storeIn: string):
}
}
}
await addFileSize(infoFile)
await addFileSize(infoFile, true)
console.log('Backup size', size / (1024 * 1024), 'Mb')
}
@@ -1687,7 +1744,7 @@ export async function compactBackup (
const oldSnapshots = [...backupInfo.snapshots]
backupInfo.snapshots = [snapshot]
let backupIndex = `${backupInfo.snapshotsIndex ?? oldSnapshots.length}`
let backupIndex = `${(backupInfo.snapshotsIndex ?? oldSnapshots.length) + 1}`
while (backupIndex.length < 6) {
backupIndex = '0' + backupIndex
}
+17 -7
View File
@@ -115,27 +115,37 @@ class BackupWorker {
ctx: MeasureContext
): Promise<{ failedWorkspaces: BaseWorkspaceInfo[], processed: number, skipped: number }> {
const workspacesIgnore = new Set(this.config.SkipWorkspaces.split(';'))
ctx.info('skipped workspaces', { workspacesIgnore })
let skipped = 0
const workspaces = (await listAccountWorkspaces(this.config.Token)).filter((it) => {
const lastBackup = it.backupInfo?.lastBackup ?? 0
if ((Date.now() - lastBackup) / 1000 < this.config.Interval) {
// No backup required, interval not elapsed
ctx.info('Skip backup', { workspace: it.workspace, lastBackup: Math.round((Date.now() - lastBackup) / 1000) })
skipped++
return false
}
if (it.lastVisit == null) {
skipped++
return false
}
const lastVisitSec = Math.floor((Date.now() - it.lastVisit) / 1000)
if (lastVisitSec > this.config.Interval) {
// No backup required, interval not elapsed
ctx.info('Skip backup, since not visited since last check', {
workspace: it.workspace,
days: Math.floor(lastVisitSec / 3600 / 24),
seconds: lastVisitSec
})
skipped++
return false
}
return !workspacesIgnore.has(it.workspace)
})
workspaces.sort((a, b) => b.lastVisit - a.lastVisit)
ctx.info('Preparing for BACKUP', {
total: workspaces.length,
skipped,
workspaces: workspaces.map((it) => it.workspace)
})
return await this.doBackup(ctx, workspaces)
}
@@ -230,7 +240,7 @@ class BackupWorker {
dataSize: Math.round((result.dataSize * 100) / (1024 * 1024)) / 100,
blobsSize: Math.round((result.blobsSize * 100) / (1024 * 1024)) / 100
}
rootCtx.warn('\n\nBACKUP STATS ', {
rootCtx.warn('BACKUP STATS', {
workspace: ws.workspace,
index,
...backupInfo,
+1 -2
View File
@@ -203,8 +203,7 @@ export async function login (user: string, password: string, workspace: string):
})
const result = await response.json()
const { token } = result.result
return token
return result.result?.token
}
export async function getUserWorkspaces (token: string): Promise<BaseWorkspaceInfo[]> {
+1
View File
@@ -98,6 +98,7 @@ export class BlobClient {
chunks.push(chunk)
})
readable.on('end', () => {
readable.destroy()
resolve()
})
})
+30 -5
View File
@@ -77,6 +77,16 @@ export class AggregatorStorageAdapter implements StorageAdapter, StorageAdapterE
async initialize (ctx: MeasureContext, workspaceId: WorkspaceId): Promise<void> {}
doTrimHash (s: string | undefined): string {
if (s == null) {
return ''
}
if (s.startsWith('"') && s.endsWith('"')) {
return s.slice(1, s.length - 1)
}
return s
}
async doSyncDocs (ctx: MeasureContext, workspaceId: WorkspaceId, docs: ListBlobResult[]): Promise<void> {
const existingBlobs = toIdMap(
await this.dbAdapter.find<Blob>(ctx, workspaceId, DOMAIN_BLOB, { _id: { $in: docs.map((it) => it._id) } })
@@ -84,10 +94,20 @@ export class AggregatorStorageAdapter implements StorageAdapter, StorageAdapterE
const toUpdate: Blob[] = []
for (const d of docs) {
const blobInfo = existingBlobs.get(d._id)
if (blobInfo === undefined || blobInfo.etag !== d.etag || blobInfo.size !== d.size) {
const stat = await this.stat(ctx, workspaceId, d._id)
if (
blobInfo === undefined || // Blob info undefined
// Provider are same and etag or size are diffrent.
(d.provider === blobInfo.provider &&
(this.doTrimHash(blobInfo.etag) !== this.doTrimHash(d.etag) || blobInfo.size !== d.size)) ||
// We have replacement in default
(d.provider === this.defaultAdapter && blobInfo?.provider !== d.provider)
) {
const stat = await this.adapters.get(d.provider)?.stat(ctx, workspaceId, d._id)
if (stat !== undefined) {
stat.provider = d.provider
toUpdate.push(stat)
} else {
ctx.error('blob not found for sync', { provider: d.provider, id: d._id, workspace: workspaceId.name })
}
}
}
@@ -120,19 +140,24 @@ export class AggregatorStorageAdapter implements StorageAdapter, StorageAdapterE
}
private makeStorageIterator (ctx: MeasureContext, workspaceId: WorkspaceId): BlobStorageIterator {
const adapters = Array.from(this.adapters.values())
const adapters = Array.from(this.adapters.entries())
let provider: [string, StorageAdapter] | undefined
let iterator: BlobStorageIterator | undefined
return {
next: async () => {
while (true) {
if (iterator === undefined && adapters.length > 0) {
iterator = await (adapters.shift() as StorageAdapter).listStream(ctx, workspaceId)
provider = adapters.shift() as [string, StorageAdapter]
iterator = await provider[1].listStream(ctx, workspaceId)
}
if (iterator === undefined) {
return []
}
const docInfos = await iterator.next()
if (docInfos.length > 0) {
for (const d of docInfos) {
d.provider = provider?.[0] as string
}
// We need to check if our stored version is fine
return docInfos
} else {
@@ -323,7 +348,7 @@ export class AggregatorStorageAdapter implements StorageAdapter, StorageAdapterE
const result = await adapter.put(ctx, workspaceId, objectName, stream, contentType, size)
if (size === undefined || size === 0) {
if (size === undefined || size === 0 || !Number.isInteger(size)) {
const docStats = await adapter.stat(ctx, workspaceId, objectName)
if (docStats !== undefined) {
if (contentType !== docStats.contentType) {
+11 -2
View File
@@ -512,8 +512,9 @@ abstract class PostgresAdapterBase implements DbAdapter {
if (val.classes !== undefined) {
if (val.classes.length === 1) {
res.push(`AND ${val.toAlias}._class = '${val.classes[0]}'`)
} else {
res.push(`AND ${val.toAlias}._class IN (${val.classes.map((c) => `'${c}'`).join(', ')})`)
}
res.push(`AND ${val.toAlias}._class IN (${val.classes.map((c) => `'${c}'`).join(', ')})`)
}
}
return res.join(' ')
@@ -863,8 +864,16 @@ abstract class PostgresAdapterBase implements DbAdapter {
private getProjectionsAliases (join: JoinProps): string[] {
if (join.table === DOMAIN_MODEL) return []
if (join.isReverse) {
let classsesQuery = ''
if (join.classes !== undefined) {
if (join.classes.length === 1) {
classsesQuery = ` AND ${join.toAlias}._class = '${join.classes[0]}'`
} else {
classsesQuery = ` AND ${join.toAlias}._class IN (${join.classes.map((c) => `'${c}'`).join(', ')})`
}
}
return [
`(SELECT jsonb_agg(${join.toAlias}.*) FROM ${join.table} AS ${join.toAlias} WHERE ${join.fromAlias}.${join.fromField} = ${join.toAlias}."${join.toField}") AS ${join.toAlias}`
`(SELECT jsonb_agg(${join.toAlias}.*) FROM ${join.table} AS ${join.toAlias} WHERE ${join.fromAlias}.${join.fromField} = ${join.toAlias}."${join.toField}" ${classsesQuery}) AS ${join.toAlias}`
]
}
const res: string[] = []
@@ -20,7 +20,7 @@ import { htmlToMarkup, isEmptyMarkup, jsonToMarkup, MarkupNodeType } from '@hcen
import { toHTML } from '@telegraf/entity'
import { CallbackQuery, Message, Update } from 'telegraf/typings/core/types/typegram'
import { translate } from '@hcengineering/platform'
import { WithId } from 'mongodb'
import { ObjectId, WithId } from 'mongodb'
import config from '../config'
import { PlatformWorker } from '../worker'
@@ -29,13 +29,13 @@ import { toTelegramFileInfo } from '../utils'
import { Command, defineCommands } from './commands'
import { ChannelRecord, MessageRecord, TelegramFileInfo, UserRecord, WorkspaceInfo } from '../types'
function encodeChannelId (workspace: string, channelId: string): string {
return `${workspace}_${channelId}`
function encodeChannelId (channelId: string): string {
return `@${channelId}`
}
function decodeChannelId (id: string): { workspace: string, channelId: string } {
const [workspace, channelId] = id.split('_')
return { workspace, channelId }
function decodeChannelId (id: string): string | undefined {
const [, channelId] = id.split('@')
return channelId
}
const getNextActionId = (workspace: string, page: number): string => `next_${workspace}_${page}`
@@ -114,10 +114,9 @@ async function handleSelectChannel (
const userRecord = await worker.getUserRecord(id)
if (userRecord === undefined) return ['', false]
const { workspace, channelId } = decodeChannelId(match)
const channels = await worker.getChannels(userRecord.email, workspace)
const channel = channels.find((it) => it._id.toString() === channelId)
const channelId = decodeChannelId(match)
if (channelId === undefined || channelId === '') return ['', false]
const channel = await worker.getChannel(userRecord.email, new ObjectId(channelId))
if (channel === undefined) return ['', false]
@@ -134,6 +133,14 @@ async function handleSelectChannel (
return [channel.name, await worker.sendMessage(channel, userMessage.message_id, text, file)]
}
async function showNoChannelsMessage (ctx: Context, worker: PlatformWorker, workspace: string): Promise<void> {
const ws = await worker.getWorkspaceInfo(workspace)
await ctx.editMessageText(
`No channels found in workspace <b>${ws?.name ?? workspace}</b>.\nTo sync channels call /${Command.SyncAllChannels} or /${Command.SyncStarredChannels}`,
{ parse_mode: 'HTML' }
)
}
async function createSelectChannelKeyboard (
ctx: NarrowedContext<TgContext, Update.MessageUpdate>,
worker: PlatformWorker,
@@ -141,6 +148,12 @@ async function createSelectChannelKeyboard (
workspace: string
): Promise<void> {
const channels = await worker.getChannels(userRecord.email, workspace)
if (channels.length === 0) {
await showNoChannelsMessage(ctx, worker, workspace)
return
}
const hasNext = channels.length > channelsPerPage
const pageChannels = getPageChannels(channels, 0)
@@ -148,9 +161,7 @@ async function createSelectChannelKeyboard (
reply_parameters: { message_id: ctx.message.message_id },
...Markup.inlineKeyboard(
[
...pageChannels.map((channel) =>
Markup.button.callback(channel.name, encodeChannelId(channel.workspace, channel._id.toString()))
),
...pageChannels.map((channel) => Markup.button.callback(channel.name, encodeChannelId(channel._id.toString()))),
...(hasNext ? [Markup.button.callback('Next>', getNextActionId(workspace, 0))] : [])
],
{ columns: 1 }
@@ -283,7 +294,7 @@ export async function setUpBot (worker: PlatformWorker): Promise<Telegraf<TgCont
ctx.processingKeyboards.delete(messageId)
})
bot.action(/.+_.+/, async (ctx) => {
bot.action(/@.+/, async (ctx) => {
const messageId = ctx.callbackQuery.message?.message_id
if (messageId === undefined) return
if (ctx.processingKeyboards.has(messageId)) return
@@ -341,11 +352,7 @@ const editChannelKeyboard = async (
const channels = await worker.getChannels(userRecord.email, workspace)
if (channels.length === 0) {
const ws = await worker.getWorkspaceInfo(workspace)
await ctx.editMessageText(
`No channels found in workspace <b>${ws?.name ?? workspace}</b>.\nTo add channels call /${Command.SyncAllChannels} or /${Command.SyncStarredChannels}`,
{ parse_mode: 'HTML' }
)
await showNoChannelsMessage(ctx, worker, workspace)
return
}
@@ -355,9 +362,7 @@ const editChannelKeyboard = async (
await ctx.editMessageReplyMarkup({
inline_keyboard: [
...pageChannels.map((channel) => [
Markup.button.callback(channel.name, encodeChannelId(channel.workspace, channel._id.toString()))
]),
...pageChannels.map((channel) => [Markup.button.callback(channel.name, encodeChannelId(channel._id.toString()))]),
[
...(hasPrev ? [Markup.button.callback('<Prev', getPrevActionId(workspace, page))] : []),
...(hasNext ? [Markup.button.callback('Next>', getNextActionId(workspace, page))] : [])
@@ -13,7 +13,7 @@
// limitations under the License.
//
import type { Collection, WithId } from 'mongodb'
import type { Collection, ObjectId, WithId } from 'mongodb'
import { MeasureContext, Ref, SortingOrder, systemAccountEmail } from '@hcengineering/core'
import { InboxNotification } from '@hcengineering/notification'
import { TelegramNotificationRequest } from '@hcengineering/telegram'
@@ -44,9 +44,11 @@ const closeWorkspaceTimeout = 10 * 60 * 1000 // 10 minutes
export class PlatformWorker {
private readonly workspacesClients = new Map<string, WorkspaceClient>()
private readonly closeWorkspaceTimeouts: Map<string, NodeJS.Timeout> = new Map<string, NodeJS.Timeout>()
private readonly intervalId: NodeJS.Timeout | undefined
private readonly otpIntervalId: NodeJS.Timeout | undefined
private readonly clearIntervalId: NodeJS.Timeout | undefined
private readonly channelsMap = new Map<string, WithId<ChannelRecord>[]>()
private readonly channelsByWorkspace = new Map<string, WithId<ChannelRecord>[]>()
private readonly channelById = new Map<ObjectId, WithId<ChannelRecord>>()
private readonly workspaceInfoById = new Map<string, WorkspaceInfo>()
private constructor (
@@ -58,12 +60,19 @@ export class PlatformWorker {
private readonly repliesStorage: Collection<ReplyRecord>,
private readonly channelsStorage: Collection<ChannelRecord>
) {
this.intervalId = setInterval(
this.otpIntervalId = setInterval(
() => {
void otpStorage.deleteMany({ expires: { $lte: Date.now() } })
},
3 * 60 * 1000
)
this.clearIntervalId = setInterval(
() => {
this.channelsByWorkspace.clear()
this.channelById.clear()
},
60 * 60 * 1000
)
}
public async getUsersToDisconnect (): Promise<UserRecord[]> {
@@ -75,8 +84,11 @@ export class PlatformWorker {
}
async close (): Promise<void> {
if (this.intervalId !== undefined) {
clearInterval(this.intervalId)
if (this.otpIntervalId !== undefined) {
clearInterval(this.otpIntervalId)
}
if (this.clearIntervalId !== undefined) {
clearInterval(this.clearIntervalId)
}
}
@@ -254,14 +266,32 @@ export class PlatformWorker {
async getChannels (email: string, workspace: string): Promise<WithId<ChannelRecord>[]> {
const key = `${email}:${workspace}`
if (this.channelsMap.has(key)) {
return this.channelsMap.get(key) ?? []
if (this.channelsByWorkspace.has(key)) {
return this.channelsByWorkspace.get(key) ?? []
}
const res = await this.channelsStorage
.find({ workspace, email }, { sort: { name: SortingOrder.Ascending } })
.toArray()
this.channelsMap.set(key, res)
this.channelsByWorkspace.set(key, res)
for (const channel of res) {
this.channelById.set(channel._id, channel)
}
return res
}
async getChannel (email: string, channelId: ObjectId): Promise<WithId<ChannelRecord> | undefined> {
if (this.channelById.has(channelId)) {
const channel = this.channelById.get(channelId)
return channel !== undefined && channel.email === email ? channel : undefined
}
const res = (await this.channelsStorage.findOne({ _id: channelId, email })) ?? undefined
if (res !== undefined) {
this.channelById.set(res._id, res)
}
return res
}
@@ -317,7 +347,12 @@ export class PlatformWorker {
await this.channelsStorage.deleteMany({ _id: { $in: toDelete.map((c) => c._id) } })
}
this.channelsMap.delete(`${email}:${workspace}`)
this.channelsByWorkspace.delete(`${email}:${workspace}`)
for (const [key, channel] of this.channelById.entries()) {
if (channel.email === email) {
this.channelById.delete(key)
}
}
}
async getWorkspaceInfo (workspaceId: string): Promise<WorkspaceInfo | undefined> {