mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-08 18:57:42 +02:00
Merge remote-tracking branch 'origin/develop' into staging
Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Vendored
+30
-3
@@ -35,9 +35,11 @@
|
||||
"args": ["src/__start.ts"],
|
||||
"env": {
|
||||
"FULLTEXT_URL": "http://localhost:4700",
|
||||
"MONGO_URL": "mongodb://localhost:27017",
|
||||
"DB_URL": "mongodb://localhost:27017",
|
||||
// "MONGO_URL": "mongodb://localhost:27017",
|
||||
// "DB_URL": "mongodb://localhost:27017",
|
||||
// "DB_URL": "postgresql://postgres:example@localhost:5432",
|
||||
"DB_URL": "postgresql://root@host.docker.internal:26257/defaultdb?sslmode=disable",
|
||||
"SERVER_PORT": "3332",
|
||||
"APM_SERVER_URL2": "http://localhost:8200",
|
||||
"METRICS_CONSOLE": "false",
|
||||
"METRICS_FILE": "${workspaceRoot}/metrics.txt", // Show metrics in console evert 30 seconds.,
|
||||
@@ -253,7 +255,7 @@
|
||||
"name": "Debug backup tool",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"args": ["src/__start.ts", "backup", "../temp/backup-test", "platform", "--force"],
|
||||
"args": ["src/__start.ts", "backup-restore", "../../../hardware/dump/alex-staff-agency", "w-haiodo-alex-staff-c-673ee7ab-87df5406ea-2b8b4d", "--skip", "blob"],
|
||||
"env": {
|
||||
"MINIO_ACCESS_KEY": "minioadmin",
|
||||
"MINIO_SECRET_KEY": "minioadmin",
|
||||
@@ -295,6 +297,31 @@
|
||||
"outputCapture": "std",
|
||||
"cwd": "${workspaceRoot}/dev/tool"
|
||||
},
|
||||
{
|
||||
"name": "Debug tool upgrade PG(tests)",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"args": ["src/__start.ts", "upgrade", "--force"],
|
||||
"env": {
|
||||
"SERVER_SECRET": "secret",
|
||||
"MINIO_ACCESS_KEY": "minioadmin",
|
||||
"MINIO_SECRET_KEY": "minioadmin",
|
||||
"MINIO_ENDPOINT": "localhost:9002",
|
||||
"TRANSACTOR_URL": "ws://localhost:3334",
|
||||
"ACCOUNT_DB_URL": "postgresql://postgres:example@localhost:5433",
|
||||
"DB_URL": "postgresql://postgres:example@localhost:5433",
|
||||
"MONGO_URL": "mongodb://localhost:27018",
|
||||
"ACCOUNTS_URL": "http://localhost:3003",
|
||||
"TELEGRAM_DATABASE": "telegram-service",
|
||||
"ELASTIC_URL": "http://localhost:9201",
|
||||
"REKONI_URL": "http://localhost:4004",
|
||||
"MODEL_VERSION": "0.6.287"
|
||||
},
|
||||
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"],
|
||||
"sourceMaps": true,
|
||||
"outputCapture": "std",
|
||||
"cwd": "${workspaceRoot}/dev/tool"
|
||||
},
|
||||
{
|
||||
"name": "Debug tool move",
|
||||
"type": "node",
|
||||
|
||||
Generated
+18
-23
@@ -1075,7 +1075,7 @@ dependencies:
|
||||
version: file:projects/time-resources.tgz(@tiptap/core@2.6.6)(@tiptap/pm@2.6.6)(@types/node@20.11.19)(esbuild@0.20.1)(postcss-load-config@4.0.2)(postcss@8.4.35)(ts-node@10.9.2)
|
||||
'@rush-temp/tool':
|
||||
specifier: file:./projects/tool.tgz
|
||||
version: file:projects/tool.tgz(bufferutil@4.0.8)(utf-8-validate@6.0.4)
|
||||
version: file:projects/tool.tgz
|
||||
'@rush-temp/tracker':
|
||||
specifier: file:./projects/tracker.tgz
|
||||
version: file:projects/tracker.tgz(@types/node@20.11.19)(esbuild@0.20.1)(ts-node@10.9.2)
|
||||
@@ -1766,9 +1766,6 @@ dependencies:
|
||||
pdfjs-dist:
|
||||
specifier: 2.12.313
|
||||
version: 2.12.313
|
||||
pg:
|
||||
specifier: 8.12.0
|
||||
version: 8.12.0
|
||||
png-chunks-extract:
|
||||
specifier: ^1.0.0
|
||||
version: 1.0.0
|
||||
@@ -1782,8 +1779,8 @@ dependencies:
|
||||
specifier: ^7.0.2
|
||||
version: 7.3.4(postcss@8.4.35)(typescript@5.3.3)(webpack@5.90.3)
|
||||
postgres:
|
||||
specifier: ^3.4.4
|
||||
version: 3.4.4
|
||||
specifier: ^3.4.5
|
||||
version: 3.4.5
|
||||
posthog-js:
|
||||
specifier: ~1.122.0
|
||||
version: 1.122.0
|
||||
@@ -16887,8 +16884,8 @@ packages:
|
||||
resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==}
|
||||
dev: false
|
||||
|
||||
/postgres@3.4.4:
|
||||
resolution: {integrity: sha512-IbyN+9KslkqcXa8AO9fxpk97PA4pzewvpi2B3Dwy9u4zpV32QicaEdgmF3eSQUzdRk7ttDHQejNgAEr4XoeH4A==}
|
||||
/postgres@3.4.5:
|
||||
resolution: {integrity: sha512-cDWgoah1Gez9rN3H4165peY9qfpEo+SA61oQv65O3cRUE1pOEoJWwddwcqKE8XZYjbblOJlYDlLV4h67HrEVDg==}
|
||||
engines: {node: '>=12'}
|
||||
dev: false
|
||||
|
||||
@@ -21012,7 +21009,7 @@ packages:
|
||||
dev: false
|
||||
|
||||
file:projects/account.tgz(@types/node@20.11.19)(esbuild@0.20.1)(ts-node@10.9.2):
|
||||
resolution: {integrity: sha512-7ojvPVqMSMXTMLyJeAIRk/7DFH31sUkEO0taWQx2aMHp7D5pfaIhFwWUFEwIAiszGSxb45SHxQNJqeRZ/+IDdw==, tarball: file:projects/account.tgz}
|
||||
resolution: {integrity: sha512-R9WSNuYeIemHJ7eAv7sCyf8IZHZott9CY1b/KJLAknaJfozXLQ6yOYszJjS/iWQRPl4TFFLgYsQaLWd1gDiqww==, tarball: file:projects/account.tgz}
|
||||
id: file:projects/account.tgz
|
||||
name: '@rush-temp/account'
|
||||
version: 0.0.0
|
||||
@@ -21029,7 +21026,7 @@ packages:
|
||||
jest: 29.7.0(@types/node@20.11.19)(ts-node@10.9.2)
|
||||
mongodb: 6.10.0
|
||||
otp-generator: 4.0.1
|
||||
postgres: 3.4.4
|
||||
postgres: 3.4.5
|
||||
prettier: 3.2.5
|
||||
ts-jest: 29.1.2(esbuild@0.20.1)(jest@29.7.0)(typescript@5.3.3)
|
||||
typescript: 5.3.3
|
||||
@@ -22174,7 +22171,7 @@ packages:
|
||||
dev: false
|
||||
|
||||
file:projects/cloud-datalake.tgz(@types/node@20.11.19)(bufferutil@4.0.8)(esbuild@0.20.1)(ts-node@10.9.2)(utf-8-validate@6.0.4):
|
||||
resolution: {integrity: sha512-KdKIEaVTjeWtCEUYfkvyrIYxglGcfM8iICOnTxRzPiVmOIP3kswu7PaUKeH/xeFZQcvRBvhtNI7XT0dBr5mODA==, tarball: file:projects/cloud-datalake.tgz}
|
||||
resolution: {integrity: sha512-4xq66fA2IsBhSePKZhWWfD1oCMF7eavO8dkefUDupGqhNUr/gv+4zPrsvoJZ2x5ZozqNvBHzbDQQeIuxg1uxNQ==, tarball: file:projects/cloud-datalake.tgz}
|
||||
id: file:projects/cloud-datalake.tgz
|
||||
name: '@rush-temp/cloud-datalake'
|
||||
version: 0.0.0
|
||||
@@ -22191,7 +22188,7 @@ packages:
|
||||
eslint-plugin-promise: 6.1.1(eslint@8.56.0)
|
||||
itty-router: 5.0.18
|
||||
jest: 29.7.0(@types/node@20.11.19)(ts-node@10.9.2)
|
||||
postgres: 3.4.4
|
||||
postgres: 3.4.5
|
||||
prettier: 3.2.5
|
||||
ts-jest: 29.1.2(esbuild@0.20.1)(jest@29.7.0)(typescript@5.6.2)
|
||||
typescript: 5.6.2
|
||||
@@ -27301,7 +27298,7 @@ packages:
|
||||
dev: false
|
||||
|
||||
file:projects/postgres.tgz(esbuild@0.20.1)(ts-node@10.9.2):
|
||||
resolution: {integrity: sha512-/6jhoJPjD7X4j5u87Epy0kCDI5gedKzlzCM/anvCuB57Igtpi6Ji1cX6LrzyJ33gbswI8tlygVs4miiYWToa5g==, tarball: file:projects/postgres.tgz}
|
||||
resolution: {integrity: sha512-L+wKYPpR4mHAuZkVGw2tuKwfngukoXRgeSCMf5TOn5bIFkwqV+HUpi0O4iDCIizyFvGftWdPQARYFRvRa0qlXg==, tarball: file:projects/postgres.tgz}
|
||||
id: file:projects/postgres.tgz
|
||||
name: '@rush-temp/postgres'
|
||||
version: 0.0.0
|
||||
@@ -27318,7 +27315,7 @@ packages:
|
||||
eslint-plugin-promise: 6.1.1(eslint@8.56.0)
|
||||
jest: 29.7.0(@types/node@20.11.19)(ts-node@10.9.2)
|
||||
pg: 8.12.0
|
||||
postgres: 3.4.4
|
||||
postgres: 3.4.5
|
||||
prettier: 3.2.5
|
||||
ts-jest: 29.1.2(esbuild@0.20.1)(jest@29.7.0)(typescript@5.3.3)
|
||||
typescript: 5.3.3
|
||||
@@ -27329,7 +27326,6 @@ packages:
|
||||
- babel-plugin-macros
|
||||
- esbuild
|
||||
- node-notifier
|
||||
- pg-native
|
||||
- supports-color
|
||||
- ts-node
|
||||
dev: false
|
||||
@@ -31936,9 +31932,8 @@ packages:
|
||||
- ts-node
|
||||
dev: false
|
||||
|
||||
file:projects/tool.tgz(bufferutil@4.0.8)(utf-8-validate@6.0.4):
|
||||
resolution: {integrity: sha512-jFXGlkB1O4yIpKHye9XX55H4xZNemZfh3KaoCr5CKcaNIE5qZV8PGRihn2SuMNt5Y10PP2cUMXhJcBEO8tfNPQ==, tarball: file:projects/tool.tgz}
|
||||
id: file:projects/tool.tgz
|
||||
file:projects/tool.tgz:
|
||||
resolution: {integrity: sha512-J1h1FNTEQWAGhkKNN3srbnXslsYEAoTnynbgY+6H1XzfABHP8gP0+g6aySbSfMn2HDM8TKfyPh/o9DGHs3tEfw==, tarball: file:projects/tool.tgz}
|
||||
name: '@rush-temp/tool'
|
||||
version: 0.0.0
|
||||
dependencies:
|
||||
@@ -31947,11 +31942,11 @@ packages:
|
||||
'@types/mime-types': 2.1.4
|
||||
'@types/minio': 7.0.18
|
||||
'@types/node': 20.11.19
|
||||
'@types/pg': 8.11.6
|
||||
'@types/request': 2.48.12
|
||||
'@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)
|
||||
bufferutil: 4.0.8
|
||||
commander: 8.3.0
|
||||
cross-env: 7.0.3
|
||||
csv-parse: 5.1.0
|
||||
@@ -31968,12 +31963,14 @@ packages:
|
||||
libphonenumber-js: 1.10.56
|
||||
mime-types: 2.1.35
|
||||
mongodb: 6.10.0
|
||||
pg: 8.12.0
|
||||
postgres: 3.4.4
|
||||
msgpackr: 1.11.0
|
||||
msgpackr-extract: 3.0.3
|
||||
postgres: 3.4.5
|
||||
prettier: 3.2.5
|
||||
ts-jest: 29.1.2(esbuild@0.20.1)(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
|
||||
utf-8-validate: 6.0.4
|
||||
ws: 8.18.0(bufferutil@4.0.8)(utf-8-validate@6.0.4)
|
||||
transitivePeerDependencies:
|
||||
- '@aws-sdk/credential-providers'
|
||||
@@ -31984,7 +31981,6 @@ packages:
|
||||
- '@swc/wasm'
|
||||
- babel-jest
|
||||
- babel-plugin-macros
|
||||
- bufferutil
|
||||
- gcp-metadata
|
||||
- kerberos
|
||||
- mongodb-client-encryption
|
||||
@@ -31992,7 +31988,6 @@ packages:
|
||||
- snappy
|
||||
- socks
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
dev: false
|
||||
|
||||
file:projects/tracker-assets.tgz(esbuild@0.20.1)(ts-node@10.9.2):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
FILES=$(git diff origin/main --name-only --diff-filter=ACMR | sed 's| |\\ |g')
|
||||
FILES=$(git diff origin/develop --name-only --diff-filter=ACMR | sed 's| |\\ |g')
|
||||
[ -z "$FILES" ] && exit 0
|
||||
|
||||
roots=$(rush list -p --json | grep "path" | cut -f 2 -d ':' | cut -f 2 -d '"')
|
||||
|
||||
@@ -3,6 +3,8 @@ MONGO_URL="mongodb://127.0.0.1:27017"
|
||||
DAYS=365
|
||||
dump='dump'
|
||||
|
||||
trap "exit" INT
|
||||
|
||||
mkdir -p ${dump}
|
||||
mkdir -p ${dump}/workspaces
|
||||
|
||||
@@ -14,15 +16,10 @@ mongosh ${MONGO_URL} --eval "$get_dbs" > ${dump}/databases.list
|
||||
mongodump --uri="${MONGO_URL}" --gzip --db account --archive="${dump}/account.gz"
|
||||
mongodump --uri="${MONGO_URL}" --gzip --db '%ai-bot' --archive="${dump}/ai-bot.gz"
|
||||
mongodump --uri="${MONGO_URL}" --gzip --db '%github' --archive="${dump}/github.gz"
|
||||
|
||||
echo '#restore script' > ${dump}/restore.sh
|
||||
echo "MONGO_URL=\"${MONGO_URL}\"" >> ${dump}/restore.sh
|
||||
echo "do_drop=" >> ${dump}/restore.sh
|
||||
echo "#do_drop=--drop" >> ${dump}/restore.sh
|
||||
|
||||
echo 'mongorestore --uri="${MONGO_URL}" --gzip ${do_drop} --nsFrom "account" --nsTo "account" --archive=./account.gz ' >> ${dump}/restore.sh
|
||||
echo 'mongorestore --uri="${MONGO_URL}" --gzip ${do_drop} --nsFrom "%ai-bot" --nsTo "%ai-bot" --archive=./ai-bot.gz ' >> ${dump}/restore.sh
|
||||
echo 'mongorestore --uri="${MONGO_URL}" --gzip ${do_drop} --nsFrom "%github" --nsTo "%github" --archive=./github.gz ' >> ${dump}/restore.sh
|
||||
mongodump --uri="${MONGO_URL}" --gzip --db 'calendar-service' --archive="${dump}/calendar-service.gz"
|
||||
mongodump --uri="${MONGO_URL}" --gzip --db 'analytics-collector' --archive="${dump}/analytics-collector.gz"
|
||||
mongodump --uri="${MONGO_URL}" --gzip --db 'gmail-service' --archive="${dump}/gmail-service.gz"
|
||||
mongodump --uri="${MONGO_URL}" --gzip --db 'telegram-service' --archive="${dump}/telegram-service.gz"
|
||||
|
||||
while IFS= read -r line; do
|
||||
arr=($line)
|
||||
@@ -35,6 +32,5 @@ while IFS= read -r line; do
|
||||
else
|
||||
mongodump --uri="$MONGO_URL" --gzip --db $db --archive=$aName
|
||||
fi
|
||||
echo "mongorestore --uri=\"\${MONGO_URL}\" --gzip \${do_drop} --nsFrom \"${db}\" --nsTo \"${db}\" --archive=\"./workspaces/${db}-${lastVisit}.gz\"" >> ${dump}/restore.sh
|
||||
done < ${dump}/databases.list
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
MONGO_URL="mongodb://127.0.0.1:27017"
|
||||
do_drop=
|
||||
#export do_drop=--drop
|
||||
|
||||
postfix=
|
||||
#postfix='-huly'
|
||||
|
||||
trap "exit" INT
|
||||
|
||||
mongorestore --uri="${MONGO_URL}" --gzip ${do_drop} --nsFrom "account.*" --nsTo "%account${postfix}.*" --archive=./account.gz
|
||||
mongorestore --uri="${MONGO_URL}" --gzip ${do_drop} --nsFrom "%ai-bot.*" --nsTo "%ai-bot${postfix}.*" --archive=./ai-bot.gz
|
||||
mongorestore --uri="${MONGO_URL}" --gzip ${do_drop} --nsFrom "%github.*" --nsTo "%github${postfix}.*" --archive=./github.gz
|
||||
mongorestore --uri="${MONGO_URL}" --gzip ${do_drop} --nsFrom "calendar-service.*" --nsTo "%calendar-service${postfix}.*" --archive=./calendar-service.gz
|
||||
mongorestore --uri="${MONGO_URL}" --gzip ${do_drop} --nsFrom "analytics-collector.*" --nsTo "%analytics-collector${postfix}.*" --archive=./analytics-collector.gz
|
||||
mongorestore --uri="${MONGO_URL}" --gzip ${do_drop} --nsFrom "gmail-service.*" --nsTo "%gmail-service${postfix}.*" --archive=./gmail-service.gz
|
||||
mongorestore --uri="${MONGO_URL}" --gzip ${do_drop} --nsFrom "telegram-service.*" --nsTo "%telegram-service${postfix}.*" --archive=./telegram-service.gz
|
||||
|
||||
|
||||
while IFS= read -r line; do
|
||||
arr=($line)
|
||||
db="${arr[0]}"
|
||||
lastVisit="${arr[1]}"
|
||||
aName="./workspaces/$db-$lastVisit.gz"
|
||||
stampName="./workspaces/$db-$lastVisit.gz.restored"
|
||||
if [ -f $aName ]; then
|
||||
if [ -f $stampName ]; then
|
||||
echo "DB already restored $aName"
|
||||
else
|
||||
echo "Restoring DB $aName"
|
||||
mongorestore --uri="${MONGO_URL}" --gzip ${do_drop} --nsFrom "${db}" --nsTo "${db}" --archive="./workspaces/${db}-${lastVisit}.gz"
|
||||
touch ${stampName}
|
||||
fi
|
||||
fi
|
||||
done < ./databases.list
|
||||
@@ -2,6 +2,8 @@ FROM node:20
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
RUN npm install --ignore-scripts=false --verbose bufferutil utf-8-validate @mongodb-js/zstd snappy msgpackr msgpackr-extract --unsafe-perm
|
||||
|
||||
COPY bundle/bundle.js ./
|
||||
|
||||
CMD [ "bash" ]
|
||||
|
||||
@@ -158,7 +158,11 @@
|
||||
"libphonenumber-js": "^1.9.46",
|
||||
"mime-types": "~2.1.34",
|
||||
"mongodb": "^6.10.0",
|
||||
"postgres": "^3.4.4",
|
||||
"ws": "^8.18.0"
|
||||
"postgres": "^3.4.5",
|
||||
"ws": "^8.18.0",
|
||||
"bufferutil": "^4.0.8",
|
||||
"utf-8-validate": "^6.0.4",
|
||||
"msgpackr": "^1.11.0",
|
||||
"msgpackr-extract": "^3.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
+35
-26
@@ -127,6 +127,7 @@ import { fixJsonMarkup, migrateMarkup } from './markup'
|
||||
import { fixMixinForeignAttributes, showMixinForeignAttributes } from './mixin'
|
||||
import { fixAccountEmails, renameAccount } from './renameAccount'
|
||||
import { moveFiles, showLostFiles } from './storage'
|
||||
import { getModelVersion } from '@hcengineering/model-all'
|
||||
|
||||
const colorConstants = {
|
||||
colorRed: '\u001b[31m',
|
||||
@@ -217,7 +218,7 @@ export function devTool (
|
||||
|
||||
program.command('version').action(() => {
|
||||
console.log(
|
||||
`tools git_version: ${process.env.GIT_REVISION ?? ''} model_version: ${process.env.MODEL_VERSION ?? ''}`
|
||||
`tools git_version: ${process.env.GIT_REVISION ?? ''} model_version: ${process.env.MODEL_VERSION ?? ''} ${JSON.stringify(getModelVersion())}`
|
||||
)
|
||||
})
|
||||
|
||||
@@ -363,34 +364,42 @@ export function devTool (
|
||||
.requiredOption('-w, --workspaceName <workspaceName>', 'Workspace name')
|
||||
.option('-e, --email <email>', 'Author email', 'platform@email.com')
|
||||
.option('-i, --init <ws>', 'Init from workspace')
|
||||
.option('-r, --region <region>', 'Region')
|
||||
.option('-b, --branding <key>', 'Branding key')
|
||||
.action(async (workspace, cmd: { email: string, workspaceName: string, init?: string, branding?: string }) => {
|
||||
const { dbUrl, txes, version, migrateOperations } = prepareTools()
|
||||
await withDatabase(dbUrl, async (db) => {
|
||||
const measureCtx = new MeasureMetricsContext('create-workspace', {})
|
||||
const brandingObj =
|
||||
cmd.branding !== undefined || cmd.init !== undefined ? { key: cmd.branding, initWorkspace: cmd.init } : null
|
||||
const wsInfo = await createWorkspaceRecord(measureCtx, db, brandingObj, cmd.email, cmd.workspaceName, workspace)
|
||||
.action(
|
||||
async (
|
||||
workspace,
|
||||
cmd: { email: string, workspaceName: string, init?: string, branding?: string, region?: string }
|
||||
) => {
|
||||
const { dbUrl, txes, version, migrateOperations } = prepareTools()
|
||||
await withDatabase(dbUrl, async (db) => {
|
||||
const measureCtx = new MeasureMetricsContext('create-workspace', {})
|
||||
const brandingObj =
|
||||
cmd.branding !== undefined || cmd.init !== undefined ? { key: cmd.branding, initWorkspace: cmd.init } : null
|
||||
const wsInfo = await createWorkspaceRecord(
|
||||
measureCtx,
|
||||
db,
|
||||
brandingObj,
|
||||
cmd.email,
|
||||
cmd.workspaceName,
|
||||
workspace,
|
||||
cmd.region,
|
||||
'manual-creation'
|
||||
)
|
||||
|
||||
// update the record so it's not taken by one of the workers for the next 60 seconds
|
||||
await updateWorkspace(db, wsInfo, {
|
||||
mode: 'creating',
|
||||
progress: 0,
|
||||
lastProcessingTime: Date.now() + 1000 * 60
|
||||
await createWorkspace(measureCtx, version, brandingObj, wsInfo, txes, migrateOperations, undefined, true)
|
||||
|
||||
await updateWorkspace(db, wsInfo, {
|
||||
mode: 'active',
|
||||
progress: 100,
|
||||
disabled: false,
|
||||
version
|
||||
})
|
||||
|
||||
console.log('create-workspace done')
|
||||
})
|
||||
|
||||
await createWorkspace(measureCtx, version, brandingObj, wsInfo, txes, migrateOperations, undefined, true)
|
||||
|
||||
await updateWorkspace(db, wsInfo, {
|
||||
mode: 'active',
|
||||
progress: 100,
|
||||
disabled: false,
|
||||
version
|
||||
})
|
||||
|
||||
console.log('create-workspace done')
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
program
|
||||
.command('set-user-role <email> <workspace> <role>')
|
||||
|
||||
@@ -114,7 +114,7 @@ interface ConfigurablePlugin extends Omit<Data<PluginConfiguration>, 'pluginId'
|
||||
type BuilderConfig = [(b: Builder) => void, Plugin] | [(b: Builder) => void, Plugin, ConfigurablePlugin | undefined]
|
||||
|
||||
export function getModelVersion (): Data<Version> {
|
||||
const rawVersion = (process.env.MODEL_VERSION ?? '0.6.0').trim().replace('v', '').split('.')
|
||||
const rawVersion = (process.env.MODEL_VERSION ?? '0.6.0').replace('"', '').trim().replace('v', '').split('.')
|
||||
if (rawVersion.length === 3) {
|
||||
return {
|
||||
major: parseInt(rawVersion[0]),
|
||||
|
||||
@@ -14,9 +14,10 @@
|
||||
//
|
||||
|
||||
import activity from '@hcengineering/activity'
|
||||
import type { Attachment, AttachmentMetadata, Photo, SavedAttachments } from '@hcengineering/attachment'
|
||||
import { IndexKind, type Blob, type Domain, type Ref } from '@hcengineering/core'
|
||||
import type { Attachment, AttachmentMetadata, Drawing, Photo, SavedAttachments } from '@hcengineering/attachment'
|
||||
import { IndexKind, type Blob, type Class, type Doc, type Domain, type Ref } from '@hcengineering/core'
|
||||
import {
|
||||
Hidden,
|
||||
Index,
|
||||
Model,
|
||||
Prop,
|
||||
@@ -28,10 +29,11 @@ import {
|
||||
UX,
|
||||
type Builder
|
||||
} from '@hcengineering/model'
|
||||
import core, { TAttachedDoc } from '@hcengineering/model-core'
|
||||
import core, { TAttachedDoc, TDoc } from '@hcengineering/model-core'
|
||||
import preference, { TPreference } from '@hcengineering/model-preference'
|
||||
import view, { createAction } from '@hcengineering/model-view'
|
||||
import workbench, { WidgetType } from '@hcengineering/workbench'
|
||||
import { getEmbeddedLabel } from '@hcengineering/platform'
|
||||
import presentation from '@hcengineering/model-presentation'
|
||||
|
||||
import attachment from './plugin'
|
||||
@@ -80,8 +82,24 @@ export class TSavedAttachments extends TPreference implements SavedAttachments {
|
||||
declare attachedTo: Ref<Attachment>
|
||||
}
|
||||
|
||||
@Model(attachment.class.Drawing, core.class.Doc, DOMAIN_ATTACHMENT)
|
||||
export class TDrawing extends TDoc implements Drawing {
|
||||
@Prop(TypeRef(core.class.Doc), getEmbeddedLabel('Parent'))
|
||||
@Index(IndexKind.Indexed)
|
||||
@Hidden()
|
||||
parent!: Ref<Doc>
|
||||
|
||||
@Prop(TypeRef(core.class.Class), getEmbeddedLabel('Parent class'))
|
||||
@Index(IndexKind.Indexed)
|
||||
@Hidden()
|
||||
parentClass!: Ref<Class<Doc>>
|
||||
|
||||
@Prop(TypeString(), getEmbeddedLabel('Content'))
|
||||
content?: string
|
||||
}
|
||||
|
||||
export function createModel (builder: Builder): void {
|
||||
builder.createModel(TAttachment, TPhoto, TSavedAttachments)
|
||||
builder.createModel(TAttachment, TDrawing, TPhoto, TSavedAttachments)
|
||||
|
||||
builder.mixin(attachment.class.Attachment, core.class.Class, view.mixin.ObjectPresenter, {
|
||||
presenter: attachment.component.AttachmentPresenter
|
||||
|
||||
+35
-16
@@ -16,14 +16,15 @@
|
||||
import contact, { type Employee, type Person } from '@hcengineering/contact'
|
||||
import {
|
||||
AccountRole,
|
||||
type CollaborativeDoc,
|
||||
type CollectionSize,
|
||||
DateRangeMode,
|
||||
type Doc,
|
||||
type Domain,
|
||||
DOMAIN_TRANSIENT,
|
||||
IndexKind,
|
||||
type Ref,
|
||||
type CollaborativeDoc,
|
||||
type Doc,
|
||||
type Timestamp,
|
||||
type CollectionSize
|
||||
type Timestamp
|
||||
} from '@hcengineering/core'
|
||||
import {
|
||||
type DevicesPreference,
|
||||
@@ -32,16 +33,16 @@ import {
|
||||
type JoinRequest,
|
||||
loveId,
|
||||
type Meeting,
|
||||
type MeetingMinutes,
|
||||
type MeetingStatus,
|
||||
type Office,
|
||||
type ParticipantInfo,
|
||||
type RequestStatus,
|
||||
type Room,
|
||||
type RoomAccess,
|
||||
type RoomInfo,
|
||||
type RoomType,
|
||||
type RoomLanguage,
|
||||
type MeetingMinutes,
|
||||
type MeetingStatus
|
||||
type RoomType
|
||||
} from '@hcengineering/love'
|
||||
import {
|
||||
type Builder,
|
||||
@@ -53,18 +54,18 @@ import {
|
||||
Model,
|
||||
Prop,
|
||||
ReadOnly,
|
||||
TypeAny,
|
||||
TypeCollaborativeDoc,
|
||||
TypeDate,
|
||||
TypeRef,
|
||||
TypeString,
|
||||
TypeTimestamp,
|
||||
UX,
|
||||
TypeAny
|
||||
UX
|
||||
} from '@hcengineering/model'
|
||||
import calendar, { TEvent } from '@hcengineering/model-calendar'
|
||||
import core, { TAttachedDoc, TDoc } from '@hcengineering/model-core'
|
||||
import preference, { TPreference } from '@hcengineering/model-preference'
|
||||
import presentation from '@hcengineering/model-presentation'
|
||||
import view, { createAction } from '@hcengineering/model-view'
|
||||
import view, { createAction, createAttributePresenter } from '@hcengineering/model-view'
|
||||
import notification from '@hcengineering/notification'
|
||||
import { getEmbeddedLabel } from '@hcengineering/platform'
|
||||
import setting from '@hcengineering/setting'
|
||||
@@ -228,12 +229,12 @@ export class TMeetingMinutes extends TAttachedDoc implements MeetingMinutes, Tod
|
||||
@Prop(PropCollection(chunter.class.ChatMessage), activity.string.Messages)
|
||||
messages?: number
|
||||
|
||||
@Prop(TypeTimestamp(), love.string.MeetingStart, { editor: view.component.TimestampPresenter })
|
||||
@Prop(TypeDate(DateRangeMode.DATETIME), love.string.MeetingStart, { editor: view.component.DateTimePresenter })
|
||||
@ReadOnly()
|
||||
@Index(IndexKind.IndexedDsc)
|
||||
declare createdOn: Timestamp
|
||||
|
||||
@Prop(TypeTimestamp(), love.string.MeetingEnd)
|
||||
@Prop(TypeDate(DateRangeMode.DATETIME), love.string.MeetingEnd, { editor: view.component.DateTimePresenter })
|
||||
@ReadOnly()
|
||||
meetingEnd?: Timestamp
|
||||
|
||||
@@ -506,10 +507,10 @@ export function createModel (builder: Builder): void {
|
||||
config: [
|
||||
'',
|
||||
{ key: 'status', presenter: love.component.MeetingMinutesStatusPresenter, label: love.string.Status },
|
||||
'createdOn',
|
||||
'meetingEnd',
|
||||
{ key: 'messages', displayProps: { key: 'messages', suffix: true } },
|
||||
{ key: 'transcription', displayProps: { key: 'transcription', suffix: true } }
|
||||
{ key: 'transcription', displayProps: { key: 'transcription', suffix: true } },
|
||||
'createdOn',
|
||||
'meetingEnd'
|
||||
],
|
||||
configOptions: {
|
||||
hiddenKeys: ['description'],
|
||||
@@ -529,10 +530,13 @@ export function createModel (builder: Builder): void {
|
||||
config: [
|
||||
'',
|
||||
{ key: 'status', presenter: love.component.MeetingMinutesStatusPresenter, label: love.string.Status },
|
||||
{ key: 'messages', displayProps: { key: 'messages', suffix: true } },
|
||||
{ key: 'transcription', displayProps: { key: 'transcription', suffix: true } },
|
||||
'createdOn',
|
||||
'meetingEnd'
|
||||
],
|
||||
configOptions: {
|
||||
hiddenKeys: ['description'],
|
||||
sortable: true
|
||||
},
|
||||
variant: 'embedded'
|
||||
@@ -637,4 +641,19 @@ export function createModel (builder: Builder): void {
|
||||
builder.mixin(love.class.MeetingMinutes, core.class.Class, view.mixin.ObjectPanelFooter, {
|
||||
editor: love.component.PanelControlBar
|
||||
})
|
||||
|
||||
createAttributePresenter(
|
||||
builder,
|
||||
view.component.DateTimePresenter,
|
||||
love.class.MeetingMinutes,
|
||||
'createdOn',
|
||||
'attribute'
|
||||
)
|
||||
createAttributePresenter(
|
||||
builder,
|
||||
view.component.DateTimePresenter,
|
||||
love.class.MeetingMinutes,
|
||||
'meetingEnd',
|
||||
'attribute'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ export default mergeIds(viewId, view, {
|
||||
TimestampPresenter: '' as AnyComponent,
|
||||
DateEditor: '' as AnyComponent,
|
||||
DatePresenter: '' as AnyComponent,
|
||||
DateTimePresenter: '' as AnyComponent,
|
||||
TableBrowser: '' as AnyComponent,
|
||||
RolePresenter: '' as AnyComponent,
|
||||
YoutubePresenter: '' as AnyComponent,
|
||||
|
||||
@@ -34,15 +34,22 @@ import {
|
||||
AttachedData,
|
||||
Mixin,
|
||||
MixinUpdate,
|
||||
MixinData
|
||||
MixinData,
|
||||
generateId
|
||||
} from '@hcengineering/core'
|
||||
import client, { clientId } from '@hcengineering/client'
|
||||
import { addLocation, getResource } from '@hcengineering/platform'
|
||||
|
||||
import { login, selectWorkspace } from './account'
|
||||
import { type ServerConfig, loadServerConfig } from './config'
|
||||
import { type MarkupOperations, type MarkupFormat, type MarkupRef, createMarkupOperations } from './markup'
|
||||
import { type PlatformClient, type ConnectOptions } from './types'
|
||||
import {
|
||||
type MarkupFormat,
|
||||
type MarkupOperations,
|
||||
type MarkupRef,
|
||||
MarkupContent,
|
||||
createMarkupOperations
|
||||
} from './markup'
|
||||
import { type PlatformClient, type ConnectOptions, WithMarkup } from './types'
|
||||
|
||||
/**
|
||||
* Create platform client
|
||||
@@ -121,41 +128,62 @@ class PlatformClientImpl implements PlatformClient {
|
||||
await this.connection.close()
|
||||
}
|
||||
|
||||
// TxOperations
|
||||
private async processMarkup<T>(id: Ref<Doc>, data: WithMarkup<T>): Promise<T> {
|
||||
const result: any = {}
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (value instanceof MarkupContent) {
|
||||
result[key] = this.markup.uploadMarkup(id, key, value.content, value.kind)
|
||||
} else {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return result as T
|
||||
}
|
||||
|
||||
// DocOperations
|
||||
|
||||
async createDoc<T extends Doc>(
|
||||
_class: Ref<Class<T>>,
|
||||
space: Ref<Space>,
|
||||
attributes: Data<T>,
|
||||
attributes: WithMarkup<Data<T>>,
|
||||
id?: Ref<T>
|
||||
): Promise<Ref<T>> {
|
||||
return await this.client.createDoc(_class, space, attributes, id)
|
||||
id ??= generateId()
|
||||
const data = await this.processMarkup<Data<T>>(id, attributes)
|
||||
return await this.client.createDoc(_class, space, data, id)
|
||||
}
|
||||
|
||||
async updateDoc<T extends Doc>(
|
||||
_class: Ref<Class<T>>,
|
||||
space: Ref<Space>,
|
||||
objectId: Ref<T>,
|
||||
operations: DocumentUpdate<T>,
|
||||
operations: WithMarkup<DocumentUpdate<T>>,
|
||||
retrieve?: boolean
|
||||
): Promise<TxResult> {
|
||||
return await this.client.updateDoc(_class, space, objectId, operations, retrieve)
|
||||
const update = await this.processMarkup<DocumentUpdate<T>>(objectId, operations)
|
||||
return await this.client.updateDoc(_class, space, objectId, update, retrieve)
|
||||
}
|
||||
|
||||
async removeDoc<T extends Doc>(_class: Ref<Class<T>>, space: Ref<Space>, objectId: Ref<T>): Promise<TxResult> {
|
||||
return await this.client.removeDoc(_class, space, objectId)
|
||||
}
|
||||
|
||||
// CollectionOperations
|
||||
|
||||
async addCollection<T extends Doc, P extends AttachedDoc>(
|
||||
_class: Ref<Class<P>>,
|
||||
space: Ref<Space>,
|
||||
attachedTo: Ref<T>,
|
||||
attachedToClass: Ref<Class<T>>,
|
||||
collection: Extract<keyof T, string> | string,
|
||||
attributes: AttachedData<P>,
|
||||
attributes: WithMarkup<AttachedData<P>>,
|
||||
id?: Ref<P>
|
||||
): Promise<Ref<P>> {
|
||||
return await this.client.addCollection(_class, space, attachedTo, attachedToClass, collection, attributes, id)
|
||||
id ??= generateId()
|
||||
const data = await this.processMarkup<AttachedData<P>>(id, attributes)
|
||||
return await this.client.addCollection(_class, space, attachedTo, attachedToClass, collection, data, id)
|
||||
}
|
||||
|
||||
async updateCollection<T extends Doc, P extends AttachedDoc>(
|
||||
@@ -165,9 +193,10 @@ class PlatformClientImpl implements PlatformClient {
|
||||
attachedTo: Ref<T>,
|
||||
attachedToClass: Ref<Class<T>>,
|
||||
collection: Extract<keyof T, string> | string,
|
||||
operations: DocumentUpdate<P>,
|
||||
operations: WithMarkup<DocumentUpdate<P>>,
|
||||
retrieve?: boolean
|
||||
): Promise<Ref<T>> {
|
||||
const update = await this.processMarkup<DocumentUpdate<P>>(objectId, operations)
|
||||
return await this.client.updateCollection(
|
||||
_class,
|
||||
space,
|
||||
@@ -175,7 +204,7 @@ class PlatformClientImpl implements PlatformClient {
|
||||
attachedTo,
|
||||
attachedToClass,
|
||||
collection,
|
||||
operations,
|
||||
update,
|
||||
retrieve
|
||||
)
|
||||
}
|
||||
@@ -191,14 +220,17 @@ class PlatformClientImpl implements PlatformClient {
|
||||
return await this.client.removeCollection(_class, space, objectId, attachedTo, attachedToClass, collection)
|
||||
}
|
||||
|
||||
// MixinOperations
|
||||
|
||||
async createMixin<D extends Doc, M extends D>(
|
||||
objectId: Ref<D>,
|
||||
objectClass: Ref<Class<D>>,
|
||||
objectSpace: Ref<Space>,
|
||||
mixin: Ref<Mixin<M>>,
|
||||
attributes: MixinData<D, M>
|
||||
attributes: WithMarkup<MixinData<D, M>>
|
||||
): Promise<TxResult> {
|
||||
return await this.client.createMixin(objectId, objectClass, objectSpace, mixin, attributes)
|
||||
const data = await this.processMarkup<MixinData<D, M>>(objectId, attributes)
|
||||
return await this.client.createMixin(objectId, objectClass, objectSpace, mixin, data)
|
||||
}
|
||||
|
||||
async updateMixin<D extends Doc, M extends D>(
|
||||
@@ -206,9 +238,10 @@ class PlatformClientImpl implements PlatformClient {
|
||||
objectClass: Ref<Class<D>>,
|
||||
objectSpace: Ref<Space>,
|
||||
mixin: Ref<Mixin<M>>,
|
||||
attributes: MixinUpdate<D, M>
|
||||
attributes: WithMarkup<MixinUpdate<D, M>>
|
||||
): Promise<TxResult> {
|
||||
return await this.client.updateMixin(objectId, objectClass, objectSpace, mixin, attributes)
|
||||
const update = await this.processMarkup<MixinUpdate<D, M>>(objectId, attributes)
|
||||
return await this.client.updateMixin(objectId, objectClass, objectSpace, mixin, update)
|
||||
}
|
||||
|
||||
// Markup
|
||||
|
||||
@@ -21,6 +21,24 @@ export type MarkupRef = CollaborativeDoc
|
||||
/** @public */
|
||||
export type MarkupFormat = 'markup' | 'html' | 'markdown'
|
||||
|
||||
/** @public */
|
||||
export class MarkupContent {
|
||||
constructor (
|
||||
readonly content: string,
|
||||
readonly kind: MarkupFormat
|
||||
) {}
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export function html (content: string): MarkupContent {
|
||||
return new MarkupContent(content, 'html')
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export function markdown (content: string): MarkupContent {
|
||||
return new MarkupContent(content, 'markdown')
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides operations for managing markup (rich-text) content.
|
||||
* @public */
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
import { type ClientSocketFactory } from '@hcengineering/client'
|
||||
import {
|
||||
CollaborativeDoc,
|
||||
type AttachedData,
|
||||
type AttachedDoc,
|
||||
type Class,
|
||||
@@ -34,7 +35,18 @@ import {
|
||||
type TxResult,
|
||||
type WithLookup
|
||||
} from '@hcengineering/core'
|
||||
import { type MarkupOperations } from './markup'
|
||||
import { type MarkupContent, type MarkupOperations } from './markup'
|
||||
|
||||
type WithPropertyType<T, X, Y> = {
|
||||
[P in keyof T]: T[P] extends X ? Y : T[P]
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export type WithMarkup<T> = WithPropertyType<
|
||||
WithPropertyType<T, CollaborativeDoc | undefined, MarkupContent | undefined>,
|
||||
CollaborativeDoc,
|
||||
MarkupContent
|
||||
>
|
||||
|
||||
/**
|
||||
* Platform API client
|
||||
@@ -77,7 +89,7 @@ export interface DocOperations {
|
||||
createDoc: <T extends Doc>(
|
||||
_class: Ref<Class<T>>,
|
||||
space: Ref<Space>,
|
||||
attributes: Data<T>,
|
||||
attributes: WithMarkup<Data<T>>,
|
||||
id?: Ref<T>
|
||||
) => Promise<Ref<T>>
|
||||
|
||||
@@ -85,7 +97,7 @@ export interface DocOperations {
|
||||
_class: Ref<Class<T>>,
|
||||
space: Ref<Space>,
|
||||
objectId: Ref<T>,
|
||||
operations: DocumentUpdate<T>,
|
||||
operations: WithMarkup<DocumentUpdate<T>>,
|
||||
retrieve?: boolean
|
||||
) => Promise<TxResult>
|
||||
|
||||
@@ -102,7 +114,7 @@ export interface CollectionOperations {
|
||||
attachedTo: Ref<T>,
|
||||
attachedToClass: Ref<Class<T>>,
|
||||
collection: Extract<keyof T, string> | string,
|
||||
attributes: AttachedData<P>,
|
||||
attributes: WithMarkup<AttachedData<P>>,
|
||||
id?: Ref<P>
|
||||
) => Promise<Ref<P>>
|
||||
|
||||
@@ -113,7 +125,7 @@ export interface CollectionOperations {
|
||||
attachedTo: Ref<T>,
|
||||
attachedToClass: Ref<Class<T>>,
|
||||
collection: Extract<keyof T, string> | string,
|
||||
operations: DocumentUpdate<P>,
|
||||
operations: WithMarkup<DocumentUpdate<P>>,
|
||||
retrieve?: boolean
|
||||
) => Promise<Ref<T>>
|
||||
|
||||
@@ -136,7 +148,7 @@ export interface MixinOperations {
|
||||
objectClass: Ref<Class<D>>,
|
||||
objectSpace: Ref<Space>,
|
||||
mixin: Ref<Mixin<M>>,
|
||||
attributes: MixinData<D, M>
|
||||
attributes: WithMarkup<MixinData<D, M>>
|
||||
) => Promise<TxResult>
|
||||
|
||||
updateMixin: <D extends Doc, M extends D>(
|
||||
@@ -144,7 +156,7 @@ export interface MixinOperations {
|
||||
objectClass: Ref<Class<D>>,
|
||||
objectSpace: Ref<Space>,
|
||||
mixin: Ref<Mixin<M>>,
|
||||
attributes: MixinUpdate<D, M>
|
||||
attributes: WithMarkup<MixinUpdate<D, M>>
|
||||
) => Promise<TxResult>
|
||||
}
|
||||
|
||||
|
||||
@@ -648,6 +648,7 @@ export interface DomainIndexConfiguration extends Doc {
|
||||
}
|
||||
|
||||
export type WorkspaceMode =
|
||||
| 'manual-creation'
|
||||
| 'pending-creation'
|
||||
| 'creating'
|
||||
| 'upgrading'
|
||||
|
||||
@@ -82,12 +82,13 @@
|
||||
return
|
||||
}
|
||||
|
||||
if (!dontUpdateRank && dragCardInitialRank !== dragCard.rank) {
|
||||
if (!dontUpdateRank && dragCardInitialRank !== dragCard.rank && dragCardInitialRank !== undefined) {
|
||||
const dragCardRank = dragCard.rank
|
||||
updates = {
|
||||
...updates,
|
||||
rank: dragCardRank
|
||||
}
|
||||
dragCard.rank = dragCardInitialRank
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await client.diffUpdate(dragCard, updates)
|
||||
@@ -175,6 +176,15 @@
|
||||
return false
|
||||
}
|
||||
|
||||
interface DragCardOverPos {
|
||||
dragCardId: Ref<Doc>
|
||||
dragCardPos: number
|
||||
overCardId: Ref<Doc>
|
||||
overCardPos: number
|
||||
}
|
||||
|
||||
let cardOverPos: DragCardOverPos | undefined
|
||||
|
||||
function cardDragOver (evt: CardDragEvent, object: Item, state: CategoryType): void {
|
||||
if (dragCard !== undefined && !dontUpdateRank) {
|
||||
const updates = getUpdateProps(dragCard, state)
|
||||
@@ -183,9 +193,29 @@
|
||||
}
|
||||
if (object._id !== dragCard._id) {
|
||||
let arr = getGroupByValues(groupByDocs, state) ?? []
|
||||
const dragCardIndex = arr.findIndex((p) => p._id === dragCard?._id)
|
||||
const targetIndex = arr.findIndex((p) => p._id === object._id)
|
||||
let dragCardIndex = -1
|
||||
let targetIndex = -1
|
||||
if (
|
||||
cardOverPos !== undefined &&
|
||||
cardOverPos.overCardId === object._id &&
|
||||
cardOverPos.dragCardId === dragCard._id
|
||||
) {
|
||||
dragCardIndex = cardOverPos.dragCardPos
|
||||
targetIndex = cardOverPos.overCardPos
|
||||
} else {
|
||||
dragCardIndex = arr.findIndex((p) => p._id === dragCard?._id)
|
||||
targetIndex = arr.findIndex((p) => p._id === object._id)
|
||||
cardOverPos = {
|
||||
dragCardId: dragCard._id,
|
||||
dragCardPos: dragCardIndex,
|
||||
overCardId: object._id,
|
||||
overCardPos: targetIndex
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
dragCardIndex !== -1 &&
|
||||
targetIndex !== -1 &&
|
||||
dragswap(evt, targetIndex, dragCardIndex) &&
|
||||
arr[targetIndex] !== undefined &&
|
||||
arr[dragCardIndex] !== undefined
|
||||
@@ -194,20 +224,28 @@
|
||||
arr = [...arr.slice(0, targetIndex), dragCard, ...arr.slice(targetIndex)]
|
||||
setGroupByValues(groupByDocs, state, arr)
|
||||
groupByDocs = groupByDocs
|
||||
cardOverPos = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
function cardDrop (evt: CardDragEvent, object: Item, state: CategoryType): void {
|
||||
|
||||
async function cardDrop (evt: CardDragEvent, object: Item, state: CategoryType): Promise<void> {
|
||||
if (!dontUpdateRank && dragCard !== undefined) {
|
||||
const arr = getGroupByValues(groupByDocs, state) ?? []
|
||||
const s = arr.findIndex((p) => p._id === dragCard?._id)
|
||||
if (s !== -1) {
|
||||
dragCard.rank = makeRank(arr[s - 1]?.rank, arr[s + 1]?.rank)
|
||||
const updates = getUpdateProps(dragCard, state)
|
||||
|
||||
if (updates === undefined) {
|
||||
await client.update(dragCard, { rank: dragCard.rank })
|
||||
}
|
||||
}
|
||||
}
|
||||
isDragging = false
|
||||
}
|
||||
|
||||
async function onDragStart (object: Item, state: CategoryType): Promise<void> {
|
||||
dragCardInitialState = state
|
||||
dragCardState = state
|
||||
@@ -383,7 +421,7 @@
|
||||
cardDragOver(evt, obj, state)
|
||||
}}
|
||||
cardDrop={(evt, obj) => {
|
||||
cardDrop(evt, obj, state)
|
||||
void cardDrop(evt, obj, state)
|
||||
}}
|
||||
{onDragStart}
|
||||
{showMenu}
|
||||
|
||||
@@ -84,21 +84,23 @@
|
||||
: state
|
||||
}
|
||||
|
||||
$: void limiter.add(async () => {
|
||||
docQuery.query(
|
||||
_class,
|
||||
groupQuery,
|
||||
(res) => {
|
||||
limitedObjects = toIdMap(res)
|
||||
},
|
||||
{ ...options, limit }
|
||||
)
|
||||
})
|
||||
$: docQuery.query(
|
||||
_class,
|
||||
groupQuery,
|
||||
(res) => {
|
||||
limitedObjects = toIdMap(res)
|
||||
},
|
||||
{ ...options, limit }
|
||||
)
|
||||
|
||||
function getObject (_id: Ref<DocWithRank>, limitedObjects: IdMap<DocWithRank>): DocWithRank | undefined {
|
||||
return limitedObjects.get(_id) ?? (_id === dragCard?._id ? dragCard : undefined)
|
||||
}
|
||||
</script>
|
||||
|
||||
{#each stateObjects as objectRef, i (objectRef._id)}
|
||||
{@const dragged = isDragging && objectRef._id === dragCard?._id}
|
||||
{@const object = limitedObjects.get(objectRef._id) ?? (objectRef._id === dragCard?._id ? dragCard : undefined)}
|
||||
{@const object = getObject(objectRef._id, limitedObjects)}
|
||||
{#if object !== undefined}
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
|
||||
@@ -33,7 +33,8 @@
|
||||
"Next": "Další",
|
||||
"FailedToPreview": "Náhled se nezdařil",
|
||||
"ContentType": "Typ obsahu",
|
||||
"ContentTypeNotSupported": "Náhled není dostupný pro tento typ obsahu"
|
||||
"ContentTypeNotSupported": "Náhled není dostupný pro tento typ obsahu",
|
||||
"StartDrawing": "Načmárejte"
|
||||
},
|
||||
"status": {
|
||||
"FileTooLarge": "Soubor je příliš velký"
|
||||
|
||||
@@ -33,7 +33,8 @@
|
||||
"Next": "Next",
|
||||
"FailedToPreview": "Failed to preview",
|
||||
"ContentType": "Content type",
|
||||
"ContentTypeNotSupported": "Preview is not available for this content type"
|
||||
"ContentTypeNotSupported": "Preview is not available for this content type",
|
||||
"StartDrawing": "Scribble over"
|
||||
},
|
||||
"status": {
|
||||
"FileTooLarge": "File too large"
|
||||
|
||||
@@ -33,7 +33,8 @@
|
||||
"Next": "Siguiente",
|
||||
"FailedToPreview": "Error al previsualizar",
|
||||
"ContentType": "Tipo de contenido",
|
||||
"ContentTypeNotSupported": "La vista previa no está disponible para este tipo de contenido"
|
||||
"ContentTypeNotSupported": "La vista previa no está disponible para este tipo de contenido",
|
||||
"StartDrawing": "Garabatear encima"
|
||||
},
|
||||
"status": {
|
||||
"FileTooLarge": "Archivo demasiado grande"
|
||||
|
||||
@@ -33,7 +33,8 @@
|
||||
"Next": "Suivant",
|
||||
"FailedToPreview": "Échec de l'aperçu",
|
||||
"ContentType": "Type de contenu",
|
||||
"ContentTypeNotSupported": "L'aperçu n'est pas disponible pour ce type de contenu"
|
||||
"ContentTypeNotSupported": "L'aperçu n'est pas disponible pour ce type de contenu",
|
||||
"StartDrawing": "Gribouiller dessus"
|
||||
},
|
||||
"status": {
|
||||
"FileTooLarge": "Fichier trop volumineux"
|
||||
|
||||
@@ -33,7 +33,8 @@
|
||||
"Next": "Successivo",
|
||||
"FailedToPreview": "Impossibile mostrare l'anteprima",
|
||||
"ContentType": "Tipo di contenuto",
|
||||
"ContentTypeNotSupported": "Anteprima non disponibile per questo tipo di contenuto"
|
||||
"ContentTypeNotSupported": "Anteprima non disponibile per questo tipo di contenuto",
|
||||
"StartDrawing": "Scarabocchiare sopra"
|
||||
},
|
||||
"status": {
|
||||
"FileTooLarge": "File troppo grande"
|
||||
|
||||
@@ -33,7 +33,8 @@
|
||||
"Next": "Seguinte",
|
||||
"FailedToPreview": "Falha ao pré-visualizar",
|
||||
"ContentType": "Tipo de conteúdo",
|
||||
"ContentTypeNotSupported": "A visualização não está disponível para este tipo de conteúdo"
|
||||
"ContentTypeNotSupported": "A visualização não está disponível para este tipo de conteúdo",
|
||||
"StartDrawing": "Scarabocchiare sopra"
|
||||
},
|
||||
"status": {
|
||||
"FileTooLarge": "Ficheiro demasiado grande"
|
||||
|
||||
@@ -33,7 +33,8 @@
|
||||
"Next": "Далее",
|
||||
"FailedToPreview": "Ошибка предпросмотра",
|
||||
"ContentType": "Тип контента",
|
||||
"ContentTypeNotSupported": "Предварительный просмотр недоступен для этого типа контента"
|
||||
"ContentTypeNotSupported": "Предварительный просмотр недоступен для этого типа контента",
|
||||
"StartDrawing": "Сделать набросок"
|
||||
},
|
||||
"status": {
|
||||
"FileTooLarge": "Файл слишком большой"
|
||||
|
||||
@@ -33,7 +33,8 @@
|
||||
"Next": "下一步",
|
||||
"FailedToPreview": "预览失败",
|
||||
"ContentType": "内容类型",
|
||||
"ContentTypeNotSupported": "此內容類型無法預覽"
|
||||
"ContentTypeNotSupported": "此內容類型無法預覽",
|
||||
"StartDrawing": "随意涂鸦"
|
||||
},
|
||||
"status": {
|
||||
"FileTooLarge": "文件太大"
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<!--
|
||||
// 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 { Button, IconDelete, IconEdit, resizeObserver } from '@hcengineering/ui'
|
||||
import { drawing, type DrawingData, type DrawingTool } from '../drawing'
|
||||
import IconEraser from './icons/Eraser.svelte'
|
||||
|
||||
export let active = false
|
||||
export let readonly = false
|
||||
export let imageWidth: number | undefined
|
||||
export let imageHeight: number | undefined
|
||||
export let drawingData: DrawingData
|
||||
export let saveDrawing: (data: any) => Promise<void>
|
||||
|
||||
let drawingTool: DrawingTool = 'pen'
|
||||
let penColor = 'blue'
|
||||
const penColors = ['red', 'green', 'blue', 'white', 'black']
|
||||
|
||||
let board: HTMLDivElement
|
||||
let toolbar: HTMLDivElement
|
||||
let toolbarInside = false
|
||||
|
||||
$: updateToolbarPosition(readonly, board, toolbar)
|
||||
|
||||
function updateToolbarPosition (readonly: boolean, board: HTMLDivElement, toolbar: HTMLDivElement): void {
|
||||
if (!readonly && board?.offsetTop !== undefined && toolbar?.clientHeight !== undefined) {
|
||||
// TODO: There should be a generic solution
|
||||
// this only estimates a free room above the picture in FilePreviewPopup
|
||||
toolbarInside = board.offsetTop <= toolbar.clientHeight * 3
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if active}
|
||||
<div
|
||||
{...$$restProps}
|
||||
style:position="relative"
|
||||
bind:this={board}
|
||||
use:resizeObserver={() => {
|
||||
updateToolbarPosition(readonly, board, toolbar)
|
||||
}}
|
||||
use:drawing={{
|
||||
readonly,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
drawingData,
|
||||
saveDrawing,
|
||||
drawingTool,
|
||||
penColor
|
||||
}}
|
||||
>
|
||||
{#if !readonly}
|
||||
<div class="toolbar" class:inside={toolbarInside} bind:this={toolbar}>
|
||||
<Button
|
||||
icon={IconDelete}
|
||||
kind="icon"
|
||||
on:click={() => {
|
||||
drawingData = {}
|
||||
}}
|
||||
/>
|
||||
<div class="divider buttons-divider" />
|
||||
<Button
|
||||
icon={IconEdit}
|
||||
kind="icon"
|
||||
selected={drawingTool === 'pen'}
|
||||
on:click={() => {
|
||||
drawingTool = 'pen'
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
icon={IconEraser}
|
||||
kind="icon"
|
||||
selected={drawingTool === 'erase'}
|
||||
on:click={() => {
|
||||
drawingTool = 'erase'
|
||||
}}
|
||||
/>
|
||||
<div class="divider buttons-divider" />
|
||||
{#each penColors as color}
|
||||
<Button
|
||||
kind="icon"
|
||||
selected={penColor === color}
|
||||
on:click={() => {
|
||||
penColor = color
|
||||
}}
|
||||
>
|
||||
<div
|
||||
slot="content"
|
||||
class="colorIcon"
|
||||
class:emphasized={color === 'white' || color === 'black'}
|
||||
style:background={color}
|
||||
/>
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<slot />
|
||||
</div>
|
||||
{:else}
|
||||
<slot />
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.toolbar {
|
||||
position: absolute;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 5px;
|
||||
bottom: 100%;
|
||||
|
||||
&.inside {
|
||||
left: 5px;
|
||||
top: 5px;
|
||||
bottom: unset;
|
||||
background-color: var(--theme-navpanel-color);
|
||||
border-radius: var(--small-BorderRadius);
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.colorIcon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
margin: -3px;
|
||||
|
||||
&.emphasized {
|
||||
box-shadow: 0px 0px 3px 0px var(--theme-button-contrast-enabled);
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin: 0 5px;
|
||||
}
|
||||
</style>
|
||||
@@ -15,7 +15,7 @@
|
||||
<script lang="ts">
|
||||
import { type Blob, type Ref } from '@hcengineering/core'
|
||||
import { getEmbeddedLabel } from '@hcengineering/platform'
|
||||
import { Dialog, tooltip } from '@hcengineering/ui'
|
||||
import { Button, Dialog, IconEdit, tooltip } from '@hcengineering/ui'
|
||||
import { createEventDispatcher, onMount } from 'svelte'
|
||||
|
||||
import { BlobMetadata } from '../types'
|
||||
@@ -36,13 +36,38 @@
|
||||
export let fullSize = false
|
||||
export let showIcon = true
|
||||
|
||||
let drawingLoading = false
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
onMount(() => {
|
||||
if (fullSize) {
|
||||
dispatch('fullsize')
|
||||
}
|
||||
if (props.drawingAvailable === true) {
|
||||
loadDrawings(props.loadDrawings)
|
||||
}
|
||||
})
|
||||
|
||||
function toggleDrawingEdit (): void {
|
||||
const editable = props.drawingEditable === true
|
||||
props = { ...props, drawingEditable: !editable }
|
||||
}
|
||||
|
||||
function loadDrawings (load: () => Promise<any>): void {
|
||||
if (load !== undefined) {
|
||||
drawingLoading = true
|
||||
load()
|
||||
.then((result) => {
|
||||
drawingLoading = false
|
||||
props.drawingData = result
|
||||
})
|
||||
.catch((error) => {
|
||||
drawingLoading = false
|
||||
console.error('Failed to load drawings for file', file, error)
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<ActionContext context={{ mode: 'browser' }} />
|
||||
@@ -65,6 +90,15 @@
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="utils">
|
||||
{#if props.drawingAvailable === true}
|
||||
<Button
|
||||
icon={IconEdit}
|
||||
kind="icon"
|
||||
disabled={drawingLoading}
|
||||
showTooltip={{ label: presentation.string.StartDrawing }}
|
||||
on:click={toggleDrawingEdit}
|
||||
/>
|
||||
{/if}
|
||||
<DownloadFileButton {name} {file} />
|
||||
<ComponentExtensions
|
||||
extension={presentation.extension.FilePreviewPopupActions}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
export let size: 'small' | 'medium' | 'large'
|
||||
const fill: string = 'currentColor'
|
||||
</script>
|
||||
|
||||
<svg class="svg-{size}" {fill} viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M8.086 2.207a2 2 0 012.828 0l3.879 3.879a2 2 0 010 2.828l-5.5 5.5A2 2 0 017.879 15H5.12a2 2 0 01-1.414-.586l-2.5-2.5a2 2 0 010-2.828l6.879-6.879zm.66 11.34L3.453 8.254 1.914 9.793a1 1 0 000 1.414l2.5 2.5a1 1 0 00.707.293H7.88a1 1 0 00.707-.293l.16-.16z"
|
||||
/>
|
||||
</svg>
|
||||
@@ -0,0 +1,398 @@
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
|
||||
export interface DrawingData {
|
||||
id?: string
|
||||
content?: string
|
||||
}
|
||||
|
||||
export interface DrawingProps {
|
||||
readonly?: boolean
|
||||
imageWidth?: number
|
||||
imageHeight?: number
|
||||
drawingData?: DrawingData
|
||||
saveDrawing?: (data: any) => Promise<void>
|
||||
|
||||
drawingTool?: DrawingTool
|
||||
penColor?: string
|
||||
}
|
||||
|
||||
interface DrawCmd {
|
||||
lineWidth: number
|
||||
erasing: boolean
|
||||
penColor: string
|
||||
points: Point[]
|
||||
}
|
||||
|
||||
export type DrawingTool = 'pen' | 'erase' | 'pan'
|
||||
|
||||
interface Point {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
function avgPoint (p1: Point, p2: Point): Point {
|
||||
return { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 }
|
||||
}
|
||||
|
||||
class DrawState {
|
||||
on = false
|
||||
tool: DrawingTool = 'pen'
|
||||
penColor = 'blue'
|
||||
penWidth = 4
|
||||
eraserWidth = 30
|
||||
minLineLength = 6
|
||||
offset: Point = { x: 0, y: 0 }
|
||||
points: Point[] = []
|
||||
scale: Point = { x: 1, y: 1 }
|
||||
ctx: CanvasRenderingContext2D
|
||||
|
||||
constructor (ctx: CanvasRenderingContext2D) {
|
||||
this.ctx = ctx
|
||||
}
|
||||
|
||||
cursorWidth = (): number => {
|
||||
return Math.max(8, this.tool === 'erase' ? this.eraserWidth : this.penWidth)
|
||||
}
|
||||
|
||||
lineScale = (): number => {
|
||||
return (this.scale.x + this.scale.y) / 2
|
||||
}
|
||||
|
||||
addPoint = (mouseX: number, mouseY: number): void => {
|
||||
this.points.push({
|
||||
x: mouseX * this.scale.x - this.offset.x,
|
||||
y: mouseY * this.scale.y - this.offset.y
|
||||
})
|
||||
}
|
||||
|
||||
isDrawingTool = (): boolean => {
|
||||
return this.tool === 'pen' || this.tool === 'erase'
|
||||
}
|
||||
|
||||
drawLive = (x: number, y: number, lastPoint = false): void => {
|
||||
window.requestAnimationFrame(() => {
|
||||
if (!lastPoint || this.points.length > 1) {
|
||||
this.addPoint(x, y)
|
||||
}
|
||||
const erasing = this.tool === 'erase'
|
||||
this.ctx.beginPath()
|
||||
this.ctx.lineCap = 'round'
|
||||
this.ctx.strokeStyle = this.penColor
|
||||
this.ctx.lineWidth = (erasing ? this.eraserWidth : this.penWidth) * this.lineScale()
|
||||
this.ctx.globalCompositeOperation = erasing ? 'destination-out' : 'source-over'
|
||||
if (this.points.length === 1) {
|
||||
this.drawPoint(this.points[0], erasing)
|
||||
} else {
|
||||
this.drawSmoothSegment(this.points, this.points.length - 1, lastPoint)
|
||||
this.ctx.stroke()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
drawCommand = (cmd: DrawCmd): void => {
|
||||
this.ctx.beginPath()
|
||||
this.ctx.lineCap = 'round'
|
||||
this.ctx.strokeStyle = cmd.penColor
|
||||
this.ctx.lineWidth = cmd.lineWidth
|
||||
this.ctx.globalCompositeOperation = cmd.erasing ? 'destination-out' : 'source-over'
|
||||
if (cmd.points.length === 1) {
|
||||
this.drawPoint(cmd.points[0], cmd.erasing)
|
||||
} else {
|
||||
for (let i = 1; i < cmd.points.length; i++) {
|
||||
this.drawSmoothSegment(cmd.points, i, i === cmd.points.length - 1)
|
||||
}
|
||||
this.ctx.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
drawPoint = (p: Point, erasing: boolean): void => {
|
||||
let r = this.ctx.lineWidth / 2
|
||||
if (!erasing) {
|
||||
// Single point looks too small compared to a line of the same width
|
||||
// So make it a bit biggers
|
||||
r *= 1.5
|
||||
}
|
||||
this.ctx.lineWidth = 0
|
||||
this.ctx.fillStyle = this.ctx.strokeStyle
|
||||
this.ctx.arc(p.x, p.y, r, 0, Math.PI * 2)
|
||||
this.ctx.fill()
|
||||
}
|
||||
|
||||
drawSmoothSegment = (points: Point[], index: number, lastPoint: boolean): void => {
|
||||
const curPos = points[index]
|
||||
const prevPos = points[index - 1]
|
||||
const avg = avgPoint(prevPos, curPos)
|
||||
if (index === 1) {
|
||||
this.ctx.moveTo(prevPos.x, prevPos.y)
|
||||
if (lastPoint) {
|
||||
this.ctx.lineTo(curPos.x, curPos.y)
|
||||
} else {
|
||||
this.ctx.quadraticCurveTo(curPos.x, curPos.y, avg.x, avg.y)
|
||||
}
|
||||
} else {
|
||||
const prevAvg = avgPoint(points[index - 2], prevPos)
|
||||
this.ctx.moveTo(prevAvg.x, prevAvg.y)
|
||||
if (lastPoint) {
|
||||
this.ctx.quadraticCurveTo(prevPos.x, prevPos.y, curPos.x, curPos.y)
|
||||
} else {
|
||||
this.ctx.quadraticCurveTo(prevPos.x, prevPos.y, avg.x, avg.y)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function drawing (node: HTMLElement, props: DrawingProps): any {
|
||||
if (
|
||||
props.imageWidth === undefined ||
|
||||
props.imageHeight === undefined ||
|
||||
node.clientWidth === undefined ||
|
||||
node.clientHeight === undefined
|
||||
) {
|
||||
console.error('Failed to create drawing: image size is not specified')
|
||||
return
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.style.position = 'absolute'
|
||||
canvas.style.left = '0'
|
||||
canvas.style.top = '0'
|
||||
canvas.style.width = '100%'
|
||||
canvas.style.height = '100%'
|
||||
canvas.width = props.imageWidth
|
||||
canvas.height = props.imageHeight
|
||||
node.appendChild(canvas)
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (ctx === null) {
|
||||
console.error('Failed to create drawing: unable to get 2d canvas context')
|
||||
node.removeChild(canvas)
|
||||
return
|
||||
}
|
||||
|
||||
const canvasCursor = document.createElement('div')
|
||||
canvasCursor.style.visibility = 'hidden'
|
||||
canvasCursor.style.position = 'absolute'
|
||||
canvasCursor.style.borderRadius = '50%'
|
||||
canvasCursor.style.cursor = 'none'
|
||||
canvasCursor.style.pointerEvents = 'none'
|
||||
node.appendChild(canvasCursor)
|
||||
|
||||
let readonly = props.readonly ?? false
|
||||
let prevPos: Point = { x: 0, y: 0 }
|
||||
|
||||
const draw = new DrawState(ctx)
|
||||
draw.tool = props.drawingTool ?? 'pan'
|
||||
draw.penColor = props.penColor ?? 'blue'
|
||||
updateCanvasCursor()
|
||||
|
||||
let modified = false
|
||||
let commands: DrawCmd[] = []
|
||||
let drawingData = props.drawingData
|
||||
parseData()
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.target === canvas) {
|
||||
draw.scale = {
|
||||
x: canvas.width / entry.contentRect.width,
|
||||
y: canvas.height / entry.contentRect.height
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
resizeObserver.observe(canvas)
|
||||
|
||||
canvas.onpointerdown = (e) => {
|
||||
if (readonly) {
|
||||
return
|
||||
}
|
||||
if (e.button !== 0) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
canvas.setPointerCapture(e.pointerId)
|
||||
|
||||
const x = e.offsetX
|
||||
const y = e.offsetY
|
||||
|
||||
draw.on = true
|
||||
draw.points = []
|
||||
prevPos = { x, y }
|
||||
if (draw.isDrawingTool()) {
|
||||
draw.addPoint(x, y)
|
||||
}
|
||||
}
|
||||
|
||||
canvas.onpointermove = (e) => {
|
||||
if (readonly) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
|
||||
const x = e.offsetX
|
||||
const y = e.offsetY
|
||||
|
||||
if (draw.isDrawingTool()) {
|
||||
const w = draw.cursorWidth()
|
||||
canvasCursor.style.left = `${x - w / 2}px`
|
||||
canvasCursor.style.top = `${y - w / 2}px`
|
||||
if (draw.on) {
|
||||
if (Math.hypot(prevPos.x - x, prevPos.y - y) < draw.minLineLength) {
|
||||
return
|
||||
}
|
||||
draw.drawLive(x, y)
|
||||
prevPos = { x, y }
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: if (draw.tool === 'pan')
|
||||
// Currently we only show drawing over attached images
|
||||
// Their sizes are fixed and no pan required
|
||||
}
|
||||
|
||||
canvas.onpointerup = (e) => {
|
||||
if (readonly) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
canvas.releasePointerCapture(e.pointerId)
|
||||
if (draw.on) {
|
||||
if (draw.isDrawingTool()) {
|
||||
draw.drawLive(e.offsetX, e.offsetY, true)
|
||||
storeCommand()
|
||||
}
|
||||
draw.on = false
|
||||
}
|
||||
}
|
||||
|
||||
canvas.onpointerenter = () => {
|
||||
if (!readonly && draw.isDrawingTool()) {
|
||||
canvasCursor.style.visibility = 'visible'
|
||||
}
|
||||
}
|
||||
|
||||
canvas.onpointerleave = () => {
|
||||
if (!readonly && draw.isDrawingTool()) {
|
||||
canvasCursor.style.visibility = 'hidden'
|
||||
}
|
||||
}
|
||||
|
||||
function storeCommand (): void {
|
||||
if (draw.points.length > 1) {
|
||||
const erasing = draw.tool === 'erase'
|
||||
const cmd: DrawCmd = {
|
||||
lineWidth: (erasing ? draw.eraserWidth : draw.penWidth) * draw.lineScale(),
|
||||
erasing,
|
||||
penColor: draw.penColor,
|
||||
points: draw.points
|
||||
}
|
||||
commands.push(cmd)
|
||||
modified = true
|
||||
}
|
||||
}
|
||||
|
||||
function updateCanvasCursor (): void {
|
||||
if (readonly) {
|
||||
canvasCursor.style.visibility = 'hidden'
|
||||
canvas.style.cursor = 'default'
|
||||
} else if (draw.isDrawingTool()) {
|
||||
canvas.style.cursor = 'none'
|
||||
const erasing = draw.tool === 'erase'
|
||||
const w = draw.cursorWidth()
|
||||
canvasCursor.style.background = erasing ? 'none' : draw.penColor
|
||||
canvasCursor.style.border = erasing ? '1px solid #333' : 'none'
|
||||
canvasCursor.style.boxShadow = erasing ? '0px 0px 0px 1px #eee inset' : 'none'
|
||||
canvasCursor.style.width = `${w}px`
|
||||
canvasCursor.style.height = `${w}px`
|
||||
} else if (draw.tool === 'pan') {
|
||||
canvas.style.cursor = 'move'
|
||||
canvasCursor.style.visibility = 'hidden'
|
||||
} else {
|
||||
canvas.style.cursor = 'default'
|
||||
canvasCursor.style.visibility = 'hidden'
|
||||
}
|
||||
}
|
||||
|
||||
function clearCanvas (): void {
|
||||
draw.ctx.reset()
|
||||
draw.offset = { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
function replayCommands (): void {
|
||||
draw.ctx.reset()
|
||||
for (const cmd of commands) {
|
||||
draw.drawCommand(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
function parseData (): void {
|
||||
clearCanvas()
|
||||
if (drawingData?.content !== undefined) {
|
||||
try {
|
||||
commands = JSON.parse(drawingData.content)
|
||||
replayCommands()
|
||||
} catch (error) {
|
||||
commands = []
|
||||
console.error('Failed to parse drawing content', error)
|
||||
}
|
||||
} else {
|
||||
commands = []
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
update (props: DrawingProps) {
|
||||
if (drawingData !== props.drawingData) {
|
||||
// Currently it expectes only the empty data on update
|
||||
// which means we pressed the "Clear canvas" button
|
||||
// We don't support yet creation of multiple drawings for the same image
|
||||
// so preserve the id to continue editing the previous drawing
|
||||
const oldId = drawingData?.id
|
||||
drawingData = props.drawingData
|
||||
if (drawingData !== undefined) {
|
||||
drawingData.id = oldId
|
||||
}
|
||||
modified = true
|
||||
parseData()
|
||||
}
|
||||
if (draw.tool !== props.drawingTool) {
|
||||
draw.tool = props.drawingTool ?? 'pen'
|
||||
updateCanvasCursor()
|
||||
}
|
||||
if (draw.penColor !== props.penColor) {
|
||||
draw.penColor = props.penColor ?? 'blue'
|
||||
updateCanvasCursor()
|
||||
}
|
||||
if (props.readonly !== readonly) {
|
||||
readonly = props.readonly ?? false
|
||||
updateCanvasCursor()
|
||||
}
|
||||
},
|
||||
destroy () {
|
||||
if (props.saveDrawing === undefined) {
|
||||
console.log('Save drawing method is not provided')
|
||||
} else {
|
||||
if (modified && (commands.length > 0 || drawingData?.id !== undefined)) {
|
||||
const data: DrawingData = drawingData ?? {}
|
||||
data.content = JSON.stringify(commands)
|
||||
props.saveDrawing(data).catch((error) => {
|
||||
console.error('Failed to save drawing', error)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ export { default as SearchResult } from './components/SearchResult.svelte'
|
||||
export { default as LiteMessageViewer } from './components/LiteMessageViewer.svelte'
|
||||
export { default as DownloadFileButton } from './components/DownloadFileButton.svelte'
|
||||
export { default as FileTypeIcon } from './components/FileTypeIcon.svelte'
|
||||
export { default as DrawingBoard } from './components/DrawingBoard.svelte'
|
||||
export { default } from './plugin'
|
||||
export * from './types'
|
||||
export * from './utils'
|
||||
@@ -66,3 +67,4 @@ export * from './image'
|
||||
export * from './preview'
|
||||
export * from './sound'
|
||||
export * from './stats'
|
||||
export * from './drawing'
|
||||
|
||||
@@ -121,7 +121,8 @@ export default plugin(presentationId, {
|
||||
Next: '' as IntlString,
|
||||
FailedToPreview: '' as IntlString,
|
||||
ContentType: '' as IntlString,
|
||||
ContentTypeNotSupported: '' as IntlString
|
||||
ContentTypeNotSupported: '' as IntlString,
|
||||
StartDrawing: '' as IntlString
|
||||
},
|
||||
extension: {
|
||||
FilePreviewExtension: '' as ComponentExtensionId,
|
||||
|
||||
@@ -478,9 +478,11 @@
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="btn-icon {iconModifier}">
|
||||
<Icon icon={iconModifier === 'overdue' && !shouldIgnoreOverdue ? DPCalendarOver : DPCalendar} size={'full'} />
|
||||
</div>
|
||||
{#if shouldShowAvatar}
|
||||
<div class="btn-icon {iconModifier}">
|
||||
<Icon icon={iconModifier === 'overdue' && !shouldIgnoreOverdue ? DPCalendarOver : DPCalendar} size={'full'} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if value !== undefined && value !== null && value.toString() !== ''}
|
||||
{#if withDate}
|
||||
{new Date(value).getDate()}
|
||||
@@ -524,7 +526,7 @@
|
||||
border-radius: 0.375rem;
|
||||
transition-property: border, background-color, color, box-shadow;
|
||||
transition-duration: 0.15s;
|
||||
cursor: pointer;
|
||||
cursor: default;
|
||||
|
||||
&.noPadding {
|
||||
padding: 0;
|
||||
|
||||
@@ -16,21 +16,15 @@
|
||||
import { type Attachment } from '@hcengineering/attachment'
|
||||
import type { WithLookup } from '@hcengineering/core'
|
||||
import { getResource } from '@hcengineering/platform'
|
||||
import presentation, {
|
||||
FilePreviewPopup,
|
||||
canPreviewFile,
|
||||
getFileUrl,
|
||||
getPreviewAlignment,
|
||||
previewTypes
|
||||
} from '@hcengineering/presentation'
|
||||
import { IconMoreH, Menu, Action as UIAction, closeTooltip, showPopup, tooltip } from '@hcengineering/ui'
|
||||
import presentation, { canPreviewFile, getFileUrl, previewTypes } from '@hcengineering/presentation'
|
||||
import { IconMoreH, Menu, Action as UIAction, showPopup, tooltip } from '@hcengineering/ui'
|
||||
import view, { Action } from '@hcengineering/view'
|
||||
import workbench from '@hcengineering/workbench'
|
||||
|
||||
import AttachmentAction from './AttachmentAction.svelte'
|
||||
import FileDownload from './icons/FileDownload.svelte'
|
||||
import attachmentPlugin from '../plugin'
|
||||
import { openAttachmentInSidebar } from '../utils'
|
||||
import { openAttachmentInSidebar, showAttachmentPreviewPopup } from '../utils'
|
||||
|
||||
export let attachment: WithLookup<Attachment>
|
||||
export let isSaved = false
|
||||
@@ -60,18 +54,7 @@
|
||||
window.open((e.target as HTMLAnchorElement).href, '_blank')
|
||||
return
|
||||
}
|
||||
closeTooltip()
|
||||
|
||||
showPopup(
|
||||
FilePreviewPopup,
|
||||
{
|
||||
file: attachment.file,
|
||||
contentType: attachment.type,
|
||||
name: attachment.name,
|
||||
metadata: attachment.metadata
|
||||
},
|
||||
getPreviewAlignment(attachment.type ?? '')
|
||||
)
|
||||
showAttachmentPreviewPopup(attachment)
|
||||
}
|
||||
|
||||
$: saveAttachmentAction = isSaved
|
||||
@@ -92,7 +75,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
const showMenu = (ev: Event) => {
|
||||
const showMenu = (ev: Event): void => {
|
||||
const actions: UIAction[] = []
|
||||
if (canPreview) {
|
||||
actions.push(openAction)
|
||||
|
||||
@@ -15,10 +15,9 @@
|
||||
<script lang="ts">
|
||||
import type { Attachment } from '@hcengineering/attachment'
|
||||
import type { WithLookup } from '@hcengineering/core'
|
||||
import { FilePreviewPopup, getFileUrl } from '@hcengineering/presentation'
|
||||
import { closeTooltip, showPopup } from '@hcengineering/ui'
|
||||
import { getFileUrl } from '@hcengineering/presentation'
|
||||
import filesize from 'filesize'
|
||||
import { getType } from '../utils'
|
||||
import { getType, showAttachmentPreviewPopup } from '../utils'
|
||||
|
||||
export let value: WithLookup<Attachment>
|
||||
|
||||
@@ -41,17 +40,7 @@
|
||||
}
|
||||
|
||||
function openAttachment (): void {
|
||||
closeTooltip()
|
||||
showPopup(
|
||||
FilePreviewPopup,
|
||||
{
|
||||
file: value.file,
|
||||
contentType: value.type,
|
||||
name: value.name,
|
||||
metadata: value.metadata
|
||||
},
|
||||
isImage(value.type) ? 'centered' : 'float'
|
||||
)
|
||||
showAttachmentPreviewPopup(value)
|
||||
}
|
||||
$: src = getFileUrl(value.file, value.name)
|
||||
</script>
|
||||
|
||||
@@ -18,17 +18,16 @@
|
||||
import core, { type WithLookup } from '@hcengineering/core'
|
||||
import presentation, {
|
||||
canPreviewFile,
|
||||
FilePreviewPopup,
|
||||
getBlobRef,
|
||||
getFileUrl,
|
||||
previewTypes,
|
||||
sizeToWidth
|
||||
} from '@hcengineering/presentation'
|
||||
import { Label, closeTooltip, showPopup } from '@hcengineering/ui'
|
||||
import { Label } from '@hcengineering/ui'
|
||||
import { permissionsStore } from '@hcengineering/view-resources'
|
||||
import filesize from 'filesize'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { getType, openAttachmentInSidebar } from '../utils'
|
||||
import { getType, openAttachmentInSidebar, showAttachmentPreviewPopup } from '../utils'
|
||||
|
||||
import AttachmentName from './AttachmentName.svelte'
|
||||
|
||||
@@ -78,18 +77,8 @@
|
||||
window.open((e.target as HTMLAnchorElement).href, '_blank')
|
||||
return
|
||||
}
|
||||
closeTooltip()
|
||||
if (value.type.startsWith('image/') || value.type.startsWith('video/') || value.type.startsWith('audio/')) {
|
||||
showPopup(
|
||||
FilePreviewPopup,
|
||||
{
|
||||
file: value.file,
|
||||
contentType: value.type,
|
||||
name: value.name,
|
||||
metadata: value.metadata
|
||||
},
|
||||
'centered'
|
||||
)
|
||||
showAttachmentPreviewPopup(value)
|
||||
} else {
|
||||
await openAttachmentInSidebar(value)
|
||||
}
|
||||
|
||||
@@ -15,14 +15,12 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Attachment } from '@hcengineering/attachment'
|
||||
import { FilePreviewPopup } from '@hcengineering/presentation'
|
||||
import { closeTooltip, showPopup } from '@hcengineering/ui'
|
||||
import { ListSelectionProvider } from '@hcengineering/view-resources'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { WithLookup } from '@hcengineering/core'
|
||||
|
||||
import { AttachmentImageSize } from '../types'
|
||||
import { getType } from '../utils'
|
||||
import { getType, showAttachmentPreviewPopup } from '../utils'
|
||||
import AttachmentActions from './AttachmentActions.svelte'
|
||||
import AttachmentImagePreview from './AttachmentImagePreview.svelte'
|
||||
import AttachmentPresenter from './AttachmentPresenter.svelte'
|
||||
@@ -47,13 +45,8 @@
|
||||
<div
|
||||
class="content flex-center buttonContainer cursor-pointer"
|
||||
on:click={() => {
|
||||
closeTooltip()
|
||||
if (listProvider !== undefined) listProvider.updateFocus(value)
|
||||
const popupInfo = showPopup(
|
||||
FilePreviewPopup,
|
||||
{ file: value.file, name: value.name, contentType: value.type, metadata: value.metadata },
|
||||
value.type.startsWith('image/') ? 'centered' : 'float'
|
||||
)
|
||||
const popupInfo = showAttachmentPreviewPopup(value)
|
||||
dispatch('open', popupInfo.id)
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -17,9 +17,10 @@
|
||||
import { Photo } from '@hcengineering/attachment'
|
||||
import { Class, Doc, Ref, Space, type WithLookup } from '@hcengineering/core'
|
||||
import { setPlatformStatus, unknownError } from '@hcengineering/platform'
|
||||
import { FilePreviewPopup, createQuery, getBlobRef, getClient, uploadFile } from '@hcengineering/presentation'
|
||||
import { Button, IconAdd, Label, Spinner, showPopup } from '@hcengineering/ui'
|
||||
import { createQuery, getBlobRef, getClient, uploadFile } from '@hcengineering/presentation'
|
||||
import { Button, IconAdd, Label, Spinner } from '@hcengineering/ui'
|
||||
import attachment from '../plugin'
|
||||
import { showAttachmentPreviewPopup } from '../utils'
|
||||
import UploadDuo from './icons/UploadDuo.svelte'
|
||||
|
||||
export let objectId: Ref<Doc>
|
||||
@@ -90,11 +91,7 @@
|
||||
const el: HTMLElement = ev.currentTarget as HTMLElement
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' })
|
||||
if (item !== undefined) {
|
||||
showPopup(
|
||||
FilePreviewPopup,
|
||||
{ file: item.file, name: item.name, contentType: item.type, metadata: item.metadata },
|
||||
item.type.startsWith('image/') ? 'centered' : 'float'
|
||||
)
|
||||
showAttachmentPreviewPopup(item)
|
||||
} else {
|
||||
inputFile.click()
|
||||
}
|
||||
|
||||
@@ -14,18 +14,28 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { type BlobMetadata, type Attachment } from '@hcengineering/attachment'
|
||||
import {
|
||||
import { type BlobMetadata, type Attachment, type Drawing } from '@hcengineering/attachment'
|
||||
import core, {
|
||||
type Blob,
|
||||
type Class,
|
||||
type TxOperations as Client,
|
||||
type Data,
|
||||
type Doc,
|
||||
type Ref,
|
||||
type Space
|
||||
type Space,
|
||||
type WithLookup
|
||||
} from '@hcengineering/core'
|
||||
import { getResource, setPlatformStatus, unknownError } from '@hcengineering/platform'
|
||||
import { type FileOrBlob, getClient, getFileMetadata, uploadFile } from '@hcengineering/presentation'
|
||||
import {
|
||||
type DrawingData,
|
||||
type FileOrBlob,
|
||||
getClient,
|
||||
getFileMetadata,
|
||||
getPreviewAlignment,
|
||||
uploadFile,
|
||||
FilePreviewPopup
|
||||
} from '@hcengineering/presentation'
|
||||
import { closeTooltip, showPopup, type PopupResult } from '@hcengineering/ui'
|
||||
import workbench, { type WidgetTab } from '@hcengineering/workbench'
|
||||
import view from '@hcengineering/view'
|
||||
|
||||
@@ -102,6 +112,7 @@ export function getType (type: string): 'image' | 'text' | 'json' | 'video' | 'a
|
||||
}
|
||||
|
||||
export async function openAttachmentInSidebar (value: Attachment): Promise<void> {
|
||||
closeTooltip()
|
||||
await openFilePreviewInSidebar(value.file, value.name, value.type, value.metadata)
|
||||
}
|
||||
|
||||
@@ -134,3 +145,48 @@ export async function openFilePreviewInSidebar (
|
||||
}
|
||||
await createFn(widget, tab, true)
|
||||
}
|
||||
|
||||
export function showAttachmentPreviewPopup (value: WithLookup<Attachment>): PopupResult {
|
||||
const props: Record<string, any> = {}
|
||||
|
||||
if (value?.type?.startsWith('image/')) {
|
||||
props.drawingAvailable = true
|
||||
props.loadDrawings = async (): Promise<DrawingData | undefined> => {
|
||||
const client = getClient()
|
||||
const drawing = await client.findOne(attachment.class.Drawing, { parent: value.file })
|
||||
if (drawing !== undefined) {
|
||||
return {
|
||||
id: drawing._id,
|
||||
content: drawing.content
|
||||
}
|
||||
}
|
||||
}
|
||||
props.saveDrawing = async (data: DrawingData): Promise<void> => {
|
||||
const client = getClient()
|
||||
if (data.id === undefined) {
|
||||
await client.createDoc(attachment.class.Drawing, value.space, {
|
||||
parent: value.file,
|
||||
parentClass: core.class.Blob,
|
||||
content: data.content
|
||||
})
|
||||
} else {
|
||||
await client.updateDoc(attachment.class.Drawing, value.space, data.id as Ref<Drawing>, {
|
||||
content: data.content
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
closeTooltip()
|
||||
return showPopup(
|
||||
FilePreviewPopup,
|
||||
{
|
||||
file: value.file,
|
||||
contentType: value.type,
|
||||
name: value.name,
|
||||
metadata: value.metadata,
|
||||
props
|
||||
},
|
||||
getPreviewAlignment(value.type ?? '')
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//
|
||||
// Copyright © 2020, 2021 Anticrm Platform Contributors.
|
||||
// Copyright © 2021 Hardcore Engineering Inc.
|
||||
// Copyright © 2021, 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
|
||||
@@ -14,7 +14,7 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import type { AttachedDoc, Blob, Class, Ref } from '@hcengineering/core'
|
||||
import type { AttachedDoc, Blob, Class, Doc, Ref } from '@hcengineering/core'
|
||||
import type { Asset, Plugin } from '@hcengineering/platform'
|
||||
import { IntlString, plugin, Resource } from '@hcengineering/platform'
|
||||
import type { Preference } from '@hcengineering/preference'
|
||||
@@ -62,6 +62,15 @@ export interface SavedAttachments extends Preference {
|
||||
attachedTo: Ref<Attachment>
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface Drawing extends Doc {
|
||||
parent: Ref<Doc>
|
||||
parentClass: Ref<Class<Doc>>
|
||||
content?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
@@ -81,6 +90,7 @@ export default plugin(attachmentId, {
|
||||
},
|
||||
class: {
|
||||
Attachment: '' as Ref<Class<Attachment>>,
|
||||
Drawing: '' as Ref<Class<Drawing>>,
|
||||
Photo: '' as Ref<Class<Photo>>,
|
||||
SavedAttachments: '' as Ref<Class<SavedAttachments>>
|
||||
},
|
||||
|
||||
@@ -26,8 +26,16 @@
|
||||
const client = getClient()
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let currentTitle = object.title
|
||||
let newTitle = object.title
|
||||
|
||||
$: if (object.title !== currentTitle) {
|
||||
newTitle = object.title
|
||||
currentTitle = object.title
|
||||
}
|
||||
|
||||
async function changeTitle (): Promise<void> {
|
||||
await client.update(object, { title: object.title })
|
||||
await client.diffUpdate(object, { title: newTitle })
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
@@ -41,7 +49,7 @@
|
||||
<EditBox
|
||||
disabled={readonly}
|
||||
placeholder={love.string.MeetingMinutes}
|
||||
bind:value={object.title}
|
||||
bind:value={newTitle}
|
||||
on:change={changeTitle}
|
||||
focusIndex={1}
|
||||
/>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
import { IntlString } from '@hcengineering/platform'
|
||||
|
||||
import love from '../plugin'
|
||||
import { getRoomName, tryConnect } from '../utils'
|
||||
import { getRoomName, tryConnect, isConnected } from '../utils'
|
||||
import { infos, invites, myInfo, myRequests, selectedRoomPlace, myOffice, currentRoom } from '../stores'
|
||||
|
||||
export let object: Room
|
||||
@@ -45,8 +45,10 @@
|
||||
dispatch('open', { ignoreKeys: ['name'] })
|
||||
})
|
||||
|
||||
let tryConnecting = false
|
||||
|
||||
async function connect (): Promise<void> {
|
||||
connecting = true
|
||||
tryConnecting = true
|
||||
const place = $selectedRoomPlace
|
||||
await tryConnect(
|
||||
$personByIdStore,
|
||||
@@ -57,17 +59,32 @@
|
||||
$invites,
|
||||
place?._id === object._id ? { x: place.x, y: place.y } : undefined
|
||||
)
|
||||
connecting = false
|
||||
tryConnecting = false
|
||||
selectedRoomPlace.set(undefined)
|
||||
}
|
||||
|
||||
let connectLabel: IntlString = love.string.StartMeeting
|
||||
$: connecting = tryConnecting || ($currentRoom?._id === object._id && !$isConnected)
|
||||
|
||||
let connectLabel: IntlString = $infos.some(({ room }) => room === object._id)
|
||||
? love.string.JoinMeeting
|
||||
: love.string.StartMeeting
|
||||
|
||||
$: if ($infos.some(({ room }) => room === object._id) && !connecting) {
|
||||
connectLabel = love.string.JoinMeeting
|
||||
} else if (!connecting) {
|
||||
connectLabel = love.string.StartMeeting
|
||||
}
|
||||
|
||||
function showConnectionButton (object: Room, isConnected: boolean, myOffice?: Room, currentRoom?: Room): boolean {
|
||||
// Do not show connect button in my office
|
||||
if (object._id === myOffice?._id) return false
|
||||
// Show during connecting with spinner
|
||||
if (connecting) return true
|
||||
// Do not show connect button if we are already connected to the room
|
||||
if (isConnected && currentRoom?._id === object._id) return false
|
||||
|
||||
return true
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex-row-stretch">
|
||||
@@ -81,7 +98,7 @@
|
||||
focusIndex={1}
|
||||
/>
|
||||
</div>
|
||||
{#if object._id !== $myOffice?._id && ($currentRoom?._id !== object._id || connecting)}
|
||||
{#if showConnectionButton(object, $isConnected, $myOffice, $currentRoom)}
|
||||
<ModernButton label={connectLabel} size="large" kind={'primary'} on:click={connect} loading={connecting} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
</script>
|
||||
|
||||
{#if doc}
|
||||
<span class="label flex-row-center flex-gap-4 ml-3 no-word-wrap">
|
||||
<span class="label flex-row-center flex-gap-4 no-word-wrap">
|
||||
<ObjectPresenter
|
||||
objectId={doc._id}
|
||||
_class={doc._class}
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
</script>
|
||||
|
||||
{#if data}
|
||||
<span class="flex-row-center" class:ml-3={attributeKey !== undefined}>
|
||||
<span class="flex-row-center">
|
||||
<StateTag type={data.type} label={data.label} />
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
|
||||
let container: HTMLElement
|
||||
let selectedItem: RoomLanguage = room.language
|
||||
$: selectedItem = room.language
|
||||
|
||||
let items: DropdownIntlItem[] = []
|
||||
$: items = Object.entries(languagesDisplayData).map(([lang, data]) => ({
|
||||
|
||||
@@ -16,16 +16,15 @@
|
||||
import { Person, type PersonAccount } from '@hcengineering/contact'
|
||||
import { Avatar, personByIdStore } from '@hcengineering/contact-resources'
|
||||
import { IdMap, getCurrentAccount } from '@hcengineering/core'
|
||||
import { isOffice, ParticipantInfo, Room, RoomAccess, RoomType } from '@hcengineering/love'
|
||||
import { isOffice, ParticipantInfo, Room, RoomAccess, RoomType, MeetingStatus } from '@hcengineering/love'
|
||||
import { Icon, Label, eventToHTMLElement, showPopup } from '@hcengineering/ui'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { getClient } from '@hcengineering/presentation'
|
||||
import { openDoc } from '@hcengineering/view-resources'
|
||||
import { get } from 'svelte/store'
|
||||
|
||||
import love from '../plugin'
|
||||
import { myInfo, selectedRoomPlace, currentRoom, currentMeetingMinutes } from '../stores'
|
||||
import { getRoomLabel, lk } from '../utils'
|
||||
import { getRoomLabel, lk, isConnected } from '../utils'
|
||||
import PersonActionPopup from './PersonActionPopup.svelte'
|
||||
import RoomLanguage from './RoomLanguage.svelte'
|
||||
|
||||
@@ -67,14 +66,20 @@
|
||||
async function openRoom (x: number, y: number): Promise<void> {
|
||||
const client = getClient()
|
||||
const hierarchy = client.getHierarchy()
|
||||
if ($currentRoom?._id === room._id) {
|
||||
if ($isConnected && $currentRoom?._id === room._id) {
|
||||
const sid = await lk.getSid()
|
||||
const meetingMinutes =
|
||||
get(currentMeetingMinutes) ?? (await client.findOne(love.class.MeetingMinutes, { sid, attachedTo: room._id }))
|
||||
if (meetingMinutes === undefined) {
|
||||
let meeting = $currentMeetingMinutes
|
||||
if (meeting?.sid !== sid || meeting?.attachedTo !== room._id || meeting?.status !== MeetingStatus.Active) {
|
||||
meeting = await client.findOne(love.class.MeetingMinutes, {
|
||||
sid,
|
||||
attachedTo: room._id,
|
||||
status: MeetingStatus.Active
|
||||
})
|
||||
}
|
||||
if (meeting === undefined) {
|
||||
await openDoc(hierarchy, room)
|
||||
} else {
|
||||
await openDoc(hierarchy, meetingMinutes)
|
||||
await openDoc(hierarchy, meeting)
|
||||
}
|
||||
} else {
|
||||
selectedRoomPlace.set({ _id: room._id, x, y })
|
||||
|
||||
@@ -15,13 +15,13 @@
|
||||
<script lang="ts">
|
||||
import { closeWidget, minimizeSidebar, WidgetState } from '@hcengineering/workbench-resources'
|
||||
import { createQuery } from '@hcengineering/presentation'
|
||||
import { MeetingMinutes, Room } from '@hcengineering/love'
|
||||
import { MeetingMinutes, MeetingStatus, Room } from '@hcengineering/love'
|
||||
import { Loading } from '@hcengineering/ui'
|
||||
|
||||
import love from '../../plugin'
|
||||
import VideoTab from './VideoTab.svelte'
|
||||
import { isCurrentInstanceConnected, lk } from '../../utils'
|
||||
import { currentRoom, currentMeetingMinutes } from '../../stores'
|
||||
import { currentMeetingMinutes, currentRoom } from '../../stores'
|
||||
import ChatTab from './ChatTab.svelte'
|
||||
import TranscriptionTab from './TranscriptionTab.svelte'
|
||||
|
||||
@@ -58,13 +58,17 @@
|
||||
}
|
||||
|
||||
$: if (sid != null && room !== undefined) {
|
||||
meetingQuery.query(love.class.MeetingMinutes, { sid, attachedTo: room._id }, async (res) => {
|
||||
meetingMinutes = res[0]
|
||||
if (meetingMinutes) {
|
||||
currentMeetingMinutes.set(meetingMinutes)
|
||||
meetingQuery.query(
|
||||
love.class.MeetingMinutes,
|
||||
{ sid, attachedTo: room._id, status: MeetingStatus.Active },
|
||||
async (res) => {
|
||||
meetingMinutes = res[0]
|
||||
if (meetingMinutes) {
|
||||
currentMeetingMinutes.set(meetingMinutes)
|
||||
}
|
||||
isMeetingMinutesLoaded = true
|
||||
}
|
||||
isMeetingMinutesLoaded = true
|
||||
})
|
||||
)
|
||||
} else {
|
||||
meetingQuery.unsubscribe()
|
||||
meetingMinutes = undefined
|
||||
|
||||
@@ -12,7 +12,9 @@ import core, {
|
||||
makeCollaborativeDoc,
|
||||
type Ref,
|
||||
type Space,
|
||||
type TxOperations
|
||||
type TxOperations,
|
||||
type Hierarchy,
|
||||
type Doc
|
||||
} from '@hcengineering/core'
|
||||
import login from '@hcengineering/login'
|
||||
import {
|
||||
@@ -79,11 +81,11 @@ import {
|
||||
import { type Widget, type WidgetTab } from '@hcengineering/workbench'
|
||||
import view from '@hcengineering/view'
|
||||
import chunter from '@hcengineering/chunter'
|
||||
import { openDoc } from '@hcengineering/view-resources'
|
||||
import { getObjectLinkFragment } from '@hcengineering/view-resources'
|
||||
|
||||
import { sendMessage } from './broadcast'
|
||||
import love from './plugin'
|
||||
import { $myPreferences, currentRoom, currentMeetingMinutes, selectedRoomPlace } from './stores'
|
||||
import { $myPreferences, currentRoom, currentMeetingMinutes, selectedRoomPlace, myOffice } from './stores'
|
||||
import RoomSettingsPopup from './components/RoomSettingsPopup.svelte'
|
||||
|
||||
export const selectedCamId = 'selectedDevice_cam'
|
||||
@@ -391,7 +393,7 @@ lk.on(RoomEvent.Connected, () => {
|
||||
sendMessage({ type: 'connect', value: true })
|
||||
isCurrentInstanceConnected.set(true)
|
||||
isRecording.set(lk.isRecording)
|
||||
initRoomMetadata(lk.metadata)
|
||||
void initRoomMetadata(lk.metadata)
|
||||
Analytics.handleEvent(LoveEvents.ConnectedToRoom)
|
||||
})
|
||||
lk.on(RoomEvent.Disconnected, () => {
|
||||
@@ -401,7 +403,7 @@ lk.on(RoomEvent.Disconnected, () => {
|
||||
Analytics.handleEvent(LoveEvents.DisconnectedFromRoom)
|
||||
})
|
||||
|
||||
function initRoomMetadata (metadata: string | undefined): void {
|
||||
async function initRoomMetadata (metadata: string | undefined): Promise<void> {
|
||||
let data: RoomMetadata
|
||||
try {
|
||||
data = metadata == null || metadata === '' ? {} : JSON.parse(metadata)
|
||||
@@ -418,25 +420,39 @@ function initRoomMetadata (metadata: string | undefined): void {
|
||||
(data.transcription == null || data.transcription === TranscriptionStatus.Idle) &&
|
||||
room?.startWithTranscription === true
|
||||
) {
|
||||
void startTranscription(room)
|
||||
await startTranscription(room)
|
||||
}
|
||||
|
||||
if (get(isRecordingAvailable) && data.recording == null && room?.startWithRecording === true) {
|
||||
void record(room)
|
||||
if (data.recording == null && room?.startWithRecording === true) {
|
||||
await record(room)
|
||||
}
|
||||
}
|
||||
export async function connect (name: string, room: Room, _id: string): Promise<void> {
|
||||
|
||||
async function withRetries (fn: () => Promise<void>, retries: number, delay: number): Promise<void> {
|
||||
for (let attempt = 0; attempt < retries; attempt++) {
|
||||
try {
|
||||
await fn()
|
||||
return
|
||||
} catch (error) {
|
||||
if (attempt >= retries) {
|
||||
throw error
|
||||
}
|
||||
console.error(error)
|
||||
console.log(`Attempt ${attempt} failed. Retrying in ${delay}ms...`)
|
||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function connect (name: string, room: Room, _id: string): Promise<void> {
|
||||
const wsURL = getMetadata(love.metadata.WebSocketURL)
|
||||
if (wsURL === undefined) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const token = await getToken(room.name, room._id, _id, name)
|
||||
await lk.connect(wsURL, token)
|
||||
sendMessage({ type: 'connect', value: true })
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
|
||||
const token = await getToken(room.name, room._id, _id, name)
|
||||
await lk.connect(wsURL, token)
|
||||
sendMessage({ type: 'connect', value: true })
|
||||
}
|
||||
|
||||
export async function awaitConnect (): Promise<void> {
|
||||
@@ -494,6 +510,20 @@ function closeMeetingMinutes (): void {
|
||||
currentMeetingMinutes.set(undefined)
|
||||
}
|
||||
|
||||
function isRoomOpened (room: Room): boolean {
|
||||
const loc = getCurrentLocation()
|
||||
|
||||
if (loc.path[2] === loveId) {
|
||||
const panel = get(panelstore).panel
|
||||
const { _id } = panel ?? {}
|
||||
|
||||
if (_id !== undefined && room._id !== undefined && _id === room._id) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export async function setCam (value: boolean): Promise<void> {
|
||||
if (value && get(currentRoom)?.type !== RoomType.Video) return
|
||||
if ($isCurrentInstanceConnected) {
|
||||
@@ -604,13 +634,9 @@ async function moveToRoom (
|
||||
sessionId
|
||||
})
|
||||
}
|
||||
const loc = getCurrentLocation()
|
||||
if (room.type === RoomType.Video && loc.path[2] !== loveId) {
|
||||
loc.path[2] = loveId
|
||||
loc.path.length = 3
|
||||
loc.fragment = undefined
|
||||
loc.query = undefined
|
||||
navigate(loc)
|
||||
|
||||
if (!isRoomOpened(room)) {
|
||||
await navigateToOfficeDoc(client.getHierarchy(), room)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -622,63 +648,71 @@ async function connectLK (currentPerson: Person, room: Room): Promise<void> {
|
||||
])
|
||||
}
|
||||
|
||||
async function navigateToOfficeDoc (hierarchy: Hierarchy, object: Doc): Promise<void> {
|
||||
const panelComponent = hierarchy.classHierarchyMixin(object._class, view.mixin.ObjectPanel)
|
||||
const comp = panelComponent?.component ?? view.component.EditDoc
|
||||
const loc = await getObjectLinkFragment(hierarchy, object, {}, comp)
|
||||
loc.path[2] = loveId
|
||||
loc.path.length = 3
|
||||
loc.query = undefined
|
||||
navigate(loc)
|
||||
}
|
||||
|
||||
async function openMeetingMinutes (room: Room): Promise<void> {
|
||||
const client = getClient()
|
||||
const sid = await lk.getSid()
|
||||
const doc = await client.findOne(love.class.MeetingMinutes, {
|
||||
sid,
|
||||
attachedTo: room._id,
|
||||
status: MeetingStatus.Active
|
||||
})
|
||||
|
||||
if (sid !== undefined) {
|
||||
const doc = await client.findOne(love.class.MeetingMinutes, { sid })
|
||||
|
||||
if (doc === undefined) {
|
||||
const date = new Date()
|
||||
.toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
timeZone: 'UTC'
|
||||
})
|
||||
.replace(',', ' at')
|
||||
const _id = generateId<MeetingMinutes>()
|
||||
const newDoc: MeetingMinutes = {
|
||||
_id,
|
||||
_class: love.class.MeetingMinutes,
|
||||
sid,
|
||||
attachedTo: room._id,
|
||||
attachedToClass: room._class,
|
||||
collection: 'meetings',
|
||||
space: core.space.Workspace,
|
||||
title: `${getRoomName(room, get(personByIdStore))} ${date}`,
|
||||
description: makeCollaborativeDoc(_id, 'description'),
|
||||
status: MeetingStatus.Active,
|
||||
modifiedBy: getCurrentAccount()._id,
|
||||
modifiedOn: Date.now()
|
||||
}
|
||||
await client.addCollection(
|
||||
love.class.MeetingMinutes,
|
||||
core.space.Workspace,
|
||||
room._id,
|
||||
room._class,
|
||||
'meetings',
|
||||
{ sid, title: newDoc.title, description: newDoc.description, status: newDoc.status },
|
||||
_id
|
||||
)
|
||||
currentMeetingMinutes.set(newDoc)
|
||||
const loc = getCurrentLocation()
|
||||
if (loc.path[2] === loveId) {
|
||||
await openDoc(client.getHierarchy(), newDoc)
|
||||
}
|
||||
} else {
|
||||
currentMeetingMinutes.set(doc)
|
||||
const loc = getCurrentLocation()
|
||||
if (loc.path[2] === loveId) {
|
||||
await openDoc(client.getHierarchy(), doc)
|
||||
}
|
||||
if (doc.status !== MeetingStatus.Active) {
|
||||
void client.update(doc, { status: MeetingStatus.Active, meetingEnd: undefined })
|
||||
}
|
||||
if (doc === undefined) {
|
||||
const date = new Date()
|
||||
.toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
timeZone: 'UTC'
|
||||
})
|
||||
.replace(',', ' at')
|
||||
const _id = generateId<MeetingMinutes>()
|
||||
const newDoc: MeetingMinutes = {
|
||||
_id,
|
||||
_class: love.class.MeetingMinutes,
|
||||
sid,
|
||||
attachedTo: room._id,
|
||||
attachedToClass: room._class,
|
||||
collection: 'meetings',
|
||||
space: core.space.Workspace,
|
||||
title: `${getRoomName(room, get(personByIdStore))} ${date}`,
|
||||
description: makeCollaborativeDoc(_id, 'description'),
|
||||
status: MeetingStatus.Active,
|
||||
modifiedBy: getCurrentAccount()._id,
|
||||
modifiedOn: Date.now()
|
||||
}
|
||||
await client.addCollection(
|
||||
love.class.MeetingMinutes,
|
||||
core.space.Workspace,
|
||||
room._id,
|
||||
room._class,
|
||||
'meetings',
|
||||
{ sid, title: newDoc.title, description: newDoc.description, status: newDoc.status },
|
||||
_id
|
||||
)
|
||||
currentMeetingMinutes.set(newDoc)
|
||||
const loc = getCurrentLocation()
|
||||
if (loc.path[2] === loveId || room.type === RoomType.Video) {
|
||||
await navigateToOfficeDoc(client.getHierarchy(), newDoc)
|
||||
}
|
||||
} else {
|
||||
currentMeetingMinutes.set(doc)
|
||||
const loc = getCurrentLocation()
|
||||
if (loc.path[2] === loveId || room.type === RoomType.Video) {
|
||||
await navigateToOfficeDoc(client.getHierarchy(), doc)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -692,9 +726,20 @@ export async function connectRoom (
|
||||
): Promise<void> {
|
||||
await disconnect()
|
||||
await moveToRoom(x, y, currentInfo, currentPerson, room, getMetadata(presentation.metadata.SessionId) ?? null)
|
||||
await connectLK(currentPerson, room)
|
||||
selectedRoomPlace.set(undefined)
|
||||
await openMeetingMinutes(room)
|
||||
try {
|
||||
await withRetries(
|
||||
async () => {
|
||||
await connectLK(currentPerson, room)
|
||||
},
|
||||
3,
|
||||
1000
|
||||
)
|
||||
await openMeetingMinutes(room)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
await leaveRoom(currentInfo, get(myOffice))
|
||||
}
|
||||
}
|
||||
|
||||
export const joinRequest: Ref<JoinRequest> | undefined = undefined
|
||||
|
||||
@@ -15,18 +15,23 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { DateRangePresenter } from '@hcengineering/ui'
|
||||
import { DateRangeMode } from '@hcengineering/core'
|
||||
|
||||
export let value: number | null | undefined
|
||||
export let onChange: ((value: number | null) => void) | undefined = undefined
|
||||
export let noShift: boolean = false
|
||||
export let shouldShowAvatar: boolean = true
|
||||
export let accent: boolean = false
|
||||
export let inline: boolean = false
|
||||
export let mode: DateRangeMode = DateRangeMode.DATE
|
||||
export let readonly: boolean = false
|
||||
</script>
|
||||
|
||||
{#if onChange !== undefined}
|
||||
{#if onChange !== undefined && !readonly}
|
||||
<DateRangePresenter
|
||||
{value}
|
||||
{noShift}
|
||||
{mode}
|
||||
editable
|
||||
on:change={(e) => onChange?.(e.detail)}
|
||||
{shouldShowAvatar}
|
||||
@@ -34,5 +39,5 @@
|
||||
{inline}
|
||||
/>
|
||||
{:else}
|
||||
<DateRangePresenter {value} {noShift} {shouldShowAvatar} {accent} {inline} />
|
||||
<DateRangePresenter {value} {noShift} {mode} {shouldShowAvatar} {accent} {inline} />
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<!--
|
||||
// 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 { DateRangeMode } from '@hcengineering/core'
|
||||
|
||||
import DatePresenter from './DatePresenter.svelte'
|
||||
|
||||
export let value: number | null | undefined
|
||||
export let onChange: ((value: number | null) => void) | undefined = undefined
|
||||
export let noShift: boolean = false
|
||||
export let shouldShowAvatar: boolean = false
|
||||
export let accent: boolean = false
|
||||
export let inline: boolean = false
|
||||
export let readonly: boolean = false
|
||||
</script>
|
||||
|
||||
<DatePresenter
|
||||
{value}
|
||||
{onChange}
|
||||
{noShift}
|
||||
{shouldShowAvatar}
|
||||
{accent}
|
||||
{inline}
|
||||
mode={DateRangeMode.DATETIME}
|
||||
{readonly}
|
||||
/>
|
||||
@@ -98,7 +98,9 @@
|
||||
|
||||
$: if (_class !== oldClass) {
|
||||
oldClass = _class
|
||||
realObjectClass = _class
|
||||
mainEditor = undefined
|
||||
fieldEditors = []
|
||||
}
|
||||
|
||||
let keys: KeyedAttribute[] = []
|
||||
@@ -289,7 +291,7 @@
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="attributes" let:direction={dir}>
|
||||
{#if headerEditor !== undefined}
|
||||
{#if headerEditor !== undefined && object._id === _id}
|
||||
<Component
|
||||
is={headerEditor}
|
||||
props={{
|
||||
|
||||
@@ -238,13 +238,14 @@
|
||||
}
|
||||
|
||||
const joinProps = (attribute: AttributeModel, object: Doc, readonly: boolean) => {
|
||||
const readonlyParams = readonly
|
||||
? {
|
||||
readonly: true,
|
||||
editable: false,
|
||||
disabled: true
|
||||
}
|
||||
: {}
|
||||
const readonlyParams =
|
||||
readonly || (attribute?.attribute?.readonly ?? false)
|
||||
? {
|
||||
readonly: true,
|
||||
editable: false,
|
||||
disabled: true
|
||||
}
|
||||
: {}
|
||||
if (attribute.collectionAttr) {
|
||||
return { object, ...attribute.props, ...readonlyParams }
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { type Blob, type Ref } from '@hcengineering/core'
|
||||
import { getBlobRef, imageSizeToRatio, type BlobMetadata } from '@hcengineering/presentation'
|
||||
import { DrawingBoard, getBlobRef, imageSizeToRatio, type BlobMetadata } from '@hcengineering/presentation'
|
||||
import { Loading } from '@hcengineering/ui'
|
||||
|
||||
export let value: Ref<Blob>
|
||||
@@ -22,6 +22,11 @@
|
||||
export let metadata: BlobMetadata | undefined
|
||||
export let fit: boolean = false
|
||||
|
||||
export let drawingAvailable: boolean
|
||||
export let drawingEditable: boolean
|
||||
export let drawingData: any
|
||||
export let saveDrawing: (data: any) => Promise<void>
|
||||
|
||||
$: originalWidth = metadata?.originalWidth
|
||||
$: originalHeight = metadata?.originalHeight
|
||||
$: pixelRatio = metadata?.pixelRatio ?? 1
|
||||
@@ -41,16 +46,27 @@
|
||||
<Loading />
|
||||
</div>
|
||||
{/if}
|
||||
<img
|
||||
on:load={() => {
|
||||
loading = false
|
||||
}}
|
||||
<DrawingBoard
|
||||
{imageWidth}
|
||||
{imageHeight}
|
||||
{drawingData}
|
||||
{saveDrawing}
|
||||
active={drawingAvailable && !loading}
|
||||
readonly={drawingAvailable && !drawingEditable}
|
||||
class="object-contain mx-auto"
|
||||
style:max-width={width}
|
||||
style:max-height={height}
|
||||
src={blobRef.src}
|
||||
srcset={blobRef.srcset}
|
||||
alt={name}
|
||||
style:height={loading ? '0' : ''}
|
||||
/>
|
||||
style={`max-width:${width};max-height:${height}`}
|
||||
>
|
||||
<img
|
||||
on:load={() => {
|
||||
loading = false
|
||||
}}
|
||||
class="object-contain mx-auto"
|
||||
style:max-width={width}
|
||||
style:max-height={height}
|
||||
src={blobRef.src}
|
||||
srcset={blobRef.srcset}
|
||||
alt={name}
|
||||
style:height={loading ? '0' : ''}
|
||||
/>
|
||||
</DrawingBoard>
|
||||
{/await}
|
||||
|
||||
@@ -31,6 +31,7 @@ import CollaborativeHTMLEditor from './components/CollaborativeHTMLEditor.svelte
|
||||
import ColorsPopup from './components/ColorsPopup.svelte'
|
||||
import DateEditor from './components/DateEditor.svelte'
|
||||
import DatePresenter from './components/DatePresenter.svelte'
|
||||
import DateTimePresenter from './components/DateTimePresenter.svelte'
|
||||
import DocAttributeBar from './components/DocAttributeBar.svelte'
|
||||
import DocNavLink from './components/DocNavLink.svelte'
|
||||
import DocReferencePresenter from './components/DocReferencePresenter.svelte'
|
||||
@@ -257,6 +258,7 @@ export default async (): Promise<Resources> => ({
|
||||
TimestampPresenter,
|
||||
DateEditor,
|
||||
DatePresenter,
|
||||
DateTimePresenter,
|
||||
RolePresenter,
|
||||
ObjectPresenter,
|
||||
EditDoc,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import core, {
|
||||
DOMAIN_TX,
|
||||
generateId,
|
||||
MeasureMetricsContext,
|
||||
type Doc,
|
||||
type LowLevelStorage,
|
||||
type Ref,
|
||||
type TxCreateDoc
|
||||
} from '@hcengineering/core'
|
||||
import builder from '@hcengineering/model-all'
|
||||
import { wrapPipeline } from '@hcengineering/server-core'
|
||||
import { getServerPipeline } from '@hcengineering/server-pipeline'
|
||||
|
||||
const model = builder().getTxes()
|
||||
// const dbURL = 'postgresql://root@localhost:26257/defaultdb?sslmode=disable'
|
||||
const dbURL = 'postgresql://postgres:example@localhost:5432'
|
||||
const STORAGE_CONFIG = 'minio|localhost:9000?accessKey=minioadmin&secretKey=minioadmin&useSSL=false'
|
||||
describe.skip('test-backup-find', () => {
|
||||
it('check create/load/clean', async () => {
|
||||
const toolCtx = new MeasureMetricsContext('-', {})
|
||||
// We should setup a DB with docuemnts and try to backup them.
|
||||
const wsUrl = { name: 'testdb-backup-test', workspaceName: 'test', workspaceUrl: 'test' }
|
||||
const { pipeline, storageAdapter } = await getServerPipeline(toolCtx, model, dbURL, wsUrl, {
|
||||
storageConfig: STORAGE_CONFIG,
|
||||
disableTriggers: true
|
||||
})
|
||||
try {
|
||||
const client = wrapPipeline(toolCtx, pipeline, wsUrl)
|
||||
const lowLevel = pipeline.context.lowLevelStorage as LowLevelStorage
|
||||
|
||||
// We need to create a backup docs if they are missing.
|
||||
await prepareTxes(lowLevel, toolCtx)
|
||||
|
||||
const docs: Doc[] = []
|
||||
while (true) {
|
||||
const chunk = await client.loadChunk(DOMAIN_TX, 0, true)
|
||||
const part = await client.loadDocs(
|
||||
DOMAIN_TX,
|
||||
chunk.docs.map((doc) => doc.id as Ref<Doc>)
|
||||
)
|
||||
docs.push(...part)
|
||||
if (chunk.finished) {
|
||||
break
|
||||
}
|
||||
}
|
||||
await client.closeChunk(0)
|
||||
expect(docs.length).toBeGreaterThan(459)
|
||||
|
||||
await client.clean(
|
||||
DOMAIN_TX,
|
||||
docs.map((doc) => doc._id)
|
||||
)
|
||||
const findDocs = await client.findAll(core.class.Tx, {})
|
||||
expect(findDocs.length).toBe(0)
|
||||
|
||||
//
|
||||
} finally {
|
||||
await pipeline.close()
|
||||
await storageAdapter.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
async function prepareTxes (lowLevel: LowLevelStorage, toolCtx: MeasureMetricsContext): Promise<void> {
|
||||
const docs = await lowLevel.rawFindAll(DOMAIN_TX, {})
|
||||
if ((docs?.length ?? 0) < 500) {
|
||||
// We need to fill some documents to be pressent
|
||||
const docs: TxCreateDoc<Doc>[] = []
|
||||
for (let i = 0; i < 500; i++) {
|
||||
docs.push({
|
||||
_class: core.class.TxCreateDoc,
|
||||
_id: generateId(),
|
||||
space: core.space.Tx,
|
||||
modifiedBy: core.account.ConfigUser,
|
||||
modifiedOn: Date.now(),
|
||||
attributes: {
|
||||
qwe: generateId()
|
||||
},
|
||||
objectClass: core.class.DocIndexState,
|
||||
objectId: generateId(),
|
||||
objectSpace: core.space.Workspace
|
||||
})
|
||||
}
|
||||
await lowLevel.upload(toolCtx, DOMAIN_TX, docs)
|
||||
}
|
||||
}
|
||||
@@ -321,13 +321,13 @@ export async function getChunterNotificationContent (
|
||||
|
||||
body = chunter.string.MessageNotificationBody
|
||||
|
||||
if (control.hierarchy.isDerived(tx.objectClass, chunter.class.DirectMessage)) {
|
||||
if (tx.attachedToClass != null && control.hierarchy.isDerived(tx.attachedToClass, chunter.class.DirectMessage)) {
|
||||
body = chunter.string.DirectNotificationBody
|
||||
title = chunter.string.DirectNotificationTitle
|
||||
}
|
||||
}
|
||||
|
||||
if (control.hierarchy.isDerived(tx.objectClass, chunter.class.ChatMessage)) {
|
||||
if (tx.attachedToClass != null && control.hierarchy.isDerived(tx.attachedToClass, chunter.class.ChatMessage)) {
|
||||
intlParamsNotLocalized = {
|
||||
title: chunter.string.ThreadMessage
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"@hcengineering/mongo": "^0.6.1",
|
||||
"@hcengineering/postgres": "^0.6.0",
|
||||
"mongodb": "^6.10.0",
|
||||
"postgres": "^3.4.4",
|
||||
"postgres": "^3.4.5",
|
||||
"@hcengineering/platform": "^0.6.11",
|
||||
"@hcengineering/core": "^0.6.32",
|
||||
"@hcengineering/contact": "^0.6.24",
|
||||
|
||||
@@ -267,6 +267,7 @@ export class WorkspaceMongoDbCollection extends MongoDbCollection<Workspace> imp
|
||||
// at the time of retrieval and not after some additional processing.
|
||||
const query: Filter<Workspace> = {
|
||||
$and: [
|
||||
{ mode: { $ne: 'manual-creation' } },
|
||||
operationQuery,
|
||||
attemptsQuery,
|
||||
region !== '' ? { region } : defaultRegionQuery,
|
||||
|
||||
@@ -398,6 +398,7 @@ export class WorkspacePostgresDbCollection extends PostgresDbCollection<Workspac
|
||||
// TODO: support returning pending deletion workspaces when we will actually want
|
||||
// to clear them with the worker.
|
||||
|
||||
whereChunks.push("mode <> 'manual-creation'")
|
||||
whereChunks.push('(attempts IS NULL OR attempts <= 3)')
|
||||
whereChunks.push(`("lastProcessingTime" IS NULL OR "lastProcessingTime" < $${values.length + 1})`)
|
||||
values.push(Date.now() - processingTimeoutMs)
|
||||
|
||||
@@ -42,7 +42,8 @@ import core, {
|
||||
versionToString,
|
||||
WorkspaceId,
|
||||
type BackupStatus,
|
||||
type Branding
|
||||
type Branding,
|
||||
type WorkspaceMode
|
||||
} from '@hcengineering/core'
|
||||
import platform, { getMetadata, PlatformError, Severity, Status, translate } from '@hcengineering/platform'
|
||||
import { type StorageAdapter } from '@hcengineering/server-core'
|
||||
@@ -940,7 +941,8 @@ async function generateWorkspaceRecord (
|
||||
branding: Branding | null,
|
||||
workspaceName: string,
|
||||
fixedWorkspace?: string,
|
||||
region?: string
|
||||
region?: string,
|
||||
initMode: WorkspaceMode = 'pending-creation'
|
||||
): Promise<Workspace> {
|
||||
type WorkspaceData = Omit<Workspace, '_id' | 'endpoint'>
|
||||
const brandingKey = branding?.key ?? 'huly'
|
||||
@@ -972,7 +974,7 @@ async function generateWorkspaceRecord (
|
||||
accounts: [],
|
||||
disabled: true,
|
||||
region: region ?? '',
|
||||
mode: 'pending-creation',
|
||||
mode: initMode,
|
||||
progress: 0,
|
||||
createdOn: Date.now(),
|
||||
lastVisit: Date.now(),
|
||||
@@ -1010,7 +1012,7 @@ async function generateWorkspaceRecord (
|
||||
accounts: [],
|
||||
disabled: true,
|
||||
region: region ?? '',
|
||||
mode: 'pending-creation',
|
||||
mode: initMode,
|
||||
progress: 0,
|
||||
createdOn: Date.now(),
|
||||
lastVisit: Date.now(),
|
||||
@@ -1051,12 +1053,13 @@ export async function createWorkspace (
|
||||
email: string,
|
||||
workspaceName: string,
|
||||
workspace?: string,
|
||||
region?: string
|
||||
region?: string,
|
||||
initMode: WorkspaceMode = 'pending-creation'
|
||||
): Promise<Workspace> {
|
||||
// We need to search for duplicate workspaceUrl
|
||||
// Safe generate workspace record.
|
||||
return await createQueue.exec(async () => {
|
||||
return await generateWorkspaceRecord(db, email, branding, workspaceName, workspace, region)
|
||||
return await generateWorkspaceRecord(db, email, branding, workspaceName, workspace, region, initMode)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1122,6 +1125,7 @@ export async function updateWorkspaceInfo (
|
||||
if (workspaceInfo === null) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspace: workspaceId }))
|
||||
}
|
||||
progress = Math.round(progress)
|
||||
|
||||
const update: Partial<WorkspaceInfo> = {}
|
||||
switch (event) {
|
||||
|
||||
@@ -23,6 +23,7 @@ import core, {
|
||||
DOMAIN_BLOB,
|
||||
DOMAIN_DOC_INDEX_STATE,
|
||||
DOMAIN_MODEL,
|
||||
DOMAIN_MODEL_TX,
|
||||
DOMAIN_TRANSIENT,
|
||||
DOMAIN_TX,
|
||||
MeasureContext,
|
||||
@@ -789,6 +790,7 @@ export async function backup (
|
||||
const blobClient = new BlobClient(transactorUrl, token, workspaceId, { storageAdapter: options.storageAdapter })
|
||||
|
||||
const domains = [
|
||||
DOMAIN_MODEL_TX,
|
||||
...connection
|
||||
.getHierarchy()
|
||||
.domains()
|
||||
@@ -1751,6 +1753,22 @@ export async function restore (
|
||||
sendSize,
|
||||
workspace: workspaceId.name
|
||||
})
|
||||
// Correct docs without space
|
||||
for (const d of docs) {
|
||||
if (d._class === core.class.DocIndexState) {
|
||||
// We need to clean old stuff from restored document.
|
||||
if ('stages' in d) {
|
||||
delete (d as any).stages
|
||||
delete (d as any).attributes
|
||||
;(d as any).needIndex = true
|
||||
;(d as any)['%hash%'] = ''
|
||||
}
|
||||
}
|
||||
if (d.space == null) {
|
||||
d.space = core.space.Workspace
|
||||
;(d as any)['%hash%'] = ''
|
||||
}
|
||||
}
|
||||
await connection.upload(c, docs)
|
||||
docs.length = 0
|
||||
sendSize = 0
|
||||
@@ -1787,7 +1805,7 @@ export async function restore (
|
||||
chunks.push(chunk)
|
||||
})
|
||||
stream.on('end', () => {
|
||||
const bf = Buffer.concat(chunks)
|
||||
const bf = Buffer.concat(chunks as any)
|
||||
const d = blobs.get(name)
|
||||
if (d === undefined) {
|
||||
blobs.set(name, { doc: undefined, buffer: bf })
|
||||
|
||||
@@ -22,19 +22,15 @@ import core, {
|
||||
ModelDb,
|
||||
SortingOrder,
|
||||
systemAccountEmail,
|
||||
type BackupClient,
|
||||
type BackupStatus,
|
||||
type Branding,
|
||||
type Client,
|
||||
type MeasureContext,
|
||||
type Tx,
|
||||
type WorkspaceIdWithUrl
|
||||
} from '@hcengineering/core'
|
||||
import { PlatformError, unknownError } from '@hcengineering/platform'
|
||||
import { listAccountWorkspaces, updateBackupInfo } from '@hcengineering/server-client'
|
||||
import {
|
||||
BackupClientOps,
|
||||
SessionDataImpl,
|
||||
wrapPipeline,
|
||||
type DbConfiguration,
|
||||
type Pipeline,
|
||||
type PipelineFactory,
|
||||
@@ -227,7 +223,7 @@ class BackupWorker {
|
||||
if (pipeline === undefined) {
|
||||
pipeline = await this.pipelineFactory(ctx, wsUrl, true, () => {}, null)
|
||||
}
|
||||
return this.wrapPipeline(ctx, pipeline, wsUrl)
|
||||
return wrapPipeline(ctx, pipeline, wsUrl)
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -266,68 +262,6 @@ class BackupWorker {
|
||||
}
|
||||
return { failedWorkspaces, processed, skipped: workspaces.length - processed }
|
||||
}
|
||||
|
||||
wrapPipeline (ctx: MeasureContext, pipeline: Pipeline, wsUrl: WorkspaceIdWithUrl): Client & BackupClient {
|
||||
const contextData = new SessionDataImpl(
|
||||
systemAccountEmail,
|
||||
'backup',
|
||||
true,
|
||||
{ targets: {}, txes: [] },
|
||||
wsUrl,
|
||||
null,
|
||||
false,
|
||||
new Map(),
|
||||
new Map(),
|
||||
pipeline.context.modelDb
|
||||
)
|
||||
ctx.contextData = contextData
|
||||
if (pipeline.context.lowLevelStorage === undefined) {
|
||||
throw new PlatformError(unknownError('Low level storage is not available'))
|
||||
}
|
||||
const backupOps = new BackupClientOps(pipeline.context.lowLevelStorage)
|
||||
|
||||
return {
|
||||
findAll: async (_class, query, options) => {
|
||||
return await pipeline.findAll(ctx, _class, query, options)
|
||||
},
|
||||
findOne: async (_class, query, options) => {
|
||||
return (await pipeline.findAll(ctx, _class, query, { ...options, limit: 1 })).shift()
|
||||
},
|
||||
clean: async (domain, docs) => {
|
||||
await backupOps.clean(ctx, domain, docs)
|
||||
},
|
||||
close: async () => {},
|
||||
closeChunk: async (idx) => {
|
||||
await backupOps.closeChunk(ctx, idx)
|
||||
},
|
||||
getHierarchy: () => {
|
||||
return pipeline.context.hierarchy
|
||||
},
|
||||
getModel: () => {
|
||||
return pipeline.context.modelDb
|
||||
},
|
||||
loadChunk: async (domain, idx, recheck) => {
|
||||
return await backupOps.loadChunk(ctx, domain, idx, recheck)
|
||||
},
|
||||
loadDocs: async (domain, docs) => {
|
||||
return await backupOps.loadDocs(ctx, domain, docs)
|
||||
},
|
||||
upload: async (domain, docs) => {
|
||||
await backupOps.upload(ctx, domain, docs)
|
||||
},
|
||||
searchFulltext: async (query, options) => {
|
||||
return {
|
||||
docs: [],
|
||||
total: 0
|
||||
}
|
||||
},
|
||||
sendForceClose: async () => {},
|
||||
tx: async (tx) => {
|
||||
return {}
|
||||
},
|
||||
notify: (...tx) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function backupService (
|
||||
|
||||
@@ -5,20 +5,26 @@ import core, {
|
||||
getTypeOf,
|
||||
systemAccountEmail,
|
||||
type Account,
|
||||
type BackupClient,
|
||||
type Branding,
|
||||
type BrandingMap,
|
||||
type BulkUpdateEvent,
|
||||
type Class,
|
||||
type Client,
|
||||
type Doc,
|
||||
type DocInfo,
|
||||
type MeasureContext,
|
||||
type ModelDb,
|
||||
type Ref,
|
||||
type SessionData,
|
||||
type TxWorkspaceEvent,
|
||||
type WorkspaceIdWithUrl
|
||||
} from '@hcengineering/core'
|
||||
import platform, { PlatformError, Severity, Status } from '@hcengineering/platform'
|
||||
import { type Hash } from 'crypto'
|
||||
import platform, { PlatformError, Severity, Status, unknownError } from '@hcengineering/platform'
|
||||
import { createHash, type Hash } from 'crypto'
|
||||
import fs from 'fs'
|
||||
import { BackupClientOps } from './storage'
|
||||
import type { Pipeline } from './types'
|
||||
|
||||
/**
|
||||
* Return some estimation for object size
|
||||
@@ -213,3 +219,77 @@ export function loadBrandingMap (brandingPath?: string): BrandingMap {
|
||||
|
||||
return brandings
|
||||
}
|
||||
|
||||
export function toDocInfo (d: Doc, bulkUpdate: Map<Ref<Doc>, string>, recheck?: boolean): DocInfo {
|
||||
let digest: string | null = (d as any)['%hash%']
|
||||
if ('%hash%' in d) {
|
||||
delete d['%hash%']
|
||||
}
|
||||
const pos = (digest ?? '').indexOf('|')
|
||||
const oldDigest = digest
|
||||
if (digest == null || digest === '' || recheck === true) {
|
||||
const size = estimateDocSize(d)
|
||||
|
||||
const hash = createHash('sha256')
|
||||
updateHashForDoc(hash, d)
|
||||
digest = hash.digest('base64')
|
||||
const newDigest = `${digest}|${size.toString(16)}`
|
||||
|
||||
if (recheck !== true || oldDigest !== newDigest) {
|
||||
bulkUpdate.set(d._id, `${digest}|${size.toString(16)}`)
|
||||
}
|
||||
return {
|
||||
id: d._id,
|
||||
hash: digest,
|
||||
size
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
id: d._id,
|
||||
hash: pos >= 0 ? digest.slice(0, pos) : digest,
|
||||
size: pos >= 0 ? parseInt(digest.slice(pos + 1), 16) : 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function wrapPipeline (
|
||||
ctx: MeasureContext,
|
||||
pipeline: Pipeline,
|
||||
wsUrl: WorkspaceIdWithUrl
|
||||
): Client & BackupClient {
|
||||
const contextData = new SessionDataImpl(
|
||||
systemAccountEmail,
|
||||
'pipeline',
|
||||
true,
|
||||
{ targets: {}, txes: [] },
|
||||
wsUrl,
|
||||
null,
|
||||
true,
|
||||
new Map(),
|
||||
new Map(),
|
||||
pipeline.context.modelDb
|
||||
)
|
||||
ctx.contextData = contextData
|
||||
if (pipeline.context.lowLevelStorage === undefined) {
|
||||
throw new PlatformError(unknownError('Low level storage is not available'))
|
||||
}
|
||||
const backupOps = new BackupClientOps(pipeline.context.lowLevelStorage)
|
||||
|
||||
return {
|
||||
findAll: (_class, query, options) => pipeline.findAll(ctx, _class, query, options),
|
||||
findOne: async (_class, query, options) =>
|
||||
(await pipeline.findAll(ctx, _class, query, { ...options, limit: 1 })).shift(),
|
||||
clean: (domain, docs) => backupOps.clean(ctx, domain, docs),
|
||||
close: () => pipeline.close(),
|
||||
closeChunk: (idx) => backupOps.closeChunk(ctx, idx),
|
||||
getHierarchy: () => pipeline.context.hierarchy,
|
||||
getModel: () => pipeline.context.modelDb,
|
||||
loadChunk: (domain, idx, recheck) => backupOps.loadChunk(ctx, domain, idx, recheck),
|
||||
loadDocs: (domain, docs) => backupOps.loadDocs(ctx, domain, docs),
|
||||
upload: (domain, docs) => backupOps.upload(ctx, domain, docs),
|
||||
searchFulltext: async (query, options) => ({ docs: [], total: 0 }),
|
||||
sendForceClose: async () => {},
|
||||
tx: (tx) => pipeline.tx(ctx, [tx]),
|
||||
notify: (...tx) => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ import FormData from 'form-data'
|
||||
import fetch, { type RequestInit, type Response } from 'node-fetch'
|
||||
import { Readable } from 'stream'
|
||||
|
||||
import { DatalakeError, NetworkError, NotFoundError } from './error'
|
||||
|
||||
/** @public */
|
||||
export interface ObjectMetadata {
|
||||
lastModified: number
|
||||
@@ -62,14 +64,16 @@ export class Client {
|
||||
let response
|
||||
try {
|
||||
response = await fetchSafe(ctx, url)
|
||||
} catch (err) {
|
||||
console.error('failed to get object', { workspace, objectName, err })
|
||||
} catch (err: any) {
|
||||
if (err.name !== 'NotFoundError') {
|
||||
console.error('failed to get object', { workspace, objectName, err })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
if (response.body == null) {
|
||||
ctx.error('bad datalake response', { objectName })
|
||||
throw new Error('Missing response body')
|
||||
throw new DatalakeError('Missing response body')
|
||||
}
|
||||
|
||||
return Readable.from(response.body)
|
||||
@@ -90,14 +94,16 @@ export class Client {
|
||||
let response
|
||||
try {
|
||||
response = await fetchSafe(ctx, url, { headers })
|
||||
} catch (err) {
|
||||
console.error('failed to get partial object', { workspace, objectName, err })
|
||||
} catch (err: any) {
|
||||
if (err.name !== 'NotFoundError') {
|
||||
console.error('failed to get partial object', { workspace, objectName, err })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
if (response.body == null) {
|
||||
ctx.error('bad datalake response', { objectName })
|
||||
throw new Error('Missing response body')
|
||||
throw new DatalakeError('Missing response body')
|
||||
}
|
||||
|
||||
return Readable.from(response.body)
|
||||
@@ -113,7 +119,10 @@ export class Client {
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetchSafe(ctx, url, { method: 'HEAD' })
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
if (err.name === 'NotFoundError') {
|
||||
return
|
||||
}
|
||||
console.error('failed to stat object', { workspace, objectName, err })
|
||||
throw err
|
||||
}
|
||||
@@ -134,9 +143,11 @@ export class Client {
|
||||
const url = this.getObjectUrl(ctx, workspace, objectName)
|
||||
try {
|
||||
await fetchSafe(ctx, url, { method: 'DELETE' })
|
||||
} catch (err) {
|
||||
console.error('failed to delete object', { workspace, objectName, err })
|
||||
throw err
|
||||
} catch (err: any) {
|
||||
if (err.name !== 'NotFoundError') {
|
||||
console.error('failed to delete object', { workspace, objectName, err })
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,7 +198,7 @@ export class Client {
|
||||
|
||||
const form = new FormData()
|
||||
const options: FormData.AppendOptions = {
|
||||
filename: encodeURIComponent(objectName),
|
||||
filename: objectName,
|
||||
contentType: metadata.type,
|
||||
knownLength: metadata.size,
|
||||
header: {
|
||||
@@ -200,13 +211,13 @@ export class Client {
|
||||
|
||||
const result = (await response.json()) as BlobUploadResult[]
|
||||
if (result.length !== 1) {
|
||||
throw new Error('Bad datalake response: ' + result.toString())
|
||||
throw new DatalakeError('Bad datalake response: ' + result.toString())
|
||||
}
|
||||
|
||||
const uploadResult = result[0]
|
||||
|
||||
if ('error' in uploadResult) {
|
||||
throw new Error('Upload failed: ' + uploadResult.error)
|
||||
throw new DatalakeError('Upload failed: ' + uploadResult.error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,7 +243,7 @@ export class Client {
|
||||
} catch (err) {
|
||||
ctx.error('failed to upload via signed url', { workspace, objectName, err })
|
||||
await this.signObjectDelete(ctx, workspace, objectName)
|
||||
throw new Error('Failed to upload via signed URL')
|
||||
throw new DatalakeError('Failed to upload via signed URL')
|
||||
}
|
||||
|
||||
await this.signObjectComplete(ctx, workspace, objectName)
|
||||
@@ -245,7 +256,7 @@ export class Client {
|
||||
return await response.text()
|
||||
} catch (err: any) {
|
||||
ctx.error('failed to sign object', { workspace, objectName, err })
|
||||
throw new Error('Failed to sign URL')
|
||||
throw new DatalakeError('Failed to sign URL')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,7 +266,7 @@ export class Client {
|
||||
await fetchSafe(ctx, url, { method: 'PUT' })
|
||||
} catch (err: any) {
|
||||
ctx.error('failed to complete signed url upload', { workspace, objectName, err })
|
||||
throw new Error('Failed to complete signed URL upload')
|
||||
throw new DatalakeError('Failed to complete signed URL upload')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,7 +276,7 @@ export class Client {
|
||||
await fetchSafe(ctx, url, { method: 'DELETE' })
|
||||
} catch (err: any) {
|
||||
ctx.error('failed to abort signed url upload', { workspace, objectName, err })
|
||||
throw new Error('Failed to abort signed URL upload')
|
||||
throw new DatalakeError('Failed to abort signed URL upload')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,12 +292,16 @@ async function fetchSafe (ctx: MeasureContext, url: string, init?: RequestInit):
|
||||
response = await fetch(url, init)
|
||||
} catch (err: any) {
|
||||
ctx.error('network error', { err })
|
||||
throw new Error(`Network error ${err}`)
|
||||
throw new NetworkError(`Network error ${err}`)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
throw new Error(response.status === 404 ? 'Not Found' : 'HTTP error ' + response.status + ': ' + text)
|
||||
if (response.status === 404) {
|
||||
throw new NotFoundError(text)
|
||||
} else {
|
||||
throw new DatalakeError(text)
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
|
||||
export class NetworkError extends Error {
|
||||
constructor (message: string) {
|
||||
super(message)
|
||||
this.name = 'NetworkError'
|
||||
}
|
||||
}
|
||||
|
||||
export class DatalakeError extends Error {
|
||||
constructor (message: string) {
|
||||
super(message)
|
||||
this.name = 'DatalakeError'
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends DatalakeError {
|
||||
constructor (message = 'Not Found') {
|
||||
super(message)
|
||||
this.name = 'NotFoundError'
|
||||
}
|
||||
}
|
||||
@@ -35,9 +35,11 @@ export interface DatalakeConfig extends StorageConfig {
|
||||
*/
|
||||
export class DatalakeService implements StorageAdapter {
|
||||
static config = 'datalake'
|
||||
client: Client
|
||||
private readonly client: Client
|
||||
|
||||
constructor (readonly opt: DatalakeConfig) {
|
||||
this.client = new Client(opt.endpoint)
|
||||
const endpoint = Number.isInteger(opt.port) ? `${opt.endpoint}:${opt.port}` : opt.endpoint
|
||||
this.client = new Client(endpoint)
|
||||
}
|
||||
|
||||
async initialize (ctx: MeasureContext, workspaceId: WorkspaceId): Promise<void> {}
|
||||
@@ -97,8 +99,6 @@ export class DatalakeService implements StorageAdapter {
|
||||
modifiedOn: result.lastModified,
|
||||
version: null
|
||||
}
|
||||
} else {
|
||||
ctx.error('no object found', { objectName, workspaceId: workspaceId.name })
|
||||
}
|
||||
} catch (err) {
|
||||
ctx.error('failed to stat object', { error: err, objectName, workspaceId: workspaceId.name })
|
||||
|
||||
@@ -63,7 +63,7 @@ import core, {
|
||||
} from '@hcengineering/core'
|
||||
import {
|
||||
estimateDocSize,
|
||||
updateHashForDoc,
|
||||
toDocInfo,
|
||||
type DbAdapter,
|
||||
type DbAdapterHandler,
|
||||
type DomainHelperOperations,
|
||||
@@ -71,8 +71,6 @@ import {
|
||||
type StorageAdapter,
|
||||
type TxAdapter
|
||||
} from '@hcengineering/server-core'
|
||||
import { calculateObjectSize } from 'bson'
|
||||
import { createHash } from 'crypto'
|
||||
import {
|
||||
type AbstractCursor,
|
||||
type AnyBulkWriteOperation,
|
||||
@@ -1062,15 +1060,16 @@ abstract class MongoAdapterBase implements DbAdapter {
|
||||
if (d == null && mode === 'hashed' && recheck !== true) {
|
||||
mode = 'non-hashed'
|
||||
await iterator.close()
|
||||
await flush(true) // We need to flush, so wrong id documents will be updated.
|
||||
iterator = coll.find({ '%hash%': { $in: ['', null] } })
|
||||
d = await ctx.with('next', { mode }, () => iterator.next())
|
||||
}
|
||||
const result: DocInfo[] = []
|
||||
if (d != null) {
|
||||
result.push(this.toDocInfo(d, bulkUpdate, recheck))
|
||||
result.push(toDocInfo(d, bulkUpdate, recheck))
|
||||
}
|
||||
if (iterator.bufferedCount() > 0) {
|
||||
result.push(...iterator.readBufferedDocuments().map((it) => this.toDocInfo(it, bulkUpdate, recheck)))
|
||||
result.push(...iterator.readBufferedDocuments().map((it) => toDocInfo(it, bulkUpdate, recheck)))
|
||||
}
|
||||
await ctx.with('flush', {}, () => flush())
|
||||
return result
|
||||
@@ -1083,42 +1082,6 @@ abstract class MongoAdapterBase implements DbAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
private toDocInfo (d: Doc, bulkUpdate: Map<Ref<Doc>, string>, recheck?: boolean): DocInfo {
|
||||
let digest: string | null = (d as any)['%hash%']
|
||||
if ('%hash%' in d) {
|
||||
delete d['%hash%']
|
||||
}
|
||||
const pos = (digest ?? '').indexOf('|')
|
||||
const oldDigest = digest
|
||||
if (digest == null || digest === '' || recheck === true) {
|
||||
let size = estimateDocSize(d)
|
||||
|
||||
if (this.options?.calculateHash !== undefined) {
|
||||
;({ digest, size } = this.options.calculateHash(d))
|
||||
} else {
|
||||
const hash = createHash('sha256')
|
||||
updateHashForDoc(hash, d)
|
||||
digest = hash.digest('base64')
|
||||
}
|
||||
const newDigest = `${digest}|${size.toString(16)}`
|
||||
|
||||
if (recheck !== true || oldDigest !== newDigest) {
|
||||
bulkUpdate.set(d._id, `${digest}|${size.toString(16)}`)
|
||||
}
|
||||
return {
|
||||
id: d._id,
|
||||
hash: digest,
|
||||
size
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
id: d._id,
|
||||
hash: digest.slice(0, pos),
|
||||
size: parseInt(digest.slice(pos + 1), 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
load (ctx: MeasureContext, domain: Domain, docs: Ref<Doc>[]): Promise<Doc[]> {
|
||||
return ctx.with('load', { domain }, async () => {
|
||||
if (docs.length === 0) {
|
||||
@@ -1678,8 +1641,7 @@ export async function uploadDocuments (ctx: MeasureContext, docs: Doc[], coll: C
|
||||
if ('%hash%' in it) {
|
||||
delete it['%hash%']
|
||||
}
|
||||
const size = digest != null ? calculateObjectSize(it) : 0
|
||||
|
||||
const size = digest != null ? estimateDocSize(it) : 0
|
||||
return {
|
||||
replaceOne: {
|
||||
filter: { _id: it._id },
|
||||
|
||||
@@ -34,8 +34,7 @@
|
||||
"@types/node": "~20.11.16"
|
||||
},
|
||||
"dependencies": {
|
||||
"pg": "8.12.0",
|
||||
"postgres": "^3.4.4",
|
||||
"postgres": "^3.4.5",
|
||||
"@hcengineering/core": "^0.6.32",
|
||||
"@hcengineering/platform": "^0.6.11",
|
||||
"@hcengineering/server-core": "^0.6.1"
|
||||
|
||||
+153
-109
@@ -60,12 +60,11 @@ import {
|
||||
type DomainHelperOperations,
|
||||
estimateDocSize,
|
||||
type ServerFindOptions,
|
||||
type TxAdapter,
|
||||
updateHashForDoc
|
||||
toDocInfo,
|
||||
type TxAdapter
|
||||
} from '@hcengineering/server-core'
|
||||
import { createHash } from 'crypto'
|
||||
import type postgres from 'postgres'
|
||||
import { getDocFieldsByDomains, translateDomain } from './schemas'
|
||||
import { getDocFieldsByDomains, getSchema, translateDomain } from './schemas'
|
||||
import { type ValueType } from './types'
|
||||
import {
|
||||
convertDoc,
|
||||
@@ -173,6 +172,7 @@ abstract class PostgresAdapterBase implements DbAdapter {
|
||||
query: DocumentQuery<T>,
|
||||
options?: Pick<FindOptions<T>, 'sort' | 'limit' | 'projection'>
|
||||
): Promise<Iterator<T>> {
|
||||
const schema = getSchema(_domain)
|
||||
const client = await this.client.reserve()
|
||||
let closed = false
|
||||
const cursorName = `cursor_${translateDomain(this.workspaceId.name)}_${translateDomain(_domain)}_${generateId()}`
|
||||
@@ -209,7 +209,7 @@ abstract class PostgresAdapterBase implements DbAdapter {
|
||||
await close(cursorName)
|
||||
return null
|
||||
}
|
||||
return result.map((p) => parseDoc(p as any, _domain))
|
||||
return result.map((p) => parseDoc(p as any, schema))
|
||||
}
|
||||
|
||||
await init()
|
||||
@@ -306,12 +306,14 @@ abstract class PostgresAdapterBase implements DbAdapter {
|
||||
const res = await client.unsafe(
|
||||
`SELECT * FROM ${translateDomain(domain)} WHERE ${translatedQuery} FOR UPDATE`
|
||||
)
|
||||
const docs = res.map((p) => parseDoc(p as any, domain))
|
||||
const schema = getSchema(domain)
|
||||
const docs = res.map((p) => parseDoc(p as any, schema))
|
||||
const domainFields = new Set(getDocFieldsByDomains(domain))
|
||||
for (const doc of docs) {
|
||||
if (doc === undefined) continue
|
||||
const prevAttachedTo = (doc as any).attachedTo
|
||||
TxProcessor.applyUpdate(doc, operations)
|
||||
const converted = convertDoc(domain, doc, this.workspaceId.name)
|
||||
const converted = convertDoc(domain, doc, this.workspaceId.name, domainFields)
|
||||
const params: any[] = [doc._id, this.workspaceId.name]
|
||||
let paramsIndex = params.length + 1
|
||||
const updates: string[] = []
|
||||
@@ -516,6 +518,7 @@ abstract class PostgresAdapterBase implements DbAdapter {
|
||||
let joinIndex: number | undefined
|
||||
let skip = false
|
||||
try {
|
||||
const schema = getSchema(domain)
|
||||
for (const column in row) {
|
||||
if (column.startsWith('reverse_lookup_')) {
|
||||
if (row[column] != null) {
|
||||
@@ -527,7 +530,7 @@ abstract class PostgresAdapterBase implements DbAdapter {
|
||||
if (res === undefined) continue
|
||||
const { obj, key } = res
|
||||
|
||||
const parsed = row[column].map((p: any) => parseDoc(p, domain))
|
||||
const parsed = row[column].map((p: any) => parseDoc(p, schema))
|
||||
obj[key] = parsed
|
||||
}
|
||||
} else if (column.startsWith('lookup_')) {
|
||||
@@ -1112,115 +1115,127 @@ abstract class PostgresAdapterBase implements DbAdapter {
|
||||
find (_ctx: MeasureContext, domain: Domain, recheck?: boolean): StorageIterator {
|
||||
const ctx = _ctx.newChild('find', { domain })
|
||||
|
||||
const getCursorName = (): string => {
|
||||
return `cursor_${translateDomain(this.workspaceId.name)}_${translateDomain(domain)}_${mode}`
|
||||
}
|
||||
|
||||
let initialized: boolean = false
|
||||
let client: postgres.ReservedSql
|
||||
let mode: 'hashed' | 'non_hashed' = 'hashed'
|
||||
let cursorName = getCursorName()
|
||||
const bulkUpdate = new Map<Ref<Doc>, string>()
|
||||
|
||||
const close = async (cursorName: string): Promise<void> => {
|
||||
try {
|
||||
await client.unsafe(`CLOSE ${cursorName}`)
|
||||
await client.unsafe('COMMIT')
|
||||
} catch (err) {
|
||||
ctx.error('Error while closing cursor', { cursorName, err })
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
}
|
||||
|
||||
const init = async (projection: string, query: string): Promise<void> => {
|
||||
cursorName = getCursorName()
|
||||
client = await this.client.reserve()
|
||||
await client.unsafe('BEGIN')
|
||||
await client.unsafe(
|
||||
`DECLARE ${cursorName} CURSOR FOR SELECT ${projection} FROM ${translateDomain(domain)} WHERE "workspaceId" = $1 AND ${query}`,
|
||||
[this.workspaceId.name]
|
||||
)
|
||||
}
|
||||
|
||||
const next = async (limit: number): Promise<Doc[]> => {
|
||||
const result = await client.unsafe(`FETCH ${limit} FROM ${cursorName}`)
|
||||
if (result.length === 0) {
|
||||
return []
|
||||
}
|
||||
return result.filter((it) => it != null).map((it) => parseDoc(it as any, domain))
|
||||
}
|
||||
const tdomain = translateDomain(domain)
|
||||
const schema = getSchema(domain)
|
||||
|
||||
const flush = async (flush = false): Promise<void> => {
|
||||
if (bulkUpdate.size > 1000 || flush) {
|
||||
if (bulkUpdate.size > 0) {
|
||||
await ctx.with('bulk-write-find', {}, () => {
|
||||
const updates = new Map(Array.from(bulkUpdate.entries()).map((it) => [it[0], { '%hash%': it[1] }]))
|
||||
return this.update(ctx, domain, updates)
|
||||
})
|
||||
const entries = Array.from(bulkUpdate.entries())
|
||||
bulkUpdate.clear()
|
||||
const updateClient = await this.client.reserve()
|
||||
try {
|
||||
while (entries.length > 0) {
|
||||
const part = entries.splice(0, 200)
|
||||
const data: string[] = part.flat()
|
||||
const indexes = part.map((val, idx) => `($${2 * idx + 1}::text, $${2 * idx + 2}::text)`).join(', ')
|
||||
await ctx.with('bulk-write-find', {}, () => {
|
||||
return this.retryTxn(updateClient, (client) =>
|
||||
client.unsafe(
|
||||
`
|
||||
UPDATE ${tdomain} SET "%hash%" = update_data.hash
|
||||
FROM (values ${indexes}) AS update_data(_id, hash)
|
||||
WHERE ${tdomain}."workspaceId" = '${this.workspaceId.name}' AND ${tdomain}."_id" = update_data._id
|
||||
`,
|
||||
data
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
} catch (err: any) {
|
||||
ctx.error('failed to update hash', { err })
|
||||
} finally {
|
||||
updateClient.release()
|
||||
}
|
||||
}
|
||||
bulkUpdate.clear()
|
||||
}
|
||||
}
|
||||
|
||||
const workspaceId = this.workspaceId
|
||||
|
||||
async function * createBulk (projection: string, query: string, limit = 50): AsyncGenerator<Doc[]> {
|
||||
const cursor = client
|
||||
.unsafe(`SELECT ${projection} FROM ${tdomain} WHERE "workspaceId" = '${workspaceId.name}' AND ${query}`)
|
||||
.cursor(limit)
|
||||
try {
|
||||
for await (const part of cursor) {
|
||||
yield part.filter((it) => it != null).map((it) => parseDoc(it as any, schema))
|
||||
}
|
||||
} catch (err: any) {
|
||||
ctx.error('failed to recieve data', { err })
|
||||
}
|
||||
}
|
||||
let bulk: AsyncGenerator<Doc[]>
|
||||
let forcedRecheck = false
|
||||
|
||||
return {
|
||||
next: async () => {
|
||||
if (!initialized) {
|
||||
if (recheck === true) {
|
||||
await this.retryTxn(client, async (client) => {
|
||||
await client`UPDATE ${client(translateDomain(domain))} SET '%hash%' = NULL WHERE "workspaceId" = ${this.workspaceId.name} AND '%hash%' IS NOT NULL`
|
||||
})
|
||||
if (client === undefined) {
|
||||
client = await this.client.reserve()
|
||||
}
|
||||
await init('_id, data', "'%hash%' IS NOT NULL AND '%hash%' <> ''")
|
||||
|
||||
if (recheck === true) {
|
||||
await this.retryTxn(
|
||||
client,
|
||||
(client) =>
|
||||
client`UPDATE ${client(tdomain)} SET "%hash%" = NULL WHERE "workspaceId" = ${this.workspaceId.name} AND "%hash%" IS NOT NULL`
|
||||
)
|
||||
}
|
||||
|
||||
initialized = true
|
||||
await flush(true) // We need to flush, so wrong id documents will be updated.
|
||||
bulk = createBulk('_id, "%hash%"', '"%hash%" IS NOT NULL AND "%hash%" <> \'\'')
|
||||
// bulk = createBulk('_id, "%hash%, data', '"%hash%" IS NOT NULL AND "%hash%" <> \'\'')
|
||||
}
|
||||
let docs = await ctx.with('next', { mode }, () => next(50))
|
||||
if (docs.length === 0 && mode === 'hashed') {
|
||||
await close(cursorName)
|
||||
|
||||
let docs = await ctx.with('next', { mode }, () => bulk.next())
|
||||
|
||||
if (!forcedRecheck && docs.done !== true && docs.value?.length > 0) {
|
||||
// Check if we have wrong hash stored, and update all of them.
|
||||
forcedRecheck = true
|
||||
|
||||
for (const d of docs.value) {
|
||||
const digest: string | null = (d as any)['%hash%']
|
||||
|
||||
const pos = (digest ?? '').indexOf('|')
|
||||
if (pos === -1) {
|
||||
await bulk.return([]) // We need to close generator
|
||||
docs = { done: true, value: undefined }
|
||||
await this.retryTxn(
|
||||
client,
|
||||
(client) =>
|
||||
client`UPDATE ${client(tdomain)} SET "%hash%" = NULL WHERE "workspaceId" = ${this.workspaceId.name} AND "%hash%" IS NOT NULL`
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ((docs.done === true || docs.value.length === 0) && mode === 'hashed') {
|
||||
forcedRecheck = true
|
||||
mode = 'non_hashed'
|
||||
await init('*', "'%hash%' IS NULL OR '%hash%' = ''")
|
||||
docs = await ctx.with('next', { mode }, () => next(50))
|
||||
bulk = createBulk('*', '"%hash%" IS NULL OR "%hash%" = \'\'')
|
||||
docs = await ctx.with('next', { mode }, () => bulk.next())
|
||||
}
|
||||
if (docs.length === 0) {
|
||||
if (docs.done === true || docs.value.length === 0) {
|
||||
return []
|
||||
}
|
||||
const result: DocInfo[] = []
|
||||
for (const d of docs) {
|
||||
let digest: string | null = (d as any)['%hash%']
|
||||
if ('%hash%' in d) {
|
||||
delete d['%hash%']
|
||||
}
|
||||
const pos = (digest ?? '').indexOf('|')
|
||||
if (digest == null || digest === '') {
|
||||
const cs = ctx.newChild('calc-size', {})
|
||||
const size = estimateDocSize(d)
|
||||
cs.end()
|
||||
|
||||
const hash = createHash('sha256')
|
||||
updateHashForDoc(hash, d)
|
||||
digest = hash.digest('base64')
|
||||
|
||||
bulkUpdate.set(d._id, `${digest}|${size.toString(16)}`)
|
||||
|
||||
await ctx.with('flush', {}, () => flush())
|
||||
result.push({
|
||||
id: d._id,
|
||||
hash: digest,
|
||||
size
|
||||
})
|
||||
} else {
|
||||
result.push({
|
||||
id: d._id,
|
||||
hash: digest.slice(0, pos),
|
||||
size: parseInt(digest.slice(pos + 1), 16)
|
||||
})
|
||||
}
|
||||
for (const d of docs.value) {
|
||||
result.push(toDocInfo(d, bulkUpdate))
|
||||
}
|
||||
await ctx.with('flush', {}, () => flush())
|
||||
return result
|
||||
},
|
||||
close: async () => {
|
||||
await ctx.with('flush', {}, () => flush(true))
|
||||
await close(cursorName)
|
||||
client?.release()
|
||||
ctx.end()
|
||||
}
|
||||
}
|
||||
@@ -1231,16 +1246,16 @@ abstract class PostgresAdapterBase implements DbAdapter {
|
||||
if (docs.length === 0) {
|
||||
return []
|
||||
}
|
||||
const connection = (await this.getConnection(ctx)) ?? this.client
|
||||
const res =
|
||||
await connection`SELECT * FROM ${connection(translateDomain(domain))} WHERE _id = ANY(${docs}) AND "workspaceId" = ${this.workspaceId.name}`
|
||||
return res.map((p) => parseDocWithProjection(p as any, domain))
|
||||
return await this.withConnection(ctx, async (connection) => {
|
||||
const res =
|
||||
await connection`SELECT * FROM ${connection(translateDomain(domain))} WHERE _id = ANY(${docs}) AND "workspaceId" = ${this.workspaceId.name}`
|
||||
return res.map((p) => parseDocWithProjection(p as any, domain))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
upload (ctx: MeasureContext, domain: Domain, docs: Doc[]): Promise<void> {
|
||||
return ctx.with('upload', { domain }, async (ctx) => {
|
||||
const arr = docs.concat()
|
||||
const fields = getDocFieldsByDomains(domain)
|
||||
const filedsWithData = [...fields, 'data']
|
||||
const insertFields: string[] = []
|
||||
@@ -1252,15 +1267,26 @@ abstract class PostgresAdapterBase implements DbAdapter {
|
||||
const insertStr = insertFields.join(', ')
|
||||
const onConflictStr = onConflict.join(', ')
|
||||
await this.withConnection(ctx, async (connection) => {
|
||||
while (arr.length > 0) {
|
||||
const part = arr.splice(0, 500)
|
||||
const domainFields = new Set(getDocFieldsByDomains(domain))
|
||||
const toUpload = [...docs]
|
||||
const tdomain = translateDomain(domain)
|
||||
while (toUpload.length > 0) {
|
||||
const part = toUpload.splice(0, 200)
|
||||
const values: any[] = []
|
||||
const vars: string[] = []
|
||||
let index = 1
|
||||
for (let i = 0; i < part.length; i++) {
|
||||
const doc = part[i]
|
||||
const variables: string[] = []
|
||||
const d = convertDoc(domain, doc, this.workspaceId.name)
|
||||
|
||||
const digest: string | null = (doc as any)['%hash%']
|
||||
if ('%hash%' in doc) {
|
||||
delete doc['%hash%']
|
||||
}
|
||||
const size = digest != null ? estimateDocSize(doc) : 0
|
||||
;(doc as any)['%hash%'] = digest == null ? null : `${digest}|${size.toString(16)}`
|
||||
const d = convertDoc(domain, doc, this.workspaceId.name, domainFields)
|
||||
|
||||
values.push(d.workspaceId)
|
||||
variables.push(`$${index++}`)
|
||||
for (const field of fields) {
|
||||
@@ -1273,21 +1299,36 @@ abstract class PostgresAdapterBase implements DbAdapter {
|
||||
}
|
||||
|
||||
const vals = vars.join(',')
|
||||
await this.retryTxn(connection, async (client) => {
|
||||
await client.unsafe(
|
||||
`INSERT INTO ${translateDomain(domain)} ("workspaceId", ${insertStr}) VALUES ${vals}
|
||||
await this.retryTxn(connection, (client) =>
|
||||
client.unsafe(
|
||||
`INSERT INTO ${tdomain} ("workspaceId", ${insertStr}) VALUES ${vals}
|
||||
ON CONFLICT ("workspaceId", _id) DO UPDATE SET ${onConflictStr};`,
|
||||
values
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async clean (ctx: MeasureContext, domain: Domain, docs: Ref<Doc>[]): Promise<void> {
|
||||
const connection = (await this.getConnection(ctx)) ?? this.client
|
||||
await connection`DELETE FROM ${connection(translateDomain(domain))} WHERE _id = ANY(${docs}) AND "workspaceId" = ${this.workspaceId.name}`
|
||||
const updateClient = await this.client.reserve()
|
||||
try {
|
||||
const tdomain = translateDomain(domain)
|
||||
const toClean = [...docs]
|
||||
while (toClean.length > 0) {
|
||||
const part = toClean.splice(0, 200)
|
||||
await ctx.with('clean', {}, () => {
|
||||
return this.retryTxn(
|
||||
updateClient,
|
||||
(client) =>
|
||||
client`DELETE FROM ${client(tdomain)} WHERE _id = ANY(${part}) AND "workspaceId" = ${this.workspaceId.name}`
|
||||
)
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
updateClient.release()
|
||||
}
|
||||
}
|
||||
|
||||
groupBy<T, P extends Doc>(
|
||||
@@ -1318,8 +1359,10 @@ abstract class PostgresAdapterBase implements DbAdapter {
|
||||
try {
|
||||
const res =
|
||||
await client`SELECT * FROM ${client(translateDomain(domain))} WHERE _id = ANY(${ids}) AND "workspaceId" = ${this.workspaceId.name} FOR UPDATE`
|
||||
const docs = res.map((p) => parseDoc(p as any, domain))
|
||||
const schema = getSchema(domain)
|
||||
const docs = res.map((p) => parseDoc(p as any, schema))
|
||||
const map = new Map(docs.map((d) => [d._id, d]))
|
||||
const domainFields = new Set(getDocFieldsByDomains(domain))
|
||||
for (const [_id, ops] of operations) {
|
||||
const doc = map.get(_id)
|
||||
if (doc === undefined) continue
|
||||
@@ -1328,7 +1371,7 @@ abstract class PostgresAdapterBase implements DbAdapter {
|
||||
;(op as any)['%hash%'] = null
|
||||
}
|
||||
TxProcessor.applyUpdate(doc, op)
|
||||
const converted = convertDoc(domain, doc, this.workspaceId.name)
|
||||
const converted = convertDoc(domain, doc, this.workspaceId.name, domainFields)
|
||||
|
||||
const columns: string[] = []
|
||||
const { extractedFields, remainingData } = parseUpdate(domain, op)
|
||||
@@ -1361,12 +1404,13 @@ abstract class PostgresAdapterBase implements DbAdapter {
|
||||
columns.push(field)
|
||||
}
|
||||
await this.withConnection(ctx, async (connection) => {
|
||||
const domainFields = new Set(getDocFieldsByDomains(domain))
|
||||
while (docs.length > 0) {
|
||||
const part = docs.splice(0, 500)
|
||||
const values: DBDoc[] = []
|
||||
for (let i = 0; i < part.length; i++) {
|
||||
const doc = part[i]
|
||||
const d = convertDoc(domain, doc, this.workspaceId.name)
|
||||
const d = convertDoc(domain, doc, this.workspaceId.name, domainFields)
|
||||
values.push(d)
|
||||
}
|
||||
await this.retryTxn(connection, async (client) => {
|
||||
@@ -1559,14 +1603,14 @@ class PostgresAdapter extends PostgresAdapterBase {
|
||||
_id: Ref<Doc>,
|
||||
forUpdate: boolean = false
|
||||
): Promise<Doc | undefined> {
|
||||
const domain = this.hierarchy.getDomain(_class)
|
||||
return ctx.with('find-doc', { _class }, async () => {
|
||||
const res =
|
||||
await client`SELECT * FROM ${this.client(translateDomain(this.hierarchy.getDomain(_class)))} WHERE _id = ${_id} AND "workspaceId" = ${this.workspaceId.name} ${
|
||||
await client`SELECT * FROM ${this.client(translateDomain(domain))} WHERE _id = ${_id} AND "workspaceId" = ${this.workspaceId.name} ${
|
||||
forUpdate ? client` FOR UPDATE` : client``
|
||||
}`
|
||||
const dbDoc = res[0]
|
||||
const domain = this.hierarchy.getDomain(_class)
|
||||
return dbDoc !== undefined ? parseDoc(dbDoc as any, domain) : undefined
|
||||
return dbDoc !== undefined ? parseDoc(dbDoc as any, getSchema(domain)) : undefined
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1622,7 +1666,7 @@ class PostgresTxAdapter extends PostgresAdapterBase implements TxAdapter {
|
||||
const res = await this
|
||||
.client`SELECT * FROM ${this.client(translateDomain(DOMAIN_MODEL_TX))} WHERE "workspaceId" = ${this.workspaceId.name} ORDER BY _id ASC, "modifiedOn" ASC`
|
||||
|
||||
const model = res.map((p) => parseDoc<Tx>(p as any, DOMAIN_MODEL_TX))
|
||||
const model = res.map((p) => parseDoc<Tx>(p as any, getSchema(DOMAIN_MODEL_TX)))
|
||||
// We need to put all core.account.System transactions first
|
||||
const systemTx: Tx[] = []
|
||||
const userTx: Tx[] = []
|
||||
|
||||
@@ -195,7 +195,6 @@ class PostgresClientReferenceImpl {
|
||||
this.onclose()
|
||||
const cl = await this.client
|
||||
await cl.end()
|
||||
console.log('Closed postgres connection')
|
||||
})()
|
||||
}
|
||||
}
|
||||
@@ -261,7 +260,12 @@ export function getDBClient (connectionString: string, database?: string): Postg
|
||||
return new ClientRef(existing)
|
||||
}
|
||||
|
||||
export function convertDoc<T extends Doc> (domain: string, doc: T, workspaceId: string): DBDoc {
|
||||
export function convertDoc<T extends Doc> (
|
||||
domain: string,
|
||||
doc: T,
|
||||
workspaceId: string,
|
||||
domainFields?: Set<string>
|
||||
): DBDoc {
|
||||
const extractedFields: Doc & Record<string, any> = {
|
||||
_id: doc._id,
|
||||
space: doc.space,
|
||||
@@ -273,9 +277,15 @@ export function convertDoc<T extends Doc> (domain: string, doc: T, workspaceId:
|
||||
}
|
||||
const remainingData: Partial<T> = {}
|
||||
|
||||
const extractedFieldsKeys = new Set(Object.keys(extractedFields))
|
||||
|
||||
domainFields = domainFields ?? new Set(getDocFieldsByDomains(domain))
|
||||
|
||||
for (const key in doc) {
|
||||
if (Object.keys(extractedFields).includes(key)) continue
|
||||
if (getDocFieldsByDomains(domain).includes(key)) {
|
||||
if (extractedFieldsKeys.has(key)) {
|
||||
continue
|
||||
}
|
||||
if (domainFields.has(key)) {
|
||||
extractedFields[key] = doc[key]
|
||||
} else {
|
||||
remainingData[key] = doc[key]
|
||||
@@ -432,8 +442,7 @@ export function parseDocWithProjection<T extends Doc> (
|
||||
return res
|
||||
}
|
||||
|
||||
export function parseDoc<T extends Doc> (doc: DBDoc, domain: string): T {
|
||||
const schema = getSchema(domain)
|
||||
export function parseDoc<T extends Doc> (doc: DBDoc, schema: Schema): T {
|
||||
const { workspaceId, data, ...rest } = doc
|
||||
for (const key in rest) {
|
||||
if ((rest as any)[key] === 'NULL' || (rest as any)[key] === null) {
|
||||
|
||||
@@ -68,10 +68,6 @@ export function getTxAdapterFactory (
|
||||
workspace: WorkspaceIdWithUrl,
|
||||
branding: Branding | null,
|
||||
opt: {
|
||||
fullTextUrl: string
|
||||
rekoniUrl: string
|
||||
indexProcessing: number // 1000
|
||||
indexParallel: number // 2
|
||||
disableTriggers?: boolean
|
||||
usePassedCtx?: boolean
|
||||
|
||||
@@ -199,19 +195,23 @@ export async function getServerPipeline (
|
||||
ctx: MeasureContext,
|
||||
model: Tx[],
|
||||
dbUrl: string,
|
||||
wsUrl: WorkspaceIdWithUrl
|
||||
wsUrl: WorkspaceIdWithUrl,
|
||||
opt?: {
|
||||
storageConfig: string
|
||||
disableTriggers?: boolean
|
||||
}
|
||||
): Promise<{
|
||||
pipeline: Pipeline
|
||||
storageAdapter: StorageAdapter
|
||||
}> {
|
||||
const storageConfig: StorageConfiguration = storageConfigFromEnv()
|
||||
const storageConfig: StorageConfiguration = storageConfigFromEnv(opt?.storageConfig)
|
||||
|
||||
const storageAdapter = buildStorageFromConfig(storageConfig)
|
||||
|
||||
const pipelineFactory = createServerPipeline(ctx, dbUrl, model, {
|
||||
externalStorage: storageAdapter,
|
||||
usePassedCtx: true,
|
||||
disableTriggers: false
|
||||
disableTriggers: opt?.disableTriggers ?? false
|
||||
})
|
||||
|
||||
try {
|
||||
|
||||
@@ -237,13 +237,14 @@ export class ClientSession implements Session {
|
||||
return this.ops
|
||||
}
|
||||
|
||||
async loadChunk (_ctx: ClientSessionCtx, domain: Domain, idx?: number, recheck?: boolean): Promise<void> {
|
||||
async loadChunk (ctx: ClientSessionCtx, domain: Domain, idx?: number, recheck?: boolean): Promise<void> {
|
||||
this.lastRequest = Date.now()
|
||||
try {
|
||||
const result = await this.getOps().loadChunk(_ctx.ctx, domain, idx, recheck)
|
||||
await _ctx.sendResponse(result)
|
||||
const result = await this.getOps().loadChunk(ctx.ctx, domain, idx, recheck)
|
||||
await ctx.sendResponse(result)
|
||||
} catch (err: any) {
|
||||
await _ctx.sendResponse({ error: err.message })
|
||||
await ctx.sendError('Failed to upload', unknownError(err))
|
||||
ctx.ctx.error('failed to loadChunk', { domain, err })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +260,8 @@ export class ClientSession implements Session {
|
||||
const result = await this.getOps().loadDocs(ctx.ctx, domain, docs)
|
||||
await ctx.sendResponse(result)
|
||||
} catch (err: any) {
|
||||
await ctx.sendResponse({ error: err.message })
|
||||
await ctx.sendError('Failed to loadDocs', unknownError(err))
|
||||
ctx.ctx.error('failed to loadDocs', { domain, err })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,7 +273,8 @@ export class ClientSession implements Session {
|
||||
try {
|
||||
await this.getOps().upload(ctx.ctx, domain, docs)
|
||||
} catch (err: any) {
|
||||
await ctx.sendResponse({ error: err.message })
|
||||
await ctx.sendError('Failed to upload', unknownError(err))
|
||||
ctx.ctx.error('failed to loadDocs', { domain, err })
|
||||
return
|
||||
}
|
||||
await ctx.sendResponse({})
|
||||
@@ -285,7 +288,8 @@ export class ClientSession implements Session {
|
||||
try {
|
||||
await this.getOps().clean(ctx.ctx, domain, docs)
|
||||
} catch (err: any) {
|
||||
await ctx.sendResponse({ error: err.message })
|
||||
await ctx.sendError('Failed to clean', unknownError(err))
|
||||
ctx.ctx.error('failed to clean', { domain, err })
|
||||
return
|
||||
}
|
||||
await ctx.sendResponse({})
|
||||
|
||||
@@ -243,8 +243,10 @@ class TSessionManager implements SessionManager {
|
||||
}
|
||||
}
|
||||
for (const r of s[1].session.requests.values()) {
|
||||
if (now - r.start > 30000) {
|
||||
this.ctx.warn('request hang found, 30sec', {
|
||||
const sec = Math.round((now - r.start) / 1000)
|
||||
if (sec > 0 && sec % 30 === 0) {
|
||||
this.ctx.warn('request hang found', {
|
||||
sec,
|
||||
wsId,
|
||||
user: s[1].session.getUser(),
|
||||
...r.params
|
||||
|
||||
@@ -16,7 +16,7 @@ import core, {
|
||||
} from '@hcengineering/core'
|
||||
import { consoleModelLogger, type MigrateOperation, type ModelLogger } from '@hcengineering/model'
|
||||
import { getTransactorEndpoint } from '@hcengineering/server-client'
|
||||
import { SessionDataImpl, type Pipeline, type StorageAdapter } from '@hcengineering/server-core'
|
||||
import { SessionDataImpl, wrapPipeline, type Pipeline, type StorageAdapter } from '@hcengineering/server-core'
|
||||
import {
|
||||
getServerPipeline,
|
||||
getTxAdapterFactory,
|
||||
@@ -26,51 +26,6 @@ import {
|
||||
import { generateToken } from '@hcengineering/server-token'
|
||||
import { initializeWorkspace, initModel, prepareTools, updateModel, upgradeModel } from '@hcengineering/server-tool'
|
||||
|
||||
function wrapPipeline (ctx: MeasureContext, pipeline: Pipeline, wsUrl: WorkspaceIdWithUrl): Client {
|
||||
const sctx = new SessionDataImpl(
|
||||
systemAccountEmail,
|
||||
'backup',
|
||||
true,
|
||||
{ targets: {}, txes: [] },
|
||||
wsUrl,
|
||||
null,
|
||||
true,
|
||||
new Map(),
|
||||
new Map(),
|
||||
pipeline.context.modelDb
|
||||
)
|
||||
|
||||
ctx.contextData = sctx
|
||||
|
||||
return {
|
||||
findAll: async (_class, query, options) => {
|
||||
return await pipeline.findAll(ctx, _class, query, options)
|
||||
},
|
||||
findOne: async (_class, query, options) => {
|
||||
return (await pipeline.findAll(ctx, _class, query, { ...options, limit: 1 })).shift()
|
||||
},
|
||||
close: async () => {
|
||||
await pipeline.close()
|
||||
},
|
||||
getHierarchy: () => {
|
||||
return pipeline.context.hierarchy
|
||||
},
|
||||
getModel: () => {
|
||||
return pipeline.context.modelDb
|
||||
},
|
||||
searchFulltext: async (query, options) => {
|
||||
return {
|
||||
docs: [],
|
||||
total: 0
|
||||
}
|
||||
},
|
||||
tx: async (tx) => {
|
||||
return await pipeline.tx(ctx, [tx])
|
||||
},
|
||||
notify: (...tx) => {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
@@ -125,10 +80,6 @@ export async function createWorkspace (
|
||||
try {
|
||||
const txFactory = getTxAdapterFactory(ctx, dbUrl, wsUrl, null, {
|
||||
externalStorage: storageAdapter,
|
||||
fullTextUrl: 'http://localhost:9200',
|
||||
indexParallel: 0,
|
||||
indexProcessing: 0,
|
||||
rekoniUrl: '',
|
||||
usePassedCtx: true
|
||||
})
|
||||
const txAdapter = await txFactory(ctx, hierarchy, dbUrl, wsId, modelDb, storageAdapter)
|
||||
|
||||
@@ -16,6 +16,7 @@ import core, {
|
||||
import love, {
|
||||
getFreeRoomPlace,
|
||||
MeetingMinutes,
|
||||
MeetingStatus,
|
||||
ParticipantInfo,
|
||||
Room,
|
||||
RoomLanguage,
|
||||
@@ -114,12 +115,8 @@ export class LoveController {
|
||||
}
|
||||
|
||||
async connect (request: ConnectMeetingRequest): Promise<void> {
|
||||
if (this.connectedRooms.has(request.roomId)) return
|
||||
|
||||
this.roomSidById.set(request.roomId, request.roomSid)
|
||||
this.connectedRooms.add(request.roomId)
|
||||
|
||||
const room = await this.getRoom(request.roomId)
|
||||
|
||||
if (room === undefined) {
|
||||
this.ctx.error('Room not found', request)
|
||||
this.roomSidById.delete(request.roomId)
|
||||
@@ -127,22 +124,23 @@ export class LoveController {
|
||||
return
|
||||
}
|
||||
|
||||
this.roomSidById.set(request.roomId, request.roomSid)
|
||||
this.connectedRooms.add(request.roomId)
|
||||
|
||||
this.ctx.info('Connecting', { room: room.name, roomId: room._id })
|
||||
|
||||
if (request.transcription) {
|
||||
const roomTokenName = getTokenRoomName(this.workspace, room.name, room._id)
|
||||
const isTranscriptionStarted = await startTranscription(this.token, roomTokenName, room.name, request.language)
|
||||
|
||||
if (!isTranscriptionStarted) {
|
||||
this.roomSidById.delete(request.roomId)
|
||||
this.connectedRooms.delete(request.roomId)
|
||||
return
|
||||
}
|
||||
await this.requestTranscription(room, request.language)
|
||||
}
|
||||
|
||||
await this.createAiParticipant(room)
|
||||
}
|
||||
|
||||
async requestTranscription (room: Room, language: RoomLanguage): Promise<void> {
|
||||
const roomTokenName = getTokenRoomName(this.workspace, room.name, room._id)
|
||||
await startTranscription(this.token, roomTokenName, room.name, language)
|
||||
}
|
||||
|
||||
async disconnect (roomId: Ref<Room>): Promise<void> {
|
||||
this.ctx.info('Disconnecting', { roomId })
|
||||
|
||||
@@ -216,7 +214,9 @@ export class LoveController {
|
||||
if (sid === '') return undefined
|
||||
|
||||
const doc =
|
||||
this.meetingMinutes.find((m) => m.sid === sid) ?? (await this.client.findOne(love.class.MeetingMinutes, { sid }))
|
||||
this.meetingMinutes.find(
|
||||
(m) => m.sid === sid && m.attachedTo === room._id && m.status === MeetingStatus.Active
|
||||
) ?? (await this.client.findOne(love.class.MeetingMinutes, { sid, room: room._id, status: MeetingStatus.Active }))
|
||||
|
||||
if (doc === undefined) {
|
||||
return undefined
|
||||
|
||||
@@ -34,7 +34,7 @@ export class WorkspaceClient {
|
||||
}
|
||||
|
||||
protected async initClient (): Promise<TxOperations> {
|
||||
const token = generateToken(systemAccountEmail, this.workspace)
|
||||
const token = generateToken(systemAccountEmail, this.workspace, { client: 'analytics' })
|
||||
this.client = await connectPlatform(token)
|
||||
|
||||
return new TxOperations(this.client, core.account.System)
|
||||
|
||||
@@ -27,7 +27,7 @@ export async function createNotification (
|
||||
const existing = await client.findOne(notification.class.CommonInboxNotification, {
|
||||
user: data.user,
|
||||
message: data.message,
|
||||
props: data.props
|
||||
...Object.fromEntries(Object.entries(data.props).map(([k, v]) => [`props.${k}`, v]))
|
||||
})
|
||||
if (existing !== undefined) {
|
||||
await client.update(existing, {
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
|
||||
export MINIO_ACCESS_KEY=minioadmin
|
||||
export MINIO_SECRET_KEY=minioadmin
|
||||
export MINIO_ENDPOINT=localhost:9000
|
||||
export MONGO_URL=mongodb://localhost:27017
|
||||
export ELASTIC_URL=http://localhost:9200
|
||||
export SERVER_SECRET=secret
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Restore workspace contents in mongo/elastic
|
||||
./tool-local.sh backup-restore ./sanity-ws sanity-ws
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
|
||||
export MODEL_VERSION=$(node ../common/scripts/show_version.js)
|
||||
export MINIO_ACCESS_KEY=minioadmin
|
||||
export MINIO_SECRET_KEY=minioadmin
|
||||
export MINIO_ENDPOINT=localhost:9000
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
export MODEL_VERSION=$(node ../common/scripts/show_version.js)
|
||||
export MINIO_ACCESS_KEY=minioadmin
|
||||
export MINIO_SECRET_KEY=minioadmin
|
||||
export MINIO_ENDPOINT=localhost:9002
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
export MODEL_VERSION=$(node ../common/scripts/show_version.js)
|
||||
export MINIO_ACCESS_KEY=minioadmin
|
||||
export MINIO_SECRET_KEY=minioadmin
|
||||
export MINIO_ENDPOINT=localhost:9002
|
||||
|
||||
@@ -38,6 +38,6 @@
|
||||
"dependencies": {
|
||||
"aws4fetch": "^1.0.20",
|
||||
"itty-router": "^5.0.18",
|
||||
"postgres": "^3.4.4"
|
||||
"postgres": "^3.4.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ export async function handleBlobHead (request: BlobRequest, env: Env, ctx: Execu
|
||||
const { bucket } = selectStorage(env, workspace)
|
||||
|
||||
const blob = await db.getBlob(sql, { workspace, name })
|
||||
if (blob === null) {
|
||||
if (blob === null || blob.deleted) {
|
||||
return error(404)
|
||||
}
|
||||
|
||||
|
||||
@@ -40,10 +40,14 @@ const withWorkspace: RequestHandler<WorkspaceRequest> = (request: WorkspaceReque
|
||||
}
|
||||
|
||||
const withBlob: RequestHandler<BlobRequest> = (request: BlobRequest) => {
|
||||
if (request.params.workspace === undefined || request.params.workspace === '') {
|
||||
return error(400, 'Missing workspace')
|
||||
}
|
||||
if (request.params.name === undefined || request.params.name === '') {
|
||||
return error(400, 'Missing blob name')
|
||||
}
|
||||
request.workspace = decodeURIComponent(request.params.name)
|
||||
request.workspace = decodeURIComponent(request.params.workspace)
|
||||
request.name = decodeURIComponent(request.params.name)
|
||||
}
|
||||
|
||||
router
|
||||
|
||||
Reference in New Issue
Block a user