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

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev
2024-11-22 16:00:21 +07:00
84 changed files with 1808 additions and 692 deletions
+30 -3
View File
@@ -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",
+18 -23
View File
@@ -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 -1
View File
@@ -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
+34
View File
@@ -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
View File
@@ -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" ]
+6 -2
View File
@@ -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
View File
@@ -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>')
+1 -1
View File
@@ -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]),
+22 -4
View File
@@ -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
View File
@@ -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'
)
}
+1
View File
@@ -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,
+49 -16
View File
@@ -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
+18
View File
@@ -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 */
+19 -7
View File
@@ -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>
}
+1
View File
@@ -648,6 +648,7 @@ export interface DomainIndexConfiguration extends Doc {
}
export type WorkspaceMode =
| 'manual-creation'
| 'pending-creation'
| 'creating'
| 'upgrading'
+43 -5
View File
@@ -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}
+13 -11
View File
@@ -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
+2 -1
View File
@@ -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ý"
+2 -1
View File
@@ -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"
+2 -1
View File
@@ -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"
+2 -1
View File
@@ -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"
+2 -1
View File
@@ -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"
+2 -1
View File
@@ -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"
+2 -1
View File
@@ -33,7 +33,8 @@
"Next": "Далее",
"FailedToPreview": "Ошибка предпросмотра",
"ContentType": "Тип контента",
"ContentTypeNotSupported": "Предварительный просмотр недоступен для этого типа контента"
"ContentTypeNotSupported": "Предварительный просмотр недоступен для этого типа контента",
"StartDrawing": "Сделать набросок"
},
"status": {
"FileTooLarge": "Файл слишком большой"
+2 -1
View File
@@ -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>
+398
View File
@@ -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)
})
}
}
}
}
}
+2
View File
@@ -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'
+2 -1
View File
@@ -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()
}
+60 -4
View File
@@ -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 ?? '')
)
}
+12 -2
View File
@@ -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
+123 -78
View File
@@ -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}
+2
View File
@@ -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,
+85
View File
@@ -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
}
+1 -1
View File
@@ -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",
+1
View File
@@ -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)
+10 -6
View File
@@ -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) {
+19 -1
View File
@@ -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 })
+2 -68
View File
@@ -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 (
+82 -2
View File
@@ -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) => {}
}
}
+34 -19
View File
@@ -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
+35
View File
@@ -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'
}
}
+4 -4
View File
@@ -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 })
+5 -43
View File
@@ -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 },
+1 -2
View File
@@ -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
View File
@@ -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[] = []
+15 -6
View File
@@ -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) {
+7 -7
View File
@@ -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 {
+11 -7
View File
@@ -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({})
+4 -2
View File
@@ -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
+1 -50
View File
@@ -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 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -38,6 +38,6 @@
"dependencies": {
"aws4fetch": "^1.0.20",
"itty-router": "^5.0.18",
"postgres": "^3.4.4"
"postgres": "^3.4.5"
}
}
+1 -1
View File
@@ -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)
}
+5 -1
View File
@@ -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