diff --git a/.vscode/launch.json b/.vscode/launch.json index c13806bcbb..1c9af387f4 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -104,9 +104,12 @@ "UPLOAD_URL": "/files", "AI_BOT_URL": "http://localhost:4010", "STATS_URL": "http://huly.local:4900", - "QUEUE_CONFIG": "localhost:19092", + "QUEUE_CONFIG": "huly.local:19092", + "REGION": "cockroach", + "QUEUE_REGION": "cockroach", "FILES_URL": "http://huly.local:4030/blob/:workspace/:blobId/:filename", - "OTEL_EXPORTER_OTLP_ENDPOINT": "http://huly.local:4318/v1/traces" + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://huly.local:4318/v1/traces", + "BRANDING_PATH": "${workspaceRoot}/dev/branding.json" }, "runtimeArgs": ["--nolazy", "-r", "ts-node/register"], "runtimeVersion": "20", @@ -972,25 +975,6 @@ "runtimeArgs": ["--nolazy", "-r", "ts-node/register"], "sourceMaps": true, "cwd": "${workspaceRoot}/services/export/pod-export" - }, - { - "name": "Msg2File", - "type": "node", - "request": "launch", - "args": ["src/index.ts"], - "env": { - "ACCOUNTS_URL": "http://localhost:3000", - "DB_URL": "postgresql://root@localhost:26257/defaultdb?sslmode=disable", - "PORT": "9087", - "SECRET": "secret", - "SERVICE_ID": "msg2file-service", - "STORAGE_CONFIG": "datalake|http://huly.local:4030" - }, - "runtimeVersion": "20", - "runtimeArgs": ["--nolazy", "-r", "ts-node/register"], - "sourceMaps": true, - "outputCapture": "std", - "cwd": "${workspaceRoot}/services/msg2file" } ] } diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 1e9b1f0b4b..68a6f5e10f 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -36,13 +36,13 @@ importers: version: 8.4.1 '@hocuspocus/provider': specifier: ^2.15.2 - version: 2.15.2(bufferutil@4.0.8)(utf-8-validate@6.0.4)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23) + version: 2.15.2(bufferutil@4.0.8)(utf-8-validate@6.0.4)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) '@hocuspocus/server': specifier: ^2.15.2 - version: 2.15.2(bufferutil@4.0.8)(utf-8-validate@6.0.4)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23) + version: 2.15.2(bufferutil@4.0.8)(utf-8-validate@6.0.4)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) '@hocuspocus/transformer': specifier: ^2.15.2 - version: 2.15.2(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)(y-prosemirror@1.3.6(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23))(yjs@13.6.23) + version: 2.15.2(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)(y-prosemirror@1.3.7(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))(yjs@13.6.27) '@koa/cors': specifier: ^5.0.0 version: 5.0.0 @@ -267,7 +267,7 @@ importers: version: file:projects/collaboration.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.25.9)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3)) '@rush-temp/collaborator': specifier: file:./projects/collaborator.tgz - version: file:projects/collaborator.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@swc/core@1.13.5)(@tiptap/pm@2.11.7)(babel-jest@29.7.0(@babel/core@7.23.9))(bufferutil@4.0.8)(gcp-metadata@5.3.0(encoding@0.1.13))(snappy@7.2.2)(socks@2.8.3)(utf-8-validate@6.0.4)(y-prosemirror@1.3.6(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23))(y-protocols@1.0.6(yjs@13.6.23)) + version: file:projects/collaborator.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@swc/core@1.13.5)(@tiptap/pm@2.11.7)(babel-jest@29.7.0(@babel/core@7.23.9))(bufferutil@4.0.8)(gcp-metadata@5.3.0(encoding@0.1.13))(snappy@7.2.2)(socks@2.8.3)(utf-8-validate@6.0.4)(y-prosemirror@1.3.7(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))(y-protocols@1.0.6(yjs@13.6.27)) '@rush-temp/collaborator-client': specifier: file:./projects/collaborator-client.tgz version: file:projects/collaborator-client.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3)) @@ -304,9 +304,6 @@ importers: '@rush-temp/communication-types': specifier: file:./projects/communication-types.tgz version: file:projects/communication-types.tgz(@types/node@22.15.29)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3)) - '@rush-temp/communication-yaml': - specifier: file:./projects/communication-yaml.tgz - version: file:projects/communication-yaml.tgz(@types/node@22.15.29)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3)) '@rush-temp/contact': specifier: file:./projects/contact.tgz version: file:projects/contact.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@types/node@22.15.29)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.25.9)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3)) @@ -912,7 +909,7 @@ importers: version: file:projects/pod-fulltext.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@swc/core@1.13.5)(babel-jest@29.7.0(@babel/core@7.23.9)) '@rush-temp/pod-github': specifier: file:./projects/pod-github.tgz - version: file:projects/pod-github.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@swc/core@1.13.5)(babel-jest@29.7.0(@babel/core@7.23.9))(bufferutil@4.0.8)(gcp-metadata@5.3.0(encoding@0.1.13))(snappy@7.2.2)(socks@2.8.3)(utf-8-validate@6.0.4)(y-prosemirror@1.3.6(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23)) + version: file:projects/pod-github.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@swc/core@1.13.5)(babel-jest@29.7.0(@babel/core@7.23.9))(bufferutil@4.0.8)(gcp-metadata@5.3.0(encoding@0.1.13))(snappy@7.2.2)(socks@2.8.3)(utf-8-validate@6.0.4)(y-prosemirror@1.3.7(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)) '@rush-temp/pod-gmail': specifier: file:./projects/pod-gmail.tgz version: file:projects/pod-gmail.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@swc/core@1.13.5)(babel-jest@29.7.0(@babel/core@7.23.9))(bufferutil@4.0.8)(encoding@0.1.13)(utf-8-validate@6.0.4) @@ -928,9 +925,6 @@ importers: '@rush-temp/pod-media': specifier: file:./projects/pod-media.tgz version: file:projects/pod-media.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@swc/core@1.13.5)(babel-jest@29.7.0(@babel/core@7.23.9)) - '@rush-temp/pod-msg2file': - specifier: file:./projects/pod-msg2file.tgz - version: file:projects/pod-msg2file.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@swc/core@1.13.5)(babel-jest@29.7.0(@babel/core@7.23.9)) '@rush-temp/pod-notification': specifier: file:./projects/pod-notification.tgz version: file:projects/pod-notification.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@swc/core@1.13.5)(babel-jest@29.7.0(@babel/core@7.23.9)) @@ -1413,7 +1407,7 @@ importers: version: file:projects/text-markdown.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@types/node@22.15.29)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.25.9)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3)) '@rush-temp/text-ydoc': specifier: file:./projects/text-ydoc.tgz - version: file:projects/text-ydoc.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@types/node@22.15.29)(babel-jest@29.7.0(@babel/core@7.23.9))(bufferutil@4.0.8)(esbuild@0.25.9)(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3))(utf-8-validate@6.0.4)(y-protocols@1.0.6(yjs@13.6.23)) + version: file:projects/text-ydoc.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@types/node@22.15.29)(babel-jest@29.7.0(@babel/core@7.23.9))(bufferutil@4.0.8)(esbuild@0.25.9)(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3))(utf-8-validate@6.0.4)(y-protocols@1.0.6(yjs@13.6.27)) '@rush-temp/theme': specifier: file:./projects/theme.tgz version: file:projects/theme.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@types/node@22.15.29)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.25.9)(postcss-load-config@4.0.2(postcss@8.5.3)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3)))(postcss@8.5.3)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3)) @@ -1539,10 +1533,10 @@ importers: version: 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/extension-code-block@2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)(highlight.js@11.11.1)(lowlight@3.3.0) '@tiptap/extension-collaboration': specifier: ^2.11.7 - version: 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)(y-prosemirror@1.3.6(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23)) + version: 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)(y-prosemirror@1.3.7(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)) '@tiptap/extension-collaboration-cursor': specifier: ^2.11.7 - version: 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(y-prosemirror@1.3.6(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23)) + version: 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(y-prosemirror@1.3.7(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)) '@tiptap/extension-document': specifier: ^2.11.7 version: 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7)) @@ -1732,9 +1726,6 @@ importers: '@types/node': specifier: ^22.15.29 version: 22.15.29 - '@types/node-cron': - specifier: ^3.0.11 - version: 3.0.11 '@types/nodemailer': specifier: ^6.4.17 version: 6.4.17 @@ -2032,6 +2023,9 @@ importers: graphql-binding: specifier: ^2.5.2 version: 2.5.2 + hash-it: + specifier: ^6.0.0 + version: 6.0.0 heic-decode: specifier: ^2.1.0 version: 2.1.0 @@ -2102,8 +2096,8 @@ importers: specifier: ~1.0.4 version: 1.0.5 lib0: - specifier: ^0.2.88 - version: 0.2.89 + specifier: ^0.2.109 + version: 0.2.114 libphonenumber-js: specifier: ^1.9.46 version: 1.10.56 @@ -2155,9 +2149,6 @@ importers: msgpackr-extract: specifier: ^3.0.3 version: 3.0.3 - node-cron: - specifier: ^3.0.3 - version: 3.0.3 node-forge: specifier: ^1.3.1 version: 1.3.1 @@ -2391,19 +2382,19 @@ importers: version: 8.18.2(bufferutil@4.0.8)(utf-8-validate@6.0.4) y-indexeddb: specifier: ^9.0.12 - version: 9.0.12(yjs@13.6.23) + version: 9.0.12(yjs@13.6.27) y-prosemirror: - specifier: ^1.3.6 - version: 1.3.6(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23) + specifier: ^1.3.7 + version: 1.3.7(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) y-protocols: specifier: ^1.0.6 - version: 1.0.6(yjs@13.6.23) + version: 1.0.6(yjs@13.6.27) y-websocket: specifier: ^2.1.0 - version: 2.1.0(yjs@13.6.23) + version: 2.1.0(yjs@13.6.27) yjs: - specifier: ^13.6.23 - version: 13.6.23 + specifier: ^13.6.27 + version: 13.6.27 zod: specifier: ^3.22.4 version: 3.24.2 @@ -4564,7 +4555,7 @@ packages: version: 0.0.0 '@rush-temp/collaboration@file:projects/collaboration.tgz': - resolution: {integrity: sha512-aDcBIrPlESppS8EWtyzKhLJSC7IEHzPdLxtA05aZtp63v6+LRARuzfhT1x53KJ1u03/2ttjGu/ts7dVEnct2kw==, tarball: file:projects/collaboration.tgz} + resolution: {integrity: sha512-Ij/ksdlgcTdsHBU00nzKWoYkQNM0RMJdaPR1ZSmOMlKe9lndge3UT23w3G0KHXelXlkzBxKfKWPEfnAy1biedw==, tarball: file:projects/collaboration.tgz} version: 0.0.0 '@rush-temp/collaborator-client@file:projects/collaborator-client.tgz': @@ -4572,7 +4563,7 @@ packages: version: 0.0.0 '@rush-temp/collaborator@file:projects/collaborator.tgz': - resolution: {integrity: sha512-cGZGxft0YIkGzuUQIhYORUQ00fi/rhyhJnA0TMYMniRDfLLewBPvRtyXIHSHVZG1AJ1TWyyp+Xe+MrKfGYDzFw==, tarball: file:projects/collaborator.tgz} + resolution: {integrity: sha512-+WZ7aCQzJBE8eF+5MM4ruyqa73msf/U6AMvX598LOHy6WwTL/id/IXTSyult37VBTYpCa64I6BhD77gTV8D4Rw==, tarball: file:projects/collaborator.tgz} version: 0.0.0 '@rush-temp/communication-assets@file:projects/communication-assets.tgz': @@ -4580,7 +4571,7 @@ packages: version: 0.0.0 '@rush-temp/communication-client-query@file:projects/communication-client-query.tgz': - resolution: {integrity: sha512-6OA/sAql1ZLhhDX9J2yZOAEgfXqSxYh6yxpH/YGrC3PfYyhVAoe7piCjpB+JpKR5wL1kzpl1eP7Bj0IEq2jNJQ==, tarball: file:projects/communication-client-query.tgz} + resolution: {integrity: sha512-+68bXbpIU6XTujL5Dj3pFCdFelV7mE+Ln6zZEvf2NPHhAcJqy3+KjHQyIbytnWhCFsGyk+HE3S08ps6Kl6TKtw==, tarball: file:projects/communication-client-query.tgz} version: 0.0.0 '@rush-temp/communication-cockroach@file:projects/communication-cockroach.tgz': @@ -4588,7 +4579,7 @@ packages: version: 0.0.0 '@rush-temp/communication-query@file:projects/communication-query.tgz': - resolution: {integrity: sha512-sXcvSDoXmgZH233jFRePio23qQosvQZoACKqKgJ2Obr+oY4ZzJ2uX7DM0lyIKbhFUuLNFl56u2RfM5ra8SxRDg==, tarball: file:projects/communication-query.tgz} + resolution: {integrity: sha512-KMDSFpLNW1ekFoAf+BbRxPeRYnMUjYu+l80F+fgtvkKsX/6oKtb6xRhQyVh4EQiRlQBaMV//1b2d7cBwuF5HDw==, tarball: file:projects/communication-query.tgz} version: 0.0.0 '@rush-temp/communication-resources@file:projects/communication-resources.tgz': @@ -4604,21 +4595,17 @@ packages: version: 0.0.0 '@rush-temp/communication-server@file:projects/communication-server.tgz': - resolution: {integrity: sha512-KVc53phC1b9CNptvJmMb706t74gsCiEQGWvCpvTlRWMd3q+pj7i2994gJ0/KzuCojcHxPYVn2QxNojpNSV66Eg==, tarball: file:projects/communication-server.tgz} + resolution: {integrity: sha512-HfKzdbndaS3opK1inqcTOrVnKvU7PS2asLmKuR4JHooiTTvFFCHcXXYO++t1QmdGmankZRn3Qsq59CIt/UPgsA==, tarball: file:projects/communication-server.tgz} version: 0.0.0 '@rush-temp/communication-shared@file:projects/communication-shared.tgz': - resolution: {integrity: sha512-SustnNpr/eopCXmm0ZahvUZXZPcD7psSWM+CkWVwiPlLVPGWMVUURFOcnC94F3OhY+7qu9N0Rgr3gDvr4VKXWA==, tarball: file:projects/communication-shared.tgz} + resolution: {integrity: sha512-/ZUh5sviGQkQGJnOe6ImJHpKCcn7xNyyMeOcl4Tr3TqXjsH+uOQ0MAFI0Ul4f1LI+dGIrp1QsZWSVWvEVprRQQ==, tarball: file:projects/communication-shared.tgz} version: 0.0.0 '@rush-temp/communication-types@file:projects/communication-types.tgz': resolution: {integrity: sha512-uzZ82V1R+h+TmxBCrmKaAfUgoiX3Z8ciPGrkc42M++M5d5mlfIjTvPh0O+y50pHoFuQ5NQ3pNIjeDkrJE8Dp0w==, tarball: file:projects/communication-types.tgz} version: 0.0.0 - '@rush-temp/communication-yaml@file:projects/communication-yaml.tgz': - resolution: {integrity: sha512-D+fijwXQM2oMeT6QOmkRVLik5uyVCNsml07rz5RvKcPEpbqRh3YBtIvcUABoCStLvNTIzYuXnqbCHzSl/ibidw==, tarball: file:projects/communication-yaml.tgz} - version: 0.0.0 - '@rush-temp/communication@file:projects/communication.tgz': resolution: {integrity: sha512-kO/x+NBE4Nmtz5KhHO0kxty/0N1A2PH4dNVTdOviEV4mVAhFm7DkgWHu0SwWJvQrCzsB0xEga3/CGuGjcPyMjA==, tarball: file:projects/communication.tgz} version: 0.0.0 @@ -4684,7 +4671,7 @@ packages: version: 0.0.0 '@rush-temp/desktop@file:projects/desktop.tgz': - resolution: {integrity: sha512-kMVLNqalurSBKidY00TqK2GIBSaEvgAKScF/32N4vsmx+pzMBkE6U63HiW+RxsqVC/ig9QikeohoDXx7Ps9kHg==, tarball: file:projects/desktop.tgz} + resolution: {integrity: sha512-EIuP2KmLDWhA4J63srKU8MI9wWCUTpNtCPGaS9P4guXJdH55cV8cqbWYbEYuWqsa28YPPoS1hel73H92Mf7nKw==, tarball: file:projects/desktop.tgz} version: 0.0.0 '@rush-temp/devmodel-resources@file:projects/devmodel-resources.tgz': @@ -4912,7 +4899,7 @@ packages: version: 0.0.0 '@rush-temp/love-resources@file:projects/love-resources.tgz': - resolution: {integrity: sha512-uB1RrbistrQwA5PIld+dgw+QnpTtS5i6wdiSAemCBe2ay5hTKExnmINPrgM/p7FEkya2sq3IVZXzric/ZYwnng==, tarball: file:projects/love-resources.tgz} + resolution: {integrity: sha512-d/rTH5piCl8x3YfKoZ2yIWklH/MU9h8WrccgwV9VS3a1lYQ2i6l+JVpO/hdMGTN6llF3REFNnMYndfcu8Siy4w==, tarball: file:projects/love-resources.tgz} version: 0.0.0 '@rush-temp/love@file:projects/love.tgz': @@ -4976,7 +4963,7 @@ packages: version: 0.0.0 '@rush-temp/model-all@file:projects/model-all.tgz': - resolution: {integrity: sha512-u5b+jP4WxAVIk6DlawxySE+qDG7iz7YD3dTT3LCgR3Ml5RkMOlej4YtbO0YQmX4wp4l4gMJKdRCgBCpVfytksg==, tarball: file:projects/model-all.tgz} + resolution: {integrity: sha512-1IC5c/j2fgqgamtZGZLUBHUuzMaqM6eguG6WTbmdTrSW8YjfUYeutKSRDRykbF6SilzaGS8eKxhNpZTIRT20fA==, tarball: file:projects/model-all.tgz} version: 0.0.0 '@rush-temp/model-analytics-collector@file:projects/model-analytics-collector.tgz': @@ -5424,7 +5411,7 @@ packages: version: 0.0.0 '@rush-temp/pod-fulltext@file:projects/pod-fulltext.tgz': - resolution: {integrity: sha512-0G3FKVxHWnyPuHWVBiR3Xtns3rLxGTC6ferQCX8NSC3dGOGOvk4TZgB8leohmJavwwxdZQKk1QFJqmXvHux10A==, tarball: file:projects/pod-fulltext.tgz} + resolution: {integrity: sha512-kPi4ermTxQtUKZ5xzbJgT07rLj2gG6m4YQgArWiONz47ooSoizi/Sg8Pu2FFyVV0klzQwqUOseuoVqgdySnFNg==, tarball: file:projects/pod-fulltext.tgz} version: 0.0.0 '@rush-temp/pod-github@file:projects/pod-github.tgz': @@ -5451,10 +5438,6 @@ packages: resolution: {integrity: sha512-5LRHcx/zPNVCJbIBfjigt+of64jfGrH5fjoDyvhASTEJbtG4RGRj8kKZoUI+1JHibDZzcVn2NLc+3nFMhksreA==, tarball: file:projects/pod-media.tgz} version: 0.0.0 - '@rush-temp/pod-msg2file@file:projects/pod-msg2file.tgz': - resolution: {integrity: sha512-7b2GrlsmevpahNIADhqNUBpshocrdjlc2L0kf2BAFVSEFpjzUtRju2XqwwoZ2l7xA0Z/b+Un9PHt+XYMWcNzlg==, tarball: file:projects/pod-msg2file.tgz} - version: 0.0.0 - '@rush-temp/pod-notification@file:projects/pod-notification.tgz': resolution: {integrity: sha512-1EeImD9ZNRxWq0jDp6o50hOpuoB0sNI54V/V1pm5VONdRF0S2lVzPCr35YehJ5BJcvbhy22au/7rk9XadoYI9w==, tarball: file:projects/pod-notification.tgz} version: 0.0.0 @@ -5524,7 +5507,7 @@ packages: version: 0.0.0 '@rush-temp/presentation@file:projects/presentation.tgz': - resolution: {integrity: sha512-nMn7FUSF2F6YuJAstYbvV8Yi9upkKqeSccsU3EOrKjJm1c57sa7x8i4K3Z6l4RoSQk86/F5CrzZVgqIMKCjF0w==, tarball: file:projects/presentation.tgz} + resolution: {integrity: sha512-r1AG89qg5hvx+usQjP8KLtkNL/j5z/8OHDcURyAb6yOhWAWNkY5Frs05L9qcPsJoN6raUaZ164jzlk0P2SiPqQ==, tarball: file:projects/presentation.tgz} version: 0.0.0 '@rush-temp/print-assets@file:projects/print-assets.tgz': @@ -5552,7 +5535,7 @@ packages: version: 0.0.0 '@rush-temp/prod@file:projects/prod.tgz': - resolution: {integrity: sha512-OvmMqiOyuDeKVGHc7g8UxMlvr9WAJsqtqICsv6kcx9vSvB7bB0iXOLY9CevyOOBDk5Y6OvRKWJKeY9pif+pjMg==, tarball: file:projects/prod.tgz} + resolution: {integrity: sha512-LkECK91CfEPLwDVaCQWN4XSeBlM1jXqboKuXtu7ftZkgG66wtt1/AlVDqwRY6o1iuvVNvuTJBSp03DMfDzBIKw==, tarball: file:projects/prod.tgz} version: 0.0.0 '@rush-temp/products-assets@file:projects/products-assets.tgz': @@ -5804,7 +5787,7 @@ packages: version: 0.0.0 '@rush-temp/server-indexer@file:projects/server-indexer.tgz': - resolution: {integrity: sha512-OYx2FWCD6jtTnsKmYnhhS3BsvhtCN9pIYkvIoSe0mzZi3xEAhnwu2T59+JpZgRO1fvD1yVxBy7nw0hyFtz03Hg==, tarball: file:projects/server-indexer.tgz} + resolution: {integrity: sha512-InjSApXF2jmClorioQIIV+JxbKtuYjl3a9i4eetEWuG2z7R+Ya7L6f98cqhEhYkdA4hM5TTZYbS+BcRLWlSqQg==, tarball: file:projects/server-indexer.tgz} version: 0.0.0 '@rush-temp/server-inventory-resources@file:projects/server-inventory-resources.tgz': @@ -6068,7 +6051,7 @@ packages: version: 0.0.0 '@rush-temp/text-core@file:projects/text-core.tgz': - resolution: {integrity: sha512-WezIZjq0OzkKSAy6wMqDFrvEsZ0HlLXzmctJ8w/io4+ywOUO2Cb9ZDXn80wwr5t3TFdb2rvfg1lMM5xDuC+RfA==, tarball: file:projects/text-core.tgz} + resolution: {integrity: sha512-wSRif2622acwkDHy2FtsEDhnDTsl8hydf/MFYSgAri0l5sQ6qOPHLuh8FZ2yf81EWTLfK/iuez70cAlYwkypMw==, tarball: file:projects/text-core.tgz} version: 0.0.0 '@rush-temp/text-editor-assets@file:projects/text-editor-assets.tgz': @@ -6076,7 +6059,7 @@ packages: version: 0.0.0 '@rush-temp/text-editor-resources@file:projects/text-editor-resources.tgz': - resolution: {integrity: sha512-vb8UXE1DL6LyA3M51Pj4uKcuxdoaPEYFcNa/P7i0zR8XsebrOJZWNAhvtIsH1NKmNycLBNhvmTxKKEM1XEaiLA==, tarball: file:projects/text-editor-resources.tgz} + resolution: {integrity: sha512-2aRRXC4H573M3+OR/vRe5pGoA4tAB3jEbufqAe4/EgKkUHvRlGVZBBUyDAfYtsIHQd7IrAEEVK1NQoC6iZE1Lg==, tarball: file:projects/text-editor-resources.tgz} version: 0.0.0 '@rush-temp/text-editor@file:projects/text-editor.tgz': @@ -6092,7 +6075,7 @@ packages: version: 0.0.0 '@rush-temp/text-ydoc@file:projects/text-ydoc.tgz': - resolution: {integrity: sha512-vKLzX1+/6aZmMzsR213dQDXADDE4cDw3M2Ofkn3r7QKV6GidHGHipTlBO4jjsCbYK4G0L1QIf8ZHi7QhpE8icQ==, tarball: file:projects/text-ydoc.tgz} + resolution: {integrity: sha512-EdNN9bQUsIb2qPGpotFbm5xIQDH1GJilcCiV66EVTlrsRkQSAjqVDPZyhQALE8200gR8K7OCSayKoQPnb/nPEA==, tarball: file:projects/text-ydoc.tgz} version: 0.0.0 '@rush-temp/text@file:projects/text.tgz': @@ -6116,7 +6099,7 @@ packages: version: 0.0.0 '@rush-temp/tool@file:projects/tool.tgz': - resolution: {integrity: sha512-bsqWzFX5qr02kfG1zQZOgHsGWzR/Z66VC16ZoQnXs6mkdHFSmQ2p2l8eNL/aYPu2qfrRkMNAnI8cu6aULfKuDg==, tarball: file:projects/tool.tgz} + resolution: {integrity: sha512-FwR6SJSyoZvzT/0uIoItaG+S6N+ofaqtNJ4ZXRJ8f454jzOGrzjgjL+BupOX+xJCY4w9zNfpMOIke7e9LPX5pA==, tarball: file:projects/tool.tgz} version: 0.0.0 '@rush-temp/tracker-assets@file:projects/tracker-assets.tgz': @@ -7176,9 +7159,6 @@ packages: '@types/mysql@2.15.27': resolution: {integrity: sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==} - '@types/node-cron@3.0.11': - resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==} - '@types/node-fetch@2.6.12': resolution: {integrity: sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==} @@ -10177,6 +10157,9 @@ packages: has-unicode@2.0.1: resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} + hash-it@6.0.0: + resolution: {integrity: sha512-KHzmSFx1KwyMPw0kXeeUD752q/Kfbzhy6dAZrjXV9kAIXGqzGvv8vhkUqj+2MGZldTo0IBpw6v7iWE7uxsvH0w==} + hasown@2.0.1: resolution: {integrity: sha512-1/th4MHjnwncwXsIW6QMzlvYL9kG5e/CpVvLRZe4XPa8TOUNbCELqmvhDmnkNsAjwaG4+I8gJJL0JBvTTLO9qA==} engines: {node: '>= 0.4'} @@ -11170,18 +11153,8 @@ packages: lexorank@1.0.5: resolution: {integrity: sha512-K1B/Yr/gIU0wm68hk/yB0p/mv6xM3ShD5aci42vOwcjof8slG8Kpo3Q7+1WTv7DaRHKWRgLPqrFDt+4GtuFAtA==} - lib0@0.2.109: - resolution: {integrity: sha512-jP0gbnyW0kwlx1Atc4dcHkBbrVAkdHjuyHxtClUPYla7qCmwIif1qZ6vQeJdR5FrOVdn26HvQT0ko01rgW7/Xw==} - engines: {node: '>=16'} - hasBin: true - - lib0@0.2.89: - resolution: {integrity: sha512-5j19vcCjsQhvLG6mcDD+nprtJUCbmqLz5Hzt5xgi9SV6RIW/Dty7ZkVZHGBuPOADMKjQuKDvuQTH495wsmw8DQ==} - engines: {node: '>=16'} - hasBin: true - - lib0@0.2.99: - resolution: {integrity: sha512-vwztYuUf1uf/1zQxfzRfO5yzfNKhTtgOByCruuiQQxWQXnPb8Itaube5ylofcV0oM0aKal9Mv+S1s1Ky0UYP1w==} + lib0@0.2.114: + resolution: {integrity: sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==} engines: {node: '>=16'} hasBin: true @@ -11808,10 +11781,6 @@ packages: node-api-version@0.2.0: resolution: {integrity: sha512-fthTTsi8CxaBXMaBAD7ST2uylwvsnYxh2PfaScwpMhos6KlSFajXQPcM4ogNE1q2s3Lbz9GCGqeIHC+C6OZnKg==} - node-cron@3.0.3: - resolution: {integrity: sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==} - engines: {node: '>=6.0.0'} - node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} @@ -14469,8 +14438,8 @@ packages: peerDependencies: yjs: ^13.0.0 - y-prosemirror@1.3.6: - resolution: {integrity: sha512-vtS2rv8+ll/TBQRqwUiqflgSuN/DhfvUQX0r5O3o5i0pO6K4pSNgFtVkOKtNWPBVkS6l9BDQjbtnDNftZnxq7Q==} + y-prosemirror@1.3.7: + resolution: {integrity: sha512-NpM99WSdD4Fx4if5xOMDpPtU3oAmTSjlzh5U4353ABbRHl1HtAFUx6HlebLZfyFxXN9jzKMDkVbcRjqOZVkYQg==} engines: {node: '>=16.0.0', npm: '>=8.0.0'} peerDependencies: prosemirror-model: ^1.7.1 @@ -14538,8 +14507,8 @@ packages: yauzl@2.10.0: resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} - yjs@13.6.23: - resolution: {integrity: sha512-ExtnT5WIOVpkL56bhLeisG/N5c4fmzKn4k0ROVfJa5TY2QHbH7F0Wu2T5ZhR7ErsFWQEFafyrnSI8TPKVF9Few==} + yjs@13.6.27: + resolution: {integrity: sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==} engines: {node: '>=16.0.0', npm: '>=8.0.0'} ylru@1.3.2: @@ -15620,41 +15589,41 @@ snapshots: '@hocuspocus/common@2.15.2': dependencies: - lib0: 0.2.99 + lib0: 0.2.114 - '@hocuspocus/provider@2.15.2(bufferutil@4.0.8)(utf-8-validate@6.0.4)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23)': + '@hocuspocus/provider@2.15.2(bufferutil@4.0.8)(utf-8-validate@6.0.4)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)': dependencies: '@hocuspocus/common': 2.15.2 '@lifeomic/attempt': 3.0.3 - lib0: 0.2.99 + lib0: 0.2.114 ws: 8.18.2(bufferutil@4.0.8)(utf-8-validate@6.0.4) - y-protocols: 1.0.6(yjs@13.6.23) - yjs: 13.6.23 + y-protocols: 1.0.6(yjs@13.6.27) + yjs: 13.6.27 transitivePeerDependencies: - bufferutil - utf-8-validate - '@hocuspocus/server@2.15.2(bufferutil@4.0.8)(utf-8-validate@6.0.4)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23)': + '@hocuspocus/server@2.15.2(bufferutil@4.0.8)(utf-8-validate@6.0.4)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)': dependencies: '@hocuspocus/common': 2.15.2 async-lock: 1.4.1 kleur: 4.1.5 - lib0: 0.2.99 + lib0: 0.2.114 uuid: 11.1.0 ws: 8.18.2(bufferutil@4.0.8)(utf-8-validate@6.0.4) - y-protocols: 1.0.6(yjs@13.6.23) - yjs: 13.6.23 + y-protocols: 1.0.6(yjs@13.6.27) + yjs: 13.6.27 transitivePeerDependencies: - bufferutil - utf-8-validate - '@hocuspocus/transformer@2.15.2(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)(y-prosemirror@1.3.6(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23))(yjs@13.6.23)': + '@hocuspocus/transformer@2.15.2(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)(y-prosemirror@1.3.7(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))(yjs@13.6.27)': dependencies: '@tiptap/core': 2.11.7(@tiptap/pm@2.11.7) '@tiptap/pm': 2.11.7 '@tiptap/starter-kit': 2.11.7 - y-prosemirror: 1.3.6(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23) - yjs: 13.6.23 + y-prosemirror: 1.3.7(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) + yjs: 13.6.27 '@humanwhocodes/config-array@0.11.14': dependencies: @@ -18947,7 +18916,7 @@ snapshots: prettier: 3.2.5 ts-jest: 29.1.2(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.25.9)(jest@29.7.0(@types/node@22.15.29)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3)))(typescript@5.8.3) typescript: 5.8.3 - yjs: 13.6.23 + yjs: 13.6.27 transitivePeerDependencies: - '@babel/core' - '@jest/types' @@ -18984,10 +18953,10 @@ snapshots: - supports-color - ts-node - '@rush-temp/collaborator@file:projects/collaborator.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@swc/core@1.13.5)(@tiptap/pm@2.11.7)(babel-jest@29.7.0(@babel/core@7.23.9))(bufferutil@4.0.8)(gcp-metadata@5.3.0(encoding@0.1.13))(snappy@7.2.2)(socks@2.8.3)(utf-8-validate@6.0.4)(y-prosemirror@1.3.6(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23))(y-protocols@1.0.6(yjs@13.6.23))': + '@rush-temp/collaborator@file:projects/collaborator.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@swc/core@1.13.5)(@tiptap/pm@2.11.7)(babel-jest@29.7.0(@babel/core@7.23.9))(bufferutil@4.0.8)(gcp-metadata@5.3.0(encoding@0.1.13))(snappy@7.2.2)(socks@2.8.3)(utf-8-validate@6.0.4)(y-prosemirror@1.3.7(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))(y-protocols@1.0.6(yjs@13.6.27))': dependencies: - '@hocuspocus/server': 2.15.2(bufferutil@4.0.8)(utf-8-validate@6.0.4)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23) - '@hocuspocus/transformer': 2.15.2(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)(y-prosemirror@1.3.6(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23))(yjs@13.6.23) + '@hocuspocus/server': 2.15.2(bufferutil@4.0.8)(utf-8-validate@6.0.4)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) + '@hocuspocus/transformer': 2.15.2(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)(y-prosemirror@1.3.7(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))(yjs@13.6.27) '@tiptap/core': 2.11.7(@tiptap/pm@2.11.7) '@tiptap/html': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7) '@types/body-parser': 1.19.5 @@ -19016,7 +18985,7 @@ snapshots: ts-node: 10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3) typescript: 5.8.3 ws: 8.18.2(bufferutil@4.0.8)(utf-8-validate@6.0.4) - yjs: 13.6.23 + yjs: 13.6.27 transitivePeerDependencies: - '@aws-sdk/credential-providers' - '@babel/core' @@ -19286,29 +19255,6 @@ snapshots: - supports-color - ts-node - '@rush-temp/communication-yaml@file:projects/communication-yaml.tgz(@types/node@22.15.29)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3))': - dependencies: - '@types/js-yaml': 4.0.9 - '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.8.3))(eslint@8.56.0)(typescript@5.8.3) - '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.8.3) - esbuild: 0.25.9 - esbuild-plugin-copy: 2.1.1(esbuild@0.25.9) - eslint: 8.56.0 - eslint-config-standard-with-typescript: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.8.3))(eslint@8.56.0)(typescript@5.8.3))(eslint-plugin-import@2.29.1(eslint@8.56.0))(eslint-plugin-n@15.7.0(eslint@8.56.0))(eslint-plugin-promise@6.1.1(eslint@8.56.0))(eslint@8.56.0)(typescript@5.8.3) - eslint-plugin-import: 2.29.1(eslint@8.56.0) - eslint-plugin-n: 15.7.0(eslint@8.56.0) - eslint-plugin-promise: 6.1.1(eslint@8.56.0) - jest: 29.7.0(@types/node@22.15.29)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3)) - js-yaml: 4.1.0 - prettier: 3.2.5 - typescript: 5.8.3 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - node-notifier - - supports-color - - ts-node - '@rush-temp/communication@file:projects/communication.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@types/node@22.15.29)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.25.9)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3))': dependencies: '@types/jest': 29.5.12 @@ -25144,7 +25090,7 @@ snapshots: - node-notifier - supports-color - '@rush-temp/pod-github@file:projects/pod-github.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@swc/core@1.13.5)(babel-jest@29.7.0(@babel/core@7.23.9))(bufferutil@4.0.8)(gcp-metadata@5.3.0(encoding@0.1.13))(snappy@7.2.2)(socks@2.8.3)(utf-8-validate@6.0.4)(y-prosemirror@1.3.6(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23))': + '@rush-temp/pod-github@file:projects/pod-github.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@swc/core@1.13.5)(babel-jest@29.7.0(@babel/core@7.23.9))(bufferutil@4.0.8)(gcp-metadata@5.3.0(encoding@0.1.13))(snappy@7.2.2)(socks@2.8.3)(utf-8-validate@6.0.4)(y-prosemirror@1.3.7(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))': dependencies: '@octokit/types': 12.6.0 '@octokit/webhooks': 12.2.0 @@ -25153,8 +25099,8 @@ snapshots: '@tiptap/extension-bubble-menu': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7) '@tiptap/extension-code': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7)) '@tiptap/extension-code-block': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7) - '@tiptap/extension-collaboration': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)(y-prosemirror@1.3.6(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23)) - '@tiptap/extension-collaboration-cursor': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(y-prosemirror@1.3.6(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23)) + '@tiptap/extension-collaboration': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)(y-prosemirror@1.3.7(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)) + '@tiptap/extension-collaboration-cursor': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(y-prosemirror@1.3.7(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)) '@tiptap/extension-gapcursor': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7) '@tiptap/extension-heading': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7)) '@tiptap/extension-highlight': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7)) @@ -25435,47 +25381,6 @@ snapshots: - node-notifier - supports-color - '@rush-temp/pod-msg2file@file:projects/pod-msg2file.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@swc/core@1.13.5)(babel-jest@29.7.0(@babel/core@7.23.9))': - dependencies: - '@tsconfig/node16': 1.0.4 - '@types/cors': 2.8.17 - '@types/express': 4.17.21 - '@types/jest': 29.5.12 - '@types/js-yaml': 4.0.9 - '@types/node': 22.15.29 - '@types/node-cron': 3.0.11 - '@types/uuid': 8.3.4 - '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.8.3))(eslint@8.56.0)(typescript@5.8.3) - '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.8.3) - cors: 2.8.5 - dotenv: 16.0.3 - esbuild: 0.25.9 - eslint: 8.56.0 - eslint-config-standard-with-typescript: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.8.3))(eslint@8.56.0)(typescript@5.8.3))(eslint-plugin-import@2.29.1(eslint@8.56.0))(eslint-plugin-n@15.7.0(eslint@8.56.0))(eslint-plugin-promise@6.1.1(eslint@8.56.0))(eslint@8.56.0)(typescript@5.8.3) - eslint-plugin-import: 2.29.1(eslint@8.56.0) - eslint-plugin-n: 15.7.0(eslint@8.56.0) - eslint-plugin-node: 11.1.0(eslint@8.56.0) - eslint-plugin-promise: 6.1.1(eslint@8.56.0) - express: 4.21.2 - jest: 29.7.0(@types/node@22.15.29)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3)) - js-yaml: 4.1.0 - node-cron: 3.0.3 - postgres: 3.4.7 - prettier: 3.2.5 - ts-jest: 29.1.2(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.25.9)(jest@29.7.0(@types/node@22.15.29)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3)))(typescript@5.8.3) - ts-node: 10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3) - typescript: 5.8.3 - uuid: 8.3.2 - transitivePeerDependencies: - - '@babel/core' - - '@jest/types' - - '@swc/core' - - '@swc/wasm' - - babel-jest - - babel-plugin-macros - - node-notifier - - supports-color - '@rush-temp/pod-notification@file:projects/pod-notification.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@swc/core@1.13.5)(babel-jest@29.7.0(@babel/core@7.23.9))': dependencies: '@tsconfig/node16': 1.0.4 @@ -26133,7 +26038,7 @@ snapshots: ts-jest: 29.1.2(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.25.9)(jest@29.7.0(@types/node@22.15.29)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3)))(typescript@5.8.3) typescript: 5.8.3 uuid: 8.3.2 - yjs: 13.6.23 + yjs: 13.6.27 transitivePeerDependencies: - '@babel/core' - '@jest/types' @@ -29891,6 +29796,7 @@ snapshots: eslint-plugin-n: 15.7.0(eslint@8.56.0) eslint-plugin-promise: 6.1.1(eslint@8.56.0) fast-equals: 5.2.2 + hash-it: 6.0.0 jest: 29.7.0(@types/node@22.15.29)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3)) jest-environment-jsdom: 29.7.0(bufferutil@4.0.8)(utf-8-validate@6.0.4) prettier: 3.2.5 @@ -29937,15 +29843,15 @@ snapshots: '@rush-temp/text-editor-resources@file:projects/text-editor-resources.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@types/node@22.15.29)(babel-jest@29.7.0(@babel/core@7.23.9))(bufferutil@4.0.8)(esbuild@0.25.9)(highlight.js@11.11.1)(postcss-load-config@4.0.2(postcss@8.5.3)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3)))(postcss@8.5.3)(prosemirror-inputrules@1.4.0)(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3))(utf-8-validate@6.0.4)': dependencies: - '@hocuspocus/provider': 2.15.2(bufferutil@4.0.8)(utf-8-validate@6.0.4)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23) + '@hocuspocus/provider': 2.15.2(bufferutil@4.0.8)(utf-8-validate@6.0.4)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) '@tiptap/core': 2.11.7(@tiptap/pm@2.11.7) '@tiptap/extension-bubble-menu': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7) '@tiptap/extension-bullet-list': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7)) '@tiptap/extension-code': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7)) '@tiptap/extension-code-block': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7) '@tiptap/extension-code-block-lowlight': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/extension-code-block@2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)(highlight.js@11.11.1)(lowlight@3.3.0) - '@tiptap/extension-collaboration': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)(y-prosemirror@1.3.6(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23)) - '@tiptap/extension-collaboration-cursor': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(y-prosemirror@1.3.6(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23)) + '@tiptap/extension-collaboration': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)(y-prosemirror@1.3.7(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)) + '@tiptap/extension-collaboration-cursor': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(y-prosemirror@1.3.7(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)) '@tiptap/extension-hard-break': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7)) '@tiptap/extension-heading': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7)) '@tiptap/extension-highlight': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7)) @@ -29978,7 +29884,7 @@ snapshots: eslint-plugin-svelte: 2.35.1(eslint@8.56.0)(svelte@4.2.20)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3)) fast-equals: 5.2.2 jest: 29.7.0(@types/node@22.15.29)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3)) - lib0: 0.2.89 + lib0: 0.2.114 lowlight: 3.3.0 mermaid: 11.4.1 prettier: 3.2.5 @@ -29995,11 +29901,11 @@ snapshots: tippy.js: 6.3.7 ts-jest: 29.1.2(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.25.9)(jest@29.7.0(@types/node@22.15.29)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3)))(typescript@5.8.3) typescript: 5.8.3 - y-indexeddb: 9.0.12(yjs@13.6.23) - y-prosemirror: 1.3.6(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23) - y-protocols: 1.0.6(yjs@13.6.23) - y-websocket: 2.1.0(yjs@13.6.23) - yjs: 13.6.23 + y-indexeddb: 9.0.12(yjs@13.6.27) + y-prosemirror: 1.3.7(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) + y-protocols: 1.0.6(yjs@13.6.27) + y-websocket: 2.1.0(yjs@13.6.27) + yjs: 13.6.27 transitivePeerDependencies: - '@babel/core' - '@jest/types' @@ -30108,7 +30014,7 @@ snapshots: - supports-color - ts-node - '@rush-temp/text-ydoc@file:projects/text-ydoc.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@types/node@22.15.29)(babel-jest@29.7.0(@babel/core@7.23.9))(bufferutil@4.0.8)(esbuild@0.25.9)(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3))(utf-8-validate@6.0.4)(y-protocols@1.0.6(yjs@13.6.23))': + '@rush-temp/text-ydoc@file:projects/text-ydoc.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@types/node@22.15.29)(babel-jest@29.7.0(@babel/core@7.23.9))(bufferutil@4.0.8)(esbuild@0.25.9)(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3))(utf-8-validate@6.0.4)(y-protocols@1.0.6(yjs@13.6.27))': dependencies: '@types/jest': 29.5.12 '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.8.3))(eslint@8.56.0)(typescript@5.8.3) @@ -30121,11 +30027,12 @@ snapshots: fast-equals: 5.2.2 jest: 29.7.0(@types/node@22.15.29)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3)) jest-environment-jsdom: 29.7.0(bufferutil@4.0.8)(utf-8-validate@6.0.4) + lib0: 0.2.114 prettier: 3.2.5 ts-jest: 29.1.2(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.25.9)(jest@29.7.0(@types/node@22.15.29)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3)))(typescript@5.8.3) typescript: 5.8.3 - y-prosemirror: 1.3.6(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23) - yjs: 13.6.23 + y-prosemirror: 1.3.7(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) + yjs: 13.6.27 transitivePeerDependencies: - '@babel/core' - '@jest/types' @@ -30348,6 +30255,7 @@ snapshots: '@elastic/elasticsearch': 7.17.14 '@faker-js/faker': 8.4.1 '@types/jest': 29.5.12 + '@types/js-yaml': 4.0.9 '@types/mime-types': 2.1.4 '@types/minio': 7.0.18 '@types/node': 22.15.29 @@ -31519,16 +31427,16 @@ snapshots: dependencies: '@tiptap/core': 2.11.7(@tiptap/pm@2.11.7) - '@tiptap/extension-collaboration-cursor@2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(y-prosemirror@1.3.6(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23))': + '@tiptap/extension-collaboration-cursor@2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(y-prosemirror@1.3.7(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))': dependencies: '@tiptap/core': 2.11.7(@tiptap/pm@2.11.7) - y-prosemirror: 1.3.6(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23) + y-prosemirror: 1.3.7(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) - '@tiptap/extension-collaboration@2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)(y-prosemirror@1.3.6(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23))': + '@tiptap/extension-collaboration@2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)(y-prosemirror@1.3.7(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))': dependencies: '@tiptap/core': 2.11.7(@tiptap/pm@2.11.7) '@tiptap/pm': 2.11.7 - y-prosemirror: 1.3.6(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23) + y-prosemirror: 1.3.7(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) '@tiptap/extension-document@2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))': dependencies: @@ -32146,8 +32054,6 @@ snapshots: dependencies: '@types/node': 22.15.29 - '@types/node-cron@3.0.11': {} - '@types/node-fetch@2.6.12': dependencies: '@types/node': 22.15.29 @@ -35811,6 +35717,8 @@ snapshots: has-unicode@2.0.1: {} + hash-it@6.0.0: {} + hasown@2.0.1: dependencies: function-bind: 1.1.2 @@ -37085,15 +36993,7 @@ snapshots: lexorank@1.0.5: {} - lib0@0.2.109: - dependencies: - isomorphic.js: 0.2.5 - - lib0@0.2.89: - dependencies: - isomorphic.js: 0.2.5 - - lib0@0.2.99: + lib0@0.2.114: dependencies: isomorphic.js: 0.2.5 @@ -37734,10 +37634,6 @@ snapshots: dependencies: semver: 7.7.2 - node-cron@3.0.3: - dependencies: - uuid: 8.3.2 - node-domexception@1.0.0: {} node-fetch@2.7.0(encoding@0.1.13): @@ -40752,41 +40648,41 @@ snapshots: xtend@4.0.2: {} - y-indexeddb@9.0.12(yjs@13.6.23): + y-indexeddb@9.0.12(yjs@13.6.27): dependencies: lib0: 0.2.89 - yjs: 13.6.23 + yjs: 13.6.27 - y-leveldb@0.1.2(yjs@13.6.23): + y-leveldb@0.1.2(yjs@13.6.27): dependencies: level: 6.0.1 - lib0: 0.2.99 - yjs: 13.6.23 + lib0: 0.2.109 + yjs: 13.6.27 optional: true - y-prosemirror@1.3.6(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23): + y-prosemirror@1.3.7(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27): dependencies: lib0: 0.2.109 prosemirror-model: 1.24.1 prosemirror-state: 1.4.3 prosemirror-view: 1.37.2 - y-protocols: 1.0.6(yjs@13.6.23) - yjs: 13.6.23 + y-protocols: 1.0.6(yjs@13.6.27) + yjs: 13.6.27 - y-protocols@1.0.6(yjs@13.6.23): + y-protocols@1.0.6(yjs@13.6.27): dependencies: lib0: 0.2.89 - yjs: 13.6.23 + yjs: 13.6.27 - y-websocket@2.1.0(yjs@13.6.23): + y-websocket@2.1.0(yjs@13.6.27): dependencies: - lib0: 0.2.89 + lib0: 0.2.114 lodash.debounce: 4.0.8 - y-protocols: 1.0.6(yjs@13.6.23) - yjs: 13.6.23 + y-protocols: 1.0.6(yjs@13.6.27) + yjs: 13.6.27 optionalDependencies: ws: 6.2.2 - y-leveldb: 0.1.2(yjs@13.6.23) + y-leveldb: 0.1.2(yjs@13.6.27) y18n@4.0.3: {} @@ -40841,9 +40737,9 @@ snapshots: buffer-crc32: 0.2.13 fd-slicer: 1.1.0 - yjs@13.6.23: + yjs@13.6.27: dependencies: - lib0: 0.2.99 + lib0: 0.2.109 ylru@1.3.2: {} diff --git a/common/scripts/docker.sh b/common/scripts/docker.sh index fc5085a4a7..01a4553752 100755 --- a/common/scripts/docker.sh +++ b/common/scripts/docker.sh @@ -19,7 +19,6 @@ rush docker:build -p 20 \ --to @hcengineering/pod-datalake \ --to @hcengineering/pod-mail-worker \ --to @hcengineering/pod-export \ ---to @hcengineering/pod-msg2file \ --to @hcengineering/pod-media \ --to @hcengineering/pod-preview \ --to @hcengineering/pod-external \ diff --git a/communication b/communication index eb30332c8b..961934e8dd 160000 --- a/communication +++ b/communication @@ -1 +1 @@ -Subproject commit eb30332c8ba9bb0820c0b3ad8aa353c393aa07c9 +Subproject commit 961934e8dd2b9df0b3ca94650e370ec900436e32 diff --git a/desktop/public/TrayIconWithBadge.ico b/desktop/public/TrayIconWithBadge.ico new file mode 100644 index 0000000000..8f3539423c Binary files /dev/null and b/desktop/public/TrayIconWithBadge.ico differ diff --git a/desktop/src/__test__/main/selfCheckingNode.test.ts b/desktop/src/__test__/main/selfCheckingNode.test.ts index b21a301256..e118730320 100644 --- a/desktop/src/__test__/main/selfCheckingNode.test.ts +++ b/desktop/src/__test__/main/selfCheckingNode.test.ts @@ -14,9 +14,8 @@ // describe('node environment self checking', () => { - test('check Jest\'s node environment', () => { - const isNodeAvailable = typeof process !== 'undefined'; - expect(isNodeAvailable).toBe(true); - }) - + test('check Jest\'s node environment', () => { + const isNodeAvailable = typeof process !== 'undefined' + expect(isNodeAvailable).toBe(true) + }) }) diff --git a/desktop/src/__test__/main/settings.test.ts b/desktop/src/__test__/main/settings.test.ts new file mode 100644 index 0000000000..3db1f225b9 --- /dev/null +++ b/desktop/src/__test__/main/settings.test.ts @@ -0,0 +1,198 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { Settings } from '../../main/settings' +import { PackedConfig } from '../../main/config' + +const createMockStore = (): { get: jest.Mock, set: jest.Mock } => { + const store: Record = {} + return { + get: jest.fn((key: string) => store[key]), + set: jest.fn((key: string, value: any) => { store[key] = value }) + } +} + +jest.mock('electron-store', () => { + return jest.fn() +}) + +describe('Settings', () => { + let originalEnvironment: NodeJS.ProcessEnv + let stubStoreInstance: ReturnType + + beforeEach(() => { + originalEnvironment = process.env + process.env = { ...originalEnvironment } + stubStoreInstance = createMockStore() + jest.clearAllMocks() + }) + + afterEach(() => { + process.env = originalEnvironment + jest.clearAllMocks() + }) + + describe('readServerUrl', () => { + test('isDev is true', () => { + const systemUnderTest = new Settings(stubStoreInstance as any, true) + + const actualUrl = systemUnderTest.readServerUrl() + + expect(actualUrl).toBe('http://huly.local:8087') + }) + + test('isDev is true and FRONT_URL is set', () => { + process.env.FRONT_URL = 'http://custom-dev.local:3000' + const systemUnderTest = new Settings(stubStoreInstance as any, true) + + const actualUrl = systemUnderTest.readServerUrl() + + expect(actualUrl).toBe('http://custom-dev.local:3000') + }) + + test('isDev is false and server is in store', () => { + const expectedUrl = 'https://stored.server.com' + stubStoreInstance.get.mockReturnValue(expectedUrl) + const systemUnderTest = new Settings(stubStoreInstance as any, false) + + const actualUrl = systemUnderTest.readServerUrl() + + expect(actualUrl).toBe(expectedUrl) + }) + + test('store is empty and packed config exists', () => { + stubStoreInstance.get.mockReturnValue(undefined) + const expectedUrl = 'https://packed.server.com' + const packedConfig: PackedConfig = { server: expectedUrl } + const systemUnderTest = new Settings(stubStoreInstance as any, false, packedConfig) + + const actualUrl = systemUnderTest.readServerUrl() + + expect(actualUrl).toBe(expectedUrl) + }) + + test('store and packed config are empty', () => { + stubStoreInstance.get.mockReturnValue(undefined) + const expectedUrl = 'https://env.server.com' + process.env.FRONT_URL = expectedUrl + const systemUnderTest = new Settings(stubStoreInstance as any, false) + + const actualUrl = systemUnderTest.readServerUrl() + + expect(actualUrl).toBe(expectedUrl) + }) + + test('all options are unavailable', () => { + stubStoreInstance.get.mockReturnValue(undefined) + delete process.env.FRONT_URL + const systemUnderTest = new Settings(stubStoreInstance as any, false) + + const result = systemUnderTest.readServerUrl() + + expect(result).toBe('https://huly.app') + }) + + test('all (store, packed config, environment) options are available', () => { + const expectedUrl = 'https://store.priority.com' + stubStoreInstance.get.mockReturnValue(expectedUrl) + process.env.FRONT_URL = 'https://env.server.com' + const packedConfig: PackedConfig = { server: 'https://packed.server.com' } + const systemUnderTest = new Settings(stubStoreInstance as any, false, packedConfig) + + const actualUrl = systemUnderTest.readServerUrl() + + expect(actualUrl).toBe(expectedUrl) + }) + }) + + describe('read/write', () => { + let systemUnderTest: Settings + beforeEach(() => { + systemUnderTest = new Settings(stubStoreInstance as any, false) + }) + + describe('isMinimizeToTrayEnabled', () => { + test.each([ + { storedValue: true, expected: true, description: 'stored value is true' }, + { storedValue: false, expected: false, description: 'stored value is false' } + ])('$description', ({ storedValue, expected }) => { + stubStoreInstance.get.mockReturnValue(storedValue) + + const actualValue = systemUnderTest.isMinimizeToTrayEnabled() + + expect(actualValue).toBe(expected) + }) + + test.each([ + { storedValue: undefined, description: 'no value is stored (undefined)' }, + { storedValue: null, description: 'stored value is null' } + ])('$description', ({ storedValue }) => { + stubStoreInstance.get.mockReturnValue(storedValue) + + const actualValue = systemUnderTest.isMinimizeToTrayEnabled() + + expect(actualValue).toBe(false) + }) + }) + + describe('setMinimizeToTrayEnabled', () => { + test.each([ + { value: true, description: 'stored value is true' }, + { value: false, description: 'stored value is false' } + ])('$description', ({ value }) => { + systemUnderTest.setMinimizeToTrayEnabled(value) + + expect(systemUnderTest.isMinimizeToTrayEnabled()).toBe(value) + }) + }) + + describe('setServerUrl', () => { + test('write than read', () => { + const expectedUrl = 'https://new.server.com' + + systemUnderTest.setServerUrl(expectedUrl) + + expect(systemUnderTest.readServerUrl()).toBe(expectedUrl) + }) + }) + + describe('getWindowBounds', () => { + test('read', () => { + const expectedValue = { x: 100, y: 300, width: 500, height: 700 } + stubStoreInstance.get.mockReturnValue(expectedValue) + + const actulValue = systemUnderTest.getWindowBounds() + + expect(actulValue).toEqual(expectedValue) + }) + + test('no bounds are stored', () => { + stubStoreInstance.get.mockReturnValue(undefined) + + expect(systemUnderTest.getWindowBounds()).toBeUndefined() + }) + }) + + describe('setWindowBounds', () => { + test('write than read', () => { + const expectedValue = { x: 100, y: 200, width: 800, height: 600 } + + systemUnderTest.setWindowBounds(expectedValue) + + expect(systemUnderTest.getWindowBounds()).toBe(expectedValue) + }) + }) + }) +}) diff --git a/desktop/src/__test__/main/trayUtils.test.ts b/desktop/src/__test__/main/trayUtils.test.ts new file mode 100644 index 0000000000..2927f6d5d7 --- /dev/null +++ b/desktop/src/__test__/main/trayUtils.test.ts @@ -0,0 +1,67 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { getBadgeIconInfo } from '../../main/trayUtils' + +describe('getBadgeIconInfo', () => { + const baseTitle = 'Huly' + + it('count of 0', () => { + const result = getBadgeIconInfo(0, baseTitle) + expect(result).toEqual({ + fileName: '', + tooltip: baseTitle + }) + }) + + it('negative count', () => { + const result = getBadgeIconInfo(-5, baseTitle) + expect(result).toEqual({ + fileName: '', + tooltip: baseTitle + }) + }) + + it.each([1, 2, 3, 4, 5, 6, 7, 8, 9])('count of %i', (count: number) => { + const result = getBadgeIconInfo(count, baseTitle) + expect(result).toEqual({ + fileName: 'TrayIconWithBadge.ico', + tooltip: `${baseTitle}: ${count} unread` + }) + }) + + describe('badge count 10-98', () => { + const testCases: number[] = [] + for (let i = 10; i <= 98; i++) { + testCases.push(i) + } + + it.each(testCases)('count of %i', (count: number) => { + const result = getBadgeIconInfo(count, baseTitle) + expect(result).toEqual({ + fileName: 'TrayIconWithBadge.ico', + tooltip: `${baseTitle}: ${count} unread` + }) + }) + }) + + it.each([99, 100, 200, 301])('count of %i', (count: number) => { + const result = getBadgeIconInfo(count, baseTitle) + expect(result).toEqual({ + fileName: 'TrayIconWithBadge.ico', + tooltip: `${baseTitle}: ${count} unread` + }) + }) +}) diff --git a/desktop/src/main/config.ts b/desktop/src/main/config.ts index bd14c20136..ea6d211224 100644 --- a/desktop/src/main/config.ts +++ b/desktop/src/main/config.ts @@ -15,12 +15,12 @@ import * as path from 'path' import * as fs from 'fs' -interface PackedConfig { +export interface PackedConfig { server?: string updatesChannelKey?: string } -const configPath = path.join(process.resourcesPath, 'config/config.json') +const configPath = path.join(process.resourcesPath, 'config', 'config.json') export function readPackedConfig (): PackedConfig | undefined { if (fs.existsSync(configPath)) { diff --git a/desktop/src/main/customMenu.ts b/desktop/src/main/customMenu.ts index 915dea39ae..84b20eac2c 100644 --- a/desktop/src/main/customMenu.ts +++ b/desktop/src/main/customMenu.ts @@ -13,10 +13,11 @@ // limitations under the License. // -import { app, BrowserWindow } from 'electron' +import { BrowserWindow } from 'electron' import { MenuBarAction, CommandLogout, CommandSelectWorkspace, CommandOpenSettings } from '../ui/types' +import { TrayController } from './tray' -export function dispatchMenuBarAction (mainWindow: BrowserWindow | undefined, action: MenuBarAction): void { +export function dispatchMenuBarAction (mainWindow: BrowserWindow | undefined, action: MenuBarAction, tray: TrayController | undefined): void { if (mainWindow == null) { return } @@ -42,7 +43,7 @@ export function dispatchMenuBarAction (mainWindow: BrowserWindow | undefined, ac mainWindow.webContents.send(CommandLogout) break case 'exit': - app.quit() + mainWindow.close() break case 'undo': mainWindow.webContents.undo() @@ -86,6 +87,13 @@ export function dispatchMenuBarAction (mainWindow: BrowserWindow | undefined, ac case 'toggle-fullscreen': mainWindow.setFullScreen(!mainWindow.isFullScreen()) break + case 'toggle-minimize-to-tray': { + if (tray != null) { + const newSetting = tray.toggleMinimizeToTray() + mainWindow.webContents.send('minimize-to-tray-setting-changed', newSetting) + } + break + } default: console.log('unknown menu action:', action) } diff --git a/services/msg2file/src/index.ts b/desktop/src/main/path.ts similarity index 75% rename from services/msg2file/src/index.ts rename to desktop/src/main/path.ts index 8dc27a41ea..5de505a45f 100644 --- a/services/msg2file/src/index.ts +++ b/desktop/src/main/path.ts @@ -13,9 +13,9 @@ // limitations under the License. // -import { config } from 'dotenv' +import { app } from 'electron' +import path from 'path' -import { main } from './main' - -config() -void main() +export function getFileInPublicBundledFolder (fileName: string): string { + return path.join(app.getAppPath(), 'dist', 'ui', 'public', fileName) +} diff --git a/desktop/src/main/settings.ts b/desktop/src/main/settings.ts new file mode 100644 index 0000000000..1996b23141 --- /dev/null +++ b/desktop/src/main/settings.ts @@ -0,0 +1,65 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Store from 'electron-store' +import { PackedConfig } from './config' + +export class Settings { + private static readonly SETTINGS_KEY_SERVER = 'server' + private static readonly SETTINGS_KEY_MINIMIZE_TO_TRAY = 'minimize-to-tray' + private static readonly SETTINGS_KEY_WINDOW_BOUNDS = 'windowBounds' + + private readonly store: Store + private readonly isDev: boolean + private readonly packedConfig?: PackedConfig + + constructor (store: Store, isDev: boolean, packedConfig?: PackedConfig) { + this.store = store + this.isDev = isDev + this.packedConfig = packedConfig + } + + readServerUrl (): string { + if (this.isDev) { + return process.env.FRONT_URL ?? 'http://huly.local:8087' + } + return ( + (this.store as any).get(Settings.SETTINGS_KEY_SERVER) as string ?? + this.packedConfig?.server ?? + process.env.FRONT_URL ?? + 'https://huly.app' + ) + } + + isMinimizeToTrayEnabled (): boolean { + return (this.store as any).get(Settings.SETTINGS_KEY_MINIMIZE_TO_TRAY) as boolean ?? false + } + + setMinimizeToTrayEnabled (enabled: boolean): void { + (this.store as any).set(Settings.SETTINGS_KEY_MINIMIZE_TO_TRAY, enabled) + } + + setServerUrl (serverUrl: string): void { + (this.store as any).set(Settings.SETTINGS_KEY_SERVER, serverUrl) + } + + getWindowBounds (): any { + return (this.store as any).get(Settings.SETTINGS_KEY_WINDOW_BOUNDS) + } + + setWindowBounds (bounds: any): void { + (this.store as any).set(Settings.SETTINGS_KEY_WINDOW_BOUNDS, bounds) + } +} diff --git a/desktop/src/main/start.ts b/desktop/src/main/start.ts index 021f96f71f..eef60b5b07 100644 --- a/desktop/src/main/start.ts +++ b/desktop/src/main/start.ts @@ -14,7 +14,7 @@ // import { config as dotenvConfig } from 'dotenv' -import { BrowserWindow, CookiesSetDetails, Notification, app, desktopCapturer, dialog, ipcMain, nativeImage, session, shell, systemPreferences, nativeTheme } from 'electron' +import { globalShortcut, Event, BrowserWindow, CookiesSetDetails, Notification, app, desktopCapturer, dialog, ipcMain, nativeImage, session, shell, systemPreferences, nativeTheme } from 'electron' import contextMenu from 'electron-context-menu' import log from 'electron-log' import Store from 'electron-store' @@ -22,7 +22,7 @@ import { ProgressInfo, UpdateInfo } from 'electron-updater' import WinBadge from 'electron-windows-badge' import * as path from 'path' -import { Config, MenuBarAction, NotificationParams, JumpListSpares } from '../ui/types' +import { Config, MenuBarAction, NotificationParams, JumpListSpares, CommandCloseTab } from '../ui/types' import { getOptions } from './args' import { addMenus } from './standardMenu' import { dispatchMenuBarAction } from './customMenu' @@ -32,536 +32,587 @@ import { generateId } from '@hcengineering/core' import { DownloadItem } from '@hcengineering/desktop-downloads' import { rebuildJumpList, setupWindowsSpecific } from './windowsSpecificSetup' import { readPackedConfig } from './config' +import { Settings } from './settings' +import { TrayController } from './tray' +import { getFileInPublicBundledFolder } from './path' -let mainWindow: BrowserWindow | undefined -let winBadge: any +let isQuiting = false -const isMac = process.platform === 'darwin' -const isWindows = process.platform === 'win32' -const isDev = process.env.NODE_ENV === 'development' - -const sessionPartition = !isDev ? 'persist:huly' : 'persist:huly_dev' -const iconKey = path.join(app.getAppPath(), 'dist', 'ui', 'public', isWindows ? 'AppIcon.ico' : 'AppIcon.png') -const preloadScriptPath = path.join(app.getAppPath(), 'dist', 'main', 'preload.js') - -const defaultWidth = 1440 -const defaultHeight = 960 - -const packedConfig = readPackedConfig() -log.info('packed config', packedConfig) - -const envPath = path.join(app.getAppPath(), isDev ? '.env-dev' : '.env') -console.log('do loading env from', envPath) -dotenvConfig({ - path: envPath -}) -const options = getOptions() - -const containerPageFileName = isWindows ? 'index.windows.html' : 'index.html' -const containerPagePath = path.join('dist', 'ui', containerPageFileName) - -// Note: using electron-store here as local storage is not available in the main process -// before the window is created -const settings = new Store() -const oldFront = readServerUrl() - -if (options.server !== undefined) { - ;(settings as any).set('server', options.server) -} - -const FRONT_URL = readServerUrl() -const serverChanged = oldFront !== FRONT_URL - -function readServerUrl (): string { - if (isDev) { - return process.env.FRONT_URL ?? 'http://huly.local:8087' - } - - return ((settings as any).get('server') as string) ?? packedConfig?.server ?? process.env.FRONT_URL ?? 'https://huly.app' -} - -// Handle creating/removing shortcuts on Windows when installing/uninstalling. -if (require('electron-squirrel-startup') === true) { +function quitApplication (): void { + isQuiting = true app.quit() } -console.log('Running Huly', process.env.MODEL_VERSION, process.env.VERSION, isMac, isDev, process.env.NODE_ENV) - -// Fix screen-sharing thumbnails being missing sometimes -// See https://github.com/electron/electron/issues/44504 -const disabledFeatures = [ - 'ThumbnailCapturerMac:capture_mode/sc_screenshot_manager', - 'ScreenCaptureKitPickerScreen', - 'ScreenCaptureKitStreamPickerSonoma' -] - -app.commandLine.appendSwitch('disable-features', disabledFeatures.join(',')) - -function setupWindowTitleBar (windowOptions: Electron.BrowserWindowConstructorOptions): void { - if (isWindows) { - // on Windows we use frameless window with custom hand-made title bar - windowOptions.frame = false - } else { - windowOptions.titleBarStyle = isMac ? 'hidden' : 'default' - } -} - -function hookOpenWindow (window: BrowserWindow): void { - window.webContents.setWindowOpenHandler(({ url }) => { - console.log('opening window', url) - - /* - We need to detect if url is "our" or external. We should - open external urls in system browser - - When we open local URLs it would be as file:///workbench/my-spc/tracker/TSK-2 - As we load only our index.html there is no security problem to pass such URLs - to open arg as well - */ - if (url.indexOf(FRONT_URL) !== 0 && url.indexOf('file://') !== 0) { - void shell.openExternal(url) - } else { - void (async (): Promise => { - const bounds = mainWindow?.getBounds() - const windowOptions: Electron.BrowserWindowConstructorOptions = { - width: bounds?.width ?? defaultWidth, - height: bounds?.height ?? defaultHeight, - x: (bounds?.x ?? 0) + 25, - y: (bounds?.y ?? 0) + 25, - trafficLightPosition: { x: 10, y: 10 }, - icon: nativeImage.createFromPath(iconKey), - webPreferences: { - devTools: true, - sandbox: false, - partition: sessionPartition, - nodeIntegration: true, - preload: preloadScriptPath, - additionalArguments: [ - `--open=${encodeURI( - new URL(url).pathname - .split('/') - .filter((it) => it.length > 0) - .join('/') - )}` - ] - } - } - setupWindowTitleBar(windowOptions) - const childWindow = new BrowserWindow(windowOptions) - await childWindow.loadFile(containerPagePath) - hookOpenWindow(childWindow) - })() - } - return { action: 'deny' } - }) -} - -function setupCookieHandler (config: Config): void { - const normalizedAccountsUrl = config.ACCOUNTS_URL.endsWith('/') ? config.ACCOUNTS_URL : config.ACCOUNTS_URL + '/' - const urls = [ - normalizedAccountsUrl, - normalizedAccountsUrl + '*' - ] - - session.defaultSession.webRequest.onHeadersReceived({ urls }, handleSetCookie) - session.fromPartition(sessionPartition).webRequest.onHeadersReceived({ urls }, handleSetCookie) -} - -function handleSetCookie (details: Electron.OnHeadersReceivedListenerDetails, callback: (headersReceivedResponse: Electron.HeadersReceivedResponse) => void): void { - if (details.responseHeaders !== undefined) { - for (const header in details.responseHeaders) { - if (header.toLowerCase() === 'set-cookie') { - const cookies = details.responseHeaders[header] - details.responseHeaders[header] = cookies.map((cookie) => { - if (!cookie.includes('SameSite=')) { - if (details.url.startsWith('https://') && !cookie.includes('; Secure')) { - cookie += '; Secure' - } - cookie += '; SameSite=None' - } - return cookie - }) - } - } - } - - // eslint-disable-next-line n/no-callback-literal - callback({ responseHeaders: { ...details.responseHeaders } }) -} - -function handleAuthRedirects (window: BrowserWindow): void { - window.webContents.on('will-redirect', (event) => { - if (event?.url.startsWith(`${FRONT_URL}/login/auth`)) { - console.log('Auth happened, redirecting to local index') - const urlObj = new URL(decodeURIComponent(event.url)) - event.preventDefault() - - void (async (): Promise => { - await window.loadFile(containerPagePath) - window.webContents.send('handle-auth', urlObj.searchParams.get('token')) - })() - } - }) -} - -function handleWillDownload (window: BrowserWindow): void { - window.webContents.session.on('will-download', (_event, item) => { - const key = generateId() - - const notifyDownloadUpdated = (): void => { - const download: DownloadItem = { - key, - state: item.isPaused() ? 'paused' : item.getState(), - fileName: item.getFilename(), - receivedBytes: item.getReceivedBytes(), - totalBytes: item.getTotalBytes(), - url: item.getURL(), - savePath: item.getSavePath() - } - window.webContents.send('handle-download-item', download) - } - - notifyDownloadUpdated() - - item.on('updated', notifyDownloadUpdated) - item.on('done', notifyDownloadUpdated) - }) -} - -const createWindow = async (): Promise => { - // Restore window position if available - const restoredBounds: any = settings.get('windowBounds') - const windowOptions: Electron.BrowserWindowConstructorOptions = { - width: restoredBounds?.width ?? defaultWidth, - height: restoredBounds?.height ?? defaultHeight, - x: restoredBounds?.x ?? undefined, - y: restoredBounds?.y ?? undefined, - trafficLightPosition: { x: 10, y: 10 }, - roundedCorners: true, - icon: nativeImage.createFromPath(iconKey), - webPreferences: { - devTools: true, - sandbox: false, - nodeIntegration: true, - // backgroundThrottling: false, - partition: sessionPartition, - preload: preloadScriptPath - } - } - setupWindowTitleBar(windowOptions) - mainWindow = new BrowserWindow(windowOptions) - app.dock?.setIcon(nativeImage.createFromPath(iconKey)) - // await mainWindow.webContents.openDevTools() - if (isDev) { - mainWindow.webContents.openDevTools() - } - await mainWindow.loadFile(containerPagePath) - addPermissionHandlers(mainWindow.webContents.session) - handleAuthRedirects(mainWindow) - handleWillDownload(mainWindow) - - // In this example, only windows with the `about:blank` url will be created. - // All other urls will be blocked. - hookOpenWindow(mainWindow) - - // Save window position on close - mainWindow.on('close', () => { - const bounds = mainWindow?.getBounds() - if (bounds !== undefined) { - settings.set('windowBounds', bounds) - } - }) - - function sendWindowMaximizedMessage (maximized: boolean): void { - mainWindow?.webContents.send('window-state-changed', maximized ? 'maximized' : 'unmaximized') - } - - mainWindow.on('blur', () => { - mainWindow?.webContents.send('window-focus-loss') - }) - - mainWindow.on('maximize', () => { - sendWindowMaximizedMessage(true) - }) - - mainWindow.on('unmaximize', () => { - sendWindowMaximizedMessage(false) - }) - - mainWindow.on('enter-full-screen', () => { - sendWindowMaximizedMessage(true) - }) - - mainWindow.on('leave-full-screen', () => { - if (mainWindow != null) { - sendWindowMaximizedMessage(mainWindow.isMaximized()) - } - }) - - if (isMac) { - mainWindow.on('close', (event) => { - // Prevent the default behavior (which would quit the app) - event.preventDefault() - // Hide the window - mainWindow?.hide() - }) - } else if (isWindows) { - winBadge = new WinBadge(mainWindow, { - font: '14px arial' - }) - } -} - -function sendCommand (cmd: string, ...args: any[]): void { - mainWindow?.webContents.send(cmd, ...args) -} - -function activateWindow (): void { - if (mainWindow != null) { - if (mainWindow.isMinimized()) { - mainWindow.restore() - } - mainWindow.show() - mainWindow.focus() - } -} - -if (isWindows) { - setupWindowsSpecific(activateWindow, sendCommand) -} else { - addMenus(sendCommand) -} - -contextMenu({ - showSaveImageAs: false, - showInspectElement: false, - showSelectAll: false -}) - -ipcMain.on('set-badge', (_event: any, badge: number) => { - app.dock?.setBadge(badge > 0 ? `${badge}` : '') - app.badgeCount = badge - - if (isWindows && winBadge !== undefined) { - winBadge.update(badge) - } -}) - -ipcMain.on('dock-bounce', (_event: any) => { - app.dock?.bounce('informational') -}) - -ipcMain.on('send-notification', (_event: any, notificationParams: NotificationParams) => { - if (Notification.isSupported()) { - const notification = new Notification(notificationParams) - - notification.on('click', () => { - mainWindow?.show() - mainWindow?.webContents.send('handle-notification-navigation', notificationParams) - }) - - notification.show() - } -}) - -ipcMain.on('set-title', (event: any, title: string) => { - const webContents = event.sender - const window = BrowserWindow.fromWebContents(webContents) - window?.setTitle(title) -}) - -ipcMain.on('set-combined-config', (_event: any, config: Config) => { - log.info('Config set: ', config) - - setupCookieHandler(config) - - const updatesUrl = process.env.DESKTOP_UPDATES_URL ?? config.DESKTOP_UPDATES_URL ?? 'https://dist.huly.io' - // NOTE: env format is: default_value;key1:value1;key2:value2... - const updatesChannels = (process.env.DESKTOP_UPDATES_CHANNEL ?? config.DESKTOP_UPDATES_CHANNELS ?? config.DESKTOP_UPDATES_CHANNEL ?? 'huly').split(';').map(c => c.trim().split(':')) - const updateChannelsMap: Record = {} - for (const channelInfo of updatesChannels) { - if (channelInfo.length === 1) { - updateChannelsMap.default = channelInfo[0] - } else if (channelInfo.length === 2) { - const [key, value] = channelInfo - updateChannelsMap[key] = value - } - } - - const updatesChannelKey = packedConfig?.updatesChannelKey ?? 'default' - const updatesChannel = updateChannelsMap[updatesChannelKey] ?? updateChannelsMap.default ?? 'huly' - - log.info('updates channels', updatesChannels) - log.info('updates channel', updatesChannelKey, updatesChannel) - - autoUpdater.setFeedURL({ - provider: 'generic', - url: updatesUrl, - channel: updatesChannel - }) - void autoUpdater.checkForUpdatesAndNotify() -}) - -ipcMain.handle('get-main-config', (_event: any, _path: any) => { - const cfg = { - CONFIG_URL: process.env.CONFIG_URL ?? '', - FRONT_URL, - INITIAL_URL: process.env.INITIAL_URL ?? '', - MODEL_VERSION: process.env.MODEL_VERSION ?? '', - VERSION: process.env.VERSION ?? '' - } - return cfg -}) -ipcMain.handle('get-host', (_event: any, _path: any) => { - return new URL(FRONT_URL).host -}) - -ipcMain.on('set-front-cookie', function (event: any, host: string, name: string, value: string) { - const webContents = event.sender - const win = BrowserWindow.fromWebContents(webContents) - const cv: CookiesSetDetails = { - url: host, - name, - value, - path: '/', - secure: true, - sameSite: 'no_restriction', - httpOnly: true, - domain: process.env.FRONT_DOMAIN - } - void win?.webContents?.session.cookies.set(cv) -}) - -ipcMain.handle('window-minimize', () => { - mainWindow?.minimize() -}) - -ipcMain.handle('window-maximize', () => { - if (mainWindow != null) { - if (mainWindow.isMaximized()) { - mainWindow.unmaximize() - } else { - mainWindow.maximize() - } - } -}) - -ipcMain.handle('window-close', () => { - mainWindow?.close() -}) - -ipcMain.handle('get-is-os-using-dark-theme', () => { - return nativeTheme.shouldUseDarkColors -}) - -ipcMain.handle('menu-action', async (_event: any, action: MenuBarAction) => { - dispatchMenuBarAction(mainWindow, action) -}) - -if (isWindows) { - ipcMain.on('rebuild-user-jump-list', (_event: any, spares: JumpListSpares) => { - rebuildJumpList(spares) - }) -} - const gotTheLock = app.requestSingleInstanceLock() if (!gotTheLock) { - app.quit() + quitApplication() +} else { + runTheApp() } -ipcMain.handle('get-screen-access', () => systemPreferences.getMediaAccessStatus('screen') === 'granted') -ipcMain.handle('get-screen-sources', () => { - return desktopCapturer.getSources({ types: ['window', 'screen'], fetchWindowIcons: true, thumbnailSize: { width: 225, height: 135 } }).then(async sources => { - return sources.map((source: any) => { - return { - ...source, - appIconURL: source.appIcon?.toDataURL(), - thumbnailURL: source.thumbnail.toDataURL() +function runTheApp (): void { + let winBadge: any + let mainWindow: BrowserWindow | undefined + + const isMac = process.platform === 'darwin' + const isWindows = process.platform === 'win32' + const isDev = process.env.NODE_ENV === 'development' + + const sessionPartition = !isDev ? 'persist:huly' : 'persist:huly_dev' + const iconKey = getFileInPublicBundledFolder(isWindows ? 'AppIcon.ico' : 'AppIcon.png') + const preloadScriptPath = path.join(app.getAppPath(), 'dist', 'main', 'preload.js') + + const defaultWidth = 1440 + const defaultHeight = 960 + + const packedConfig = readPackedConfig() + log.info('packed config', packedConfig) + + const envPath = path.join(app.getAppPath(), isDev ? '.env-dev' : '.env') + console.log('do loading env from', envPath) + dotenvConfig({ + path: envPath + }) + const options = getOptions() + + const containerPageFileName = isWindows ? 'index.windows.html' : 'index.html' + const containerPagePath = path.join('dist', 'ui', containerPageFileName) + + const CloseTabHotKey = 'CommandOrControl+W' + + // Note: using electron-store here as local storage is not available in the main process + // before the window is created + const settings = new Settings(new Store(), isDev, packedConfig) + const oldFront = settings.readServerUrl() + + if (options.server !== undefined) { + settings.setServerUrl(options.server) + } + + const FRONT_URL = settings.readServerUrl() + const serverChanged = oldFront !== FRONT_URL + + // Handle creating/removing shortcuts on Windows when installing/uninstalling. + if (require('electron-squirrel-startup') === true) { + quitApplication() + } + + console.log('Running Huly', process.env.MODEL_VERSION, process.env.VERSION, isMac, isDev, process.env.NODE_ENV) + + // Fix screen-sharing thumbnails being missing sometimes + // See https://github.com/electron/electron/issues/44504 + const disabledFeatures = [ + 'ThumbnailCapturerMac:capture_mode/sc_screenshot_manager', + 'ScreenCaptureKitPickerScreen', + 'ScreenCaptureKitStreamPickerSonoma' + ] + + app.commandLine.appendSwitch('disable-features', disabledFeatures.join(',')) + + function setupWindowTitleBar (windowOptions: Electron.BrowserWindowConstructorOptions): void { + if (isWindows) { + // on Windows we use frameless window with custom hand-made title bar + windowOptions.frame = false + } else { + windowOptions.titleBarStyle = isMac ? 'hidden' : 'default' + } + } + + function hookOpenWindow (window: BrowserWindow): void { + window.webContents.setWindowOpenHandler(({ url }) => { + console.log('opening window', url) + + /* + We need to detect if url is "our" or external. We should + open external urls in system browser + + When we open local URLs it would be as file:///workbench/my-spc/tracker/TSK-2 + As we load only our index.html there is no security problem to pass such URLs + to open arg as well + */ + if (url.indexOf(FRONT_URL) !== 0 && url.indexOf('file://') !== 0) { + void shell.openExternal(url) + } else { + void (async (): Promise => { + const bounds = mainWindow?.getBounds() + const windowOptions: Electron.BrowserWindowConstructorOptions = { + width: bounds?.width ?? defaultWidth, + height: bounds?.height ?? defaultHeight, + x: (bounds?.x ?? 0) + 25, + y: (bounds?.y ?? 0) + 25, + trafficLightPosition: { x: 10, y: 10 }, + icon: nativeImage.createFromPath(iconKey), + webPreferences: { + devTools: true, + sandbox: false, + partition: sessionPartition, + nodeIntegration: true, + preload: preloadScriptPath, + additionalArguments: [ + `--open=${encodeURI( + new URL(url).pathname + .split('/') + .filter((it) => it.length > 0) + .join('/') + )}` + ] + } + } + setupWindowTitleBar(windowOptions) + const childWindow = new BrowserWindow(windowOptions) + await childWindow.loadFile(containerPagePath) + hookOpenWindow(childWindow) + })() + } + return { action: 'deny' } + }) + } + + function setupCookieHandler (config: Config): void { + const normalizedAccountsUrl = config.ACCOUNTS_URL.endsWith('/') ? config.ACCOUNTS_URL : config.ACCOUNTS_URL + '/' + const urls = [ + normalizedAccountsUrl, + normalizedAccountsUrl + '*' + ] + + session.defaultSession.webRequest.onHeadersReceived({ urls }, handleSetCookie) + session.fromPartition(sessionPartition).webRequest.onHeadersReceived({ urls }, handleSetCookie) + } + + function handleSetCookie (details: Electron.OnHeadersReceivedListenerDetails, callback: (headersReceivedResponse: Electron.HeadersReceivedResponse) => void): void { + if (details.responseHeaders !== undefined) { + for (const header in details.responseHeaders) { + if (header.toLowerCase() === 'set-cookie') { + const cookies = details.responseHeaders[header] + details.responseHeaders[header] = cookies.map((cookie) => { + if (!cookie.includes('SameSite=')) { + if (details.url.startsWith('https://') && !cookie.includes('; Secure')) { + cookie += '; Secure' + } + cookie += '; SameSite=None' + } + return cookie + }) + } + } + } + + // eslint-disable-next-line n/no-callback-literal + callback({ responseHeaders: { ...details.responseHeaders } }) + } + + function handleAuthRedirects (window: BrowserWindow): void { + window.webContents.on('will-redirect', (event) => { + if (event?.url.startsWith(`${FRONT_URL}/login/auth`)) { + console.log('Auth happened, redirecting to local index') + const urlObj = new URL(decodeURIComponent(event.url)) + event.preventDefault() + + void (async (): Promise => { + await window.loadFile(containerPagePath) + window.webContents.send('handle-auth', urlObj.searchParams.get('token')) + })() } }) - }) -}) - -async function onReady (): Promise { - await createWindow() - - if (serverChanged) { - mainWindow?.webContents.send('logout') } -} -app.on('ready', () => { - void onReady() -}) + function handleWillDownload (window: BrowserWindow): void { + window.webContents.session.on('will-download', (_event, item) => { + const key = generateId() -app.on('window-all-closed', () => { - if (process.platform !== 'darwin') { - app.quit() + const notifyDownloadUpdated = (): void => { + const download: DownloadItem = { + key, + state: item.isPaused() ? 'paused' : item.getState(), + fileName: item.getFilename(), + receivedBytes: item.getReceivedBytes(), + totalBytes: item.getTotalBytes(), + url: item.getURL(), + savePath: item.getSavePath() + } + window.webContents.send('handle-download-item', download) + } + + notifyDownloadUpdated() + + item.on('updated', notifyDownloadUpdated) + item.on('done', notifyDownloadUpdated) + }) } -}) -if (isMac) { - app.on('activate', () => { - if (mainWindow != null && (mainWindow.isMinimized() || !mainWindow.isVisible())) { + const createWindow = async (): Promise => { + const restoredBounds: any = settings.getWindowBounds() + const windowOptions: Electron.BrowserWindowConstructorOptions = { + width: restoredBounds?.width ?? defaultWidth, + height: restoredBounds?.height ?? defaultHeight, + x: restoredBounds?.x ?? undefined, + y: restoredBounds?.y ?? undefined, + trafficLightPosition: { x: 10, y: 10 }, + roundedCorners: true, + icon: nativeImage.createFromPath(iconKey), + webPreferences: { + devTools: true, + sandbox: false, + nodeIntegration: true, + partition: sessionPartition, + preload: preloadScriptPath + } + } + setupWindowTitleBar(windowOptions) + mainWindow = new BrowserWindow(windowOptions) + app.dock?.setIcon(nativeImage.createFromPath(iconKey)) + if (isDev) { + mainWindow.webContents.openDevTools() + } + await mainWindow.loadFile(containerPagePath) + addPermissionHandlers(mainWindow.webContents.session) + handleAuthRedirects(mainWindow) + handleWillDownload(mainWindow) + + // In this example, only windows with the `about:blank` url will be created. + // All other urls will be blocked. + hookOpenWindow(mainWindow) + + function minimizeToTrayIsOn (): boolean { + return settings.isMinimizeToTrayEnabled() && trayController != null + } + + mainWindow.on('close', (event: Event) => { + const bounds = mainWindow?.getBounds() + if (bounds != null) { + settings.setWindowBounds(bounds) + } + + if (minimizeToTrayIsOn()) { + if (!isQuiting) { + event.preventDefault() + mainWindow?.hide() + return false + } + } + + if (isMac) { + event.preventDefault() + mainWindow?.hide() + } + }) + + function sendWindowMaximizedMessage (maximized: boolean): void { + mainWindow?.webContents.send('window-state-changed', maximized ? 'maximized' : 'unmaximized') + } + + mainWindow.on('focus', () => { + const registered = globalShortcut.register(CloseTabHotKey, () => { + sendCommand(CommandCloseTab) + }) + if (!registered) { + console.log(`failed to register global shortcut on ${CloseTabHotKey}`) + } + }) + + mainWindow.on('blur', () => { + globalShortcut.unregister(CloseTabHotKey) + mainWindow?.webContents.send('window-focus-loss') + }) + + mainWindow.on('maximize', () => { + sendWindowMaximizedMessage(true) + }) + + mainWindow.on('minimize', () => { + if (minimizeToTrayIsOn()) { + mainWindow?.hide() + } + }) + + mainWindow.on('unmaximize', () => { + sendWindowMaximizedMessage(false) + }) + + mainWindow.on('enter-full-screen', () => { + sendWindowMaximizedMessage(true) + }) + + mainWindow.on('leave-full-screen', () => { + if (mainWindow != null) { + sendWindowMaximizedMessage(mainWindow.isMaximized()) + } + }) + + if (isWindows) { + winBadge = new WinBadge(mainWindow, { + font: '14px arial' + }) + } + } + + function sendCommand (cmd: string, ...args: any[]): void { + mainWindow?.webContents.send(cmd, ...args) + } + + function activateWindow (): void { + if (mainWindow != null) { + if (mainWindow.isMinimized()) { + mainWindow.restore() + } mainWindow.show() + mainWindow.focus() + } + } + + let trayController: TrayController | undefined + + void app.whenReady().then(() => { + trayController = new TrayController(settings, activateWindow, quitApplication) + }) + + if (isWindows) { + setupWindowsSpecific(activateWindow, sendCommand) + } else { + addMenus(sendCommand) + } + + contextMenu({ + showSaveImageAs: false, + showInspectElement: false, + showSelectAll: false + }) + + ipcMain.on('set-badge', (_event: any, badge: number) => { + app.dock?.setBadge(badge > 0 ? `${badge}` : '') + app.badgeCount = badge + + if (isWindows && winBadge !== undefined) { + winBadge.update(badge) + } + trayController?.updateTrayBadge(badge) + }) + + ipcMain.on('dock-bounce', (_event: any) => { + app.dock?.bounce('informational') + }) + + ipcMain.on('send-notification', (_event: any, notificationParams: NotificationParams) => { + if (Notification.isSupported()) { + const notification = new Notification(notificationParams) + + notification.on('click', () => { + mainWindow?.show() + mainWindow?.webContents.send('handle-notification-navigation', notificationParams) + }) + + notification.show() } }) -} -app.on('before-quit', () => { - if (mainWindow !== undefined && !mainWindow.isDestroyed()) { - const bounds = mainWindow.getBounds() - settings.set('windowBounds', bounds) + + ipcMain.on('set-title', (event: any, title: string) => { + const webContents = event.sender + const window = BrowserWindow.fromWebContents(webContents) + window?.setTitle(title) + }) + + ipcMain.on('set-combined-config', (_event: any, config: Config) => { + log.info('Config set: ', config) + + setupCookieHandler(config) + + const updatesUrl = process.env.DESKTOP_UPDATES_URL ?? config.DESKTOP_UPDATES_URL ?? 'https://dist.huly.io' + // NOTE: env format is: default_value;key1:value1;key2:value2... + const updatesChannels = (process.env.DESKTOP_UPDATES_CHANNEL ?? config.DESKTOP_UPDATES_CHANNELS ?? config.DESKTOP_UPDATES_CHANNEL ?? 'huly').split(';').map(c => c.trim().split(':')) + const updateChannelsMap: Record = {} + for (const channelInfo of updatesChannels) { + if (channelInfo.length === 1) { + updateChannelsMap.default = channelInfo[0] + } else if (channelInfo.length === 2) { + const [key, value] = channelInfo + updateChannelsMap[key] = value + } + } + + const updatesChannelKey = packedConfig?.updatesChannelKey ?? 'default' + const updatesChannel = updateChannelsMap[updatesChannelKey] ?? updateChannelsMap.default ?? 'huly' + + log.info('updates channels', updatesChannels) + log.info('updates channel', updatesChannelKey, updatesChannel) + + autoUpdater.setFeedURL({ + provider: 'generic', + url: updatesUrl, + channel: updatesChannel + }) + void autoUpdater.checkForUpdatesAndNotify() + }) + + ipcMain.handle('get-main-config', (_event: any, _path: any) => { + const cfg = { + CONFIG_URL: process.env.CONFIG_URL ?? '', + FRONT_URL, + INITIAL_URL: process.env.INITIAL_URL ?? '', + MODEL_VERSION: process.env.MODEL_VERSION ?? '', + VERSION: process.env.VERSION ?? '' + } + return cfg + }) + ipcMain.handle('get-host', (_event: any, _path: any) => { + return new URL(FRONT_URL).host + }) + + ipcMain.on('set-front-cookie', function (event: any, host: string, name: string, value: string) { + const webContents = event.sender + const win = BrowserWindow.fromWebContents(webContents) + const cv: CookiesSetDetails = { + url: host, + name, + value, + path: '/', + secure: true, + sameSite: 'no_restriction', + httpOnly: true, + domain: process.env.FRONT_DOMAIN + } + void win?.webContents?.session.cookies.set(cv) + }) + + ipcMain.handle('window-minimize', () => { + mainWindow?.minimize() + }) + + ipcMain.handle('window-maximize', () => { + if (mainWindow != null) { + if (mainWindow.isMaximized()) { + mainWindow.unmaximize() + } else { + mainWindow.maximize() + } + } + }) + + ipcMain.handle('window-close', () => { + mainWindow?.close() + }) + + ipcMain.handle('get-is-os-using-dark-theme', () => { + return nativeTheme.shouldUseDarkColors + }) + + ipcMain.handle('menu-action', async (_event: any, action: MenuBarAction) => { + dispatchMenuBarAction(mainWindow, action, trayController) + }) + + if (isWindows) { + ipcMain.on('rebuild-user-jump-list', (_event: any, spares: JumpListSpares) => { + rebuildJumpList(spares) + }) } - // Note: in case the app is exited by auto-updater all windows will be destroyed at this point - if (mainWindow === undefined || mainWindow.isDestroyed()) return + + ipcMain.handle('get-screen-access', () => systemPreferences.getMediaAccessStatus('screen') === 'granted') + ipcMain.handle('get-screen-sources', () => { + return desktopCapturer.getSources({ types: ['window', 'screen'], fetchWindowIcons: true, thumbnailSize: { width: 225, height: 135 } }).then(async sources => { + return sources.map((source: any) => { + return { + ...source, + appIconURL: source.appIcon?.toDataURL(), + thumbnailURL: source.thumbnail.toDataURL() + } + }) + }) + }) + + ipcMain.handle('get-minimize-to-tray-enabled', () => { + return settings.isMinimizeToTrayEnabled() + }) + + async function onReady (): Promise { + await createWindow() + + if (serverChanged) { + mainWindow?.webContents.send('logout') + } + } + + app.on('ready', () => { + void onReady() + }) + + app.on('will-quit', () => { + globalShortcut.unregister(CloseTabHotKey) + }) + + app.on('window-all-closed', () => { + globalShortcut.unregister(CloseTabHotKey) + if (process.platform !== 'darwin') { + app.quit() + } + }) if (isMac) { - mainWindow?.removeAllListeners('close') - mainWindow?.close() - } -}) - -// Note: it is reset when the app is relaunched after update -let isUpdating = false - -autoUpdater.on('update-available', (info: UpdateInfo) => { - if (isUpdating) return - - void dialog - .showMessageBox({ - type: 'info', - buttons: ['Update & Restart', 'Quit'], - defaultId: 0, - message: `A new version ${info.version} is available and it is required to continue. It will be downloaded and installed automatically.` - }) - .then(({ response }: any) => { - log.info(`Update dialog exit code: ${response}`) // eslint-disable-line no-console - - if (response !== 0) { - app.quit() + app.on('activate', () => { + if (mainWindow != null && (mainWindow.isMinimized() || !mainWindow.isVisible())) { + mainWindow.show() } - isUpdating = true - setDownloadProgress(0) }) -}) - -autoUpdater.on('download-progress', (progressObj: ProgressInfo) => { - setDownloadProgress(progressObj.percent) -}) - -function setDownloadProgress (percent: number): void { - if (mainWindow === undefined) { - return } - mainWindow.setProgressBar(percent / 100) - mainWindow.webContents.send('handle-update-download-progress', percent) + app.on('before-quit', () => { + isQuiting = true + + if (mainWindow !== undefined && !mainWindow.isDestroyed()) { + const bounds = mainWindow.getBounds() + settings.setWindowBounds(bounds) + } + + // Note: in case the app is exited by auto-updater all windows will be destroyed at this point + if (mainWindow === undefined || mainWindow.isDestroyed()) { + return + } + + if (isMac) { + mainWindow?.removeAllListeners('close') + mainWindow?.close() + } + }) + + // Note: it is reset when the app is relaunched after update + let isUpdating = false + + autoUpdater.on('update-available', (info: UpdateInfo) => { + if (isUpdating) return + + void dialog + .showMessageBox({ + type: 'info', + buttons: ['Update & Restart', 'Quit'], + defaultId: 0, + message: `A new version ${info.version} is available and it is required to continue. It will be downloaded and installed automatically.` + }) + .then(({ response }: any) => { + log.info(`Update dialog exit code: ${response}`) // eslint-disable-line no-console + + if (response !== 0) { + quitApplication() + } + isUpdating = true + setDownloadProgress(0) + }) + }) + + autoUpdater.on('download-progress', (progressObj: ProgressInfo) => { + setDownloadProgress(progressObj.percent) + }) + + function setDownloadProgress (percent: number): void { + if (mainWindow === undefined) { + return + } + mainWindow.setProgressBar(percent / 100) + mainWindow.webContents.send('handle-update-download-progress', percent) + } + + autoUpdater.on('update-downloaded', (_info: any) => { + // We have listeners that prevents the app from being exited on mac + app.removeAllListeners('window-all-closed') + mainWindow?.removeAllListeners('close') + + autoUpdater.quitAndInstall() + }) } - -autoUpdater.on('update-downloaded', (_info: any) => { - // We have listeners that prevents the app from being exited on mac - app.removeAllListeners('window-all-closed') - mainWindow?.removeAllListeners('close') - - autoUpdater.quitAndInstall() -}) diff --git a/desktop/src/main/tray.ts b/desktop/src/main/tray.ts new file mode 100644 index 0000000000..9529c99d7d --- /dev/null +++ b/desktop/src/main/tray.ts @@ -0,0 +1,133 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { Menu, NativeImage, nativeImage, Tray } from 'electron' +import { WindowAction } from '../ui/types' +import { Settings } from './settings' +import { getFileInPublicBundledFolder } from './path' +import { getBadgeIconInfo } from './trayUtils' + +export class TrayController { + private tray: Tray | undefined = undefined + private readonly defaultIcon: NativeImage + private readonly notificationIcon: NativeImage + private badgeCount: number = 0 + + constructor ( + private readonly settings: Settings, + private readonly activateWindow: WindowAction, + private readonly quitApplication: WindowAction) { + this.defaultIcon = nativeImage.createFromPath(getFileInPublicBundledFolder('AppIcon.ico')) + this.defaultIcon.setTemplateImage(true) + + this.notificationIcon = nativeImage.createFromPath(getFileInPublicBundledFolder('AppIconNotification.png')) + this.notificationIcon.setTemplateImage(true) + + if (this.settings.isMinimizeToTrayEnabled()) { + this.instantiateTray() + } + } + + public updateTrayBadge (count: number): void { + const countChanged = this.badgeCount !== count + this.badgeCount = count + if (!countChanged) { + return + } + + this.evaluateIcon(count) + } + + public setMinimizeToTrayEnabled (enabled: boolean): void { + this.settings.setMinimizeToTrayEnabled(enabled) + if (enabled) { + if (this.tray == null) { + this.instantiateTray() + } + } else { + if (this.tray != null) { + this.tray.destroy() + this.tray = undefined + } + } + } + + public toggleMinimizeToTray (): boolean { + const currentSetting = this.settings.isMinimizeToTrayEnabled() + const newSetting = !currentSetting + this.setMinimizeToTrayEnabled(newSetting) + return newSetting + } + + private instantiateTray (): void { + this.tray = createTray( + () => { this.activateWindow() }, + () => { this.quitApplication() }, + this.defaultIcon + ) + this.evaluateIcon(this.badgeCount) + } + + private evaluateIcon (badgeCount: number): void { + if (this.tray == null) { + return + } + + const iconInfo = getBadgeIconInfo(badgeCount, BASE_TITLE) + if (badgeCount > 0) { + const iconFilePath = getFileInPublicBundledFolder(iconInfo.fileName) + const icon = nativeImage.createFromPath(iconFilePath) + this.tray.setImage(icon) + } else { + this.tray.setImage(this.defaultIcon) + } + + this.tray.setToolTip(iconInfo.tooltip) + } +} + +const BASE_TITLE = 'Huly' + +function createTray (activateWindow: WindowAction, quitApplication: WindowAction, icon: NativeImage): Tray { + const tray = new Tray(icon) + + const contextMenu = Menu.buildFromTemplate([ + { + label: 'Show', + click: () => { + activateWindow() + } + }, + { + label: 'Exit', + click: () => { + quitApplication() + } + } + ]) + + tray.setContextMenu(contextMenu) + tray.setToolTip(BASE_TITLE) + + tray.on('double-click', () => { + activateWindow() + }) + + tray.on('click', (): void => { + activateWindow() + }) + + return tray +} diff --git a/services/msg2file/src/parser.ts b/desktop/src/main/trayUtils.ts similarity index 50% rename from services/msg2file/src/parser.ts rename to desktop/src/main/trayUtils.ts index 9bb223e3d2..b3e50385e4 100644 --- a/services/msg2file/src/parser.ts +++ b/desktop/src/main/trayUtils.ts @@ -13,28 +13,21 @@ // limitations under the License. // -import { Readable } from 'stream' -import { ParsedFile } from '@hcengineering/communication-types' -import { parseYaml } from '@hcengineering/communication-yaml' - -export async function parseFileStream (stream: Readable): Promise { - return await new Promise((resolve, reject) => { - let yamlData = '' - - stream.on('data', (chunk) => { - yamlData += chunk.toString() - }) - - stream.on('end', () => { - try { - resolve(parseYaml(yamlData)) - } catch (error) { - reject(error) - } - }) - - stream.on('error', (error) => { - reject(error) - }) - }) +export interface BadgeIconInfo { + fileName: string + tooltip: string +} + +export function getBadgeIconInfo (badgeCount: number, baseTitle: string): BadgeIconInfo { + if (badgeCount <= 0) { + return { + fileName: '', + tooltip: baseTitle + } + } + + return { + fileName: 'TrayIconWithBadge.ico', + tooltip: `${baseTitle}: ${badgeCount} unread` + } } diff --git a/desktop/src/main/windowsSpecificSetup.ts b/desktop/src/main/windowsSpecificSetup.ts index feb8393425..d62e3ee927 100644 --- a/desktop/src/main/windowsSpecificSetup.ts +++ b/desktop/src/main/windowsSpecificSetup.ts @@ -14,106 +14,103 @@ // import { app, JumpListItem, JumpListCategory } from 'electron' -import { Command, CommandOpenSettings, CommandOpenInbox, CommandOpenApplication, JumpListSpares } from '../ui/types' +import { Command, CommandOpenSettings, CommandOpenInbox, CommandOpenApplication, JumpListSpares, WindowAction, SendCommandDelegate } from '../ui/types' import * as path from 'path' const JUMP_COMMANDS = { INBOX: '--jump-to-inbox', - SETTINGS: '--jump-to-settings', + SETTINGS: '--jump-to-settings' } as const type JumpCommand = typeof JUMP_COMMANDS[keyof typeof JUMP_COMMANDS] const JUMP_TO_APP_COMMAND_PREFIX = '--jump-to-app=' -function getIconPath(iconName: string): string { - return path.join(process.resourcesPath, 'icons', iconName) +function getIconPath (iconName: string): string { + return path.join(process.resourcesPath, 'icons', iconName) } -function addFixedFunctionJumpListItems(spares: JumpListSpares, tasks: JumpListItem[]): void { +function addFixedFunctionJumpListItems (spares: JumpListSpares, tasks: JumpListItem[]): void { + tasks.push({ + type: 'task', + program: process.execPath, + args: JUMP_COMMANDS.SETTINGS, + iconPath: getIconPath('SettingsIcon.ico'), + title: spares.settingsLabel, + iconIndex: 0 + }) + tasks.push({ + type: 'task', + program: process.execPath, + args: JUMP_COMMANDS.INBOX, + iconPath: getIconPath('InboxIcon.ico'), + title: spares.inboxLabel, + iconIndex: 0 + }) +} + +export function rebuildJumpList (spares: JumpListSpares): void { + const tasks: JumpListItem[] = [] + + addFixedFunctionJumpListItems(spares, tasks) + + if (spares.applications.length > 0) { tasks.push({ - type: 'task', - program: process.execPath, - args: JUMP_COMMANDS.SETTINGS, - iconPath: getIconPath('SettingsIcon.ico'), - title: spares.settingsLabel, - iconIndex: 0, + type: 'separator' }) + } + + for (const application of spares.applications) { + const iconFileName = application.id.replaceAll(':', '_') + '.ico' + const cliArguments = `${JUMP_TO_APP_COMMAND_PREFIX}${application.alias}` tasks.push({ - type: 'task', - program: process.execPath, - args: JUMP_COMMANDS.INBOX, - iconPath: getIconPath('InboxIcon.ico'), - title: spares.inboxLabel, - iconIndex: 0, + type: 'task', + program: process.execPath, + args: cliArguments, + iconPath: getIconPath(iconFileName), + title: application.title, + iconIndex: 0 }) + } + + const category: JumpListCategory = { + type: 'tasks', + items: tasks + } + + app.setJumpList([category]) } -export function rebuildJumpList(spares: JumpListSpares): void { - const tasks: JumpListItem[] = []; +export function setupWindowsSpecific (activateWindow: WindowAction, sendCommand: SendCommandDelegate): void { + app.setAppUserModelId(app.getName()) - addFixedFunctionJumpListItems(spares, tasks) - - if (spares.applications.length > 0) { - tasks.push({ - type: 'separator' - }) - } - - for (const application of spares.applications) { - const iconFileName = application.id.replaceAll(":", "_") + '.ico' - const cliArguments = `${JUMP_TO_APP_COMMAND_PREFIX}${application.alias}` - tasks.push({ - type: 'task', - program: process.execPath, - args: cliArguments, - iconPath: getIconPath(iconFileName), - title: application.title, - iconIndex: 0, - }) - } - - const category: JumpListCategory = { - type: 'tasks', - items: tasks, - } - - app.setJumpList([category]) -} - -export function setupWindowsSpecific(activateWindow: () => void, sendCommand: (cmd: Command, ...args: any[]) => void): void { - - app.setAppUserModelId(app.getName()) - - app.on('second-instance', (_event: any, commandLine: any, _workingDirectory: any) => { - const commandArgument = commandLine[1] as string - if (typeof commandArgument === 'string' && commandArgument.startsWith(JUMP_TO_APP_COMMAND_PREFIX)) { - const applicationId = commandArgument.replace(JUMP_TO_APP_COMMAND_PREFIX, '') - sendCommand(CommandOpenApplication, [applicationId]) - - } else { - const jumpCommandCode = commandArgument as JumpCommand - let command: Command | undefined - switch (jumpCommandCode) { - case JUMP_COMMANDS.INBOX: - command = CommandOpenInbox - break; - case JUMP_COMMANDS.SETTINGS: - command = CommandOpenSettings - break; - default: { - // compile-time check: if jumpCommand is not a known value, this line errors - const _exhaustive: never = jumpCommandCode - return; // or handle error - } - } - - if (command) { - sendCommand(command) - } + app.on('second-instance', (_event: any, commandLine: any, _workingDirectory: any) => { + const commandArgument = commandLine[1] as string + if (typeof commandArgument === 'string' && commandArgument.startsWith(JUMP_TO_APP_COMMAND_PREFIX)) { + const applicationId = commandArgument.replace(JUMP_TO_APP_COMMAND_PREFIX, '') + sendCommand(CommandOpenApplication, [applicationId]) + } else { + const jumpCommandCode = commandArgument as JumpCommand + let command: Command | undefined + switch (jumpCommandCode) { + case JUMP_COMMANDS.INBOX: + command = CommandOpenInbox + break + case JUMP_COMMANDS.SETTINGS: + command = CommandOpenSettings + break + default: { + // compile-time check: if jumpCommand is not a known value, this line errors + const _exhaustive: never = jumpCommandCode + break } + } - activateWindow() - }) - -} \ No newline at end of file + if (command !== undefined) { + sendCommand(command) + } + } + + activateWindow() + }) +} diff --git a/desktop/src/ui/index.ts b/desktop/src/ui/index.ts index 9a51e98867..e5ec3d6454 100644 --- a/desktop/src/ui/index.ts +++ b/desktop/src/ui/index.ts @@ -2,7 +2,7 @@ import { loginId } from '@hcengineering/login' import { loveId } from '@hcengineering/love' import { timeId } from '@hcengineering/time' -import { getEmbeddedLabel, getMetadata, translate } from '@hcengineering/platform' +import { getEmbeddedLabel, getMetadata, getResource, translate } from '@hcengineering/platform' import presentation, { MessageBox, setDownloadProgress, getClient } from '@hcengineering/presentation' import setting, { settingId } from '@hcengineering/setting' import { @@ -20,14 +20,14 @@ import { handleDownloadItem } from '@hcengineering/desktop-downloads' import notification, { notificationId } from '@hcengineering/notification' import { chatId } from '@hcengineering/chat' import workbench, { workbenchId, logOut } from '@hcengineering/workbench' -import { encodeObjectURI } from '@hcengineering/view' +import view, { Action, encodeObjectURI } from '@hcengineering/view' import { resolveLocation } from '@hcengineering/notification-resources' -import { isOwnerOrMaintainer, getCurrentAccount } from '@hcengineering/core' +import { isOwnerOrMaintainer, getCurrentAccount, Ref } from '@hcengineering/core' import { configurePlatform } from './platform' import { setupTitleBarMenu } from './titleBarMenu' import { defineScreenShare, defineGetDisplayMedia } from './screenShare' -import { CommandLogout, CommandSelectWorkspace, CommandOpenSettings, CommandOpenInbox, CommandOpenPlanner, CommandOpenOffice, CommandOpenApplication, LaunchApplication, NotificationParams } from './types' +import { CommandLogout, CommandSelectWorkspace, CommandOpenSettings, CommandOpenInbox, CommandOpenPlanner, CommandOpenOffice, CommandOpenApplication, LaunchApplication, NotificationParams, CommandCloseTab } from './types' import { ipcMainExposed } from './typesUtils' import { themeStore, ThemeVariant } from '@hcengineering/theme' import type { Application } from '@hcengineering/workbench' @@ -37,6 +37,19 @@ function currentOsIsWindows (): boolean { return (window as any).windowsPlatform === true } +async function executePlatformAction (action: Ref): Promise { + try { + const client = getClient() + const actions = client.getModel().findAllSync(view.class.Action, { _id: action }) + if (actions.length > 0) { + const actionBody = await getResource(actions[0].action) + await actionBody(undefined) + } + } catch (error) { + console.error('failed to execute platform action', error) + } +} + defineScreenShare() defineGetDisplayMedia() @@ -46,18 +59,18 @@ window.addEventListener('DOMContentLoaded', () => { if (currentOsIsWindows()) { const titleBarRoot = document.getElementById('desktop-app-titlebar-root') if (titleBarRoot != null) { - const menuBar = setupTitleBarMenu(ipcMain, titleBarRoot) + void setupTitleBarMenu(ipcMain, titleBarRoot).then((menuBar) => { + themeStore.subscribe((themeOptions) => { + if (themeOptions != null) { + menuBar.setTheme(themeOptions.variant) + } + }) - themeStore.subscribe((themeOptions) => { - if (themeOptions != null) { - menuBar.setTheme(themeOptions.variant) - } - }) - - void ipcMain.isOsUsingDarkTheme().then((isDarkTheme) => { - menuBar.setTheme(isDarkTheme ? ThemeVariant.Dark : ThemeVariant.Light) - }).catch(() => { - menuBar.setTheme(ThemeVariant.Light) // fallback + void ipcMain.isOsUsingDarkTheme().then((isDarkTheme) => { + menuBar.setTheme(isDarkTheme ? ThemeVariant.Dark : ThemeVariant.Light) + }).catch(() => { + menuBar.setTheme(ThemeVariant.Light) // fallback + }) }) } } @@ -124,6 +137,10 @@ window.addEventListener('DOMContentLoaded', () => { openScreen(loveId) }) + ipcMain.on(CommandCloseTab, () => { + void executePlatformAction(workbench.action.CloseCurrentTab) + }) + ipcMain.on(CommandOpenPlanner, () => { openScreen(timeId) }) diff --git a/desktop/src/ui/platform.ts b/desktop/src/ui/platform.ts index 36570461ff..e7580f8f86 100644 --- a/desktop/src/ui/platform.ts +++ b/desktop/src/ui/platform.ts @@ -298,6 +298,7 @@ export async function configurePlatform (onWorkbenchConnect?: () => Promise Promise l.trim()) : ['en', 'ru', 'es', 'pt', 'zh', 'fr', 'cs', 'it', 'de', 'ja'] + const languages = myBranding.languages !== undefined && myBranding.languages !== '' ? myBranding.languages.split(',').map((l) => l.trim()) : ['en', 'ru', 'es', 'pt', 'zh', 'fr', 'cs', 'it', 'de', 'ja', 'tr'] setMetadata(uiPlugin.metadata.Languages, languages) diff --git a/desktop/src/ui/preload.ts b/desktop/src/ui/preload.ts index d5ea5f61af..a5e324c43c 100644 --- a/desktop/src/ui/preload.ts +++ b/desktop/src/ui/preload.ts @@ -1,7 +1,7 @@ // preload.js import { contextBridge, ipcRenderer } from 'electron' -import { BrandingMap, Config, IPCMainExposed, MenuBarAction, NotificationParams, JumpListSpares } from './types' +import { BrandingMap, Config, IPCMainExposed, JumpListSpares, MenuBarAction, NotificationParams } from './types' /** * @public @@ -18,27 +18,23 @@ export function concatLink (host: string, path: string): string { } async function loadServerConfig (url: string): Promise { - let retries = 5 + let retries = 1 let res: Response | undefined - do { + while (true) { try { res = await fetch(url, { keepalive: true }) + if (res === undefined) { + // In theory should never get here + throw new Error('Failed to load server config') + } break } catch (e) { - retries-- - if (retries === 0) { - throw new Error(`Failed to load server config: ${e}`) - } - await new Promise((resolve) => setTimeout(resolve, 1000 * (5 - retries))) + retries++ + await new Promise((resolve) => setTimeout(resolve, 1000 * Math.min(5, retries))) } - } while (retries > 0) - - if (res === undefined) { - // In theory should never get here - throw new Error('Failed to load server config') } return await res.json() @@ -181,6 +177,13 @@ const expose: IPCMainExposed = { cancelBackup: () => { ipcRenderer.send('cancel-backup') }, startBackup: (token, endpoint, wsIds) => { ipcRenderer.send('start-backup', token, endpoint, wsIds) }, - rebuildJumpList: (spares: JumpListSpares) => { ipcRenderer.send('rebuild-user-jump-list', spares) } + rebuildJumpList: (spares: JumpListSpares) => { ipcRenderer.send('rebuild-user-jump-list', spares) }, + + isMinimizeToTrayEnabled: async () => { + return await ipcRenderer.invoke('get-minimize-to-tray-enabled') + }, + onMinimizeToTraySettingChanged: (callback: (enabled: boolean) => void) => { + ipcRenderer.on('minimize-to-tray-setting-changed', (_event, enabled: boolean) => { callback(enabled) }) + } } contextBridge.exposeInMainWorld('electron', expose) diff --git a/desktop/src/ui/titleBarMenu.ts b/desktop/src/ui/titleBarMenu.ts index f0c5c1987f..6121d38224 100644 --- a/desktop/src/ui/titleBarMenu.ts +++ b/desktop/src/ui/titleBarMenu.ts @@ -18,9 +18,14 @@ import { isMenuBarAction } from './typesUtils' import { TitleBarMenuState } from './titleBarMenuState' import { ThemeVariant, type ThemeVariantType } from '@hcengineering/theme' -export function setupTitleBarMenu (ipcMain: IPCMainExposed, root: HTMLElement): MenuBar { +const ToggleMinimizeToTrayAction: MenuBarAction = 'toggle-minimize-to-tray' + +const LabelMinimizeToTrayEnabled = '☑ Minimize to tray' +const LabelMinimizeToTrayDisabled = '☐ Minimize to tray' + +export async function setupTitleBarMenu (ipcMain: IPCMainExposed, root: HTMLElement): Promise { const themeManager = new ThemeManager(ThemeVariant.Light) - const menuManager = new MenuBarManager(root) + const menuManager = new MenuBarManager(root, await ipcMain.isMinimizeToTrayEnabled()) const menuBar = menuManager.getView() @@ -35,6 +40,13 @@ export function setupTitleBarMenu (ipcMain: IPCMainExposed, root: HTMLElement): menuManager.attachEventListeners(ipcMain) + ipcMain.onMinimizeToTraySettingChanged((enabled) => { + const toggle = root.querySelector(`[data-action="${ToggleMinimizeToTrayAction}"]`) + if (toggle != null) { + toggle.textContent = enabled ? LabelMinimizeToTrayEnabled : LabelMinimizeToTrayDisabled + } + }) + ipcMain.onWindowStateChange((_event, state) => { const maximizeButton = root.querySelector('#maximize-button') if (maximizeButton != null) { @@ -58,7 +70,7 @@ export class MenuBar { } } -export function buildHulyApplicationMenu (): HTMLElement { +export function buildHulyApplicationMenu (minimizeToTrayEnabled: boolean): HTMLElement { const menuBuilder = new MenuBuilder() const MenuFileIndex = 0 @@ -93,6 +105,11 @@ export function buildHulyApplicationMenu (): HTMLElement { .addSeparator(MenuViewIndex) .addMenuItem(MenuViewIndex, 'Toggle Fullscreen', 'toggle-fullscreen', 'F11', 'l') + const MenuWindowIndex = 3 + const ToggleMinimizeToTrayLabel = minimizeToTrayEnabled ? LabelMinimizeToTrayEnabled : LabelMinimizeToTrayDisabled + menuBuilder.addTopLevelMenu('Window', 'w') + .addMenuItem(MenuWindowIndex, ToggleMinimizeToTrayLabel, 'toggle-minimize-to-tray', undefined, 'm') + return menuBuilder.build() } @@ -140,7 +157,7 @@ export class MenuBuilder { label: string, action: MenuBarAction, shortcut?: string, - acceleratorChar: string | null = null + acceleratorChar: string | undefined = undefined ): this { if (topLevelMenuIndex >= 0 && topLevelMenuIndex < this.menus.length) { const item: MenuItem = { @@ -281,7 +298,7 @@ class MenuBarManager { private readonly StateStyleKeyboardSelected = 'desktop-app-keyboard-selected' private readonly StateStyleAltModeActive = 'desktop-app-alt-active' - constructor (private readonly root: HTMLElement) { + constructor (private readonly root: HTMLElement, minimizeToTrayEnabled: boolean) { this.state = new TitleBarMenuState( () => this.topLevelMenus().length, (topLevelMenuIndex: number) => { @@ -290,7 +307,7 @@ class MenuBarManager { } ) - this.view = buildHulyApplicationMenu() + this.view = buildHulyApplicationMenu(minimizeToTrayEnabled) } public getView (): HTMLElement { diff --git a/desktop/src/ui/titleBarMenuState.ts b/desktop/src/ui/titleBarMenuState.ts index ada16d9fdc..dd358c994a 100644 --- a/desktop/src/ui/titleBarMenuState.ts +++ b/desktop/src/ui/titleBarMenuState.ts @@ -14,113 +14,113 @@ // export class TitleBarMenuState { - private altModeActive: boolean = false - private topLevelMenuExpanded: boolean = false - private focusedTopLevelMenuIndex: number | null = null - private focusedChildMenuIndex: number | null = null + private altModeActive: boolean = false + private topLevelMenuExpanded: boolean = false + private focusedTopLevelMenuIndex: number | null = null + private focusedChildMenuIndex: number | null = null - private readonly topLevelMenuCount: () => number; - private readonly menuItemsCount: (topLevelMenuIndex: number) => number; + private readonly topLevelMenuCount: () => number + private readonly menuItemsCount: (topLevelMenuIndex: number) => number - public constructor( - topLevelMenuCount: () => number, - menuItemsCount: (topLevelMenuIndex: number) => number - ) { - this.topLevelMenuCount = topLevelMenuCount - this.menuItemsCount = menuItemsCount + public constructor ( + topLevelMenuCount: () => number, + menuItemsCount: (topLevelMenuIndex: number) => number + ) { + this.topLevelMenuCount = topLevelMenuCount + this.menuItemsCount = menuItemsCount + } + + public focusChildMenu (): void { + if (this.focusedTopLevelMenuIndex != null && this.topLevelMenuExpanded) { + this.focusedChildMenuIndex = 0 + } + } + + public expandTopLevelMenu (topLevelMenuIndex: number): void { + if (topLevelMenuIndex < 0 || topLevelMenuIndex >= this.topLevelMenuCount()) { + return } - public focusChildMenu() { - if (null != this.focusedTopLevelMenuIndex && this.topLevelMenuExpanded) { - this.focusedChildMenuIndex = 0 - } + if (this.focusedTopLevelMenuIndex === topLevelMenuIndex && this.topLevelMenuExpanded) { + this.closeAll() + } else { + this.focusedTopLevelMenuIndex = topLevelMenuIndex + this.topLevelMenuExpanded = true + } + } + + public exitAltMode (): void { + this.altModeActive = false + } + + public enterAltMode (topLevelMenuIndex: number | null): void { + this.altModeActive = true + + if (topLevelMenuIndex == null || topLevelMenuIndex < 0 || topLevelMenuIndex >= this.topLevelMenuCount()) { + return } - public expandTopLevelMenu(topLevelMenuIndex: number): void { - if (0 > topLevelMenuIndex || topLevelMenuIndex >= this.topLevelMenuCount()) { - return - } - - if (this.focusedTopLevelMenuIndex === topLevelMenuIndex && this.topLevelMenuExpanded) { - this.closeAll() - } else { - this.focusedTopLevelMenuIndex = topLevelMenuIndex - this.topLevelMenuExpanded = true - } + this.focusedTopLevelMenuIndex = topLevelMenuIndex + } + + public closeAll (): void { + this.altModeActive = false + this.topLevelMenuExpanded = false + this.focusedTopLevelMenuIndex = null + this.focusedChildMenuIndex = null + } + + public defocus (): void { + if (this.topLevelMenuExpanded) { + this.topLevelMenuExpanded = false + this.focusedChildMenuIndex = null + } else { + this.closeAll() + } + } + + public moveFocusHorizontal (increment: number): void { + if (Math.abs(increment) !== 1 || this.focusedTopLevelMenuIndex == null) { + return } - public exitAltMode(): void { - this.altModeActive = false + const topLevelMenuCount = this.topLevelMenuCount() + this.focusedTopLevelMenuIndex = (this.focusedTopLevelMenuIndex + topLevelMenuCount + increment) % topLevelMenuCount + + if (this.topLevelMenuExpanded) { + this.focusedChildMenuIndex = 0 + } + } + + public moveFocusVertical (increment: number): void { + if (Math.abs(increment) !== 1 || this.focusedTopLevelMenuIndex == null) { + return } - public enterAltMode(topLevelMenuIndex: number | null): void { - this.altModeActive = true - - if (null == topLevelMenuIndex || 0 > topLevelMenuIndex || topLevelMenuIndex >= this.topLevelMenuCount()) { - return - } - - this.focusedTopLevelMenuIndex = topLevelMenuIndex + const menuItemsCount = this.menuItemsCount(this.focusedTopLevelMenuIndex) + + this.topLevelMenuExpanded = true + + if (menuItemsCount === 0) { + return } - public closeAll(): void { - this.altModeActive = false - this.topLevelMenuExpanded = false - this.focusedTopLevelMenuIndex = null - this.focusedChildMenuIndex = null - } + this.focusedChildMenuIndex = ((this.focusedChildMenuIndex ?? -1) + menuItemsCount + increment) % menuItemsCount + } - public defocus(): void { - if (this.topLevelMenuExpanded) { - this.topLevelMenuExpanded = false - this.focusedChildMenuIndex = null - } else { - this.closeAll(); - } - } + public get isAltModeActive (): boolean { + return this.altModeActive + } - public moveFocusHorizontal(increment: number): void { - if (Math.abs(increment) != 1 || null == this.focusedTopLevelMenuIndex) { - return; - } - - const topLevelMenuCount = this.topLevelMenuCount() - this.focusedTopLevelMenuIndex = (this.focusedTopLevelMenuIndex + topLevelMenuCount + increment) % topLevelMenuCount - - if (this.topLevelMenuExpanded) { - this.focusedChildMenuIndex = 0 - } - } + public get isTopLevelMenuExpanded (): boolean { + return this.topLevelMenuExpanded + } - public moveFocusVertical(increment: number): void { - if (Math.abs(increment) != 1 || null == this.focusedTopLevelMenuIndex) { - return; - } + public get FocusedTopLevelMenuIndex (): number | null { + return this.focusedTopLevelMenuIndex + } - const menuItemsCount = this.menuItemsCount(this.focusedTopLevelMenuIndex) - - this.topLevelMenuExpanded = true - - if (menuItemsCount === 0) { - return; - } - - this.focusedChildMenuIndex = ((this.focusedChildMenuIndex ?? -1) + menuItemsCount + increment) % menuItemsCount - } - - public get isAltModeActive(): boolean { - return this.altModeActive - } - - public get isTopLevelMenuExpanded(): boolean { - return this.topLevelMenuExpanded - } - - public get FocusedTopLevelMenuIndex(): number | null { - return this.focusedTopLevelMenuIndex - } - - public get FocusedChildMenuIndex(): number | null { - return this.focusedChildMenuIndex - } -} \ No newline at end of file + public get FocusedChildMenuIndex (): number | null { + return this.focusedChildMenuIndex + } +} diff --git a/desktop/src/ui/types.ts b/desktop/src/ui/types.ts index 1543e00dbd..0888fdb1a0 100644 --- a/desktop/src/ui/types.ts +++ b/desktop/src/ui/types.ts @@ -56,6 +56,7 @@ export interface Config { PULSE_URL?: string PASSWORD_STRICTNESS?: 'very_strict' | 'strict' | 'normal' | 'none' EXCLUDED_APPLICATIONS_FOR_ANONYMOUS?: string + HULYLAKE_URL?: string } export interface Branding { @@ -84,6 +85,7 @@ export const CommandOpenPlanner = 'open-planner' as const export const CommandSelectWorkspace = 'select-workspace' as const export const CommandLogout = 'logout' as const export const CommandOpenApplication = 'open-application' as const +export const CommandCloseTab = 'close-tab' as const export type Command = typeof CommandOpenSettings | @@ -92,7 +94,8 @@ export type Command = typeof CommandOpenPlanner | typeof CommandSelectWorkspace | typeof CommandLogout | - typeof CommandOpenApplication + typeof CommandOpenApplication | + typeof CommandCloseTab export interface NotificationParams { title: string @@ -104,8 +107,26 @@ export interface NotificationParams { objectClass?: Ref> } -export const MenuBarActions = ['settings', 'select-workspace', 'logout', 'exit', 'undo', 'redo', 'cut', 'copy', 'paste', 'delete', 'select-all', 'reload', 'force-reload', 'toggle-devtools', - 'zoom-in', 'zoom-out', 'restore-size', 'toggle-fullscreen'] as const +export const MenuBarActions = [ + 'settings', + 'select-workspace', + 'logout', + 'exit', + 'undo', + 'redo', + 'cut', + 'copy', + 'paste', + 'delete', + 'select-all', + 'reload', + 'force-reload', + 'toggle-devtools', + 'zoom-in', + 'zoom-out', + 'restore-size', + 'toggle-fullscreen', + 'toggle-minimize-to-tray'] as const export type MenuBarAction = typeof MenuBarActions[number] @@ -151,4 +172,10 @@ export interface IPCMainExposed { executeMenuBarAction: (action: MenuBarAction) => void rebuildJumpList: (spares: JumpListSpares) => void + + isMinimizeToTrayEnabled: () => Promise + onMinimizeToTraySettingChanged: (callback: (enabled: boolean) => void) => void } + +export type SendCommandDelegate = (cmd: Command, ...args: any[]) => void +export type WindowAction = () => void diff --git a/desktop/start-dev.ps1 b/desktop/start-dev.ps1 index 94bbd625f5..aad3d20939 100644 --- a/desktop/start-dev.ps1 +++ b/desktop/start-dev.ps1 @@ -1,11 +1,9 @@ -$MODEL_VERSION = node ../common/scripts/show_version.js | Out-String -$MODEL_VERSION = $MODEL_VERSION.Trim() - -$VERSION = node ../common/scripts/show_tag.js | Out-String -$VERSION = $VERSION.Trim() - -# Set environment variable -$env:NODE_ENV = "development" - -# Run Electron -electron --no-sandbox ./ \ No newline at end of file +$MODEL_VERSION = node ../common/scripts/show_version.js | Out-String +$MODEL_VERSION = $MODEL_VERSION.Trim() + +$VERSION = node ../common/scripts/show_tag.js | Out-String +$VERSION = $VERSION.Trim() + +$env:NODE_ENV = "development" + +electron --no-sandbox ./ diff --git a/dev/docker-compose.min.yaml b/dev/docker-compose.min.yaml index 7f57288aae..955d91bb67 100644 --- a/dev/docker-compose.min.yaml +++ b/dev/docker-compose.min.yaml @@ -236,7 +236,6 @@ services: - LAST_NAME_FIRST=true - BRANDING_PATH=/var/cfg/branding.json - AI_BOT_URL=http://huly.local:4010 - - MSG2FILE_URL=http://huly.local:9087 - COMMUNICATION_TIME_LOGGING_ENABLED=true restart: unless-stopped fulltext_cockroach: @@ -285,6 +284,22 @@ services: - DB_URL=${DB_CR_URL} - BUCKETS=blobs,eu|http://minio:9000?accessKey=minioadmin&secretKey=minioadmin restart: unless-stopped + redis: + image: redis:8.0.2-alpine3.21 + ports: + - 6379:6379 + restart: unless-stopped + hulypulse: + image: hardcoreeng/hulypulse + depends_on: + redis: + condition: service_started + ports: + - 8099:8099 + environment: + - HULY_REDIS_URLS=redis://redis:6379 + - HULY_BIND_PORT=8099 + restart: unless-stopped volumes: db: dbpg: diff --git a/dev/docker-compose.yaml b/dev/docker-compose.yaml index 1e1317f460..42ba98f2dc 100644 --- a/dev/docker-compose.yaml +++ b/dev/docker-compose.yaml @@ -286,6 +286,7 @@ services: # - DISABLE_SIGNUP=true - OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318/v1/traces - PULSE_URL=ws://huly.local:8099/ws + - HULYLAKE_URL=http://huly.local:8096 restart: unless-stopped transactor_cockroach: image: hardcoreeng/transactor @@ -321,13 +322,13 @@ services: - LAST_NAME_FIRST=true - BRANDING_PATH=/var/cfg/branding.json - AI_BOT_URL=http://huly.local:4010 - - MSG2FILE_URL=http://huly.local:9087 - COMMUNICATION_TIME_LOGGING_ENABLED=true - RATE_LIMIT_MAX=250 # 250 requests per 30 seconds - RATE_LIMIT_WINDOW=30000 - FILES_URL=http://huly.local:4030/blob/:workspace/:blobId/:filename - COMMUNICATION_API_ENABLED=true - OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318/v1/traces + - HYLYLAKE_URL=http://huly.local:8096 restart: unless-stopped rekoni: image: hardcoreeng/rekoni-service @@ -366,6 +367,7 @@ services: - REKONI_URL=http://huly.local:4004 - ACCOUNTS_URL=http://huly.local:3000 - OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318/v1/traces + - HULYLAKE_URL=http://huly.local:8096 print: image: hardcoreeng/print extra_hosts: @@ -440,21 +442,6 @@ services: # - POSTHOG_HOST=${POSTHOG_HOST} # - POSTHOG_API_KEY=${POSTHOG_API_KEY} # - MAX_PAYLOAD_SIZE=10mb - msg2file: - image: hardcoreeng/msg2file - ports: - - 9087:9087 - extra_hosts: - - 'huly.local:host-gateway' - restart: unless-stopped - environment: - - ACCOUNTS_URL=http://huly.local:3000 - - DB_URL=postgresql://root@huly.local:26257/defaultdb?sslmode=disable - - PORT=9087 - - SECRET=secret - - SERVICE_ID=msg2file-service - - STORAGE_CONFIG=${STORAGE_CONFIG} - - OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318/v1/traces export: image: hardcoreeng/export extra_hosts: diff --git a/dev/prod/public/config.json b/dev/prod/public/config.json index eb8b80f1be..2c17577b86 100644 --- a/dev/prod/public/config.json +++ b/dev/prod/public/config.json @@ -28,5 +28,6 @@ "COMMUNICATION_API_ENABLED": "true", "BACKUP_URL": "http://huly.local:4039/api/backup", "PULSE_URL": "ws://huly.local:8099/ws", + "HULYLAKE_URL": "http://huly.local:8096", "EXCLUDED_APPLICATIONS_FOR_ANONYMOUS": "[\"chunter\", \"notification\"]" } diff --git a/dev/prod/src/platform.ts b/dev/prod/src/platform.ts index 2e8490e115..c8a9a099b8 100644 --- a/dev/prod/src/platform.ts +++ b/dev/prod/src/platform.ts @@ -201,7 +201,8 @@ export interface Config { COMMUNICATION_API_ENABLED?: string BILLING_URL?: string, EXCLUDED_APPLICATIONS_FOR_ANONYMOUS?: string, - PULSE_URL?: string + PULSE_URL?: string, + HULYLAKE_URL?: string } export interface Branding { @@ -491,10 +492,11 @@ export async function configurePlatform() { setMetadata(billingPlugin.metadata.BillingURL, config.BILLING_URL ?? '') setMetadata(presentation.metadata.PulseUrl, config.PULSE_URL) + setMetadata(presentation.metadata.HulylakeUrl, config.HULYLAKE_URL ?? '') const languages = myBranding.languages ? (myBranding.languages as string).split(',').map((l) => l.trim()) - : ['en', 'ru', 'es', 'pt', 'zh', 'fr', 'cs', 'it', 'de', 'ja'] + : ['en', 'ru', 'es', 'pt', 'zh', 'fr', 'cs', 'it', 'de', 'ja', 'tr'] setMetadata(uiPlugin.metadata.Languages, languages) diff --git a/dev/tool/package.json b/dev/tool/package.json index a41b6a7e8d..1b585041fc 100644 --- a/dev/tool/package.json +++ b/dev/tool/package.json @@ -21,7 +21,7 @@ "docker:staging": "../../common/scripts/docker_tag.sh hardcoreeng/tool staging", "docker:push": "../../common/scripts/docker_tag.sh hardcoreeng/tool", "run-local-mongo": "rush bundle --to @hcengineering/tool >/dev/null && cross-env SERVER_SECRET=secret FULLTEXT_URL=http://localhost:4700 ACCOUNTS_URL=http://localhost:3000 TRANSACTOR_URL=ws://localhost:3333 MINIO_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin MINIO_ENDPOINT=localhost ACCOUNT_DB_URL=mongodb://localhost:27017 DB_URL=mongodb://localhost:27017 TELEGRAM_DATABASE=telegram-service REKONI_URL=http://localhost:4004 MODEL_VERSION=$(node ../../common/scripts/show_version.js) GIT_REVISION=$(git describe --all --long) QUEUE_CONFIG='localhost:19092' node --expose-gc --max-old-space-size=18000 ./bundle/bundle.js", - "run-local": "rush bundle --to @hcengineering/tool >/dev/null && cross-env SERVER_SECRET=secret FULLTEXT_URL=http://localhost:4702 ACCOUNTS_URL=http://localhost:3000 TRANSACTOR_URL=ws://localhost:3332 STORAGE_CONFIG='datalake|http://huly.local:4030' ACCOUNT_DB_URL=postgresql://root@huly.local:26257/defaultdb?sslmode=disable DB_URL=postgresql://root@huly.local:26257/defaultdb?sslmode=disable TELEGRAM_DATABASE=telegram-service REKONI_URL=http://localhost:4004 REGION_INFO='cockroach|CockroachDB' MODEL_VERSION=$(node ../../common/scripts/show_version.js) GIT_REVISION=$(git describe --all --long) QUEUE_CONFIG='localhost:19092' node --expose-gc --max-old-space-size=18000 $TOOL_OPT ./bundle/bundle.js", + "run-local": "rush bundle --to @hcengineering/tool >/dev/null && cross-env SERVER_SECRET=secret FULLTEXT_URL=http://localhost:4702 ACCOUNTS_URL=http://localhost:3000 TRANSACTOR_URL=ws://localhost:3332 STORAGE_CONFIG='datalake|http://huly.local:4030' HULYLAKE_URL=http://huly.local:8096 ACCOUNT_DB_URL=postgresql://root@huly.local:26257/defaultdb?sslmode=disable DB_URL=postgresql://root@huly.local:26257/defaultdb?sslmode=disable TELEGRAM_DATABASE=telegram-service REKONI_URL=http://localhost:4004 REGION_INFO='cockroach|CockroachDB' MODEL_VERSION=$(node ../../common/scripts/show_version.js) GIT_REVISION=$(git describe --all --long) QUEUE_CONFIG='localhost:19092' node --expose-gc --max-old-space-size=18000 $TOOL_OPT ./bundle/bundle.js", "run-local-brk": "rush bundle --to @hcengineering/tool >/dev/null && cross-env SERVER_SECRET=secret ACCOUNTS_URL=http://localhost:3000 TRANSACTOR_URL=ws://localhost:3333 MINIO_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin MINIO_ENDPOINT=localhost ACCOUNT_DB_URL=mongodb://localhost:27017 DB_URL=mongodb://localhost:27017 TELEGRAM_DATABASE=telegram-service REKONI_URL=http://localhost:4004 MODEL_VERSION=$(node ../../common/scripts/show_version.js) GIT_REVISION=$(git describe --all --long) node --inspect-brk --enable-source-maps --max-old-space-size=18000 ./bundle/bundle.js", "run": "rush bundle --to @hcengineering/tool >/dev/null && cross-env node --max-old-space-size=8000 ./bundle/bundle.js", "upgrade-mongo": "rushx run-local-mongo upgrade-workspace -- $1", @@ -55,7 +55,8 @@ "@types/request": "~2.48.8", "jest": "^29.7.0", "ts-jest": "^29.1.1", - "@types/jest": "^29.5.5" + "@types/jest": "^29.5.5", + "@types/js-yaml": "^4.0.9" }, "dependencies": { "@elastic/elasticsearch": "^7.17.14", @@ -180,6 +181,8 @@ "msgpackr-extract": "^3.0.3", "@hcengineering/kafka": "^0.6.0", "@hcengineering/api-client": "^0.6.0", - "@faker-js/faker": "^8.4.1" + "@faker-js/faker": "^8.4.1", + "@hcengineering/hulylake-client": "^0.6.0", + "@hcengineering/communication-types": "^0.1.0" } } diff --git a/dev/tool/src/communication.ts b/dev/tool/src/communication.ts new file mode 100644 index 0000000000..312095a7e7 --- /dev/null +++ b/dev/tool/src/communication.ts @@ -0,0 +1,895 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { type Workspace } from '@hcengineering/account' +import { type HulylakeClient, type JsonPatch } from '@hcengineering/hulylake-client' +import type postgres from 'postgres' +import { + generateUuid, + groupByArray, + type MeasureContext, + notEmpty, + type PersonId, + type PersonUuid, + type WorkspaceUuid +} from '@hcengineering/core' +import { + type Attachment, + type AttachmentData, + type AttachmentDoc, + type AttachmentID, + type AttachmentUpdateData, + type BlobID, + type CardID, + type CardType, + type Emoji, + type Markdown, + type MessageDoc, + type MessageExtra, + type MessageID, + type MessagesDoc, + type MessagesGroup, + type MessagesGroupDoc, + type MessagesGroupsDoc, + MessageType, + type SocialID, + type ThreadDoc +} from '@hcengineering/communication-types' +import { type AccountClient } from '@hcengineering/account-client' + +const MAX_MESSAGES_SIZE = 95 * 1024 + +const MESSAGES_TABLE = 'communication.message' +const PATCH_TABLE = 'communication.patch' +const ATTACHMENT_TABLE = 'communication.attachment' +const REACTION_TABLE = 'communication.reaction' +const THREAD_INDEX_TABLE = 'communication.thread_index' + +export async function migrateWorkspaceMessages ( + ctx: MeasureContext, + ws: Workspace, + card: CardID | undefined, + db: postgres.Sql, + hulylake: HulylakeClient, + accountClient: AccountClient, + personUuidBySocialId: Map +): Promise { + await migrateMessages(ctx, ws, card, db, hulylake, accountClient, personUuidBySocialId) +} + +async function migrateMessages ( + ctx: MeasureContext, + ws: Workspace, + card: CardID | undefined, + db: postgres.Sql, + hulylake: HulylakeClient, + accountClient: AccountClient, + personUuidBySocialId: Map +): Promise { + const force = card != null && card !== '' + const cards = card != null && card !== '' ? [card] : await getMessagesCards(ws.uuid, db) + ctx.info(`Cards for migration: ${cards.length}, force: ${force}`) + let i = 0 + for (const card of cards) { + try { + i++ + const groups = await getGroups(ctx, hulylake, card) + const lastGroup = groups[groups.length - 1] + + ctx.info(`Migrating messages for card ${card} index ${i} from ${lastGroup?.toDate?.toISOString() ?? 'null'}`) + const cursor = getMessagesCursor(ws.uuid, card, db, force ? undefined : lastGroup?.toDate) + + for await (const messages of cursor) { + ctx.info(`Messages count ${messages.length}`) + if (messages.length === 0) continue + const oldMessages = messages.map(deserializeOldMessage).filter(notEmpty) + await migrateMessagesBatch(ctx, card, hulylake, accountClient, personUuidBySocialId, oldMessages) + } + } catch (e) { + ctx.error('Failed to migrate messages for card', card) + ctx.error('Error', { error: e }) + } + } +} + +async function migrateMessagesBatch ( + ctx: MeasureContext, + cardId: CardID, + hulylake: HulylakeClient, + accountClient: AccountClient, + personUuidBySocialId: Map, + messages: OldMessage[] +): Promise { + const newMessages: MessageDoc[] = [] + for (const oldMessage of messages) { + const newMessageDoc = await oldMessageToNewMessageDoc(oldMessage, accountClient, personUuidBySocialId) + newMessages.push(newMessageDoc) + } + const chunks = chunkMessagesBySize(ctx, newMessages) + for (const chunk of chunks) { + const fromDate = chunk.from + const toDate = chunk.to + const blobId = generateUuid() as BlobID + const newGroupDoc: MessagesGroupDoc = { + cardId, + blobId, + fromDate: fromDate.toISOString(), + toDate: toDate.toISOString(), + count: chunk.count + } + const newMessagesDoc: MessagesDoc = { + cardId, + fromDate: fromDate.toISOString(), + toDate: toDate.toISOString(), + messages: chunk.chunk, + language: 'original' + } + + const jsonPatches: JsonPatch[] = [ + { + hop: 'add', + path: `/${blobId}`, + value: newGroupDoc, + safe: true + } as const + ] + + await hulylake.patchJson(`${cardId}/messages/groups`, jsonPatches, undefined, { + maxRetries: 3, + isRetryable: () => true, + delayStrategy: { + getDelay: () => 1000 + } + }) + await hulylake.putJson(`${cardId}/messages/${blobId}`, newMessagesDoc, undefined, { + maxRetries: 3, + isRetryable: () => true, + delayStrategy: { + getDelay: () => 1000 + } + }) + } +} + +async function getGroups (ctx: MeasureContext, hulylake: HulylakeClient, cardId: CardID): Promise { + const res = await hulylake.getJson(`${cardId}/messages/groups`, { + maxRetries: 3, + isRetryable: () => true, + delayStrategy: { + getDelay: () => 500 + } + }) + if (res.body != null) { + return deserializeMessagesGroups(res.body) + } + + await hulylake.putJson(`${cardId}/messages/groups`, {}, undefined, { + maxRetries: 3, + isRetryable: () => true, + delayStrategy: { + getDelay: () => 1000 + } + }) + return [] +} + +function chunkMessagesBySize ( + ctx: MeasureContext, + messages: MessageDoc[] +): Array<{ chunk: Record, from: Date, to: Date, count: number }> { + const chunks: Array<{ chunk: Record, from: Date, to: Date, count: number }> = [] + + let current: { chunk: Record, from?: Date, to?: Date, count: number } = { + chunk: {}, + count: 0 + } + + for (const msg of messages) { + current.chunk[msg.id] = msg + + if (sizeOfJson(current.chunk) <= MAX_MESSAGES_SIZE) { + const d = new Date(msg.created) + current.count += 1 + current.from = current.from != null ? (d < current.from ? d : current.from) : d + current.to = current.to != null ? (d > current.to ? d : current.to) : d + continue + } + + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete current.chunk[msg.id] + + if (sizeOfJson(msg) > MAX_MESSAGES_SIZE) { + ctx.warn('Message size is too big, skipping') + continue + } + + if (Object.keys(current.chunk).length === 0) { + ctx.warn('Message size is too big, skipping') + continue + } + + if (current.from != null && current.to != null) { + chunks.push({ + chunk: current.chunk, + from: current.from, + to: current.to, + count: current.count + }) + } + + current = { + chunk: {}, + count: 0 + } + current.chunk[msg.id] = msg + + if (sizeOfJson(current.chunk) <= MAX_MESSAGES_SIZE) { + const d = new Date(msg.created) + current.from = current.from != null ? (d < current.from ? d : current.from) : d + current.to = current.to != null ? (d > current.to ? d : current.to) : d + current.count = 1 + } else { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete current.chunk[msg.id] + ctx.warn(`Message ${msg.id} still exceeds limit, skipping`) + } + } + + if (current.from != null && current.to != null) { + chunks.push({ + chunk: current.chunk, + from: current.from, + to: current.to, + count: current.count + }) + } + + return chunks +} + +async function oldMessageToNewMessageDoc ( + oldMessage: OldMessage, + accountClient: AccountClient, + personUuidBySocialId: Map +): Promise { + const reactions: Record> = {} + const attachments: Record = {} + const threads: Record = {} + + const reactionsByEmoji = groupByArray(oldMessage.reactions, (it) => it.reaction) + for (const [_emoji, oldReactions] of reactionsByEmoji.entries()) { + const emoji = _emoji as Emoji + if (reactions[emoji] == null) { + reactions[emoji] = {} + } + for (const oldReaction of oldReactions) { + const personUuid = await getPersonUuidBySocialId(accountClient, personUuidBySocialId, oldReaction.creator) + + if (personUuid == null) continue + if (reactions[emoji][personUuid] == null) { + reactions[emoji][personUuid] = { + count: 1, + date: oldReaction.created.toISOString() + } + } + } + } + + for (const oldAttachment of oldMessage.attachments) { + attachments[oldAttachment.id] = { + id: oldAttachment.id, + mimeType: + oldAttachment.mimeType ?? + (oldAttachment as any)?.params?.mimeType ?? + (oldAttachment as any).type ?? + (oldAttachment as any)?.params?.type ?? + '', + params: oldAttachment.params, + creator: oldAttachment.creator, + created: oldAttachment.created.toISOString(), + modified: oldAttachment.modified?.toISOString() ?? null + } + } + + if (oldMessage.thread != null) { + threads[oldMessage.thread.threadId] = { + threadId: oldMessage.thread.threadId, + threadType: oldMessage.thread.threadType, + repliesCount: oldMessage.thread.repliesCount, + lastReplyDate: oldMessage.thread.lastReply?.toISOString() ?? null, + repliedPersons: {} + } + } + return { + id: oldMessage.id, + cardId: oldMessage.cardId, + type: oldMessage.type === 'activity' ? MessageType.Activity : MessageType.Text, + content: oldMessage.content, + extra: oldMessage.extra ?? {}, + creator: oldMessage.creator, + created: oldMessage.created.toISOString(), + modified: oldMessage.edited?.toISOString() ?? null, + reactions, + attachments, + threads + } +} + +function sizeOfJson (obj: unknown): number { + return Buffer.byteLength(JSON.stringify(obj), 'utf8') +} + +async function getPersonUuidBySocialId ( + ccountClient: AccountClient, + personUuidBySocialId: Map, + socialId: PersonId +): Promise { + if (personUuidBySocialId.has(socialId)) { + return personUuidBySocialId.get(socialId) + } + const personUuid = await ccountClient.findPersonBySocialId(socialId) + if (personUuid != null) { + personUuidBySocialId.set(socialId, personUuid) + } + + return personUuid +} + +async function getMessagesCards (ws: WorkspaceUuid, db: postgres.Sql): Promise { + const table = db(MESSAGES_TABLE) + const res = await db` + SELECT DISTINCT card_id + FROM ${table} + WHERE workspace_id = ${ws} + ` + + return res.map((r) => r.card_id) +} + +function getMessagesCursor ( + ws: WorkspaceUuid, + cardId: CardID, + db: postgres.Sql, + from?: Date +): AsyncIterable { + const messagesTable = db(MESSAGES_TABLE) + const threadIndexTable = db(THREAD_INDEX_TABLE) + const attachmentTable = db(ATTACHMENT_TABLE) + const reactionTable = db(REACTION_TABLE) + const patchTable = db(PATCH_TABLE) + + return db`WITH + limited_messages AS ( + SELECT * + FROM ${messagesTable} m + WHERE m.workspace_id = ${ws} + AND m.card_id = ${cardId} + ${from != null ? db`AND m.created > ${from}` : db``} + ORDER BY m.created ASC + ), + + agg_attachments AS ( + SELECT + a.workspace_id, + a.card_id, + a.message_id, + jsonb_agg(jsonb_build_object( + 'id', a.id, + 'type', a.type, + 'params', a.params, + 'creator', a.creator, + 'created', a.created, + 'modified', a.modified + )) AS attachments + FROM ${attachmentTable} a + INNER JOIN limited_messages m + ON m.workspace_id = a.workspace_id + AND m.card_id = a.card_id + AND m.id = a.message_id + GROUP BY a.workspace_id, a.card_id, a.message_id + ), + + agg_reactions AS ( + SELECT + r.workspace_id, + r.card_id, + r.message_id, + jsonb_agg(jsonb_build_object( + 'reaction', r.reaction, + 'creator', r.creator, + 'created', r.created + )) AS reactions + FROM ${reactionTable} r + INNER JOIN limited_messages m + ON m.workspace_id = r.workspace_id + AND m.card_id = r.card_id + AND m.id = r.message_id + GROUP BY r.workspace_id, r.card_id, r.message_id + ), + + agg_patches AS ( + SELECT + p.workspace_id, + p.card_id, + p.message_id, + jsonb_agg( + jsonb_build_object( + 'type', p.type, + 'data', p.data, + 'creator', p.creator, + 'created', p.created + ) ORDER BY p.created ASC + ) AS patches + FROM ${patchTable} p + INNER JOIN limited_messages m + ON m.workspace_id = p.workspace_id + AND m.card_id = p.card_id + AND m.id = p.message_id + GROUP BY p.workspace_id, p.card_id, p.message_id + ) + + SELECT m.id::text, + m.card_id, + m.type, + m.content, + m.creator, + m.created, + m.data, + t.thread_id AS thread_id, + t.thread_type AS thread_type, + t.replies_count::int AS replies_count, + t.last_reply AS last_reply, + COALESCE(a.attachments, '[]'::jsonb) AS attachments, + COALESCE(r.reactions, '[]'::jsonb) AS reactions, + COALESCE(p.patches, '[]'::jsonb) AS patches + FROM limited_messages m + LEFT JOIN ${threadIndexTable} t + ON t.workspace_id = m.workspace_id + AND t.card_id = m.card_id + AND t.message_id = m.id + LEFT JOIN agg_attachments a + ON a.workspace_id = m.workspace_id + AND a.card_id = m.card_id + AND a.message_id = m.id + LEFT JOIN agg_reactions r + ON r.workspace_id = m.workspace_id + AND r.card_id = m.card_id + AND r.message_id = m.id + LEFT JOIN agg_patches p + ON p.workspace_id = m.workspace_id + AND p.card_id = m.card_id + AND p.message_id = m.id + ORDER BY m.created ASC;`.cursor(200) +} + +function deserializeMessagesGroups (groups: MessagesGroupsDoc): MessagesGroup[] { + return Object.values(groups) + .map((group) => { + const g: MessagesGroup = { + cardId: group.cardId, + blobId: group.blobId, + fromDate: new Date(group.fromDate), + toDate: new Date(group.toDate), + count: Number(group.count) + } + + return g + }) + .sort((a, b) => a.fromDate.getTime() - b.fromDate.getTime()) +} + +function deserializePatch (raw: any): Patch { + return { + type: raw.type, + messageId: String(raw.message_id) as MessageID, + data: raw.data, + creator: raw.creator, + created: new Date(raw.created) + } +} + +function deserializeOldMessage (raw: any): OldMessage | undefined { + const patches: Patch[] = (raw.patches ?? []).map((it: any) => deserializePatch(it)) + const rawMessage: OldMessage = { + id: String(raw.id) as MessageID, + type: raw.type, + cardId: raw.card_id, + content: raw.content, + creator: raw.creator, + created: new Date(raw.created), + removed: false, + extra: raw.data, + thread: + raw.thread_id != null && raw.thread_type != null + ? { + cardId: raw.card_id, + messageId: String(raw.id) as MessageID, + threadId: raw.thread_id, + threadType: raw.thread_type, + repliesCount: raw.replies_count != null ? Number(raw.replies_count) : 0, + lastReply: raw.last_reply ?? new Date() + } + : undefined, + reactions: (raw.reactions ?? []).map(deserializeReaction), + attachments: (raw.attachments ?? []).map(deserializeAttachment) + } + + if (patches.length === 0) { + return rawMessage + } + + return applyPatches( + rawMessage, + patches.filter((it) => it.type === PatchType.update || it.type === PatchType.remove) + ) +} + +function deserializeReaction (raw: any): OldReaction { + return { + reaction: raw.reaction, + creator: raw.creator, + created: new Date(raw.created) + } +} + +function deserializeAttachment (raw: any): Attachment { + return { + id: String(raw.id) as AttachmentID, + type: raw.type, + params: raw.params, + creator: raw.creator, + created: new Date(raw.created), + modified: raw.modified != null ? new Date(raw.modified) : undefined + } as any as Attachment +} + +export function applyPatches (message: OldMessage, patches: Patch[]): OldMessage | undefined { + if (patches.length === 0) return message + + let result: OldMessage | undefined = message + for (const p of patches) { + result = applyPatch(message, p) + } + return result +} + +function applyPatch (message: OldMessage, patch: Patch): OldMessage | undefined { + if (message.removed) { + return undefined + } + + switch (patch.type) { + case PatchType.update: { + if (patch.created.getTime() < (message.edited?.getTime() ?? 0)) { + return message + } + return { + ...message, + edited: patch.created, + content: patch.data.content ?? message.content, + extra: patch.data.extra ?? message.extra + } + } + case PatchType.remove: { + return undefined + } + case PatchType.reaction: + return patchReactions(message, patch) + case PatchType.attachment: + return patchAttachments(message, patch) + case PatchType.thread: + return patchThread(message, patch) + } +} + +function patchAttachments (message: OldMessage, patch: AttachmentPatch): OldMessage { + if (patch.data.operation === 'add') { + return addAttachments(message, patch.data.attachments, patch.created, patch.creator) + } else if (patch.data.operation === 'remove') { + return removeAttachments(message, patch.data.ids) + } else if (patch.data.operation === 'set') { + return setAttachments(message, patch.data.attachments, patch.created, patch.creator) + } else if (patch.data.operation === 'update') { + return updateAttachments(message, patch.data.attachments, patch.created) + } + return message +} + +function patchReactions (message: OldMessage, patch: ReactionPatch): OldMessage { + if (patch.data.operation === 'add') { + return setReaction(message, patch.data.reaction, patch.creator, patch.created) + } else if (patch.data.operation === 'remove') { + return removeReaction(message, patch.data.reaction, patch.creator) + } + return message +} + +function setReaction (message: OldMessage, reaction: string, creator: SocialID, created: Date): OldMessage { + const isExist = message.reactions.some((it) => it.reaction === reaction && it.creator === creator) + if (isExist) return message + message.reactions.push({ + reaction, + creator, + created + }) + return message +} + +function removeReaction (message: OldMessage, reaction: string, creator: SocialID): OldMessage { + const reactions = message.reactions.filter((it) => it.reaction !== reaction || it.creator !== creator) + if (reactions.length === message.reactions.length) return message + + return { + ...message, + reactions + } +} + +function addAttachments (message: OldMessage, data: AttachmentData[], created: Date, creator: SocialID): OldMessage { + const newAttachments: Attachment[] = [] + for (const attach of data) { + const isExists = message.attachments.some((it) => it.id === attach.id) + if (isExists === undefined) continue + const attachment: Attachment = { + ...attach, + created, + creator + } as any + newAttachments.push(attachment) + } + + if (newAttachments.length === 0) return message + return { + ...message, + attachments: [...message.attachments, ...newAttachments] + } +} + +function updateAttachments (message: OldMessage, updates: AttachmentUpdateData[], date: Date): OldMessage { + if (updates.length === 0) return message + const updatedAttachments: Attachment[] = [] + for (const attachment of message.attachments) { + const update = updates.find((it) => it.id === attachment.id) + if (update === undefined) { + updatedAttachments.push(attachment) + } else { + updatedAttachments.push({ + ...attachment, + params: { + ...attachment.params, + ...update.params + }, + modified: date.getTime() > (attachment.modified?.getTime() ?? 0) ? date : attachment.modified + } as any) + } + } + + return { + ...message, + attachments: updatedAttachments + } +} + +function removeAttachments (message: OldMessage, ids: AttachmentID[]): OldMessage { + const attachments = message.attachments.filter((it) => !ids.includes(it.id)) + if (attachments.length === message.attachments.length) return message + + return { + ...message, + attachments + } +} + +function setAttachments (message: OldMessage, data: AttachmentData[], created: Date, creator: SocialID): OldMessage { + if (data.length === 0) return message + return { + ...message, + attachments: data.map( + (it) => + ({ + ...it, + created, + creator + }) as any + ) + } +} + +function patchThread (message: OldMessage, patch: ThreadPatch): OldMessage { + if (patch.data.operation === 'attach') { + return attachThread(message, patch.data.threadId, patch.data.threadType) + } else if (patch.data.operation === 'update') { + return updateThread( + message, + patch.data.threadId, + patch.data.threadType, + patch.data.repliesCountOp, + patch.data.lastReply + ) + } + return message +} + +function attachThread (message: OldMessage, threadId: CardID, threadType: CardType): OldMessage { + if (message.thread !== undefined) return message + return { + ...message, + thread: { + cardId: message.cardId, + messageId: message.id, + threadId, + threadType, + repliesCount: 0, + lastReply: new Date() + } + } +} + +function updateThread ( + message: OldMessage, + threadId: CardID, + threadType?: CardType, + repliesCountOp?: 'increment' | 'decrement', + lastReply?: Date +): OldMessage { + if (repliesCountOp === undefined && lastReply === undefined) return message + if (message.thread === undefined) return message + if (message.thread.threadId !== threadId) return message + + let count = message.thread.repliesCount + if (repliesCountOp === 'increment') { + count = count + 1 + } + + if (repliesCountOp === 'decrement') { + count = Math.max(count - 1, 0) + } + + return { + ...message, + thread: { + ...message.thread, + repliesCount: count, + threadType: threadType ?? message.thread.threadType, + lastReply: lastReply ?? message.thread.lastReply + } + } +} + +interface OldMessage { + id: MessageID + cardId: CardID + type: 'message' | 'activity' + content: Markdown + extra?: MessageExtra + creator: SocialID + created: Date + + removed: boolean + edited?: Date + + reactions: OldReaction[] + attachments: Attachment[] + thread?: OldThread +} + +interface OldReaction { + reaction: string + creator: SocialID + created: Date +} + +interface OldThread { + cardId: CardID + messageId: MessageID + threadId: CardID + threadType: CardType + repliesCount: number + lastReply: Date +} + +type Patch = UpdatePatch | RemovePatch | ReactionPatch | ThreadPatch | AttachmentPatch + +enum PatchType { + update = 'update', + remove = 'remove', + reaction = 'reaction', + attachment = 'attachment', + thread = 'thread' +} + +interface BasePatch { + messageId: MessageID + type: PatchType + creator: SocialID + created: Date + + data: Record +} + +interface UpdatePatch extends BasePatch { + type: PatchType.update + data: UpdatePatchData +} + +interface UpdatePatchData { + content?: Markdown + extra?: MessageExtra +} + +interface RemovePatch extends BasePatch { + type: PatchType.remove + data: RemovePatchData +} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +interface RemovePatchData {} + +interface ReactionPatch extends BasePatch { + type: PatchType.reaction + data: AddReactionPatchData | RemoveReactionPatchData +} + +interface AddReactionPatchData { + operation: 'add' + reaction: string +} + +interface RemoveReactionPatchData { + operation: 'remove' + reaction: string +} + +interface AttachmentPatch extends BasePatch { + type: PatchType.attachment + data: AddAttachmentsPatchData | RemoveAttachmentsPatchData | SetAttachmentsPatchData | UpdateAttachmentsPatchData +} + +interface AddAttachmentsPatchData { + operation: 'add' + attachments: AttachmentData[] +} + +interface RemoveAttachmentsPatchData { + operation: 'remove' + ids: AttachmentID[] +} + +interface SetAttachmentsPatchData { + operation: 'set' + attachments: AttachmentData[] +} + +interface UpdateAttachmentsPatchData { + operation: 'update' + attachments: AttachmentUpdateData[] +} + +interface ThreadPatch extends BasePatch { + type: PatchType.thread + data: AttachThreadPatchData | UpdateThreadPatchData +} + +interface AttachThreadPatchData { + operation: 'attach' + threadId: CardID + threadType: CardType +} + +interface UpdateThreadPatchData { + operation: 'update' + threadId: CardID + threadType?: CardType + repliesCountOp?: 'increment' | 'decrement' + lastReply?: Date +} diff --git a/dev/tool/src/db.ts b/dev/tool/src/db.ts index 37b9c4d672..201489a6f9 100644 --- a/dev/tool/src/db.ts +++ b/dev/tool/src/db.ts @@ -1911,7 +1911,7 @@ export async function restoreTrustedV6Workspace ( url } - const queue = getPlatformQueue('tool', workspace.region) + const queue = getPlatformQueue('tool', region) const wsProducer = queue.getProducer(ctx, QueueTopic.Workspace) await wsProducer.send(ctx, uuid, [workspaceEvents.restoring()]) diff --git a/dev/tool/src/index.ts b/dev/tool/src/index.ts index 1126d9886c..4e88e4a7e4 100644 --- a/dev/tool/src/index.ts +++ b/dev/tool/src/index.ts @@ -56,6 +56,14 @@ import { type Account as OldAccount, type Workspace as OldWorkspace } from '@hcengineering/account-service' +import { getClient as getHulylakeClient } from '@hcengineering/hulylake-client' +import { + getDBClient, + createPostgreeDestroyAdapter, + createPostgresAdapter, + createPostgresTxAdapter, + shutdownPostgres +} from '@hcengineering/postgres' import { faker } from '@faker-js/faker' import { getPlatformQueue } from '@hcengineering/kafka' @@ -69,6 +77,7 @@ import { isDeletingMode, MeasureMetricsContext, metricsToString, + SocialId, SocialIdType, systemAccountEmail, systemAccountUuid, @@ -80,7 +89,8 @@ import { type Tx, type Version, type WorkspaceDataId, - type WorkspaceUuid + type WorkspaceUuid, + type PersonUuid } from '@hcengineering/core' import { consoleModelLogger, type MigrateOperation } from '@hcengineering/model' import { @@ -92,12 +102,6 @@ import { } from '@hcengineering/mongo' import { getModelVersion } from '@hcengineering/model-all' -import { - createPostgreeDestroyAdapter, - createPostgresAdapter, - createPostgresTxAdapter, - shutdownPostgres -} from '@hcengineering/postgres' import { QueueTopic, workspaceEvents, @@ -131,6 +135,8 @@ import { mkdir, writeFile } from 'fs/promises' import { dirname } from 'path' import { restoreMarkupRefs } from './markup' import { restoreGithubIntegrations } from './restoreGithub' +import { migrateWorkspaceMessages } from './communication' +import { type CardID } from '@hcengineering/communication-types' const colorConstants = { colorRed: '\u001b[31m', @@ -2845,6 +2851,54 @@ export function devTool ( }, dbUrl) }) + program + .command('migrate-communication-to-hulylake ') + .description('Migrate communication messages to hulylake') + .option('-c, --card ', 'Card to migrate') + .action(async (workspace: WorkspaceUuid, cmd: { card?: CardID }) => { + const { dbUrl } = prepareTools() + const hulylakeUrl = process.env.HULYLAKE_URL ?? '' + + console.log('Workspace', workspace) + console.log('Card', cmd.card) + if (hulylakeUrl === '') { + throw new Error('HULYLAKE_URL should be specified') + } + + const token = generateToken(systemAccountUuid, undefined, { + service: 'tool' + }) + const db = getDBClient(dbUrl, undefined, 'tool') + const dbClient = await db.getClient() + const accountClient = getAccountClient(token) + const personUuidBySocialId = new Map() + + await withAccountDatabase(async (accountDb) => { + const ws = (await accountDb.workspace.find({ uuid: workspace }))[0] + if (ws === undefined) { + throw new Error(`Workspace ${workspace} not found`) + } + try { + const hulylake = getHulylakeClient(hulylakeUrl, ws.uuid, token) + console.log('start workspace migration', ws.name) + await migrateWorkspaceMessages( + toolCtx.newChild(ws.name, {}), + ws, + cmd.card, + dbClient, + hulylake, + accountClient, + personUuidBySocialId + ) + console.log('done workspace migration', ws.name) + } catch (err: any) { + console.error('failed to migrate workspace', ws.name) + console.error(err) + } + db.close() + }, dbUrl) + }) + extendProgram?.(program) process.on('unhandledRejection', (reason, promise) => { diff --git a/models/all/src/index.ts b/models/all/src/index.ts index 5b82ed2d90..b056719d9f 100644 --- a/models/all/src/index.ts +++ b/models/all/src/index.ts @@ -474,7 +474,7 @@ export default function buildModel (): Builder { [ chatModel, chatId, - { label: chat.string.Chat, hidden: true, enabled: false, beta: true, classFilter: defaultFilter } + { label: chat.string.Chat, hidden: false, enabled: true, beta: true, classFilter: defaultFilter } ], [achievementModel, achievementId], [emojiModel, emojiId], diff --git a/models/card/src/actions.ts b/models/card/src/actions.ts new file mode 100644 index 0000000000..f66c26fbc1 --- /dev/null +++ b/models/card/src/actions.ts @@ -0,0 +1,218 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { type Builder } from '@hcengineering/model' +import presentation from '@hcengineering/model-presentation' +import setting from '@hcengineering/model-setting' +import view, { createAction } from '@hcengineering/model-view' +import workbench from '@hcengineering/model-workbench' +import card from './plugin' + +export function createActions (builder: Builder): void { + createAction(builder, { + action: card.actionImpl.EditSpace, + label: presentation.string.Edit, + icon: view.icon.Edit, + input: 'focus', + category: view.category.General, + target: card.class.CardSpace, + visibilityTester: view.function.CanEditSpace, + query: {}, + context: { + mode: ['context', 'browser'], + group: 'edit' + } + }) + + createAction(builder, { + action: view.actionImpl.CopyTextToClipboard, + actionProps: { + textProvider: card.function.GetSpaceAccessPublicLink + }, + label: card.string.GetIndividualPublicLink, + icon: view.icon.CopyLink, + input: 'any', + category: view.category.General, + target: card.class.CardSpace, + query: {}, + visibilityTester: card.function.CanGetSpaceAccessPublicLink, + context: { + mode: ['context', 'browser'] + } + }) + + createAction( + builder, + { + action: view.actionImpl.ShowPopup, + actionProps: { + component: card.component.SetParentActionPopup, + element: 'top', + fillProps: { + _objects: 'value' + } + }, + label: card.string.SetParent, + icon: card.icon.MasterTag, + input: 'none', + category: card.category.Card, + target: card.class.Card, + context: { + mode: ['context'], + application: card.app.Card, + group: 'associate' + } + }, + card.action.SetParent + ) + + createAction( + builder, + { + action: view.actionImpl.UpdateDocument, + actionProps: { + key: 'parent', + value: null + }, + query: { + parent: { $ne: null, $exists: true } + }, + label: card.string.UnsetParent, + icon: card.icon.MasterTag, + input: 'none', + category: card.category.Card, + target: card.class.Card, + context: { + mode: ['context'], + application: card.app.Card, + group: 'associate' + } + }, + card.action.UnsetParent + ) + + createAction(builder, { + action: view.actionImpl.ShowPopup, + actionProps: { + component: card.component.CreateTag, + props: { + _class: card.class.Tag + }, + fillProps: { + _object: 'parent' + } + }, + label: card.string.CreateTag, + input: 'focus', + icon: view.icon.Add, + category: setting.category.Settings, + target: card.class.MasterTag, + context: { + mode: ['context', 'browser'], + group: 'edit' + } + }) + + createAction(builder, { + action: view.actionImpl.ShowPopup, + actionProps: { + component: card.component.CreateTag, + props: { + _class: card.class.Tag + }, + fillProps: { + _object: 'parent' + } + }, + label: card.string.CreateTag, + input: 'focus', + icon: view.icon.Add, + category: setting.category.Settings, + target: card.class.Tag, + context: { + mode: ['context', 'browser'], + group: 'edit' + } + }) + + createAction( + builder, + { + action: card.actionImpl.DeleteMasterTag, + label: workbench.string.Delete, + icon: view.icon.Delete, + input: 'any', + category: view.category.General, + target: card.class.MasterTag, + context: { + mode: ['context', 'browser'], + group: 'remove' + } + }, + card.action.DeleteMasterTag + ) + + createAction(builder, { + action: card.actionImpl.DeleteMasterTag, + label: workbench.string.Delete, + icon: view.icon.Delete, + input: 'any', + category: view.category.General, + target: card.class.Tag, + context: { + mode: ['context', 'browser'], + group: 'remove' + } + }) + + createAction(builder, { + action: view.actionImpl.ShowPopup, + actionProps: { + component: card.component.ChangeType, + fillProps: { + _object: 'value' + } + }, + label: card.string.ChangeType, + input: 'focus', + icon: card.icon.MasterTag, + category: setting.category.Settings, + target: card.class.Card, + context: { + mode: ['context', 'browser'], + group: 'edit' + } + }) + + createAction(builder, { + action: view.actionImpl.ShowPopup, + actionProps: { + component: card.component.CreateTag, + props: { + _class: card.class.MasterTag + }, + fillProps: { + _object: 'parent' + } + }, + label: card.string.CreateMasterTag, + input: 'focus', + icon: card.icon.MasterTag, + category: setting.category.Settings, + target: card.class.MasterTag, + context: { + mode: ['context', 'browser'], + group: 'edit' + } + }) +} diff --git a/models/card/src/index.ts b/models/card/src/index.ts index baec78eff0..d46e1fbd96 100644 --- a/models/card/src/index.ts +++ b/models/card/src/index.ts @@ -13,6 +13,7 @@ import activity from '@hcengineering/activity' import { + type CanCreateCardResource, type Card, cardId, type CardNavigation, @@ -20,7 +21,6 @@ import { type CardSpace, type CardViewDefaults, type CreateCardExtension, - type CanCreateCardResource, DOMAIN_CARD, type FavoriteCard, type MasterTag, @@ -60,17 +60,18 @@ import { import attachment from '@hcengineering/model-attachment' import { TAttachedDoc, TClass, TDoc, TMixin, TSpace } from '@hcengineering/model-core' import { createPublicLinkAction } from '@hcengineering/model-guest' +import preference, { TPreference } from '@hcengineering/model-preference' import presentation from '@hcengineering/model-presentation' import setting from '@hcengineering/model-setting' -import view, { createAction, type Viewlet } from '@hcengineering/model-view' +import view, { type Viewlet } from '@hcengineering/model-view' import workbench, { WidgetType } from '@hcengineering/model-workbench' import { type Asset, getEmbeddedLabel, type IntlString, type Resource } from '@hcengineering/platform' import time, { type ToDo } from '@hcengineering/time' +import { PaletteColorIndexes } from '@hcengineering/ui/src/colors' import { type AnyComponent } from '@hcengineering/ui/src/types' import { type BuildModelKey } from '@hcengineering/view' -import preference, { TPreference } from '@hcengineering/model-preference' import card from './plugin' -import { PaletteColorIndexes } from '@hcengineering/ui/src/colors' +import { createActions } from './actions' export { cardId } from '@hcengineering/card' @@ -399,37 +400,7 @@ export function createModel (builder: Builder): void { } }) - createAction(builder, { - action: card.actionImpl.EditSpace, - label: presentation.string.Edit, - icon: view.icon.Edit, - input: 'focus', - category: view.category.General, - target: card.class.CardSpace, - visibilityTester: view.function.CanEditSpace, - query: {}, - context: { - mode: ['context', 'browser'], - group: 'edit' - } - }) - - createAction(builder, { - action: view.actionImpl.CopyTextToClipboard, - actionProps: { - textProvider: card.function.GetSpaceAccessPublicLink - }, - label: card.string.GetIndividualPublicLink, - icon: view.icon.CopyLink, - input: 'any', - category: view.category.General, - target: card.class.CardSpace, - query: {}, - visibilityTester: card.function.CanGetSpaceAccessPublicLink, - context: { - mode: ['context', 'browser'] - } - }) + createActions(builder) builder.mixin(card.class.CardSpace, core.class.Class, workbench.mixin.SpaceView, { view: { @@ -450,6 +421,19 @@ export function createModel (builder: Builder): void { locationDataResolver: card.resolver.LocationData, navigatorModel: { specials: [ + { + id: 'all', + label: card.string.AllCards, + icon: card.icon.All, + component: workbench.component.SpecialView, + componentProps: { + _class: card.class.Card, + icon: card.icon.All, + label: card.string.AllCards, + defaultViewletDescriptor: card.viewlet.CardFeedDescriptor + }, + position: 'top' + }, { id: 'browser', label: core.string.Spaces, @@ -484,31 +468,6 @@ export function createModel (builder: Builder): void { presenter: card.component.SpacePresenter }) - createAction( - builder, - { - action: view.actionImpl.ShowPopup, - actionProps: { - component: card.component.SetParentActionPopup, - element: 'top', - fillProps: { - _objects: 'value' - } - }, - label: card.string.SetParent, - icon: card.icon.MasterTag, - input: 'none', - category: card.category.Card, - target: card.class.Card, - context: { - mode: ['context'], - application: card.app.Card, - group: 'associate' - } - }, - card.action.SetParent - ) - builder.mixin(card.class.Card, core.class.Class, view.mixin.AttributeEditor, { inlineEditor: card.component.CardEditor }) @@ -517,31 +476,6 @@ export function createModel (builder: Builder): void { inlineEditor: card.component.CardArrayEditor }) - createAction( - builder, - { - action: view.actionImpl.UpdateDocument, - actionProps: { - key: 'parent', - value: null - }, - query: { - parent: { $ne: null, $exists: true } - }, - label: card.string.UnsetParent, - icon: card.icon.MasterTag, - input: 'none', - category: card.category.Card, - target: card.class.Card, - context: { - mode: ['context'], - application: card.app.Card, - group: 'associate' - } - }, - card.action.UnsetParent - ) - builder.createDoc( view.class.Viewlet, core.space.Model, @@ -680,50 +614,6 @@ export function createModel (builder: Builder): void { encode: card.function.GetCardLink }) - createAction(builder, { - action: view.actionImpl.ShowPopup, - actionProps: { - component: card.component.CreateTag, - props: { - _class: card.class.Tag - }, - fillProps: { - _object: 'parent' - } - }, - label: card.string.CreateTag, - input: 'focus', - icon: view.icon.Add, - category: setting.category.Settings, - target: card.class.MasterTag, - context: { - mode: ['context', 'browser'], - group: 'edit' - } - }) - - createAction(builder, { - action: view.actionImpl.ShowPopup, - actionProps: { - component: card.component.CreateTag, - props: { - _class: card.class.Tag - }, - fillProps: { - _object: 'parent' - } - }, - label: card.string.CreateTag, - input: 'focus', - icon: view.icon.Add, - category: setting.category.Settings, - target: card.class.Tag, - context: { - mode: ['context', 'browser'], - group: 'edit' - } - }) - builder.mixin(card.class.MasterTag, core.class.Class, view.mixin.IgnoreActions, { actions: [setting.action.CreateMixin, view.action.OpenInNewTab, view.action.Delete, setting.action.DeleteMixin] }) @@ -732,77 +622,6 @@ export function createModel (builder: Builder): void { actions: [setting.action.CreateMixin, view.action.OpenInNewTab, view.action.Delete, setting.action.DeleteMixin] }) - createAction( - builder, - { - action: card.actionImpl.DeleteMasterTag, - label: workbench.string.Delete, - icon: view.icon.Delete, - input: 'any', - category: view.category.General, - target: card.class.MasterTag, - context: { - mode: ['context', 'browser'], - group: 'remove' - } - }, - card.action.DeleteMasterTag - ) - - createAction(builder, { - action: card.actionImpl.DeleteMasterTag, - label: workbench.string.Delete, - icon: view.icon.Delete, - input: 'any', - category: view.category.General, - target: card.class.Tag, - context: { - mode: ['context', 'browser'], - group: 'remove' - } - }) - - createAction(builder, { - action: view.actionImpl.ShowPopup, - actionProps: { - component: card.component.ChangeType, - fillProps: { - _object: 'value' - } - }, - label: card.string.ChangeType, - input: 'focus', - icon: card.icon.MasterTag, - category: setting.category.Settings, - target: card.class.Card, - context: { - mode: ['context', 'browser'], - group: 'edit' - } - }) - - createAction(builder, { - action: view.actionImpl.ShowPopup, - actionProps: { - component: card.component.CreateTag, - props: { - _class: card.class.MasterTag - }, - fillProps: { - _object: 'parent' - } - }, - label: card.string.CreateMasterTag, - input: 'focus', - icon: card.icon.MasterTag, - category: setting.category.Settings, - target: card.class.MasterTag, - context: { - mode: ['context', 'browser'], - group: 'edit' - } - }) - builder.createDoc( setting.class.WorkspaceSettingCategory, core.space.Model, @@ -849,6 +668,10 @@ export function createModel (builder: Builder): void { forceIndex: true }) + builder.mixin(card.class.Card, core.class.Class, view.mixin.ObjectFactory, { + create: card.function.CardFactory + }) + builder.createDoc( presentation.class.ObjectSearchCategory, core.space.Model, diff --git a/models/communication/src/actions.ts b/models/communication/src/actions.ts index 53ea42dd85..6d1802b7dd 100644 --- a/models/communication/src/actions.ts +++ b/models/communication/src/actions.ts @@ -36,7 +36,7 @@ export function buildMessageActions (builder: Builder): void { communication.class.MessageAction, core.space.Model, { - label: communication.string.ReplyInThread, + label: communication.string.Reply, icon: communication.icon.MessageMultiple, action: communication.messageActionImpl.ReplyInThread, visibilityTester: communication.function.CanReplyInThread, diff --git a/models/contact/src/index.ts b/models/contact/src/index.ts index 84f69c7092..70459acd78 100644 --- a/models/contact/src/index.ts +++ b/models/contact/src/index.ts @@ -921,6 +921,10 @@ export function createModel (builder: Builder): void { presenter: contact.component.PersonPresenter }) + builder.mixin(contact.class.SocialIdentity, core.class.Class, view.mixin.ObjectPresenter, { + presenter: contact.component.SocialIdentityPresenter + }) + builder.mixin(core.class.TypeAccountUuid, core.class.Class, view.mixin.ArrayEditor, { inlineEditor: contact.component.AccountArrayEditor }) @@ -1141,6 +1145,7 @@ export function createModel (builder: Builder): void { label: contact.string.MergePersons, category: contact.category.Contact, target: contact.class.Person, + visibilityTester: contact.function.CanMergePersons, input: 'focus', context: { mode: ['context'], diff --git a/models/contact/src/plugin.ts b/models/contact/src/plugin.ts index 73122a5076..74eaacf87c 100644 --- a/models/contact/src/plugin.ts +++ b/models/contact/src/plugin.ts @@ -32,6 +32,7 @@ export default mergeIds(contactId, contact, { }, component: { PersonPresenter: '' as AnyComponent, + SocialIdentityPresenter: '' as AnyComponent, ContactRefPresenter: '' as AnyComponent, ContactPresenter: '' as AnyComponent, EditPerson: '' as AnyComponent, diff --git a/models/controlled-documents/src/permissions.ts b/models/controlled-documents/src/permissions.ts index 3aa0db34bb..34bc585581 100644 --- a/models/controlled-documents/src/permissions.ts +++ b/models/controlled-documents/src/permissions.ts @@ -12,6 +12,7 @@ export function definePermissions (builder: Builder): void { core.space.Model, { label: documents.string.ReviewDocumentPermission, + scope: 'space', description: documents.string.ReviewDocumentDescription }, documents.permission.ReviewDocument @@ -22,6 +23,7 @@ export function definePermissions (builder: Builder): void { core.space.Model, { label: documents.string.ApproveDocumentPermission, + scope: 'space', description: documents.string.ApproveDocumentDescription }, documents.permission.ApproveDocument @@ -32,6 +34,7 @@ export function definePermissions (builder: Builder): void { core.space.Model, { label: documents.string.ArchiveDocumentPermission, + scope: 'space', description: documents.string.ArchiveDocumentDescription }, documents.permission.ArchiveDocument @@ -42,6 +45,7 @@ export function definePermissions (builder: Builder): void { core.space.Model, { label: documents.string.CoAuthorDocumentPermission, + scope: 'space', description: documents.string.CoAuthorDocumentDescription }, documents.permission.CoAuthorDocument @@ -52,6 +56,7 @@ export function definePermissions (builder: Builder): void { core.space.Model, { label: documents.string.CreateDocumentPermission, + scope: 'space', description: documents.string.CreateDocumentDescription }, documents.permission.CreateDocument @@ -62,6 +67,7 @@ export function definePermissions (builder: Builder): void { core.space.Model, { label: documents.string.UpdateDocumentOwnerPermission, + scope: 'space', description: documents.string.UpdateDocumentOwnerDescription }, documents.permission.UpdateDocumentOwner @@ -72,6 +78,7 @@ export function definePermissions (builder: Builder): void { core.space.Model, { label: documents.string.CreateDocumentCategoryPermission, + scope: 'space', description: documents.string.CreateDocumentCategoryDescription }, documents.permission.CreateDocumentCategory @@ -82,6 +89,7 @@ export function definePermissions (builder: Builder): void { core.space.Model, { label: documents.string.UpdateDocumentCategoryPermission, + scope: 'space', description: documents.string.UpdateDocumentCategoryDescription }, documents.permission.UpdateDocumentCategory @@ -92,6 +100,7 @@ export function definePermissions (builder: Builder): void { core.space.Model, { label: documents.string.DeleteDocumentCategoryPermission, + scope: 'space', description: documents.string.DeleteDocumentCategoryDescription }, documents.permission.DeleteDocumentCategory diff --git a/models/core/src/permissions.ts b/models/core/src/permissions.ts index 6bb0c0e00b..3c731d1f3d 100644 --- a/models/core/src/permissions.ts +++ b/models/core/src/permissions.ts @@ -23,6 +23,7 @@ export function definePermissions (builder: Builder): void { core.space.Model, { label: core.string.CreateObject, + scope: 'space', description: core.string.CreateObjectDescription }, core.permission.CreateObject @@ -33,6 +34,7 @@ export function definePermissions (builder: Builder): void { core.space.Model, { label: core.string.UpdateObject, + scope: 'space', description: core.string.UpdateObjectDescription }, core.permission.UpdateObject @@ -43,6 +45,7 @@ export function definePermissions (builder: Builder): void { core.space.Model, { label: core.string.DeleteObject, + scope: 'space', description: core.string.DeleteObjectDescription }, core.permission.DeleteObject @@ -53,36 +56,20 @@ export function definePermissions (builder: Builder): void { core.space.Model, { label: core.string.ForbidDeleteObject, + txClass: core.class.TxRemoveDoc, + forbid: true, + scope: 'space', description: core.string.ForbidDeleteObjectDescription }, core.permission.ForbidDeleteObject ) - builder.createDoc( - core.class.Permission, - core.space.Model, - { - label: core.string.UpdateObject, - description: core.string.UpdateObjectDescription - }, - core.permission.UpdateObject - ) - - builder.createDoc( - core.class.Permission, - core.space.Model, - { - label: core.string.DeleteObject, - description: core.string.DeleteObjectDescription - }, - core.permission.DeleteObject - ) - builder.createDoc( core.class.Permission, core.space.Model, { label: core.string.UpdateSpace, + scope: 'space', description: core.string.UpdateSpaceDescription }, core.permission.UpdateSpace @@ -93,6 +80,7 @@ export function definePermissions (builder: Builder): void { core.space.Model, { label: core.string.ArchiveSpace, + scope: 'space', description: core.string.ArchiveSpaceDescription }, core.permission.ArchiveSpace diff --git a/models/core/src/security.ts b/models/core/src/security.ts index 54234e3009..3225d2de6c 100644 --- a/models/core/src/security.ts +++ b/models/core/src/security.ts @@ -29,7 +29,9 @@ import { type SpaceTypeDescriptor, type TypedSpace, type AccountUuid, - type TxAccessLevel + type TxAccessLevel, + type Tx, + type Doc } from '@hcengineering/core' import { ArrOf, @@ -155,6 +157,10 @@ export class TRole extends TAttachedDoc implements Role { @UX(core.string.Permission) export class TPermission extends TDoc implements Permission { label!: IntlString + txClass?: Ref> + forbid?: boolean + objectClass?: Ref>> + scope?: 'space' | 'workspace' description?: IntlString icon?: Asset } diff --git a/models/document/src/index.ts b/models/document/src/index.ts index 024909f6ca..ea38b9d755 100644 --- a/models/document/src/index.ts +++ b/models/document/src/index.ts @@ -63,6 +63,7 @@ import { type Asset, getEmbeddedLabel } from '@hcengineering/platform' import tags from '@hcengineering/tags' import time, { type ToDo, type Todoable } from '@hcengineering/time' import document from './plugin' +import { definePermissions } from './permissions' export { documentId } from '@hcengineering/document' @@ -550,6 +551,7 @@ export function createModel (builder: Builder): void { defineDocument(builder) defineApplication(builder) + definePermissions(builder) builder.createDoc(core.class.DomainIndexConfiguration, core.space.Model, { domain: DOMAIN_DOCUMENT, diff --git a/models/document/src/permissions.ts b/models/document/src/permissions.ts new file mode 100644 index 0000000000..210b0d6624 --- /dev/null +++ b/models/document/src/permissions.ts @@ -0,0 +1,19 @@ +import type { Builder } from '@hcengineering/model' +import core from '@hcengineering/core' +import document from '@hcengineering/document' + +export function definePermissions (builder: Builder): void { + builder.createDoc( + core.class.Permission, + core.space.Model, + { + label: document.string.ForbidCreateTeamspacePermission, + scope: 'workspace', + txClass: core.class.TxCreateDoc, + objectClass: document.class.Teamspace, + forbid: true, + description: document.string.ForbidCreateTeamspacePermissionDescription + }, + document.permission.ForbidCreateTeamspace + ) +} diff --git a/models/drive/src/index.ts b/models/drive/src/index.ts index e44badf673..68171798fd 100644 --- a/models/drive/src/index.ts +++ b/models/drive/src/index.ts @@ -63,6 +63,7 @@ import workbench from '@hcengineering/model-workbench' import { getEmbeddedLabel } from '@hcengineering/platform' import drive from './plugin' +import { definePermissions } from './permissions' export { driveId } from '@hcengineering/drive' export { driveOperation } from './migration' @@ -790,4 +791,5 @@ export function createModel (builder: Builder): void { defineFile(builder) defineFileVersion(builder) defineApplication(builder) + definePermissions(builder) } diff --git a/models/drive/src/permissions.ts b/models/drive/src/permissions.ts new file mode 100644 index 0000000000..89b28e7f09 --- /dev/null +++ b/models/drive/src/permissions.ts @@ -0,0 +1,19 @@ +import type { Builder } from '@hcengineering/model' +import core from '@hcengineering/core' +import drive from '@hcengineering/drive' + +export function definePermissions (builder: Builder): void { + builder.createDoc( + core.class.Permission, + core.space.Model, + { + label: drive.string.ForbidCreateDrivePermission, + scope: 'workspace', + txClass: core.class.TxCreateDoc, + objectClass: drive.class.Drive, + forbid: true, + description: drive.string.ForbidCreateDrivePermissionDescription + }, + drive.permission.ForbidCreateDrive + ) +} diff --git a/models/lead/src/index.ts b/models/lead/src/index.ts index 84d37b56c9..849dd26bb6 100644 --- a/models/lead/src/index.ts +++ b/models/lead/src/index.ts @@ -32,6 +32,7 @@ import { type ViewOptionsModel } from '@hcengineering/view' import lead from './plugin' import { defineSpaceType } from './spaceType' +import { definePermissions } from './permissions' import { TCustomer, TFunnel, TLead } from './types' export { leadId } from '@hcengineering/lead' @@ -660,4 +661,5 @@ export function createModel (builder: Builder): void { }) defineSpaceType(builder) + definePermissions(builder) } diff --git a/models/lead/src/permissions.ts b/models/lead/src/permissions.ts new file mode 100644 index 0000000000..afb8d5287d --- /dev/null +++ b/models/lead/src/permissions.ts @@ -0,0 +1,19 @@ +import type { Builder } from '@hcengineering/model' +import core from '@hcengineering/core' +import lead from '@hcengineering/lead' + +export function definePermissions (builder: Builder): void { + builder.createDoc( + core.class.Permission, + core.space.Model, + { + label: lead.string.ForbidCreateFunnelPermission, + scope: 'workspace', + txClass: core.class.TxCreateDoc, + objectClass: lead.class.Funnel, + forbid: true, + description: lead.string.ForbidCreateFunnelPermissionDescription + }, + lead.permission.ForbidCreateFunnel + ) +} diff --git a/models/love/src/index.ts b/models/love/src/index.ts index b185597208..775e5d556b 100644 --- a/models/love/src/index.ts +++ b/models/love/src/index.ts @@ -719,6 +719,12 @@ export function createModel (builder: Builder): void { love.completion.MeetingMinutesCategory ) + // Extensions + builder.createDoc(presentation.class.ComponentPointExtension, core.space.Model, { + extension: contact.extension.EmployeePopupActions, + component: love.component.InviteEmployeeButton + }) + createAttributePresenter( builder, view.component.DateTimePresenter, diff --git a/models/love/src/plugin.ts b/models/love/src/plugin.ts index e7ef324548..1c54922679 100644 --- a/models/love/src/plugin.ts +++ b/models/love/src/plugin.ts @@ -32,7 +32,8 @@ export default mergeIds(loveId, love, { WidgetSwitcher: '' as AnyComponent, RoomLanguageEditor: '' as AnyComponent, MediaPopupItemExt: '' as AnyComponent, - SharingStateIndicator: '' as AnyComponent + SharingStateIndicator: '' as AnyComponent, + InviteEmployeeButton: '' as AnyComponent }, app: { Love: '' as Ref diff --git a/models/process/package.json b/models/process/package.json index d2fde4d349..56935986c0 100644 --- a/models/process/package.json +++ b/models/process/package.json @@ -45,6 +45,7 @@ "@hcengineering/setting": "^0.6.17", "@hcengineering/model-time": "^0.6.0", "@hcengineering/rank": "^0.6.4", + "@hcengineering/notification": "^0.6.23", "@hcengineering/process": "^0.6.0", "@hcengineering/process-resources": "^0.6.0", "@hcengineering/model-presentation": "^0.6.0", diff --git a/models/process/src/actions.ts b/models/process/src/actions.ts new file mode 100644 index 0000000000..4fa5a49ec1 --- /dev/null +++ b/models/process/src/actions.ts @@ -0,0 +1,118 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import card from '@hcengineering/card' +import core from '@hcengineering/core' +import { type Builder } from '@hcengineering/model' +import process from './plugin' + +export function defineMethods (builder: Builder): void { + builder.createDoc( + process.class.Method, + core.space.Model, + { + label: process.string.RunProcess, + objectClass: process.class.Process, + editor: process.component.SubProcessEditor, + presenter: process.component.SubProcessPresenter, + createdContext: { _class: process.class.Execution }, + requiredParams: ['_id'] + }, + process.method.RunSubProcess + ) + + builder.createDoc( + process.class.Method, + core.space.Model, + { + label: process.string.CreateToDo, + editor: process.component.ToDoEditor, + objectClass: process.class.ProcessToDo, + presenter: process.component.ToDoPresenter, + createdContext: { _class: process.class.ProcessToDo, nameField: 'title' }, + requiredParams: ['title', 'user'], + defaultParams: { + withRollback: true + } + }, + process.method.CreateToDo + ) + + builder.createDoc( + process.class.Trigger, + core.space.Model, + { + label: process.string.OnExecutionStart, + icon: process.icon.Process, + init: true, + requiredParams: [] + }, + process.trigger.OnExecutionStart + ) + + builder.createDoc( + process.class.Method, + core.space.Model, + { + label: process.string.UpdateCard, + editor: process.component.UpdateCardEditor, + objectClass: card.class.Card, + createdContext: null, + presenter: process.component.UpdateCardPresenter, + requiredParams: [] + }, + process.method.UpdateCard + ) + + builder.createDoc( + process.class.Method, + core.space.Model, + { + label: process.string.CreateCard, + objectClass: card.class.Card, + editor: process.component.CreateCardEditor, + presenter: process.component.CreateCardPresenter, + createdContext: { _class: card.class.Card }, + requiredParams: ['title', '_class'] + }, + process.method.CreateCard + ) + + builder.createDoc( + process.class.Method, + core.space.Model, + { + label: core.string.AddRelation, + objectClass: core.class.Relation, + editor: process.component.AddRelationEditor, + presenter: process.component.AddRelationPresenter, + createdContext: { _class: core.class.Relation }, + requiredParams: ['association', 'direction', '_id'] + }, + process.method.AddRelation + ) + + builder.createDoc( + process.class.Method, + core.space.Model, + { + label: card.string.AddTag, + objectClass: card.class.Tag, + editor: process.component.AddTagEditor, + presenter: process.component.AddTagPresenter, + createdContext: { _class: card.class.Card }, + requiredParams: ['_id'] + }, + process.method.AddTag + ) +} diff --git a/models/process/src/functions.ts b/models/process/src/functions.ts new file mode 100644 index 0000000000..1a6b4c74af --- /dev/null +++ b/models/process/src/functions.ts @@ -0,0 +1,520 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import contact from '@hcengineering/contact' +import core from '@hcengineering/core' +import { type Builder } from '@hcengineering/model' +import process from './plugin' + +export function defineFunctions (builder: Builder): void { + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.TypeString, + category: 'attribute', + label: process.string.UpperCase, + type: 'transform' + }, + process.function.UpperCase + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.TypeString, + category: 'attribute', + label: process.string.LowerCase, + type: 'transform' + }, + process.function.LowerCase + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.TypeString, + category: 'attribute', + label: process.string.Trim, + type: 'transform' + }, + process.function.Trim + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.TypeString, + category: 'attribute', + label: process.string.Prepend, + allowMany: true, + type: 'transform', + editor: process.transformEditor.AppendEditor + }, + process.function.Prepend + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.TypeString, + category: 'attribute', + label: process.string.Append, + allowMany: true, + type: 'transform', + editor: process.transformEditor.AppendEditor + }, + process.function.Append + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.TypeString, + category: 'attribute', + label: process.string.Replace, + allowMany: true, + type: 'transform', + editor: process.transformEditor.ReplaceEditor + }, + process.function.Replace + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.TypeString, + category: 'attribute', + label: process.string.ReplaceAll, + allowMany: true, + type: 'transform', + editor: process.transformEditor.ReplaceEditor + }, + process.function.ReplaceAll + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.TypeString, + category: 'attribute', + label: process.string.Split, + allowMany: true, + type: 'transform', + editor: process.transformEditor.SplitEditor + }, + process.function.Split + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.TypeString, + category: 'attribute', + label: process.string.Cut, + allowMany: true, + editor: process.transformEditor.CutEditor, + type: 'transform' + }, + process.function.Cut + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.ArrOf, + category: undefined, + label: process.string.FirstValue, + type: 'reduce' + }, + process.function.FirstValue + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.ArrOf, + type: 'reduce', + category: undefined, + label: process.string.LastValue + }, + process.function.LastValue + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.ArrOf, + type: 'reduce', + category: undefined, + label: process.string.Random + }, + process.function.Random + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.ArrOf, + category: 'array', + label: process.string.Each, + type: 'reduce' + }, + process.function.All + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.TypeNumber, + type: 'transform', + category: 'attribute', + label: process.string.Add, + presenter: process.transformPresenter.NumberPresenter, + allowMany: true, + editor: process.transformEditor.NumberEditor + }, + process.function.Add + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.TypeNumber, + category: 'attribute', + label: process.string.Subtract, + presenter: process.transformPresenter.NumberPresenter, + allowMany: true, + editor: process.transformEditor.NumberEditor, + type: 'transform' + }, + process.function.Subtract + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.TypeNumber, + category: 'attribute', + label: process.string.Multiply, + presenter: process.transformPresenter.NumberPresenter, + type: 'transform', + allowMany: true, + editor: process.transformEditor.NumberEditor + }, + process.function.Multiply + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.TypeNumber, + category: 'attribute', + label: process.string.Divide, + presenter: process.transformPresenter.NumberPresenter, + type: 'transform', + allowMany: true, + editor: process.transformEditor.NumberEditor + }, + process.function.Divide + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.TypeNumber, + category: 'attribute', + label: process.string.Modulo, + presenter: process.transformPresenter.NumberPresenter, + type: 'transform', + allowMany: true, + editor: process.transformEditor.NumberEditor + }, + process.function.Modulo + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.TypeNumber, + category: 'attribute', + label: process.string.Power, + presenter: process.transformPresenter.NumberPresenter, + type: 'transform', + allowMany: true, + editor: process.transformEditor.NumberEditor + }, + process.function.Power + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.TypeNumber, + category: 'attribute', + label: process.string.Sqrt, + presenter: process.transformPresenter.NumberPresenter, + type: 'transform', + allowMany: true + }, + process.function.Sqrt + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.TypeNumber, + category: 'attribute', + label: process.string.Round, + type: 'transform', + allowMany: true + }, + process.function.Round + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.TypeNumber, + category: 'attribute', + label: process.string.Absolute, + type: 'transform', + allowMany: true + }, + process.function.Absolute + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.TypeNumber, + category: 'attribute', + label: process.string.Ceil, + type: 'transform', + allowMany: true + }, + process.function.Ceil + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.TypeNumber, + category: 'attribute', + label: process.string.Floor, + type: 'transform', + allowMany: true + }, + process.function.Floor + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.TypeDate, + category: 'attribute', + label: process.string.Offset, + editor: process.transformEditor.DateOffsetEditor, + type: 'transform' + }, + process.function.Offset + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.TypeDate, + category: 'attribute', + label: process.string.FirstWorkingDayAfter, + type: 'transform' + }, + process.function.FirstWorkingDayAfter + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.ArrOf, + category: 'array', + label: process.string.Insert, + allowMany: true, + editor: process.transformEditor.MultiArrayElementEditor, + type: 'transform' + }, + process.function.Insert + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.ArrOf, + category: 'array', + label: process.string.Remove, + allowMany: true, + editor: process.transformEditor.ArrayElementEditor, + type: 'transform' + }, + process.function.Remove + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.ArrOf, + category: 'array', + label: process.string.RemoveFirst, + allowMany: true, + type: 'transform' + }, + process.function.RemoveFirst + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.ArrOf, + category: 'array', + label: process.string.RemoveLast, + allowMany: true, + type: 'transform' + }, + process.function.RemoveLast + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: contact.mixin.Employee, + editor: process.component.RoleEditor, + presenter: process.transformPresenter.RolePresenter, + category: 'array', + label: core.string.Role, + type: 'context' + }, + process.function.RoleContext + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.ArrOf, + category: 'array', + label: process.string.EmptyArray, + type: 'context' + }, + process.function.EmptyArray + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: contact.mixin.Employee, + category: 'attribute', + label: process.string.ExecutionInitiator, + type: 'context' + }, + process.function.ExecutionEmployeeInitiator + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: contact.class.Person, + category: 'attribute', + label: process.string.ExecutionInitiator, + type: 'context' + }, + process.function.ExecutionInitiator + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.TypeDate, + category: 'attribute', + label: process.string.ExecutionStarted, + type: 'context' + }, + process.function.ExecutionStarted + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: contact.class.Person, + category: 'attribute', + label: process.string.CurrentUser, + type: 'context' + }, + process.function.CurrentUser + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: contact.mixin.Employee, + category: 'attribute', + label: process.string.CurrentUser, + type: 'context' + }, + process.function.CurrentEmployee + ) + + builder.createDoc( + process.class.ProcessFunction, + core.space.Model, + { + of: core.class.TypeDate, + category: 'attribute', + label: process.string.CurrentDate, + type: 'context' + }, + process.function.CurrentDate + ) +} diff --git a/models/process/src/index.ts b/models/process/src/index.ts index 99f34839eb..88c67600ae 100644 --- a/models/process/src/index.ts +++ b/models/process/src/index.ts @@ -12,7 +12,6 @@ // limitations under the License. import card, { type Card, type MasterTag, type Tag } from '@hcengineering/card' -import contact from '@hcengineering/contact' import core, { AccountRole, type Class, @@ -43,10 +42,12 @@ import presentation from '@hcengineering/model-presentation' import { TToDo } from '@hcengineering/model-time' import view, { createAction } from '@hcengineering/model-view' import workbench from '@hcengineering/model-workbench' +import notification from '@hcengineering/notification' import { type Asset, type IntlString, type Resource } from '@hcengineering/platform' import { type CheckFunc, type ContextId, + type CreatedContext, type Execution, type ExecutionContext, type ExecutionError, @@ -69,6 +70,9 @@ import { import time from '@hcengineering/time' import { type AnyComponent } from '@hcengineering/ui' import { type AttributeCategory } from '@hcengineering/view' +import { defineMethods } from './actions' +import { defineTriggers } from './triggers' +import { defineFunctions } from './functions' import process from './plugin' const DOMAIN_PROCESS = 'process' as Domain @@ -206,7 +210,7 @@ export class TMethod extends TDoc implements Method { editor!: AnyComponent - contextClass!: Ref> | null + createdContext!: CreatedContext presenter?: AnyComponent @@ -247,6 +251,8 @@ export class TUpdateCriteriaComponent extends TDoc implements UpdateCriteriaComp editor!: AnyComponent of!: Ref>> + + props!: Record } export * from './migration' @@ -265,6 +271,28 @@ export function createModel (builder: Builder): void { TUpdateCriteriaComponent ) + builder.createDoc( + notification.class.NotificationType, + core.space.Model, + { + hidden: false, + generated: false, + allowedForAuthor: true, + label: process.string.NewProcessToDo, + group: time.ids.TimeNotificationGroup, + txClasses: [core.class.TxCreateDoc], + objectClass: process.class.ProcessToDo, + onlyOwn: true, + defaultEnabled: true, + templates: { + textTemplate: '{body}', + htmlTemplate: '

{body}

', + subjectTemplate: '{title}' + } + }, + process.ids.ProcessToDoCreated + ) + createAction(builder, { action: view.actionImpl.Delete, label: view.string.Delete, @@ -339,444 +367,9 @@ export function createModel (builder: Builder): void { process.pipeline.ProcessMiddleware ) - builder.createDoc( - process.class.ProcessFunction, - core.space.Model, - { - of: core.class.TypeString, - category: 'attribute', - label: process.string.UpperCase, - type: 'transform' - }, - process.function.UpperCase - ) - - builder.createDoc( - process.class.ProcessFunction, - core.space.Model, - { - of: core.class.TypeString, - category: 'attribute', - label: process.string.LowerCase, - type: 'transform' - }, - process.function.LowerCase - ) - - builder.createDoc( - process.class.ProcessFunction, - core.space.Model, - { - of: core.class.TypeString, - category: 'attribute', - label: process.string.Trim, - type: 'transform' - }, - process.function.Trim - ) - - builder.createDoc( - process.class.ProcessFunction, - core.space.Model, - { - of: core.class.TypeString, - category: 'attribute', - label: process.string.Prepend, - allowMany: true, - type: 'transform', - editor: process.transformEditor.AppendEditor - }, - process.function.Prepend - ) - - builder.createDoc( - process.class.ProcessFunction, - core.space.Model, - { - of: core.class.TypeString, - category: 'attribute', - label: process.string.Append, - allowMany: true, - type: 'transform', - editor: process.transformEditor.AppendEditor - }, - process.function.Append - ) - - builder.createDoc( - process.class.ProcessFunction, - core.space.Model, - { - of: core.class.TypeString, - category: 'attribute', - label: process.string.Replace, - allowMany: true, - type: 'transform', - editor: process.transformEditor.ReplaceEditor - }, - process.function.Replace - ) - - builder.createDoc( - process.class.ProcessFunction, - core.space.Model, - { - of: core.class.TypeString, - category: 'attribute', - label: process.string.ReplaceAll, - allowMany: true, - type: 'transform', - editor: process.transformEditor.ReplaceEditor - }, - process.function.ReplaceAll - ) - - builder.createDoc( - process.class.ProcessFunction, - core.space.Model, - { - of: core.class.TypeString, - category: 'attribute', - label: process.string.Split, - allowMany: true, - type: 'transform', - editor: process.transformEditor.SplitEditor - }, - process.function.Split - ) - - builder.createDoc( - process.class.ProcessFunction, - core.space.Model, - { - of: core.class.TypeString, - category: 'attribute', - label: process.string.Cut, - allowMany: true, - editor: process.transformEditor.CutEditor, - type: 'transform' - }, - process.function.Cut - ) - - builder.createDoc( - process.class.ProcessFunction, - core.space.Model, - { - of: core.class.ArrOf, - category: undefined, - label: process.string.FirstValue, - type: 'reduce' - }, - process.function.FirstValue - ) - - builder.createDoc( - process.class.ProcessFunction, - core.space.Model, - { - of: core.class.ArrOf, - type: 'reduce', - category: undefined, - label: process.string.LastValue - }, - process.function.LastValue - ) - - builder.createDoc( - process.class.ProcessFunction, - core.space.Model, - { - of: core.class.ArrOf, - type: 'reduce', - category: undefined, - label: process.string.Random - }, - process.function.Random - ) - - builder.createDoc( - process.class.ProcessFunction, - core.space.Model, - { - of: core.class.ArrOf, - category: 'array', - label: process.string.Each, - type: 'reduce' - }, - process.function.All - ) - - builder.createDoc( - process.class.ProcessFunction, - core.space.Model, - { - of: core.class.TypeNumber, - type: 'transform', - category: 'attribute', - label: process.string.Add, - presenter: process.transformPresenter.NumberPresenter, - allowMany: true, - editor: process.transformEditor.NumberEditor - }, - process.function.Add - ) - - builder.createDoc( - process.class.ProcessFunction, - core.space.Model, - { - of: core.class.TypeNumber, - category: 'attribute', - label: process.string.Subtract, - presenter: process.transformPresenter.NumberPresenter, - allowMany: true, - editor: process.transformEditor.NumberEditor, - type: 'transform' - }, - process.function.Subtract - ) - - builder.createDoc( - process.class.ProcessFunction, - core.space.Model, - { - of: core.class.TypeNumber, - category: 'attribute', - label: process.string.Multiply, - presenter: process.transformPresenter.NumberPresenter, - type: 'transform', - allowMany: true, - editor: process.transformEditor.NumberEditor - }, - process.function.Multiply - ) - - builder.createDoc( - process.class.ProcessFunction, - core.space.Model, - { - of: core.class.TypeNumber, - category: 'attribute', - label: process.string.Divide, - presenter: process.transformPresenter.NumberPresenter, - type: 'transform', - allowMany: true, - editor: process.transformEditor.NumberEditor - }, - process.function.Divide - ) - - builder.createDoc( - process.class.ProcessFunction, - core.space.Model, - { - of: core.class.TypeNumber, - category: 'attribute', - label: process.string.Modulo, - presenter: process.transformPresenter.NumberPresenter, - type: 'transform', - allowMany: true, - editor: process.transformEditor.NumberEditor - }, - process.function.Modulo - ) - - builder.createDoc( - process.class.ProcessFunction, - core.space.Model, - { - of: core.class.TypeNumber, - category: 'attribute', - label: process.string.Power, - presenter: process.transformPresenter.NumberPresenter, - type: 'transform', - allowMany: true, - editor: process.transformEditor.NumberEditor - }, - process.function.Power - ) - - builder.createDoc( - process.class.ProcessFunction, - core.space.Model, - { - of: core.class.TypeNumber, - category: 'attribute', - label: process.string.Round, - type: 'transform', - allowMany: true - }, - process.function.Round - ) - - builder.createDoc( - process.class.ProcessFunction, - core.space.Model, - { - of: core.class.TypeNumber, - category: 'attribute', - label: process.string.Absolute, - type: 'transform', - allowMany: true - }, - process.function.Absolute - ) - - builder.createDoc( - process.class.ProcessFunction, - core.space.Model, - { - of: core.class.TypeNumber, - category: 'attribute', - label: process.string.Ceil, - type: 'transform', - allowMany: true - }, - process.function.Ceil - ) - - builder.createDoc( - process.class.ProcessFunction, - core.space.Model, - { - of: core.class.TypeNumber, - category: 'attribute', - label: process.string.Floor, - type: 'transform', - allowMany: true - }, - process.function.Floor - ) - - builder.createDoc( - process.class.ProcessFunction, - core.space.Model, - { - of: core.class.TypeDate, - category: 'attribute', - label: process.string.Offset, - editor: process.transformEditor.DateOffsetEditor, - type: 'transform' - }, - process.function.Offset - ) - - builder.createDoc( - process.class.ProcessFunction, - core.space.Model, - { - of: core.class.TypeDate, - category: 'attribute', - label: process.string.FirstWorkingDayAfter, - type: 'transform' - }, - process.function.FirstWorkingDayAfter - ) - - builder.createDoc( - process.class.ProcessFunction, - core.space.Model, - { - of: core.class.ArrOf, - category: 'array', - label: process.string.Insert, - allowMany: true, - editor: process.transformEditor.MultiArrayElementEditor, - type: 'transform' - }, - process.function.Insert - ) - - builder.createDoc( - process.class.ProcessFunction, - core.space.Model, - { - of: core.class.ArrOf, - category: 'array', - label: process.string.Remove, - allowMany: true, - editor: process.transformEditor.ArrayElementEditor, - type: 'transform' - }, - process.function.Remove - ) - - builder.createDoc( - process.class.ProcessFunction, - core.space.Model, - { - of: core.class.ArrOf, - category: 'array', - label: process.string.RemoveFirst, - allowMany: true, - type: 'transform' - }, - process.function.RemoveFirst - ) - - builder.createDoc( - process.class.ProcessFunction, - core.space.Model, - { - of: core.class.ArrOf, - category: 'array', - label: process.string.RemoveLast, - allowMany: true, - type: 'transform' - }, - process.function.RemoveLast - ) - - builder.createDoc( - process.class.ProcessFunction, - core.space.Model, - { - of: contact.mixin.Employee, - editor: process.component.RoleEditor, - presenter: process.transformPresenter.RolePresenter, - category: 'array', - label: core.string.Role, - type: 'context' - }, - process.function.RoleContext - ) - - builder.createDoc( - process.class.ProcessFunction, - core.space.Model, - { - of: contact.class.Person, - category: 'attribute', - label: process.string.CurrentUser, - type: 'context' - }, - process.function.CurrentUser - ) - - builder.createDoc( - process.class.ProcessFunction, - core.space.Model, - { - of: contact.mixin.Employee, - category: 'attribute', - label: process.string.CurrentUser, - type: 'context' - }, - process.function.CurrentEmployee - ) - - builder.createDoc( - process.class.ProcessFunction, - core.space.Model, - { - of: core.class.TypeDate, - category: 'attribute', - label: process.string.CurrentDate, - type: 'context' - }, - process.function.CurrentDate - ) + defineFunctions(builder) + defineMethods(builder) + defineTriggers(builder) builder.mixin(process.class.Process, core.class.Class, view.mixin.AttributePresenter, { presenter: process.component.ProcessPresenter @@ -989,183 +582,6 @@ export function createModel (builder: Builder): void { process.section.CardProcesses ) - builder.createDoc( - process.class.Method, - core.space.Model, - { - label: process.string.RunProcess, - objectClass: process.class.Process, - editor: process.component.SubProcessEditor, - presenter: process.component.SubProcessPresenter, - contextClass: process.class.Execution, - requiredParams: ['_id'] - }, - process.method.RunSubProcess - ) - - builder.createDoc( - process.class.Method, - core.space.Model, - { - label: process.string.CreateToDo, - editor: process.component.ToDoEditor, - objectClass: process.class.ProcessToDo, - presenter: process.component.ToDoPresenter, - contextClass: process.class.ProcessToDo, - requiredParams: ['state', 'title', 'user'], - defaultParams: { - withRollback: true - } - }, - process.method.CreateToDo - ) - - builder.createDoc( - process.class.Trigger, - core.space.Model, - { - label: process.string.OnExecutionStart, - icon: process.icon.Process, - init: true, - requiredParams: [] - }, - process.trigger.OnExecutionStart - ) - - builder.createDoc( - process.class.Method, - core.space.Model, - { - label: process.string.UpdateCard, - editor: process.component.UpdateCardEditor, - objectClass: card.class.Card, - contextClass: null, - presenter: process.component.UpdateCardPresenter, - requiredParams: [] - }, - process.method.UpdateCard - ) - - builder.createDoc( - process.class.Method, - core.space.Model, - { - label: process.string.CreateCard, - objectClass: card.class.Card, - editor: process.component.CreateCardEditor, - presenter: process.component.CreateCardPresenter, - contextClass: card.class.Card, - requiredParams: ['title', '_class'] - }, - process.method.CreateCard - ) - - builder.createDoc( - process.class.Method, - core.space.Model, - { - label: core.string.AddRelation, - objectClass: core.class.Relation, - editor: process.component.AddRelationEditor, - presenter: process.component.AddRelationPresenter, - contextClass: core.class.Relation, - requiredParams: ['association', 'direction', '_id'] - }, - process.method.AddRelation - ) - - builder.createDoc( - process.class.Method, - core.space.Model, - { - label: card.string.AddTag, - objectClass: card.class.Tag, - editor: process.component.AddTagEditor, - presenter: process.component.AddTagPresenter, - contextClass: card.class.Card, - requiredParams: ['_id'] - }, - process.method.AddTag - ) - - builder.createDoc( - process.class.Trigger, - core.space.Model, - { - label: process.string.OnToDoDone, - icon: process.icon.ToDo, - editor: process.component.ToDoCloseEditor, - presenter: process.component.ToDoSettingPresenter, - requiredParams: ['_id'], - checkFunction: process.triggerCheck.ToDo, - init: false, - auto: true - }, - process.trigger.OnToDoClose - ) - - builder.createDoc( - process.class.Trigger, - core.space.Model, - { - label: process.string.OnToDoCancelled, - icon: process.icon.ToDoRemove, - editor: process.component.ToDoRemoveEditor, - presenter: process.component.ToDoSettingPresenter, - requiredParams: ['_id'], - checkFunction: process.triggerCheck.ToDo, - init: false, - auto: true - }, - process.trigger.OnToDoRemove - ) - - builder.createDoc( - process.class.Trigger, - core.space.Model, - { - label: process.string.OnCardUpdate, - icon: process.icon.OnCardUpdate, - editor: process.component.CardUpdateEditor, - presenter: process.component.CardUpdatePresenter, - requiredParams: [], - checkFunction: process.triggerCheck.UpdateCheck, - init: false, - auto: true - }, - process.trigger.OnCardUpdate - ) - - builder.createDoc( - process.class.Trigger, - core.space.Model, - { - label: process.string.OnSubProcessesDone, - icon: process.icon.WaitSubprocesses, - checkFunction: process.triggerCheck.SubProcessesDoneCheck, - requiredParams: [], - init: false, - auto: true - }, - process.trigger.OnSubProcessesDone - ) - - builder.createDoc( - process.class.Trigger, - core.space.Model, - { - label: process.string.WaitUntil, - icon: process.icon.Time, - requiredParams: [], - init: false, - auto: true, - editor: process.component.TimeEditor, - presenter: process.component.TimePresenter, - checkFunction: process.triggerCheck.Time - }, - process.trigger.OnTime - ) - builder.createDoc(card.class.MasterTagEditorSection, core.space.Model, { id: 'processes', label: process.string.Processes, @@ -1179,50 +595,83 @@ export function createModel (builder: Builder): void { builder.createDoc(process.class.UpdateCriteriaComponent, core.space.Model, { category: 'attribute', - editor: process.criteriaEditor.StringCriteria, - of: core.class.TypeString + editor: process.criteriaEditor.BaseCriteria, + of: core.class.TypeString, + props: { + modes: ['Equal', 'StringContains', 'Exists'] + } }) builder.createDoc(process.class.UpdateCriteriaComponent, core.space.Model, { category: 'attribute', - editor: process.criteriaEditor.StringCriteria, - of: core.class.TypeHyperlink + editor: process.criteriaEditor.BaseCriteria, + of: core.class.TypeHyperlink, + props: { + modes: ['Equal', 'StringContains', 'Exists'] + } }) builder.createDoc(process.class.UpdateCriteriaComponent, core.space.Model, { category: 'attribute', - editor: process.criteriaEditor.NumberCriteria, - of: core.class.TypeNumber + editor: process.criteriaEditor.BaseCriteria, + of: core.class.TypeNumber, + props: { + modes: ['Equal', 'GT', 'LT', 'Between', 'Exists'] + } }) builder.createDoc(process.class.UpdateCriteriaComponent, core.space.Model, { category: 'attribute', - editor: process.criteriaEditor.DateCriteria, - of: core.class.TypeDate + editor: process.criteriaEditor.BaseCriteria, + of: core.class.TypeDate, + props: { + modes: ['Equal', 'GT', 'LT', 'Between', 'Exists'] + } }) builder.createDoc(process.class.UpdateCriteriaComponent, core.space.Model, { category: 'attribute', - editor: process.criteriaEditor.BooleanCriteria, - of: core.class.TypeBoolean + editor: process.criteriaEditor.BaseCriteria, + of: core.class.TypeBoolean, + props: { + modes: ['Equal', 'NotEqual', 'Exists'] + } }) builder.createDoc(process.class.UpdateCriteriaComponent, core.space.Model, { category: 'array', - editor: process.criteriaEditor.ArrayCriteria, - of: core.class.ArrOf + editor: process.criteriaEditor.BaseCriteria, + of: core.class.ArrOf, + props: { + modes: [ + 'ArrayAll', + 'ArrayAny', + 'ArrayNotIncludes', + 'ArraySizeEquals', + 'ArraySizeGt', + 'ArraySizeGte', + 'ArraySizeLt', + 'ArraySizeLte' + ] + } }) builder.createDoc(process.class.UpdateCriteriaComponent, core.space.Model, { category: 'attribute', - editor: process.criteriaEditor.EnumCriteria, - of: core.class.EnumOf + editor: process.criteriaEditor.BaseCriteria, + of: core.class.EnumOf, + props: { + modes: ['Equal', 'NotEqual', 'Exists'] + } }) builder.createDoc(process.class.UpdateCriteriaComponent, core.space.Model, { category: 'object', - editor: process.criteriaEditor.RefCriteria, - of: core.class.RefTo + editor: process.criteriaEditor.BaseCriteria, + of: core.class.RefTo, + props: { + modes: ['Equal', 'NotEqual', 'Exists'] + } }) } diff --git a/models/process/src/plugin.ts b/models/process/src/plugin.ts index 7dec168837..7188dc8da6 100644 --- a/models/process/src/plugin.ts +++ b/models/process/src/plugin.ts @@ -30,7 +30,8 @@ export default mergeIds(processId, process, { ProcessMiddleware: '' as Ref }, ids: { - ProcessSettings: '' as Ref + ProcessSettings: '' as Ref, + ProcessToDoCreated: '' as Ref }, actionImpl: { ContinueExecution: '' as ViewAction @@ -40,6 +41,7 @@ export default mergeIds(processId, process, { ContinueExecution: '' as Ref> }, string: { + NewProcessToDo: '' as IntlString, ConfigLabel: '' as IntlString, ConfigDescription: '' as IntlString, LogAction: '' as IntlString diff --git a/models/process/src/triggers.ts b/models/process/src/triggers.ts new file mode 100644 index 0000000000..c454858bd6 --- /dev/null +++ b/models/process/src/triggers.ts @@ -0,0 +1,112 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import core from '@hcengineering/core' +import { type Builder } from '@hcengineering/model' +import process from './plugin' + +export function defineTriggers (builder: Builder): void { + builder.createDoc( + process.class.Trigger, + core.space.Model, + { + label: process.string.OnToDoDone, + icon: process.icon.ToDo, + editor: process.component.ToDoCloseEditor, + presenter: process.component.ToDoSettingPresenter, + requiredParams: ['_id'], + checkFunction: process.triggerCheck.ToDo, + init: false, + auto: true + }, + process.trigger.OnToDoClose + ) + + builder.createDoc( + process.class.Trigger, + core.space.Model, + { + label: process.string.OnToDoCancelled, + icon: process.icon.ToDoRemove, + editor: process.component.ToDoRemoveEditor, + presenter: process.component.ToDoSettingPresenter, + requiredParams: ['_id'], + checkFunction: process.triggerCheck.ToDo, + init: false, + auto: true + }, + process.trigger.OnToDoRemove + ) + + builder.createDoc( + process.class.Trigger, + core.space.Model, + { + label: process.string.WhenCardMatches, + icon: process.icon.WhenCardMatches, + editor: process.component.CardUpdateEditor, + presenter: process.component.CardUpdatePresenter, + requiredParams: [], + checkFunction: process.triggerCheck.MatchCheck, + init: false, + auto: true + }, + process.trigger.OnCardUpdate + ) + + builder.createDoc( + process.class.Trigger, + core.space.Model, + { + label: process.string.WhenFieldChanges, + icon: process.icon.WhenFieldChanges, + editor: process.component.FieldChangesEditor, + presenter: process.component.CardUpdatePresenter, + requiredParams: [], + checkFunction: process.triggerCheck.FieldChangedCheck, + init: false, + auto: true + }, + process.trigger.WhenFieldChanges + ) + + builder.createDoc( + process.class.Trigger, + core.space.Model, + { + label: process.string.OnSubProcessesDone, + icon: process.icon.WaitSubprocesses, + checkFunction: process.triggerCheck.SubProcessesDoneCheck, + requiredParams: [], + init: false, + auto: true + }, + process.trigger.OnSubProcessesDone + ) + + builder.createDoc( + process.class.Trigger, + core.space.Model, + { + label: process.string.WaitUntil, + icon: process.icon.Time, + requiredParams: [], + init: false, + auto: true, + editor: process.component.TimeEditor, + presenter: process.component.TimePresenter, + checkFunction: process.triggerCheck.Time + }, + process.trigger.OnTime + ) +} diff --git a/models/recruit/src/index.ts b/models/recruit/src/index.ts index 1db7678b7c..9da4ca3b8e 100644 --- a/models/recruit/src/index.ts +++ b/models/recruit/src/index.ts @@ -37,6 +37,7 @@ import { type KeyBinding, type ViewOptionModel, type ViewOptionsModel } from '@h import recruit from './plugin' import { createReviewModel, reviewTableConfig, reviewTableOptions } from './review' import { defineSpaceType } from './spaceType' +import { definePermissions } from './permissions' import { TApplicant, TApplicantMatch, TCandidate, TOpinion, TReview, TVacancy, TVacancyList } from './types' export { recruitId } from '@hcengineering/recruit' @@ -1019,24 +1020,28 @@ export function createModel (builder: Builder): void { }, override: [recruit.action.CreateGlobalApplication] }) - createAction(builder, { - action: view.actionImpl.ShowPopup, - actionProps: { - component: recruit.component.CreateCandidate, - element: 'top' + createAction( + builder, + { + action: view.actionImpl.ShowPopup, + actionProps: { + component: recruit.component.CreateCandidate, + element: 'top' + }, + label: recruit.string.CreateTalent, + icon: recruit.icon.Create, + keyBinding: ['keyC'], + input: 'none', + category: recruit.category.Recruit, + target: core.class.Doc, + context: { + mode: ['workbench', 'browser'], + application: recruit.app.Recruit, + group: 'create' + } }, - label: recruit.string.CreateTalent, - icon: recruit.icon.Create, - keyBinding: ['keyC'], - input: 'none', - category: recruit.category.Recruit, - target: core.class.Doc, - context: { - mode: ['workbench', 'browser'], - application: recruit.app.Recruit, - group: 'create' - } - }) + recruit.action.CreateTalent + ) createAction(builder, { action: view.actionImpl.ShowPopup, @@ -1596,4 +1601,5 @@ export function createModel (builder: Builder): void { ) defineSpaceType(builder) + definePermissions(builder) } diff --git a/models/recruit/src/permissions.ts b/models/recruit/src/permissions.ts new file mode 100644 index 0000000000..beeb9b11cb --- /dev/null +++ b/models/recruit/src/permissions.ts @@ -0,0 +1,19 @@ +import type { Builder } from '@hcengineering/model' +import core from '@hcengineering/core' +import recruit from '@hcengineering/recruit' + +export function definePermissions (builder: Builder): void { + builder.createDoc( + core.class.Permission, + core.space.Model, + { + label: recruit.string.ForbidCreateVacancyPermission, + scope: 'workspace', + txClass: core.class.TxCreateDoc, + objectClass: recruit.class.Vacancy, + forbid: true, + description: recruit.string.ForbidCreateVacancyPermissionDescription + }, + recruit.permission.ForbidCreateVacancy + ) +} diff --git a/models/server-process/src/index.ts b/models/server-process/src/index.ts index 9cc3d90c7b..0d9c6310fb 100644 --- a/models/server-process/src/index.ts +++ b/models/server-process/src/index.ts @@ -61,7 +61,11 @@ export function createModel (builder: Builder): void { }) builder.mixin(process.trigger.OnCardUpdate, process.class.Trigger, serverProcess.mixin.TriggerImpl, { - serverCheckFunc: serverProcess.func.OnCardUpdateCheck + serverCheckFunc: serverProcess.func.MatchCardCheck + }) + + builder.mixin(process.trigger.WhenFieldChanges, process.class.Trigger, serverProcess.mixin.TriggerImpl, { + serverCheckFunc: serverProcess.func.FieldChangedCheck }) builder.mixin(process.trigger.OnTime, process.class.Trigger, serverProcess.mixin.TriggerImpl, { @@ -173,6 +177,10 @@ export function createModel (builder: Builder): void { func: serverProcess.transform.Power }) + builder.mixin(process.function.Sqrt, process.class.ProcessFunction, serverProcess.mixin.FuncImpl, { + func: serverProcess.transform.Sqrt + }) + builder.mixin(process.function.Round, process.class.ProcessFunction, serverProcess.mixin.FuncImpl, { func: serverProcess.transform.Round }) @@ -229,6 +237,27 @@ export function createModel (builder: Builder): void { func: serverProcess.transform.RemoveLast }) + builder.mixin(process.function.EmptyArray, process.class.ProcessFunction, serverProcess.mixin.FuncImpl, { + func: serverProcess.transform.EmptyArray + }) + + builder.mixin( + process.function.ExecutionEmployeeInitiator, + process.class.ProcessFunction, + serverProcess.mixin.FuncImpl, + { + func: serverProcess.transform.ExecutionInitiator + } + ) + + builder.mixin(process.function.ExecutionInitiator, process.class.ProcessFunction, serverProcess.mixin.FuncImpl, { + func: serverProcess.transform.ExecutionInitiator + }) + + builder.mixin(process.function.ExecutionStarted, process.class.ProcessFunction, serverProcess.mixin.FuncImpl, { + func: serverProcess.transform.ExecutionStarted + }) + builder.createDoc(serverCore.class.Trigger, core.space.Model, { trigger: serverProcess.trigger.OnExecutionContinue, txMatch: { diff --git a/models/time/src/index.ts b/models/time/src/index.ts index bf5717c712..d9e5f60c49 100644 --- a/models/time/src/index.ts +++ b/models/time/src/index.ts @@ -52,7 +52,7 @@ import document from '@hcengineering/model-document' import tracker from '@hcengineering/model-tracker' import view, { createAction } from '@hcengineering/model-view' import workbench from '@hcengineering/model-workbench' -import notification from '@hcengineering/notification' +import notification, { type NotificationGroup } from '@hcengineering/notification' import recruit from '@hcengineering/recruit' import tags from '@hcengineering/tags' import { @@ -372,7 +372,7 @@ export function createModel (builder: Builder): void { generated: false, allowedForAuthor: true, label: time.string.NewToDo, - group: time.ids.TimeNotificationGroup, + group: time.ids.TimeNotificationGroup as Ref, txClasses: [core.class.TxCreateDoc], objectClass: time.class.ProjectToDo, onlyOwn: true, diff --git a/models/time/src/plugin.ts b/models/time/src/plugin.ts index 5ef69c4a54..8c2d0db0ab 100644 --- a/models/time/src/plugin.ts +++ b/models/time/src/plugin.ts @@ -15,7 +15,7 @@ import { type Client, type Doc, type Ref } from '@hcengineering/core' import { type Application } from '@hcengineering/model-workbench' -import { type NotificationGroup, type NotificationType } from '@hcengineering/notification' +import { type NotificationType } from '@hcengineering/notification' import { type IntlString, mergeIds, type Resource } from '@hcengineering/platform' import { timeId } from '@hcengineering/time' import time from '@hcengineering/time-resources/src/plugin' @@ -56,7 +56,6 @@ export default mergeIds(timeId, time, { Team: '' as Ref }, ids: { - TimeNotificationGroup: '' as Ref, ToDoCreated: '' as Ref }, function: { diff --git a/models/tracker/src/index.ts b/models/tracker/src/index.ts index 0563ea0a1f..04395afc0e 100644 --- a/models/tracker/src/index.ts +++ b/models/tracker/src/index.ts @@ -32,6 +32,7 @@ import { PaletteColorIndexes } from '@hcengineering/ui/src/colors' import { createActions as defineActions } from './actions' import tracker from './plugin' import { definePresenters } from './presenters' +import { definePermissions } from './permissions' import { DOMAIN_TRACKER, TClassicProjectTypeData, @@ -709,6 +710,7 @@ export function createModel (builder: Builder): void { ] }) + definePermissions(builder) defineSpaceType(builder) } diff --git a/models/tracker/src/permissions.ts b/models/tracker/src/permissions.ts new file mode 100644 index 0000000000..6ebddfedeb --- /dev/null +++ b/models/tracker/src/permissions.ts @@ -0,0 +1,19 @@ +import type { Builder } from '@hcengineering/model' +import core from '@hcengineering/core' +import tracker from '@hcengineering/tracker' + +export function definePermissions (builder: Builder): void { + builder.createDoc( + core.class.Permission, + core.space.Model, + { + label: tracker.string.ForbidCreateProjectPermission, + txClass: core.class.TxCreateDoc, + objectClass: tracker.class.Project, + forbid: true, + scope: 'workspace', + description: tracker.string.ForbidCreateProjectPermissionDescription + }, + tracker.permission.ForbidCreateProject + ) +} diff --git a/models/training/src/index.ts b/models/training/src/index.ts index 82980172d8..edaf0c0cbd 100644 --- a/models/training/src/index.ts +++ b/models/training/src/index.ts @@ -354,18 +354,22 @@ function defineTraining (builder: Builder): void { definePermission(builder, training.permission.ChangeSomeoneElsesTrainingOwner, { label: training.string.Permission_ChangeSomeoneElsesTrainingOwner, + scope: 'space', description: training.string.Permission_ChangeSomeoneElsesTrainingOwner_Description }) definePermission(builder, training.permission.CreateTraining, { label: training.string.Permission_CreateTraining, + scope: 'space', description: training.string.Permission_CreateTraining_Description }) definePermission(builder, training.permission.ViewSomeoneElsesTrainingOverview, { label: training.string.Permission_ViewSomeoneElsesTrainingOverview, + scope: 'space', description: training.string.Permission_ViewSomeoneElsesTrainingOverview_Description }) definePermission(builder, training.permission.ViewSomeoneElsesTrainingQuestions, { label: training.string.Permission_ViewSomeoneElsesTrainingQuestions, + scope: 'space', description: training.string.Permission_ViewSomeoneElsesTrainingQuestions_Description }) } @@ -491,14 +495,17 @@ function defineTrainingRequest (builder: Builder): void { definePermission(builder, training.permission.ChangeSomeoneElsesSentRequestOwner, { label: training.string.Permission_ChangeSomeoneElsesSentRequestOwner, + scope: 'space', description: training.string.Permission_ChangeSomeoneElsesSentRequestOwner_Description }) definePermission(builder, training.permission.CreateRequestOnSomeoneElsesTraining, { label: training.string.Permission_CreateRequestOnSomeoneElsesTraining, + scope: 'space', description: training.string.Permission_CreateRequestOnSomeoneElsesTraining_Description }) definePermission(builder, training.permission.ViewSomeoneElsesSentRequest, { label: training.string.Permission_ViewSomeoneElsesSentRequest, + scope: 'space', description: training.string.Permission_ViewSomeoneElsesSentRequest_Description }) })() @@ -753,6 +760,7 @@ function defineTrainingAttempt (builder: Builder): void { definePermission(builder, training.permission.ViewSomeoneElsesTraineesResults, { label: training.string.Permission_ViewSomeoneElsesTraineesResults, + scope: 'space', description: training.string.Permission_ViewSomeoneElsesTraineesResults_Description }) } diff --git a/models/view/src/index.ts b/models/view/src/index.ts index f9e012dd24..5ca2d0d41f 100644 --- a/models/view/src/index.ts +++ b/models/view/src/index.ts @@ -244,7 +244,7 @@ export class TObjectValidator extends TClass implements ObjectValidator { @Mixin(view.mixin.ObjectFactory, core.class.Class) export class TObjectFactory extends TClass implements ObjectFactory { component?: AnyComponent - create?: Resource<() => Promise> + create?: Resource<(props?: Record) => Promise | undefined>> } @Mixin(view.mixin.ObjectTitle, core.class.Class) @@ -835,6 +835,25 @@ export function createModel (builder: Builder): void { view.action.Join ) + createAction(builder, { + action: view.actionImpl.ShowPopup, + actionProps: { + component: view.component.AddRelationPopup, + fillProps: { + _objects: 'value' + } + }, + label: core.string.AddRelation, + input: 'any', + icon: view.icon.CopyLink, + category: view.category.Editor, + target: core.class.Doc, + context: { + mode: ['context', 'browser'], + group: 'associate' + } + }) + createAction( builder, { diff --git a/models/view/src/plugin.ts b/models/view/src/plugin.ts index d756b561fa..448c0c4bbe 100644 --- a/models/view/src/plugin.ts +++ b/models/view/src/plugin.ts @@ -96,7 +96,8 @@ export default mergeIds(viewId, view, { BaseDocPresenter: '' as AnyComponent, MasterDetailView: '' as AnyComponent, AssociationPresenter: '' as AnyComponent, - TreeView: '' as AnyComponent + TreeView: '' as AnyComponent, + AddRelationPopup: '' as AnyComponent }, string: { Table: '' as IntlString, diff --git a/models/workbench/src/index.ts b/models/workbench/src/index.ts index 60b57853aa..ec6061eab9 100644 --- a/models/workbench/src/index.ts +++ b/models/workbench/src/index.ts @@ -192,6 +192,22 @@ export function createModel (builder: Builder): void { group: 'edit' } }) + + createAction( + builder, + { + action: workbench.actionImpl.CloseCurrentTab, + label: presentation.string.Close, + icon: view.icon.Delete, + keyBinding: ['Meta + keyW', 'Ctrl + keyW'], + input: 'none', + category: workbench.category.Workbench, + allowedForEditableContent: 'always', + target: core.class.Doc, + context: { mode: ['workbench', 'browser', 'panel', 'editor', 'input'] } + }, + workbench.action.CloseCurrentTab + ) } export default workbench diff --git a/models/workbench/src/plugin.ts b/models/workbench/src/plugin.ts index 35a63ec27e..4eb7bc7c1b 100644 --- a/models/workbench/src/plugin.ts +++ b/models/workbench/src/plugin.ts @@ -40,6 +40,7 @@ export default mergeIds(workbenchId, workbench, { actionImpl: { PinTab: '' as Resource<(doc?: Doc | Doc[]) => Promise>, UnpinTab: '' as Resource<(doc?: Doc | Doc[]) => Promise>, - CloseTab: '' as Resource<(doc?: Doc | Doc[]) => Promise> + CloseTab: '' as Resource<(doc?: Doc | Doc[]) => Promise>, + CloseCurrentTab: '' as Resource<(doc?: Doc | Doc[]) => Promise> } }) diff --git a/packages/account-client/src/client.ts b/packages/account-client/src/client.ts index b6f8de7c7c..96baa83b22 100644 --- a/packages/account-client/src/client.ts +++ b/packages/account-client/src/client.ts @@ -220,6 +220,7 @@ export interface AccountClient { getIntegrationSecret: (integrationSecretKey: IntegrationSecretKey) => Promise listIntegrationsSecrets: (filter: Partial) => Promise getAccountInfo: (uuid: AccountUuid) => Promise + canMergeSpecifiedPersons: (primaryPerson: PersonUuid, secondaryPerson: PersonUuid) => Promise mergeSpecifiedPersons: (primaryPerson: PersonUuid, secondaryPerson: PersonUuid) => Promise mergeSpecifiedAccounts: (primaryAccount: AccountUuid, secondaryAccount: AccountUuid) => Promise addEmailSocialId: (email: string) => Promise @@ -1072,6 +1073,15 @@ class AccountClientImpl implements AccountClient { return await this.rpc(request) } + async canMergeSpecifiedPersons (primaryPerson: PersonUuid, secondaryPerson: PersonUuid): Promise { + const request = { + method: 'canMergeSpecifiedPersons' as const, + params: { primaryPerson, secondaryPerson } + } + + return await this.rpc(request) + } + async mergeSpecifiedPersons (primaryPerson: PersonUuid, secondaryPerson: PersonUuid): Promise { const request = { method: 'mergeSpecifiedPersons' as const, diff --git a/packages/account-client/src/types.ts b/packages/account-client/src/types.ts index 41ef820273..73a1584e45 100644 --- a/packages/account-client/src/types.ts +++ b/packages/account-client/src/types.ts @@ -40,6 +40,7 @@ export interface LoginInfoWorkspace { endpoint: EndpointInfo role: AccountRole | null progress?: number + branding?: string } export interface LoginInfoWithWorkspaces extends LoginInfo { diff --git a/packages/api-client/src/rest/tx.ts b/packages/api-client/src/rest/tx.ts index b090c50f2d..d13a68ecea 100644 --- a/packages/api-client/src/rest/tx.ts +++ b/packages/api-client/src/rest/tx.ts @@ -82,7 +82,7 @@ class RestTxClient implements Client { params: DomainParams, options?: DomainRequestOptions ): Promise> { - throw new Error('Domain request operation not supported') + return await this.client.domainRequest(domain, params, options) } async findOne( diff --git a/packages/core/lang/cs.json b/packages/core/lang/cs.json index 878cc9109d..8a312b656e 100644 --- a/packages/core/lang/cs.json +++ b/packages/core/lang/cs.json @@ -69,4 +69,4 @@ "PersonId": "Osoba", "AccountId": "Účet" } -} \ No newline at end of file +} diff --git a/packages/core/lang/fr.json b/packages/core/lang/fr.json index 7b248d769b..bb4b311b71 100644 --- a/packages/core/lang/fr.json +++ b/packages/core/lang/fr.json @@ -69,4 +69,4 @@ "PersonId": "Id de personne", "AccountId": "Compte" } -} \ No newline at end of file +} diff --git a/packages/core/lang/tr.json b/packages/core/lang/tr.json new file mode 100644 index 0000000000..1f862d2e08 --- /dev/null +++ b/packages/core/lang/tr.json @@ -0,0 +1,72 @@ +{ + "string": { + "Id": "Id", + "Space": "Alan", + "Spaces": "Alanlar", + "SpacesDescription": "Tüm alanların alan tipini yönet", + "TypedSpace": "Tipli alan", + "SpaceType": "Alan tipi", + "Modified": "Değiştirildi", + "ModifiedDate": "Değiştirilme tarihi", + "ModifiedBy": "Değiştiren", + "Class": "Sınıf", + "AttachedTo": "Eklenen", + "AttachedToClass": "Eklenen sınıf", + "Name": "İsim", + "Description": "Açıklama", + "ShortDescription": "Kısa açıklama", + "Descriptor": "Tanımlayıcı", + "TargetClass": "Hedef sınıf", + "Role": "Rol", + "Roles": "Roller", + "Private": "Özel", + "Archived": "Arşivlendi", + "ClassLabel": "Tip", + "ClassPropertyLabel": "Etiket", + "String": "Metin", + "Markup": "İşaretleme", + "Number": "Sayı", + "Boolean": "Onay kutusu", + "Timestamp": "Zaman damgası", + "Date": "Tarih", + "IntlString": "IntlString", + "Ref": "Referans", + "Collection": "Koleksiyon", + "Array": "Çoklu Seçim", + "Enum": "Seçim", + "Members": "Üyeler", + "Hyperlink": "URL", + "MarkupBlobRef": "İşbirlikçi", + "Object": "Nesne", + "System": "Sistem", + "CreatedBy": "Oluşturan", + "CreatedDate": "Oluşturulma tarihi", + "Status": "Durum", + "StatusCategory": "Durum kategorisi", + "Account": "Hesap", + "Rank": "Sıra", + "Owners": "Sahipler", + "Permission": "İzin", + "CreateObject": "Nesne oluştur", + "UpdateObject": "Nesneyi güncelle", + "DeleteObject": "Nesneyi sil", + "ForbidDeleteObject": "Nesne silmeyi yasakla", + "UpdateSpace": "Alanı güncelle", + "ArchiveSpace": "Alanı arşivle", + "CreateObjectDescription": "Kullanıcılara alanda nesne oluşturma yetkisi verir", + "UpdateObjectDescription": "Kullanıcılara alandaki nesneleri güncelleme yetkisi verir", + "DeleteObjectDescription": "Kullanıcılara alandaki nesneleri silme yetkisi verir", + "ForbidDeleteObjectDescription": "Kullanıcıların alandaki nesneleri silmesini yasaklar", + "UpdateSpaceDescription": "Kullanıcılara alanı güncelleme yetkisi verir", + "ArchiveSpaceDescription": "Kullanıcılara alanı arşivleme yetkisi verir", + "AutoJoin": "Otomatik katıl", + "AutoJoinDescr": "Yeni çalışanları bu alana otomatik olarak ekle", + "BlobSize": "Boyut", + "BlobContentType": "İçerik türü", + "Relation": "İlişki", + "Relations": "İlişkiler", + "AddRelation": "İlişki ekle", + "PersonId": "Kişi", + "AccountId": "Hesap" + } +} diff --git a/packages/core/src/classes.ts b/packages/core/src/classes.ts index 17673915a0..12aa10bacf 100644 --- a/packages/core/src/classes.ts +++ b/packages/core/src/classes.ts @@ -17,6 +17,7 @@ import type { Asset, IntlString, Plugin } from '@hcengineering/platform' import type { DocumentQuery } from './storage' import { type WorkspaceDataId, type WorkspaceUuid } from './utils' +import { Tx } from '.' /** * @public @@ -333,6 +334,15 @@ export interface TypeDate extends Type { withShift: boolean } +/** + * @public + */ +export interface TypeNumber extends Type { + min?: number + max?: number + digits?: number // Number of digits after comma +} + /** * @public */ @@ -520,6 +530,10 @@ export type RolesAssignment = Record, AccountUuid[] | undefined> */ export interface Permission extends Doc { label: IntlString + txClass?: Ref> + forbid?: boolean + objectClass?: Ref> + scope?: 'space' | 'workspace' description?: IntlString icon?: Asset } diff --git a/packages/hulylake-client/src/client.ts b/packages/hulylake-client/src/client.ts index aba1bf55ba..956a8f6d23 100644 --- a/packages/hulylake-client/src/client.ts +++ b/packages/hulylake-client/src/client.ts @@ -33,7 +33,7 @@ class Client implements HulylakeClient { } private objectUrl (key: string): string { - return `${this.baseUrl}/api/${this.workspace}/${key}` + return `${this.baseUrl}/api/${this.workspace}/${encodeURIComponent(key)}` } private authHeaders (init?: HeadersInit): Headers { diff --git a/packages/hulylake-client/src/error.ts b/packages/hulylake-client/src/error.ts index a5f9281201..a26cbd711a 100644 --- a/packages/hulylake-client/src/error.ts +++ b/packages/hulylake-client/src/error.ts @@ -26,10 +26,3 @@ export class HulylakeError extends Error { this.name = 'HulylakeError' } } - -export class NotFoundError extends Error { - constructor (message: string) { - super(message) - this.name = 'NotFoundError' - } -} diff --git a/packages/hulylake-client/src/types.ts b/packages/hulylake-client/src/types.ts index bc7c4a7443..bce6daf6f0 100644 --- a/packages/hulylake-client/src/types.ts +++ b/packages/hulylake-client/src/types.ts @@ -69,6 +69,7 @@ export type JsonPatch = | { op: 'test', path: string, value: any } | { hop: 'add', path: string, value: any, safe?: boolean } | { hop: 'inc', path: string, value: number, safe?: boolean } + | { hop: 'remove', path: string, safe?: boolean } export interface HulyResponse { ok: boolean diff --git a/packages/hulylake-client/src/utils.ts b/packages/hulylake-client/src/utils.ts index fadc0a6f1b..5202756d51 100644 --- a/packages/hulylake-client/src/utils.ts +++ b/packages/hulylake-client/src/utils.ts @@ -14,7 +14,7 @@ // import { RetryOptions, withRetry } from '@hcengineering/retry' -import { HulylakeError, NetworkError, NotFoundError } from './error' +import { HulylakeError, NetworkError } from './error' async function innerFetchSafe (url: string | URL, init?: RequestInit): Promise { let response @@ -31,7 +31,7 @@ async function innerFetchSafe (url: string | URL, init?: RequestInit): Promise { /** * @public */ -export function TypeNumber (): Type { - return { _class: core.class.TypeNumber, label: core.string.Number, icon: core.icon.TypeNumber } +export function TypeNumber (min?: number, max?: number, digits?: number): TypeNumberType { + return { _class: core.class.TypeNumber, label: core.string.Number, icon: core.icon.TypeNumber, min, max, digits } } /** diff --git a/packages/platform/lang/tr.json b/packages/platform/lang/tr.json new file mode 100644 index 0000000000..c813db201c --- /dev/null +++ b/packages/platform/lang/tr.json @@ -0,0 +1,29 @@ +{ + "status": { + "LoadingPlugin": "{plugin} eklentisi yükleniyor...", + "UnknownError": "Bilinmeyen hata: {message}", + "InvalidId": "Geçersiz Id: {id}", + "BadRequest": "Hatalı istek", + "Forbidden": "Yasak", + "Conflict": "Çakışma", + "ExpiredLink": "Bu davet bağlantısının süresi dolmuş", + "Unauthorized": "Yetkisiz", + "UnknownMethod": "Bilinmeyen metod: {method}", + "InternalServerError": "Dahili sunucu hatası", + "MaintenanceWarning": "Bakım Planlandı:", + "MaintenanceWarningTime": "{time, plural, =1 {bir dakikadan az} other {# dakika}}", + "AccountNotFound": "Hesap bulunamadı veya girilen kimlik bilgileri yanlış", + "AccountNotConfirmed": "Hesap doğrulanmadı", + "WorkspaceNotFound": "Çalışma alanı bulunamadı", + "AccountAlreadyExists": "Hesap zaten mevcut", + "WorkspaceRateLimit": "Sunucu meşgul, Lütfen biraz bekleyin ve tekrar deneyin", + "AccountAlreadyConfirmed": "Hesap zaten doğrulandı", + "WorkspaceAlreadyExists": "Çalışma alanı zaten mevcut", + "InvalidOtp": "Geçersiz kod", + "InviteNotFound": "E-posta ile davet bulunamadı: {email}", + "WorkspaceLimitReached": "Çalışma alanı limitine ulaştınız. Lütfen bizimle iletişime geçin...", + "ReadOnlyAccount": "Anonim Misafir Demo", + "SystemAccount": "Sistem hesabı", + "SocialIdAlreadyExists": "Sosyal ID zaten mevcut" + } +} diff --git a/packages/presentation/lang/tr.json b/packages/presentation/lang/tr.json new file mode 100644 index 0000000000..75b2c50701 --- /dev/null +++ b/packages/presentation/lang/tr.json @@ -0,0 +1,61 @@ +{ + "string": { + "Create": "Oluştur", + "Cancel": "İptal", + "Ok": "Tamam", + "Save": "Kaydet", + "Saved": "Kaydedildi...", + "Download": "İndir", + "DownloadOriginal": "Orijinali indir", + "Delete": "Sil", + "Close": "Kapat", + "NotSelected": "Seçilmedi", + "Deselect": "Seçimi kaldır", + "Archived": "(arşivlendi)", + "AddSocialLinks": "Sosyal bağlantılar ekle", + "EditSocialLinks": "Sosyal bağlantıları düzenle", + "Change": "Değiştir", + "Remove": "Kaldır", + "Search": "Ara...", + "Spaces": "Alanlar", + "NumberSpaces": "{count, plural, =0 {İçinde} =1 {1 yerde} other {# yerde}}", + "InThis": "Bu {space} içinde", + "NoMatchesInThis": "Bu {space} içinde eşleşme yok", + "NoMatchesFound": "Eşleşme bulunamadı", + "NotInThis": "Bu {space} içinde değil", + "Match": "Eşleşme", + "Add": "Ekle", + "Edit": "Düzenle", + "DocumentPreview": "Önizleme", + "MakePrivate": "Özel yap", + "MakePrivateDescription": "Sadece üyeler görebilir", + "Created": "Oluşturuldu", + "Selected": "Seçildi", + "NoResults": "Gösterilecek sonuç yok", + "Next": "İleri", + "FailedToPreview": "Önizleme başarısız", + "ContentType": "İçerik türü", + "ContentTypeNotSupported": "Bu içerik türü için önizleme mevcut değil", + "StartDrawing": "Üzerine çiz", + "DrawingHistory": "Çizim geçmişi", + "ColorAdd": "Renk önayarı ekle", + "ColorRemove": "Renk önayarını kaldır", + "ColorReset": "Renk önayarlarını sıfırla", + "Copy": "Kopyala", + "DocumentUrlCopied": "Kopyalandı!", + "CopyLink": "Bağlantıyı kopyala", + "AccessDenied": "Nesne mevcut değil veya erişim izniniz yok.", + "UnableToFollowMention": "Bahsedilen nesne açılamıyor", + "Undo": "Geri al", + "Redo": "Yinele", + "ClearCanvas": "Tuvali temizle", + "PenTool": "Kalem aracı", + "EraserTool": "Silgi aracı", + "PanTool": "Kaydırma aracı", + "TextTool": "Metin aracı", + "PaletteManagementMenu": "Renk önayarlarını yönet" + }, + "status": { + "FileTooLarge": "Dosya çok büyük" + } +} diff --git a/packages/presentation/package.json b/packages/presentation/package.json index 62bd5e93cc..859147965a 100644 --- a/packages/presentation/package.json +++ b/packages/presentation/package.json @@ -61,11 +61,12 @@ "@hcengineering/emoji": "^0.6.0", "@hcengineering/theme": "^0.6.5", "@hcengineering/retry": "^0.6.0", + "@hcengineering/hulylake-client": "^0.6.0", "fast-equals": "^5.2.2", "png-chunks-extract": "^1.0.0", "svelte": "^4.2.20", "uuid": "^8.3.2", - "yjs": "^13.6.23" + "yjs": "^13.6.27" }, "repository": "https://github.com/hcengineering/platform", "publishConfig": { diff --git a/packages/presentation/src/communication.ts b/packages/presentation/src/communication.ts index 664818f37d..ad1d309d15 100644 --- a/packages/presentation/src/communication.ts +++ b/packages/presentation/src/communication.ts @@ -33,26 +33,24 @@ import { type UpdateAttachmentsOperation, type UpdateNotificationContextEvent, type UpdateNotificationEvent, - type UpdateNotificationQuery, + type NotificationQuery, type UpdatePatchEvent } from '@hcengineering/communication-sdk-types' import { - type AccountID, + type AccountUuid, type CardID, type CardType, type Collaborator, type ContextID, type FindCollaboratorsParams, type FindLabelsParams, - type FindMessagesGroupsParams, - type FindMessagesParams, type FindNotificationContextParams, type FindNotificationsParams, + type FindMessagesMetaParams, type Label, type Markdown, type Message, type MessageID, - type MessagesGroup, MessageType, type Notification, type NotificationContext, @@ -61,7 +59,12 @@ import { type AttachmentData, type AttachmentParams, type AttachmentUpdateData, - type WithTotal + type WithTotal, + type NotificationID, + type Emoji, + type MessageMeta, + type FindMessagesGroupParams, + type MessagesGroup } from '@hcengineering/communication-types' import core, { generateId, @@ -71,17 +74,19 @@ import core, { SocialIdType, type Tx, type TxDomainEvent, - AccountRole + AccountRole, + generateUuid } from '@hcengineering/core' import { onDestroy } from 'svelte' import { addNotification, NotificationSeverity, languageStore } from '@hcengineering/ui' -import { translate } from '@hcengineering/platform' +import { getMetadata, translate } from '@hcengineering/platform' import view from '@hcengineering/view' -import { v4 as uuid } from 'uuid' - -import { getCurrentWorkspaceUuid, getFilesUrl } from './file' -import { addTxListener, removeTxListener, type TxListener } from './utils' import { get } from 'svelte/store' +import { getClient as getHulylakeClient } from '@hcengineering/hulylake-client' + +import { getCurrentWorkspaceUuid } from './file' +import { addTxListener, removeTxListener, type TxListener } from './utils' +import presentation from './plugin' export { createCollaboratorsQuery, @@ -107,7 +112,12 @@ export async function setCommunicationClient (platformClient: PlatformClient): P client.close() } const _client = new Client(platformClient) - initLiveQueries(_client, getCurrentWorkspaceUuid(), getFilesUrl(), onDestroy) + + const token = getMetadata(presentation.metadata.Token) ?? '' + const hulylakeUrl = getMetadata(presentation.metadata.HulylakeUrl) ?? '' + const hulylake = getHulylakeClient(hulylakeUrl, getCurrentWorkspaceUuid(), token) + + initLiveQueries(_client, hulylake, onDestroy) client = _client onClientListeners.forEach((fn) => { fn() @@ -161,7 +171,7 @@ class Client { async createMessage (cardId: CardID, cardType: CardType, content: Markdown): Promise { const event: CreateMessageEvent = { type: MessageEventType.CreateMessage, - messageType: MessageType.Message, + messageType: MessageType.Text, cardId, cardType, content, @@ -198,28 +208,28 @@ class Client { await this.sendEvent(event) } - async addReaction (cardId: CardID, messageId: MessageID, reaction: string): Promise { + async addReaction (cardId: CardID, messageId: MessageID, emoji: Emoji): Promise { const event: ReactionPatchEvent = { type: MessageEventType.ReactionPatch, cardId, messageId, operation: { opcode: 'add', - reaction + reaction: emoji }, socialId: this.getSocialId() } await this.sendEvent(event) } - async removeReaction (cardId: CardID, messageId: MessageID, reaction: string): Promise { + async removeReaction (cardId: CardID, messageId: MessageID, emoji: Emoji): Promise { const event: ReactionPatchEvent = { type: MessageEventType.ReactionPatch, cardId, messageId, operation: { opcode: 'remove', - reaction + reaction: emoji }, socialId: this.getSocialId() } @@ -245,7 +255,7 @@ class Client { opcode: 'add', attachments: ops.add.map((it) => ({ ...it, - id: it.id ?? (uuid() as AttachmentID) + id: it.id ?? (generateUuid() as AttachmentID) })) }) } @@ -262,7 +272,7 @@ class Client { opcode: 'set', attachments: ops.set.map((it) => ({ ...it, - id: it.id ?? (uuid() as AttachmentID) + id: it.id ?? (generateUuid() as AttachmentID) })) }) } @@ -286,7 +296,7 @@ class Client { await this.sendEvent(event) } - async addCollaborators (cardId: CardID, cardType: CardType, collaborators: AccountID[]): Promise { + async addCollaborators (cardId: CardID, cardType: CardType, collaborators: AccountUuid[]): Promise { const event: AddCollaboratorsEvent = { type: NotificationEventType.AddCollaborators, cardId, @@ -297,7 +307,7 @@ class Client { await this.sendEvent(event) } - async removeCollaborators (cardId: CardID, cardType: CardType, collaborators: AccountID[]): Promise { + async removeCollaborators (cardId: CardID, cardType: CardType, collaborators: AccountUuid[]): Promise { const event: RemoveCollaboratorsEvent = { type: NotificationEventType.RemoveCollaborators, cardId, @@ -329,7 +339,11 @@ class Client { await this.sendEvent(event) } - async updateNotifications (contextId: ContextID, query: UpdateNotificationQuery, read: boolean): Promise { + async updateNotifications ( + contextId: ContextID, + query: Pick & { id?: NotificationID }, + read: boolean + ): Promise { const event: UpdateNotificationEvent = { type: NotificationEventType.UpdateNotification, contextId, @@ -342,15 +356,15 @@ class Client { await this.sendEvent(event) } - async findMessages (params: FindMessagesParams, queryId?: number): Promise { + async findMessagesMeta (params: FindMessagesMetaParams): Promise { return ( - await this.connection.domainRequest(COMMUNICATION, { - findMessages: { params, queryId } + await this.connection.domainRequest(COMMUNICATION, { + findMessagesMeta: { params } }) ).value } - async findMessagesGroups (params: FindMessagesGroupsParams): Promise { + async findMessagesGroups (params: FindMessagesGroupParams): Promise { return ( await this.connection.domainRequest(COMMUNICATION, { findMessagesGroups: { params } @@ -360,19 +374,22 @@ class Client { async findNotificationContexts ( params: FindNotificationContextParams, - queryId?: number + subscription?: number | string ): Promise { return ( await this.connection.domainRequest(COMMUNICATION, { - findNotificationContexts: { params, queryId } + findNotificationContexts: { params, subscription } }) ).value } - async findNotifications (params: FindNotificationsParams, queryId?: number): Promise> { + async findNotifications ( + params: FindNotificationsParams, + subscription?: number | string + ): Promise> { return ( await this.connection.domainRequest>(COMMUNICATION, { - findNotifications: { params, queryId } + findNotifications: { params, subscription } }) ).value } @@ -393,9 +410,15 @@ class Client { ).value } - async unsubscribeQuery (id: number): Promise { + async subscribeCard (cardId: CardID, subscription: string | number): Promise { await this.connection.domainRequest(COMMUNICATION, { - unsubscribeQuery: id + subscribeCard: { cardId, subscription } + }) + } + + async unsubscribeCard (cardId: CardID, subscription: string | number): Promise { + await this.connection.domainRequest(COMMUNICATION, { + unsubscribeCard: { cardId, subscription } }) } @@ -438,7 +461,7 @@ class Client { return id } - private getAccount (): AccountID { + private getAccount (): AccountUuid { return getCurrentAccount().uuid } } diff --git a/packages/presentation/src/components/DocPopup.svelte b/packages/presentation/src/components/DocPopup.svelte index 24d3ad34b8..fe5519bc9c 100644 --- a/packages/presentation/src/components/DocPopup.svelte +++ b/packages/presentation/src/components/DocPopup.svelte @@ -79,14 +79,11 @@ findObjectPresenter(_class) } - const checkSelected = (item?: Doc): void => { - if (item === undefined) { - return - } - if (selectedElements.has(item._id)) { - selectedElements.delete(item._id) + const checkSelected = (_id: Ref): void => { + if (selectedElements.has(_id)) { + selectedElements.delete(_id) } else { - selectedElements.add(item._id) + selectedElements.add(_id) } selectedObjects = Array.from(selectedElements) @@ -97,12 +94,16 @@ let selection = 0 let list: ListView - async function handleSelection (evt: Event | undefined, objects: Doc[], selection: number): Promise { + function handleSelection (evt: Event | undefined, objects: Doc[], selection: number): void { const item = objects[selection] if (item === undefined) { return } + select(item) + } + + function select (item: Doc): void { if (!multiSelect) { if (allowDeselect) { selected = item._id === selected ? undefined : item._id @@ -111,7 +112,7 @@ } dispatch(closeAfterSelect ? 'close' : 'update', selected !== undefined ? item : undefined) } else { - checkSelected(item) + checkSelected(item._id) } } @@ -129,27 +130,35 @@ if (key.code === 'Enter') { key.preventDefault() key.stopPropagation() - void handleSelection(key, objects, selection) + handleSelection(key, objects, selection) } } const manager = createFocusManager() - function onCreate (): void { + async function onCreate (): Promise { if (create === undefined) { return } - const c = create - showPopup(c.component, c.props ?? {}, 'top', async (res) => { + const handler = async (res: Ref | undefined) => { if (res != null) { // We expect reference to new object. - const newPerson = await getClient().findOne(_class, { _id: res }) - if (newPerson !== undefined) { - search = c.update?.(newPerson) ?? '' - dispatch('created', newPerson) - dispatch('search', search) + const newObject = await getClient().findOne(_class, { _id: res }) + if (newObject !== undefined) { + dispatch('created', newObject) + select(newObject) } } - }) + } + const c = create + if (c.component !== undefined) { + showPopup(c.component, c.props ?? {}, 'top', handler) + } else if (c.func !== undefined) { + const impl = await getResource(c.func) + if (impl !== undefined) { + const newObjectId = await impl(c.props) + await handler(newObjectId) + } + } } function toAny (obj: any): any { return obj @@ -248,7 +257,7 @@ class="menu-item withList w-full flex-row-center" disabled={readonly || isDeselectDisabled || loading} on:click={() => { - void handleSelection(undefined, objects, item) + handleSelection(undefined, objects, item) }} > {#if type === 'text'} diff --git a/packages/presentation/src/plugin.ts b/packages/presentation/src/plugin.ts index 295594569c..b52c3d7893 100644 --- a/packages/presentation/src/plugin.ts +++ b/packages/presentation/src/plugin.ts @@ -180,7 +180,8 @@ export default plugin(presentationId, { StatsUrl: '' as Metadata, MailUrl: '' as Metadata, PreviewUrl: '' as Metadata, - PulseUrl: '' as Metadata + PulseUrl: '' as Metadata, + HulylakeUrl: '' as Metadata }, status: { FileTooLarge: '' as StatusCode diff --git a/packages/presentation/src/types.ts b/packages/presentation/src/types.ts index 5f7ede44e3..3bf75bf463 100644 --- a/packages/presentation/src/types.ts +++ b/packages/presentation/src/types.ts @@ -35,10 +35,11 @@ export interface ObjectSearchResult { * @public */ export interface ObjectCreate { - component: AnyComponent + component: AnyComponent | undefined props?: Record label: IntlString update?: (doc: Doc) => string + func?: Resource<(props?: Record) => Promise | undefined>> } /** diff --git a/packages/text-core/package.json b/packages/text-core/package.json index a97d00fb6f..f19ac1979f 100644 --- a/packages/text-core/package.json +++ b/packages/text-core/package.json @@ -40,7 +40,8 @@ }, "dependencies": { "@hcengineering/core": "^0.6.32", - "fast-equals": "^5.2.2" + "fast-equals": "^5.2.2", + "hash-it": "^6.0.0" }, "repository": "https://github.com/hcengineering/platform", "publishConfig": { diff --git a/packages/text-core/src/markup/__tests__/utils.test.ts b/packages/text-core/src/markup/__tests__/utils.test.ts new file mode 100644 index 0000000000..740aa1c64f --- /dev/null +++ b/packages/text-core/src/markup/__tests__/utils.test.ts @@ -0,0 +1,49 @@ +import { hashAttrs, stripHash } from '../utils' + +describe('hashAttrs', () => { + it('should return a hash of length 8', () => { + const attrs = { a: 1 } + const hash = hashAttrs(attrs) + expect(hash.length).toEqual(8) + }) + it('should return the same hash for the same attrs', () => { + const attrs = { a: 1, b: 2 } + const hash1 = hashAttrs(attrs) + const hash2 = hashAttrs(attrs) + expect(hash1).toEqual(hash2) + }) + + it('should return different hashes for different attrs', () => { + const attrs1 = { a: 1, b: 2 } + const attrs2 = { a: 1, b: 3 } + const hash1 = hashAttrs(attrs1) + const hash2 = hashAttrs(attrs2) + expect(hash1).not.toEqual(hash2) + }) +}) + +describe('stripHash', () => { + it('should return the name without the hash', () => { + const name = 'bold--c0decafe' + const result = stripHash(name) + expect(result).toEqual('bold') + }) + + it('should return the original name if no hash is present', () => { + const name = 'bold' + const result = stripHash(name) + expect(result).toEqual(name) + }) + + it('should return the original name if the hash is not 8 characters long', () => { + const name = 'bold--1234567' + const result = stripHash(name) + expect(result).toEqual(name) + }) + + it('should return the original name if the hash is not a valid base64 string', () => { + const name = 'bold--invalid!' + const result = stripHash(name) + expect(result).toEqual(name) + }) +}) diff --git a/packages/text-core/src/markup/utils.ts b/packages/text-core/src/markup/utils.ts index 7b187aac46..dd5efd7630 100644 --- a/packages/text-core/src/markup/utils.ts +++ b/packages/text-core/src/markup/utils.ts @@ -16,9 +16,11 @@ import { Markup } from '@hcengineering/core' import { deepEqual } from 'fast-equals' +import hashIt from 'hash-it' + import { nodeDoc, nodeParagraph, nodeText } from './dsl' -import { MarkupMark, MarkupNode, MarkupNodeType, emptyMarkupNode } from './model' -import { traverseNode } from './traverse' +import { MarkupMark, MarkupMarkType, MarkupNode, MarkupNodeType, emptyMarkupNode } from './model' +import { traverseAllMarks, traverseNode } from './traverse' /** @public */ export const EmptyMarkup: Markup = jsonToMarkup(emptyMarkupNode()) @@ -124,7 +126,11 @@ export function markupToJSON (markup: Markup): MarkupNode { // But there seem to be some cases when it contains HTML or plain text // So we need to handle those cases and produce valid MarkupNode if (markup.startsWith('{')) { - return JSON.parse(markup) as MarkupNode + const json = JSON.parse(markup) as MarkupNode + traverseAllMarks(json, (node, mark) => { + mark.type = stripHash(mark.type) as MarkupMarkType + }) + return json } else { return nodeDoc(nodeParagraph(nodeText(markup))) } @@ -207,3 +213,17 @@ export function markupToText (markup: Markup): string { return fragments.join('').trim() } + +// see https://github.com/yjs/y-prosemirror/blob/master/src/lib.js#L402 +const hashedMarkNameRegex = /(.*)(--[a-zA-Z0-9+/=]{8})$/ + +/** @public */ +export function stripHash (attrName: string): string { + return hashedMarkNameRegex.exec(attrName)?.[1] ?? attrName +} + +/** @public */ +export function hashAttrs (attrs: any): string { + const hash = hashIt(attrs) + return (hash >>> 0).toString(16).padStart(8, '0') +} diff --git a/packages/text-ydoc/package.json b/packages/text-ydoc/package.json index 4f476f5423..0d55175375 100644 --- a/packages/text-ydoc/package.json +++ b/packages/text-ydoc/package.json @@ -37,12 +37,13 @@ "@types/jest": "^29.5.5", "jest-environment-jsdom": "^29.7.0", "fast-equals": "^5.2.2", - "y-prosemirror": "^1.3.6" + "y-prosemirror": "^1.3.7" }, "dependencies": { "@hcengineering/core": "^0.6.32", "@hcengineering/text": "^0.6.5", - "yjs": "^13.6.23" + "@hcengineering/text-core": "^0.6.0", + "yjs": "^13.6.27" }, "repository": "https://github.com/hcengineering/platform", "publishConfig": { diff --git a/packages/text-ydoc/src/__tests__/ydoc.test.ts b/packages/text-ydoc/src/__tests__/ydoc.test.ts index 3b6a99bd23..43d6f6fab5 100644 --- a/packages/text-ydoc/src/__tests__/ydoc.test.ts +++ b/packages/text-ydoc/src/__tests__/ydoc.test.ts @@ -25,6 +25,7 @@ declare global { namespace jest { interface Matchers { toEqualMarkup: (expected: string) => R + toEqualYdoc: (expected: YDoc) => R } } } @@ -35,8 +36,22 @@ expect.extend({ return { message: () => pass - ? `Expected markup strings NOT to be equal:\n Received: ${received}\n Expected: ${expected}` - : `Expected markup strings to be equal:\n Received: ${received}\n Expected: ${expected}`, + ? `Expected markup strings NOT to be equal:\n Expected: ${expected}\n Received: ${received}` + : `Expected markup strings to be equal:\n Expected: ${expected}\n Received: ${received}`, + pass + } + }, + + toEqualYdoc (received: YDoc, expected: YDoc) { + const expectedJSON = expected.toJSON() + const receivedJSON = received.toJSON() + + const pass = deepEqual(expectedJSON, receivedJSON) + return { + message: () => + pass + ? `Expected yjs documents NOT to be equal:\n Expected: ${JSON.stringify(expectedJSON)}\n Received: ${JSON.stringify(receivedJSON)}` + : `Expected yjs documents to be equal:\n Expected: ${JSON.stringify(expectedJSON)}\n Received: ${JSON.stringify(receivedJSON)}`, pass } } @@ -54,52 +69,157 @@ function referenceYDocToMarkup (ydoc: YDoc, field: string): Markup { return jsonToMarkup(json as MarkupNode) } -const markups: Array<[string, Markup]> = [ - ['text', '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello world"}]}]}'], - [ - 'text with bold mark', - '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","marks":[{"type":"bold","attrs":{}}],"text":"hello world"}]}]}' - ], - [ - 'separate paragraphs with bold mark', - '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","marks":[{"type":"bold","attrs":{}}],"text":"hello"}]},{"type":"paragraph","content":[{"type":"text","marks":[{"type":"bold","attrs":{}}],"text":"world"}]}]}' - ], - [ - 'mixed text and text with bold mark', - '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello "},{"type":"text","marks":[{"type":"bold","attrs":{}}],"text":"world"}]}]}' - ], - [ - 'mixed text with italic and text with bold and italic marks', - '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","marks":[{"type":"italic","attrs":{}}],"text":"hello "},{"type":"text","marks":[{"type":"bold","attrs":{}},{"type":"italic","attrs":{}}],"text":"world"}]}]}' - ], - [ - 'text with link and italic marks', - '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello "},{"type":"text","text":"hello world","marks":[{"type":"link","attrs":{"href":"http://example.com","target":"_blank","rel":"noopener noreferrer","class":"cursor-pointer"}},{"type":"italic","attrs":{}}]}]}]}' - ], - [ - 'image', - '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"image","attrs":{"src":"http://example.com/image.jpg","alt":"image"}}]}]}' - ], - [ - 'table with formatting inside', - '{"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colspan":1,"rowspan":1},"content":[{"type":"paragraph","content":[{"type":"text","marks":[{"type":"bold","attrs":{}}],"text":"1"}]}]},{"type":"tableCell","attrs":{"colspan":1,"rowspan":1},"content":[{"type":"paragraph","content":[{"type":"text","marks":[{"type":"italic","attrs":{}}],"text":"2"}]}]}]},{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colspan":1,"rowspan":1},"content":[{"type":"codeBlock","content":[{"type":"text","text":"3"}]}]},{"type":"tableCell","attrs":{"colspan":1,"rowspan":1},"content":[{"type":"paragraph","content":[{"type":"text","text":"4"}]}]}]}]}]}' +const markups: Array<{ name: string, markup: Markup, skipYdocCompare?: boolean }> = [ + { + name: 'text', + markup: '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello world"}]}]}' + }, + { + name: 'text with bold mark', + markup: + '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","marks":[{"type":"bold","attrs":{}}],"text":"hello world"}]}]}' + }, + { + name: 'separate paragraphs with bold mark', + markup: + '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","marks":[{"type":"bold","attrs":{}}],"text":"hello"}]},{"type":"paragraph","content":[{"type":"text","marks":[{"type":"bold","attrs":{}}],"text":"world"}]}]}' + }, + { + name: 'mixed text and text with bold mark', + markup: + '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello "},{"type":"text","marks":[{"type":"bold","attrs":{}}],"text":"world"}]}]}' + }, + { + name: 'mixed text with italic and text with bold and italic marks', + markup: + '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","marks":[{"type":"italic","attrs":{}}],"text":"hello "},{"type":"text","marks":[{"type":"bold","attrs":{}},{"type":"italic","attrs":{}}],"text":"world"}]}]}' + }, + { + name: 'text with link and italic marks', + markup: + '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello "},{"type":"text","text":"hello world","marks":[{"type":"link","attrs":{"href":"http://example.com","target":"_blank","rel":"noopener noreferrer","class":"cursor-pointer"}},{"type":"italic","attrs":{}}]}]}]}' + }, + { + name: 'image', + markup: + '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"image","attrs":{"src":"http://example.com/image.jpg","alt":"image"}}]}]}' + }, + { + name: 'table with formatting inside', + markup: + '{"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colspan":1,"rowspan":1},"content":[{"type":"paragraph","content":[{"type":"text","marks":[{"type":"bold","attrs":{}}],"text":"1"}]}]},{"type":"tableCell","attrs":{"colspan":1,"rowspan":1},"content":[{"type":"paragraph","content":[{"type":"text","marks":[{"type":"italic","attrs":{}}],"text":"2"}]}]}]},{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colspan":1,"rowspan":1},"content":[{"type":"codeBlock","content":[{"type":"text","text":"3"}]}]},{"type":"tableCell","attrs":{"colspan":1,"rowspan":1},"content":[{"type":"paragraph","content":[{"type":"text","text":"4"}]}]}]}]}]}' + }, + { + name: 'non-overlapping marks', + markup: `{ + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "xy", + "marks": [ + { + "type": "inline-comment", + "attrs": { + "thread": "1" + } + } + ] + }, + { + "type": "text", + "text": "z", + "marks": [ + { + "type": "inline-comment", + "attrs": { + "thread": "2" + } + } + ] + } + ] + } ] +}`, + skipYdocCompare: true + }, + { + name: 'overlapping marks', + markup: `{ + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "x", + "marks": [ + { + "type": "inline-comment", + "attrs": { + "thread": "1" + } + } + ] + }, + { + "type": "text", + "text": "y", + "marks": [ + { + "type": "inline-comment", + "attrs": { + "thread": "1" + } + }, + { + "type": "inline-comment", + "attrs": { + "thread": "2" + } + } + ] + }, + { + "type": "text", + "text": "z", + "marks": [ + { + "type": "inline-comment", + "attrs": { + "thread": "2" + } + } + ] + } + ] + } + ] +}`, + skipYdocCompare: true + } ] describe('markupToYDoc', () => { - describe.each(markups)('compare with reference', (name, markup) => { - it(name, () => { - const expected = referenceMarkupToYDoc(markup, 'test') - const actual = markupToYDoc(markup, 'test') - expect(actual.toJSON()).toEqual(expected.toJSON()) - }) + describe.each(markups)('compare with reference', ({ name, markup, skipYdocCompare }) => { + if (skipYdocCompare !== true) { + it(name, () => { + const expected = referenceMarkupToYDoc(markup, 'test') + const actual = markupToYDoc(markup, 'test') + expect(actual).toEqualYdoc(expected) + }) + } }) - describe.each(markups)('converts markup to ydoc and back', (name, markup) => { + describe.each(markups)('converts markup to ydoc and back', ({ name, markup }) => { it(name, () => { - console.log(markup, name) const ydoc = markupToYDoc(markup, 'test') - const actual = referenceYDocToMarkup(ydoc, 'test') + const actual = yDocToMarkup(ydoc, 'test') expect(actual).toEqualMarkup(markup) }) @@ -107,7 +227,7 @@ describe('markupToYDoc', () => { }) describe('yDocToMarkup', () => { - describe.each(markups)('compare with original', (name, markup) => { + describe.each(markups)('compare with original', ({ name, markup }) => { it(name, () => { const ydoc = referenceMarkupToYDoc(markup, 'test') const actual = yDocToMarkup(ydoc, 'test') @@ -116,7 +236,7 @@ describe('yDocToMarkup', () => { }) }) - describe.each(markups)('compare with reference', (name, markup) => { + describe.each(markups)('compare with reference', ({ name, markup }) => { it(name, () => { const ydoc = referenceMarkupToYDoc(markup, 'test') const expected = referenceYDocToMarkup(ydoc, 'test') @@ -126,12 +246,14 @@ describe('yDocToMarkup', () => { }) }) - describe.each(markups)('converts ydoc to markup and back', (name, markup) => { - it(name, () => { - const expected = referenceMarkupToYDoc(markup, 'test') - const actual = referenceMarkupToYDoc(yDocToMarkup(expected, 'test'), 'test') + describe.each(markups)('converts ydoc to markup and back', ({ name, markup, skipYdocCompare }) => { + if (skipYdocCompare !== true) { + it(name, () => { + const expected = referenceMarkupToYDoc(markup, 'test') + const actual = referenceMarkupToYDoc(yDocToMarkup(expected, 'test'), 'test') - expect(actual.toJSON()).toEqual(expected.toJSON()) - }) + expect(actual).toEqualYdoc(expected) + }) + } }) }) diff --git a/packages/text-ydoc/src/ydoc.ts b/packages/text-ydoc/src/ydoc.ts index 937ac86fc8..811fa34dc8 100644 --- a/packages/text-ydoc/src/ydoc.ts +++ b/packages/text-ydoc/src/ydoc.ts @@ -15,6 +15,7 @@ import { generateId, Markup } from '@hcengineering/core' import { jsonToMarkup, MarkupMarkType, MarkupNodeType, markupToJSON, type MarkupNode } from '@hcengineering/text' +import { hashAttrs, stripHash } from '@hcengineering/text-core' import { Doc as YDoc, XmlElement as YXmlElement, XmlFragment as YXmlFragment, XmlText as YXmlText } from 'yjs' /** @@ -49,14 +50,22 @@ function nodeToXmlElement (parent: YXmlFragment, node: MarkupNode): YXmlElement if (node.content !== undefined && node.content.length > 0) { node.content.map((p) => nodeToXmlElement(elem, p)) } - } else { + } else if (elem instanceof YXmlText) { // https://github.com/yjs/y-prosemirror/blob/master/src/plugins/sync-plugin.js#L777 const attributes: Record = {} if (node.marks !== undefined) { + const attrCount = new Map() node.marks.forEach((mark) => { - attributes[mark.type] = mark.attrs ?? {} + attrCount.set(mark.type, (attrCount.get(mark.type) ?? 0) + 1) + }) + + node.marks.forEach((mark) => { + const count = attrCount.get(mark.type) ?? 0 + const type = count > 1 ? `${mark.type}--${hashAttrs(mark.attrs)}` : mark.type + attributes[type] = mark.attrs ?? {} }) } + elem.applyDelta([ { insert: node.text ?? '', @@ -119,7 +128,7 @@ function xmlFragmentToNode (fragment: YXmlFragment): MarkupNode[] { // Convert attributes to marks if (op.attributes != null) { textNode.marks = Object.entries(op.attributes).map(([type, attrs]) => ({ - type: type as MarkupMarkType, + type: stripHash(type) as MarkupMarkType, attrs: attrs as Record })) } diff --git a/packages/theme/styles/_layouts.scss b/packages/theme/styles/_layouts.scss index efc6754a2e..e1c0c7c1de 100644 --- a/packages/theme/styles/_layouts.scss +++ b/packages/theme/styles/_layouts.scss @@ -561,6 +561,7 @@ input.search { .mx-2 { margin: 0 .5rem; } .mx-3 { margin: 0 .75rem; } .mx-4 { margin: 0 1rem; } +.mx-8 { margin: 0 2rem; } .mx-10 { margin: 0 2.5rem; } .mx-auto { margin: 0 auto; } .my-1 { margin: .25rem 0; } diff --git a/packages/theme/styles/_text-editor.scss b/packages/theme/styles/_text-editor.scss index 7fca2d4a57..e33278dfa1 100644 --- a/packages/theme/styles/_text-editor.scss +++ b/packages/theme/styles/_text-editor.scss @@ -303,6 +303,7 @@ .text-editor-image-container { img { max-width: 100%; + height: auto; } } diff --git a/packages/ui/lang/cs.json b/packages/ui/lang/cs.json index 704862577a..7698769d63 100644 --- a/packages/ui/lang/cs.json +++ b/packages/ui/lang/cs.json @@ -57,6 +57,7 @@ "Czech": "Čeština", "German": "Deutsch", "Japanese": "日本語", + "Turkish": "Türkçe", "MinutesBefore": "{minutes, plural, =1 {1 minuta před} other {# minut před}}", "HoursBefore": "{hours, plural, =1 {1 hodina před} other {# hodin před}}", "DaysBefore": "{days, plural, =1 {1 den před} other {# dní před}}", diff --git a/packages/ui/lang/de.json b/packages/ui/lang/de.json index 55d8a0d147..638f70c222 100644 --- a/packages/ui/lang/de.json +++ b/packages/ui/lang/de.json @@ -57,6 +57,7 @@ "Czech": "Tschechisch", "German": "Deutsch", "Japanese": "Japanisch", + "Turkish": "Türkçe", "MinutesBefore": "{minutes, plural, =1 {eine Minute vorher} other {# Minuten vorher}}", "HoursBefore": "{hours, plural, =1 {eine Stunde vorher} other {# Stunden vorher}}", "DaysBefore": "{days, plural, =1 {einen Tag vorher} other {# Tage vorher}}", diff --git a/packages/ui/lang/en.json b/packages/ui/lang/en.json index a7f6282a17..7c325e107f 100644 --- a/packages/ui/lang/en.json +++ b/packages/ui/lang/en.json @@ -57,6 +57,7 @@ "Czech": "Čeština", "German": "Deutsch", "Japanese": "日本語", + "Turkish": "Türkçe", "MinutesBefore": "{minutes, plural, =1 {a minute before} other {# minutes before}}", "HoursBefore": "{hours, plural, =1 {an hour before} other {# hours before}}", "DaysBefore": "{days, plural, =1 {a day before} other {# days before}}", diff --git a/packages/ui/lang/es.json b/packages/ui/lang/es.json index 1f8fece65e..81e01a8627 100644 --- a/packages/ui/lang/es.json +++ b/packages/ui/lang/es.json @@ -57,6 +57,7 @@ "Czech": "Čeština", "German": "Deutsch", "Japanese": "日本語", + "Turkish": "Türkçe", "MinutesBefore": "{minutes, plural, =1 {un minuto antes} other {# minutos antes}}", "HoursBefore": "{hours, plural, =1 {una hora antes} other {# horas antes}}", "DaysBefore": "{days, plural, =1 {un día antes} other {# días antes}}", diff --git a/packages/ui/lang/fr.json b/packages/ui/lang/fr.json index 5081fc70be..2de9794825 100644 --- a/packages/ui/lang/fr.json +++ b/packages/ui/lang/fr.json @@ -57,6 +57,7 @@ "Czech": "Čeština", "German": "Deutsch", "Japanese": "日本語", + "Turkish": "Türkçe", "MinutesBefore": "{minutes, plural, =1 {une minute avant} other {# minutes avant}}", "HoursBefore": "{hours, plural, =1 {une heure avant} other {# heures avant}}", "DaysBefore": "{days, plural, =1 {un jour avant} other {# jours avant}}", diff --git a/packages/ui/lang/it.json b/packages/ui/lang/it.json index 15c212ddba..0854a3f1f4 100644 --- a/packages/ui/lang/it.json +++ b/packages/ui/lang/it.json @@ -57,6 +57,7 @@ "Czech": "Čeština", "German": "Deutsch", "Japanese": "日本語", + "Turkish": "Türkçe", "MinutesBefore": "{minutes, plural, =1 {un minuto prima} other {# minuti prima}}", "HoursBefore": "{hours, plural, =1 {un'ora prima} other {# ore prima}}", "DaysBefore": "{days, plural, =1 {un giorno prima} other {# giorni prima}}", diff --git a/packages/ui/lang/ja.json b/packages/ui/lang/ja.json index c6d9531c15..902609e4a9 100644 --- a/packages/ui/lang/ja.json +++ b/packages/ui/lang/ja.json @@ -57,6 +57,7 @@ "Czech": "Čeština", "German": "Deutsch", "Japanese": "日本語", + "Turkish": "Türkçe", "MinutesBefore": "{minutes, plural, =1 {1分前} other {#分前}}", "HoursBefore": "{hours, plural, =1 {1時間前} other {#時間前}}", "DaysBefore": "{days, plural, =1 {1日前} other {#日前}}", diff --git a/packages/ui/lang/pt.json b/packages/ui/lang/pt.json index 8e08ad8965..116b30fbb7 100644 --- a/packages/ui/lang/pt.json +++ b/packages/ui/lang/pt.json @@ -57,6 +57,7 @@ "Czech": "Čeština", "German": "Deutsch", "Japanese": "日本語", + "Turkish": "Türkçe", "MinutesBefore": "{minutes, plural, =1 {um minuto antes} other {# minutos antes}}", "HoursBefore": "{hours, plural, =1 {uma hora antes} other {# horas antes}}", "DaysBefore": "{days, plural, =1 {um dia antes} other {# dias antes}}", diff --git a/packages/ui/lang/ru.json b/packages/ui/lang/ru.json index 081716b7b2..28145e035b 100644 --- a/packages/ui/lang/ru.json +++ b/packages/ui/lang/ru.json @@ -57,6 +57,7 @@ "Czech": "Čeština", "German": "Deutsch", "Japanese": "日本語", + "Turkish": "Türkçe", "MinutesBefore": "{minutes, plural, =1 {за минуту} one {за # минуту} few {за # минуты} other {за # минут}}", "HoursBefore": "{hours, plural, =1 {за час} one {за # час} few {за # часа} other {за # часов}}", "DaysBefore": "{days, plural, =1 {за день} one {за # день} few {за # дня} other {за # дней}}", diff --git a/packages/ui/lang/tr.json b/packages/ui/lang/tr.json new file mode 100644 index 0000000000..b6b406f3e9 --- /dev/null +++ b/packages/ui/lang/tr.json @@ -0,0 +1,113 @@ +{ + "string": { + "EditBoxPlaceholder": "Metin yazın...", + "Ok": "Tamam", + "Cancel": "İptal", + "Save": "Kaydet", + "Publish": "Yayınla", + "SaveDraft": "Taslak kaydet", + "MinutesAgo": "{minutes, plural, =0 {bir dakikadan az önce} =1 {bir dakika önce} other {# dakika önce}}", + "HoursAgo": "{hours, plural, =0 {bir saatten az önce} =1 {bir saat önce} other {# saat önce}}", + "DaysAgo": "{days, plural, =0 {bugün} =1 {dün} other {# gün önce}}", + "MonthsAgo": "{months, plural, =0 {bu ay} =1 {bir ay önce} other {# ay önce}}", + "YearsAgo": "{years, plural, =0 {bu yıl} =1 {bir yıl önce} other {# yıl önce}}", + "Minutes": "{minutes, plural, =0 {bir dakikadan az} =1 {bir dakika} other {# dakika}}", + "Hours": "{hours, plural, =0 {bir saatten az} =1 {bir saat} other {# saat}}", + "Days": "{days, plural, =0 {bugün} =1 {1 gün} other {# gün}}", + "Weeks": "{weeks, plural, =0 {bu hafta} =1 {1 hafta} other {# hafta}}", + "Months": "{months, plural, =0 {bu ay} =1 {1 ay} other {# ay}}", + "Years": "{years, plural, =0 {bu yıl} =1 {bir yıl} other {# yıl}}", + "Before": "Önce", + "After": "Sonra", + "MinutesShort": "{value}dk", + "HoursShort": "{value}sa", + "DaysShort": "{value}g", + "ShowMore": "Daha fazla göster", + "ShowLess": "Daha az göster", + "Search": "Ara", + "SearchDots": "Ara...", + "SearchResults": "Arama Sonuçları", + "Suggested": "Önerilen", + "TimeTooltip": "{value}", + "SelectDate": "Tarih seç", + "None": "Hiçbiri", + "NotSelected": "Seçilmedi", + "Today": "Bugün", + "NoDate": "Tarih yok", + "StartDate": "Başlangıç tarihi", + "TargetDate": "Hedef tarih", + "Overdue": "Gecikmiş", + "DueDate": "Bitiş tarihi", + "AddDueDate": "Bitiş tarihi ekle", + "SetDueDate": "Bitiş tarihi belirle", + "EditDueDate": "Bitiş tarihini düzenle", + "SaveDueDate": "Bitiş tarihini kaydet", + "NeedsToBeCompletedByThisDate": "Bu tarihe kadar tamamlanmalı", + "DueDatePopupTitle": "Bitiş: {value}", + "DueDatePopupOverdueTitle": "Bitiş tarihiydi: {value}", + "DueDatePopupDescription": "{value, plural, =0 {Bugün} =1 {Yarın} other {# gün kaldı}}", + "DueDatePopupOverdueDescription": "{value, plural, =1 {1 gün gecikti} other {# gün gecikti}}", + "English": "English", + "Russian": "Русский", + "Portuguese": "Português", + "Spanish": "Español", + "Chinese": "中文", + "French": "Français", + "Italian": "Italiano", + "Czech": "Čeština", + "German": "Deutsch", + "Japanese": "日本語", + "Turkish": "Türkçe", + "MinutesBefore": "{minutes, plural, =1 {bir dakika önce} other {# dakika önce}}", + "HoursBefore": "{hours, plural, =1 {bir saat önce} other {# saat önce}}", + "DaysBefore": "{days, plural, =1 {bir gün önce} other {# gün önce}}", + "WeeksBefore": "{weeks, plural, =1 {bir hafta önce} other {# hafta önce}}", + "MonthsBefore": "{months, plural, =1 {bir ay önce} other {# ay önce}}", + "MinutesAfter": "{minutes, plural, =1 {bir dakika sonra} other {# dakika sonra}}", + "HoursAfter": "{hours, plural, =1 {bir saat sonra} other {# saat sonra}}", + "DaysAfter": "{days, plural, =1 {bir gün sonra} other {# gün sonra}}", + "WeeksAfter": "{weeks, plural, =1 {bir hafta sonra} other {# hafta sonra}}", + "MonthsAfter": "{months, plural, =1 {bir ay sonra} other {# ay sonra}}", + "NoActionsDefined": "Uygulanabilir işlem yok", + "Incoming": "Gelen", + "HoursLabel": "Saat", + "Back": "Geri", + "Next": "İleri", + "DropdownDefaultLabel": "Öğe Seç", + "DD": "GG", + "MM": "AA", + "YYYY": "YYYY", + "HH": "SS", + "Remove": "Kaldır", + "NoResults": "Sonuç Yok", + "MoreCount": "{count} daha", + "Spacious": "Geniş", + "Compact": "Kompakt", + "ThemeLight": "Açık", + "ThemeDark": "Koyu", + "ThemeSystem": "Sistem", + "NoTimeZonesFound": "Zaman dilimi bulunamadı", + "Selected": "Seçildi:", + "Submit": "Gönder", + "NextStep": "Sonraki adım", + "TypeHere": "Buraya yazın...", + "NormalSize": "Normal boyut", + "FullSize": "Tam boyut", + "UseMaxWidth": "Maksimum genişlik", + "Sidebar": "Kenar çubuğu", + "FontSize": "Yazı boyutu", + "Language": "Dil", + "DaysWOValue": "{days, plural, =1 {gün} other {gün}}", + "WeeksWOValue": "{weeks, plural, =1 {hafta} other {hafta}}", + "MonthsWOValue": "{months, plural, =1 {ay} other {ay}}", + "Filter": "Filtre", + "Clear": "Temizle", + "Yesterday": "Dün", + "ThisWeek": "Bu hafta", + "ThisMonth": "Bu ay", + "ThisYear": "Bu yıl", + "EmojiStyle": "Emoji Stili", + "EmojiSystem": "Sistem Varsayılanı", + "EmojiNoto": "Noto Color" + } +} diff --git a/packages/ui/lang/zh.json b/packages/ui/lang/zh.json index 0cab25fd23..2627632a58 100644 --- a/packages/ui/lang/zh.json +++ b/packages/ui/lang/zh.json @@ -57,6 +57,7 @@ "Czech": "Čeština", "German": "Deutsch", "Japanese": "日本語", + "Turkish": "Türkçe", "MinutesBefore": "{minutes, plural, =1 {一分钟前} other {# 分钟前}}", "HoursBefore": "{hours, plural, =1 {一小时前} other {# 小时前}}", "DaysBefore": "{days, plural, =1 {一天前} other {# 天前}}", diff --git a/packages/ui/src/components/EditBox.svelte b/packages/ui/src/components/EditBox.svelte index a4938fc579..f80ac3f02f 100644 --- a/packages/ui/src/components/EditBox.svelte +++ b/packages/ui/src/components/EditBox.svelte @@ -31,6 +31,8 @@ export let placeholderParam: any | undefined = undefined export let format: 'text' | 'password' | 'number' | 'text-multiline' = 'text' export let maxDigitsAfterPoint: number | undefined = undefined + export let minValue: number | undefined = undefined + export let maxValue: number | undefined = undefined export let formatter: ((value: string | number) => string | number) | undefined = undefined export let kind: EditStyle = 'editbox' export let autoFocus: boolean = false @@ -48,17 +50,27 @@ let phTranslate: string = '' $: { - if ( - format === 'number' && - maxDigitsAfterPoint && - value && - !value.toString().match(`^\\d+\\.?\\d{0,${maxDigitsAfterPoint}}$`) - ) { + if (format === 'number' && maxDigitsAfterPoint !== undefined && typeof value === 'number' && value) { value = floorFractionDigits(Number(value), maxDigitsAfterPoint) } else if (formatter !== undefined && value != null) { value = formatter(value) } } + + function setValue ( + val: number | string | undefined, + maxValue: number | undefined, + minValue: number | undefined + ): void { + if (typeof val !== 'number' || format !== 'number') return + if (minValue !== undefined && maxValue !== undefined && maxValue < minValue) return + if (minValue !== undefined && val < minValue) value = minValue + if (maxValue !== undefined && val > maxValue) value = maxValue + dispatch('change') + } + + $: setValue(value, maxValue, minValue) + $: translateCB(placeholder, placeholderParam ?? {}, $themeStore.language, (res) => { phTranslate = res }) diff --git a/packages/ui/src/components/HeaderButton.svelte b/packages/ui/src/components/HeaderButton.svelte new file mode 100644 index 0000000000..5c258e8ab5 --- /dev/null +++ b/packages/ui/src/components/HeaderButton.svelte @@ -0,0 +1,130 @@ + + + +{#if mainAction !== undefined} + {#if loading} + + {:else} +
+ {#if items.length === 1} + + {:else} + { + items.find((a) => a.id === ev.detail)?.callback() + }} + on:click={mainAction.callback} + mainButtonId={mainAction.id !== null ? String(mainAction.id).replaceAll(':', '-') : undefined} + showTooltipMain={{ + direction: 'bottom', + label: mainAction.label, + keys: mainAction.keyBinding + }} + > +
+ {#if mainAction.draft === true} +
+ {/if} +
+ + {/if} +
+ {/if} +{/if} + + diff --git a/packages/ui/src/components/Modal.svelte b/packages/ui/src/components/Modal.svelte index 4b422c5e72..80582e4c4d 100644 --- a/packages/ui/src/components/Modal.svelte +++ b/packages/ui/src/components/Modal.svelte @@ -23,6 +23,7 @@ export let type: 'type-aside' | 'type-popup' | 'type-component' export let width: 'large' | 'medium' | 'small' | 'x-small' | 'menu' | undefined = undefined + export let maxWidth: string | undefined = undefined export let label: IntlString | undefined = undefined export let labelProps: any | undefined = undefined export let okAction: () => Promise | void = () => {} @@ -69,7 +70,12 @@ -
+
maxValue) value = maxValue + dispatch('change') } + + $: setValue(value, maxValue, minValue) + $: style = `max-width: ${maxWidth || (parentWidth ? `${parentWidth}px` : 'max-content')};` $: translateCB(placeholder, placeholderParam ?? {}, $themeStore.language, (res) => { phTranslate = res @@ -152,8 +163,26 @@ on:blur />
-
@@ -166,6 +195,7 @@ padding: 0rem 0.125rem 0 0.5rem; border: 1px solid var(--theme-divider-color); border-radius: 0.375rem; + width: max-content; input { margin: 0; diff --git a/packages/ui/src/components/ShowMore.svelte b/packages/ui/src/components/ShowMore.svelte index 0dbce97a1b..b505e39e4d 100644 --- a/packages/ui/src/components/ShowMore.svelte +++ b/packages/ui/src/components/ShowMore.svelte @@ -21,9 +21,9 @@ export let limit: number = 240 export let ignore: boolean = false export let fixed: boolean = false + export let bigger: boolean = false let cHeight: number - let bigger: boolean = false let crop: boolean = false const toggle = (): void => { diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index a0a4f1eb99..a20b1632c3 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -56,6 +56,7 @@ export { getCurrentLocation, locationToUrl, navigate, location, setLocationStora export { default as EditBox } from './components/EditBox.svelte' export { default as Label } from './components/Label.svelte' export { default as Button } from './components/Button.svelte' +export { default as HeaderButton } from './components/HeaderButton.svelte' export { default as ButtonWithDropdown } from './components/ButtonWithDropdown.svelte' export { default as ButtonGroup } from './components/ButtonGroup.svelte' export { default as FilterButton } from './components/FilterButton.svelte' diff --git a/packages/ui/src/types.ts b/packages/ui/src/types.ts index 0740e09b9a..b87e52f9cf 100644 --- a/packages/ui/src/types.ts +++ b/packages/ui/src/types.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. // -import type { Blob, Ref, Timestamp } from '@hcengineering/core' +import type { AccountRole, Blob, Permission, Ref, Timestamp, TypedSpace } from '@hcengineering/core' import type { Asset, IntlString, @@ -519,6 +519,21 @@ export interface SelectPopupValueType { } } +/** + * @public + */ +export interface HeaderButtonAction extends SelectPopupValueType { + callback: () => void + keyBindingPromise?: Promise + keyBinding?: string[] | undefined + draft?: boolean + accountRole?: AccountRole + permissions?: Array<{ + id: Ref + space: Ref + }> +} + /** * @public */ diff --git a/plugins/achievement-assets/lang/tr.json b/plugins/achievement-assets/lang/tr.json new file mode 100644 index 0000000000..3fbb442d0c --- /dev/null +++ b/plugins/achievement-assets/lang/tr.json @@ -0,0 +1,8 @@ +{ + "string": { + "Achievements": "Başarılar", + "EarliestAdopter": "İlk Kullanıcı", + "Epic": "Destansı", + "Legendary": "Efsanevi" + } +} diff --git a/plugins/activity-assets/lang/cs.json b/plugins/activity-assets/lang/cs.json index a2c296e2fe..19ef963a02 100644 --- a/plugins/activity-assets/lang/cs.json +++ b/plugins/activity-assets/lang/cs.json @@ -55,6 +55,7 @@ "RemovedObjectType": "Odstraněný {type} : {title}", "AttributeSetTo": "{name} nastaveno na {value}", "AddedTag": "Přidaný štítek: {title}", - "RemovedTag": "Odebráný štítek: {title}" + "RemovedTag": "Odebráný štítek: {title}", + "ValueTooLarge": "Hodnota je příliš velká na zobrazení" } } \ No newline at end of file diff --git a/plugins/activity-assets/lang/de.json b/plugins/activity-assets/lang/de.json index 440a7e237e..2bdd15ee70 100644 --- a/plugins/activity-assets/lang/de.json +++ b/plugins/activity-assets/lang/de.json @@ -55,6 +55,7 @@ "RemovedObjectType": "{objectType} entfernt", "AttributeSetTo": "{attribute} auf {value} gesetzt", "AddedTag": "Tag hinzugefügt: {title}", - "RemovedTag": "Tag entfernt: {title}" + "RemovedTag": "Tag entfernt: {title}", + "ValueTooLarge": "Wert zu groß zum Anzeigen" } } \ No newline at end of file diff --git a/plugins/activity-assets/lang/en.json b/plugins/activity-assets/lang/en.json index e3181c7cae..c64251c31d 100644 --- a/plugins/activity-assets/lang/en.json +++ b/plugins/activity-assets/lang/en.json @@ -55,6 +55,7 @@ "RemovedObjectType": "Removed {type} : {title}", "AddedTag": "Added tag: {title}", "RemovedTag": "Removed tag: {title}", - "AttributeSetTo": "{name} set to {value}" + "AttributeSetTo": "{name} set to {value}", + "ValueTooLarge": "Value too large to display" } } \ No newline at end of file diff --git a/plugins/activity-assets/lang/es.json b/plugins/activity-assets/lang/es.json index fddad7133b..0621d47cf1 100644 --- a/plugins/activity-assets/lang/es.json +++ b/plugins/activity-assets/lang/es.json @@ -54,6 +54,7 @@ "RemovedObjectType": "Eliminado {type} : {title}", "AttributeSetTo": "{name} establecido en {value}", "AddedTag": "Etiqueta añadida: {title}", - "RemovedTag": "Etiqueta eliminada: {title}" + "RemovedTag": "Etiqueta eliminada: {title}", + "ValueTooLarge": "Valor demasiado grande para mostrar" } } \ No newline at end of file diff --git a/plugins/activity-assets/lang/fr.json b/plugins/activity-assets/lang/fr.json index 176cb6e628..b3ade63865 100644 --- a/plugins/activity-assets/lang/fr.json +++ b/plugins/activity-assets/lang/fr.json @@ -55,6 +55,7 @@ "RemovedObjectType": "Supprimé {type} : {title}", "AttributeSetTo": "{name} défini sur {value}", "AddedTag": "Tag ajouté: {title}", - "RemovedTag": "Tag supprimé: {title}" + "RemovedTag": "Tag supprimé: {title}", + "ValueTooLarge": "Valeur trop grande pour être affichée" } } \ No newline at end of file diff --git a/plugins/activity-assets/lang/it.json b/plugins/activity-assets/lang/it.json index 1099d1e8b6..af41d8e7a0 100644 --- a/plugins/activity-assets/lang/it.json +++ b/plugins/activity-assets/lang/it.json @@ -55,6 +55,7 @@ "RemovedObjectType": "{type} rimosso: {title}", "AttributeSetTo": "{name} impostato su {value}", "AddedTag": "Tag aggiunto: {title}", - "RemovedTag": "Tag rimosso: {title}" + "RemovedTag": "Tag rimosso: {title}", + "ValueTooLarge": "Valore troppo grande per essere visualizzato" } } diff --git a/plugins/activity-assets/lang/ja.json b/plugins/activity-assets/lang/ja.json index 7947e1e220..f577714659 100644 --- a/plugins/activity-assets/lang/ja.json +++ b/plugins/activity-assets/lang/ja.json @@ -55,7 +55,8 @@ "RemovedObjectType": "{type} を削除:{title}", "AttributeSetTo": "{name} を {value} に設定しました", "AddedTag": "タグを追加しました:{title}", - "RemovedTag": "タグを削除しました:{title}" + "RemovedTag": "タグを削除しました:{title}", + "ValueTooLarge": "表示するには値が大きすぎます" } } \ No newline at end of file diff --git a/plugins/activity-assets/lang/pt.json b/plugins/activity-assets/lang/pt.json index cc37b6f2f0..7cd2d7e266 100644 --- a/plugins/activity-assets/lang/pt.json +++ b/plugins/activity-assets/lang/pt.json @@ -54,6 +54,7 @@ "RemovedObjectType": "Removido {type} : {title}", "AttributeSetTo": "{name} definido para {value}", "AddedTag": "Tag adicionado: {title}", - "RemovedTag": "Tag removido: {title}" + "RemovedTag": "Tag removido: {title}", + "ValueTooLarge": "Valor demasiado grande para mostrar" } } \ No newline at end of file diff --git a/plugins/activity-assets/lang/ru.json b/plugins/activity-assets/lang/ru.json index fe8d330f44..751df9a565 100644 --- a/plugins/activity-assets/lang/ru.json +++ b/plugins/activity-assets/lang/ru.json @@ -55,6 +55,7 @@ "RemovedObjectType": "Удален(ы) {type} : {title}", "AttributeSetTo": "{name} установлен на {value}", "AddedTag": "Добавлен тег: {title}", - "RemovedTag": "Удален тег: {title}" + "RemovedTag": "Удален тег: {title}", + "ValueTooLarge": "Значение слишком велико для отображения" } } \ No newline at end of file diff --git a/plugins/activity-assets/lang/tr.json b/plugins/activity-assets/lang/tr.json new file mode 100644 index 0000000000..43bf631f3f --- /dev/null +++ b/plugins/activity-assets/lang/tr.json @@ -0,0 +1,61 @@ +{ + "string": { + "Activity": "Aktivite", + "Added": "eklendi", + "All": "Tümü", + "AllActivity": "Tüm aktiviteler", + "Attributes": "Öznitelikler", + "Changed": "Değiştirildi", + "CollectionUpdated": "{collection} güncellendi", + "Created": "Oluşturuldu", + "DocAdded": "{_class} eklendi", + "DocCreated": "{_class} oluşturuldu", + "DocDeleted": "{_class} silindi", + "Edit": "Düzenle", + "Edited": "Düzenlendi", + "Emoji": "Emoji", + "For": "İçin", + "From": "kaynak", + "In": "İçinde", + "At": "de", + "LastReply": "Son yanıt", + "New": "Yeni", + "NewestFirst": "Önce en yeniler", + "Pinned": "Sabitlendi", + "Reacted": "Tepki verildi", + "Reactions": "Tepkiler", + "Reaction": "Tepki", + "Removed": "kaldırıldı", + "Replies": "Yanıtlar", + "RepliesCount": "{replies, plural, =1 {# yanıt} other {# yanıt}}", + "Set": "ayarlandı", + "To": "hedef", + "Unset": "Kaldırıldı", + "Update": "Güncelle", + "Updated": "Güncellendi", + "UpdatedCollection": "Güncellendi", + "Message": "Mesaj", + "Mentioned": "Bahsedildi", + "You": "Sen", + "Mentions": "Bahsetmeler", + "MentionedYouIn": "{title} içinde senden bahsetti", + "Messages": "Mesajlar", + "Thread": "Başlık", + "AddReaction": "Tepki ekle", + "SaveForLater": "Sonraya kaydet", + "RemoveFromLater": "Kayıtlılardan kaldır", + "ReactionNotificationTitle": "{title} üzerine tepki", + "ReactionNotificationBody": "{senderName}: {reaction}", + "NewObject": "Yeni {object}", + "RemovedObject": "Kaldırılan {object}", + "ChangedObject": "Değişen {object}", + "UnsetObject": "Kaldırılan {object}", + "UpdatedObject": "Güncellenen {object}", + "NewObjectType": "Yeni {type}: {title}", + "RemovedObjectType": "Kaldırılan {type}: {title}", + "AddedTag": "Etiket eklendi: {title}", + "RemovedTag": "Etiket kaldırıldı: {title}", + "AttributeSetTo": "{name}, {value} olarak ayarlandı", + "ValueTooLarge": "Değer görüntülenemeyecek kadar büyük" + } +} diff --git a/plugins/activity-assets/lang/zh.json b/plugins/activity-assets/lang/zh.json index e3f17a5322..7b2f012c54 100644 --- a/plugins/activity-assets/lang/zh.json +++ b/plugins/activity-assets/lang/zh.json @@ -55,6 +55,7 @@ "RemovedObjectType": "移除 {type} : {title}", "AttributeSetTo": "{name} 设置为 {value}", "AddedTag": "添加标签: {title}", - "RemovedTag": "移除标签: {title}" + "RemovedTag": "移除标签: {title}", + "ValueTooLarge": "值过大,无法显示" } } diff --git a/plugins/activity-resources/src/activity.ts b/plugins/activity-resources/src/activity.ts index 47cab72ebe..5cf6e364e1 100644 --- a/plugins/activity-resources/src/activity.ts +++ b/plugins/activity-resources/src/activity.ts @@ -19,6 +19,7 @@ import { writable } from 'svelte/store' export const savedMessagesStore = writable>>([]) export const messageInFocus = writable | undefined>(undefined) +export const editingMessageStore = writable | undefined>(undefined) const savedMessagesQuery = createQuery(true) diff --git a/plugins/activity-resources/src/components/Activity.svelte b/plugins/activity-resources/src/components/Activity.svelte index 8b0587d567..9ba9f3d299 100644 --- a/plugins/activity-resources/src/components/Activity.svelte +++ b/plugins/activity-resources/src/components/Activity.svelte @@ -20,7 +20,7 @@ DisplayActivityMessage, WithReferences } from '@hcengineering/activity' - import { Class, Doc, Ref, SortingOrder } from '@hcengineering/core' + import { Class, Doc, getCurrentAccount, Ref, SortingOrder } from '@hcengineering/core' import { createQuery, getClient } from '@hcengineering/presentation' import { Grid, Section, Spinner, location, Lazy } from '@hcengineering/ui' import { onDestroy, onMount } from 'svelte' @@ -30,7 +30,7 @@ import { combineActivityMessages, sortActivityMessages } from '../activityMessagesUtils' import { canGroupMessages, getMessageFromLoc, getSpace, getActivityNewestFirst } from '../utils' import ActivityMessagePresenter from './activity-message/ActivityMessagePresenter.svelte' - import { messageInFocus } from '../activity' + import { editingMessageStore, messageInFocus } from '../activity' export let object: WithReferences export let showCommenInput: boolean = true @@ -125,7 +125,7 @@ }, delay) } - async function scrollToMessage (id?: Ref): Promise { + async function scrollToMessage (id?: Ref, withoutAnimation?: boolean): Promise { if (!id || boundary == null || activityBox == null) { return } @@ -144,7 +144,9 @@ isAutoScroll = true prevScrollTimestamp = 0 - restartAnimation(msgElement) + if (!withoutAnimation) { + restartAnimation(msgElement) + } msgElement.scrollIntoView({ behavior: 'instant' }) } @@ -250,6 +252,41 @@ } $: void updateActivityMessages(object._id, isNewestFirst ? SortingOrder.Descending : SortingOrder.Ascending) + + export function editLastMessage (): void { + if (isMessagesLoading) return + + const me = getCurrentAccount() + const mySocialIds = new Set(me.socialIds) + + const start = isNewestFirst ? 0 : filteredMessages.length - 1 + const end = isNewestFirst ? filteredMessages.length : -1 + const step = isNewestFirst ? 1 : -1 + + let lastMessage: ActivityMessage | undefined + + for (let i = start; i !== end; i += step) { + const m = filteredMessages[i] + if (m.collection === 'comments' && m.createdBy != null && mySocialIds.has(m.createdBy)) { + lastMessage = m + break + } + } + + if (lastMessage == null) return + editingMessageStore.set(lastMessage._id) + + void scrollToMessage(lastMessage._id, true) + } + + function handleKeyDown (e: KeyboardEvent): void { + const key = e.key + + if ((key === 'ArrowUp' && !isNewestFirst) || (key === 'ArrowDown' && isNewestFirst)) { + if ($editingMessageStore !== undefined) return + editLastMessage() + } + }
@@ -275,7 +312,7 @@
{/if} @@ -319,7 +356,7 @@ {/if} diff --git a/plugins/activity-resources/src/components/ActivityFilter.svelte b/plugins/activity-resources/src/components/ActivityFilter.svelte index 3858f029e9..f9389f3a83 100644 --- a/plugins/activity-resources/src/components/ActivityFilter.svelte +++ b/plugins/activity-resources/src/components/ActivityFilter.svelte @@ -24,7 +24,7 @@ import activity from '../plugin' import FilterPopup from './FilterPopup.svelte' import IconClose from './icons/Close.svelte' - import { sortActivityMessages } from '../activityMessagesUtils' + import { activityMessagesComparator } from '../activityMessagesUtils' import { setActivityNewestFirst } from '../utils' export let messages: ActivityMessage[] @@ -91,9 +91,13 @@ selected: Ref[] | Ref, sortOrder: SortingOrder ): Promise { - const sortedMessages = sortActivityMessages(messages, sortOrder).sort(({ isPinned }) => - isPinned && sortOrder === SortingOrder.Ascending ? -1 : 1 - ) + const baseComparator = (m1: ActivityMessage, m2: ActivityMessage): number => + sortOrder === SortingOrder.Ascending ? activityMessagesComparator(m1, m2) : activityMessagesComparator(m2, m1) + const sortedMessages = messages.sort((message1, message2) => { + const isPinned1 = message1.isPinned ?? false + const isPinned2 = message2.isPinned ?? false + return isPinned1 === isPinned2 ? baseComparator(message1, message2) : Number(isPinned2) - Number(isPinned1) + }) if (selected === allId) { filtered = sortedMessages diff --git a/plugins/activity-resources/src/components/doc-update-message/attributes/SetAttributesPresenter.svelte b/plugins/activity-resources/src/components/doc-update-message/attributes/SetAttributesPresenter.svelte index 222ec50551..5dc87f8c75 100644 --- a/plugins/activity-resources/src/components/doc-update-message/attributes/SetAttributesPresenter.svelte +++ b/plugins/activity-resources/src/components/doc-update-message/attributes/SetAttributesPresenter.svelte @@ -33,6 +33,8 @@ $: isUnset = values.length > 0 && !values.some((value) => value != null && value !== '') $: isTextType = getIsTextType(attributeModel) + $: isTooLarge = + isTextType && (values[0] === activity.string.ValueTooLarge || prevValue === activity.string.ValueTooLarge) let isDiffShown = false @@ -48,7 +50,11 @@ {:else if isTextType} - {#if preview} + {#if isTooLarge} +
+
+ {:else if preview}
diff --git a/plugins/chat-resources/src/location.ts b/plugins/chat-resources/src/location.ts index 06e8bd44c8..1dd655b6b4 100644 --- a/plugins/chat-resources/src/location.ts +++ b/plugins/chat-resources/src/location.ts @@ -142,7 +142,11 @@ export async function resolveLocationData (loc: Location): Promise return {} } - return { name: object.title } + return { + name: object.title, + objectId: object._id, + objectClass: object._class + } } if (typeId !== undefined) { diff --git a/plugins/chunter-assets/lang/tr.json b/plugins/chunter-assets/lang/tr.json new file mode 100644 index 0000000000..358ad9f13c --- /dev/null +++ b/plugins/chunter-assets/lang/tr.json @@ -0,0 +1,138 @@ +{ + "string": { + "ApplicationLabelChunter": "Sohbet", + "LeftComment": "yorum bıraktı", + "Channels": "Kanallar", + "DirectMessages": "Direkt mesajlar", + "CreateChannel": "Yeni Kanal", + "NewDirectMessage": "Yeni Direkt Mesaj", + "ChannelName": "İsim", + "ChannelNamePlaceholder": "Kanal", + "ChannelDescription": "Açıklama", + "Channel": "Kanal", + "DirectMessage": "Direkt mesaj", + "EditUpdate": "Kaydet...", + "EditCancel": "İptal", + "Comments": "Yorumlar", + "About": "Hakkında", + "Members": "Üyeler", + "NoMembers": "Üye yok", + "MentionedIn": "Senden bahsetti: ", + "Mentioned": "Bahsedildi", + "ContactInfo": "İletişim Bilgisi", + "Content": "İçerik", + "Comment": "Yorum", + "Message": "Mesaj", + "MessageOn": "Mesaj tarihi", + "On": "tarihinde", + "Reference": "Referans", + "Chat": "Sohbet", + "In": "İçinde", + "MentionNotification": "Bahsedildi", + "Topic": "Konu", + "Thread": "Başlık", + "Threads": "Başlıklar", + "New": "Yeni", + "MarkUnread": "Okunmadı olarak işaretle", + "GetNewReplies": "Yeni yanıtlardan haberdar ol", + "TurnOffReplies": "Yanıt bildirimlerini kapat", + "PinMessage": "Mesajı sabitle", + "UnpinMessage": "Sabitleneni kaldır", + "Pinned": "Sabitlendi", + "EditMessage": "Mesajı düzenle", + "Edited": "düzenlendi", + "DeleteMessage": "Mesajı sil", + "AndYou": "{participants, plural, =0 {Sadece sen} other {ve sen}}", + "ShowMoreReplies": "{count} yanıt daha göster", + "Settings": "Ayarlar", + "ArchiveChannel": "Kanalı arşivle", + "UnarchiveChannel": "Arşivden çıkar", + "ArchiveConfirm": "Bir kanalı arşivlediğinizde, herkes için arşivlenir.

Kimse mesaj gönderemez, ancak kanalın geçmişine erişim devam eder.

Kanalın içeriğini arama yoluyla bulabilirsiniz.
Ve gerekirse gelecekte kanalı arşivden çıkarabilirsiniz.\n", + "UnarchiveConfirm": "Kanalı arşivden çıkarmak istiyor musunuz?", + "AddToSaved": "Kayıtlılara ekle", + "RemoveFromSaved": "Kayıtlılardan kaldır", + "EmptySavedHeader": "Daha sonra dönmek için mesajlar ekleyin", + "EmptySavedText": "Yapılacaklarınızı işaretleyin veya bir şeyi başka bir zamana kaydedin. Kayıtlı öğelerinizi sadece siz görebilirsiniz, bu yüzden istediğiniz gibi kullanın.", + "SharedBy": "{name} tarafından {time} paylaşıldı", + "LeaveChannel": "Kanaldan ayrıl", + "ChannelBrowser": "Kanal tarayıcı", + "Saved": "Kayıtlı", + "ConvertToPrivate": "Özel kanala dönüştür", + "MessagesBrowser": "Mesaj tarayıcı", + "CreateBy": "Oluşturan", + "ThreadMessage": "Başlık mesajı", + "ChunterBrowser": "Tarayıcı", + "Messages": "Mesajlar", + "NoResults": "Sonuç yok", + "CopyLink": "Bağlantıyı kopyala", + "FilterBacklinks": "Geri bağlantılar", + "DM": "Direkt mesaj", + "DMNotification": "Size mesaj gönderdi", + "ConfigLabel": "Sohbet", + "ConfigDescription": "Metin iletişimi gerçekleştirmek için uzantı", + "LastMessage": "Son mesaj", + "You": "Sen", + "YouHaveJoinedTheConversation": "Sohbete katıldınız", + "NoMessages": "Henüz mesaj yok", + "DirectNotificationTitle": "{senderName}", + "DirectNotificationBody": "{message}", + "MessageNotificationBody": "{senderName}: {message}", + "AddCommentPlaceholder": "Yorum ekle...", + "Reacted": "Tepki verildi", + "Docs": "Dokümanlar", + "NewestFirst": "Önce en yeniler", + "ReplyToThread": "Başlığa yanıt ver", + "SentMessage": "Mesaj gönderildi", + "Direct": "Direkt", + "RepliedToThread": "Başlığa yanıt verildi", + "RepliedTo": "yanıtlandı: {message}", + "AllChannels": "Tüm kanallar", + "AllContacts": "Tüm kişiler", + "NewChannel": "Yeni kanal", + "TopicOptional": "Konu (opsiyonel)", + "Visibility": "Görünürlük", + "Public": "Genel", + "Private": "Özel", + "NewDirectChat": "Yeni direkt sohbet", + "AddMembers": "Üye ekle", + "PinnedCount": "{count} sabitlendi", + "LoadingHistory": "Geçmiş yükleniyor...", + "UnpinChannels": "Tüm kanalların sabitlemesini kaldır", + "ArchiveActivityConfirmationTitle": "Tüm aktivite kanallarını arşivle?", + "ArchiveActivityConfirmationMessage": "Tüm aktivite kanallarını arşivlemek istediğinizden emin misiniz? Bu işlem geri alınamaz.", + "CloseConversation": "Sohbeti kapat", + "Starred": "Yıldızlı", + "DeleteStarred": "Yıldızlıyı sil", + "StarChannel": "Kanalı yıldızla", + "StarConversation": "Sohbeti yıldızla", + "UnstarChannel": "Kanal yıldızını kaldır", + "UnstarConversation": "Sohbet yıldızını kaldır", + "JoinChannelHeader": "Başlamak için \"Katıl\"a tıklayın.", + "JoinChannelText": "Katıldıktan sonra tüm mesajları okuyabilir ve tartışmaya katkıda bulunabilirsiniz.", + "NoMessagesInChannel": "Şu anda mesaj yok", + "SendMessagesInChannel": "Sohbeti başlatmak için ilk mesajı gönderin", + "LatestMessages": "↓ Son mesajlar", + "Joined": "Katıldı", + "Left": "Ayrıldı", + "Added": "Eklendi", + "Removed": "Kaldırıldı", + "CreatedChannelOn": "Bu kanal {date} tarihinde oluşturuldu", + "ChannelMessages": "Kanal mesajları", + "JoinChannel": "Kanala katıl", + "YouJoinedChannel": "Kanala katıldınız", + "AndMore": "ve {count} daha", + "IsTyping": "{count, plural, =1 {yazıyor} other {yazıyorlar}}...", + "ThreadIn": "{name} içinde başlık", + "TranslateMessage": "Mesajı çevir", + "Translate": "Çevir", + "ShowOriginal": "Orijinali göster", + "Translating": "Çevriliyor...", + "StartConversation": "Sohbet başlat", + "ViewingThreadFromArchivedChannel": "Arşivlenmiş bir kanaldan başlık görüntülüyorsunuz", + "ViewingArchivedChannel": "Arşivlenmiş bir kanal görüntülüyorsunuz", + "OpenChatInSidebar": "Sohbeti kenar çubuğunda aç", + "ResolveThread": "Çöz", + "NoThreadsYet": "Henüz başlık yok.", + "SummarizeMessages": "Tartışmayı özetle" + } +} diff --git a/plugins/chunter-resources/src/components/ChannelInput.svelte b/plugins/chunter-resources/src/components/ChannelInput.svelte index ccc9c02594..d8823b7545 100644 --- a/plugins/chunter-resources/src/components/ChannelInput.svelte +++ b/plugins/chunter-resources/src/components/ChannelInput.svelte @@ -30,6 +30,7 @@ export let collection: string | undefined export let isThread = false export let autofocus = true + export let onKeyDown: ((e: KeyboardEvent) => void) | undefined = undefined const client = getClient() const hierarchy = client.getHierarchy() @@ -86,7 +87,7 @@ {:else} diff --git a/plugins/chunter-resources/src/components/ChannelTypingInfo.svelte b/plugins/chunter-resources/src/components/ChannelTypingInfo.svelte index ec0064ed1b..bb48155ef1 100644 --- a/plugins/chunter-resources/src/components/ChannelTypingInfo.svelte +++ b/plugins/chunter-resources/src/components/ChannelTypingInfo.svelte @@ -13,13 +13,12 @@ // limitations under the License. --> - + {#if typingPersonsLabel !== ''} {typingPersonsLabel} diff --git a/plugins/chunter-resources/src/components/DirectMessageButton.svelte b/plugins/chunter-resources/src/components/DirectMessageButton.svelte index e0cfbff3df..dc10f34e69 100644 --- a/plugins/chunter-resources/src/components/DirectMessageButton.svelte +++ b/plugins/chunter-resources/src/components/DirectMessageButton.svelte @@ -44,12 +44,22 @@ } - +
+ +
+ + diff --git a/plugins/chunter-resources/src/components/ReverseChannelScrollView.svelte b/plugins/chunter-resources/src/components/ReverseChannelScrollView.svelte index f8f0eddd83..a9f1076d63 100644 --- a/plugins/chunter-resources/src/components/ReverseChannelScrollView.svelte +++ b/plugins/chunter-resources/src/components/ReverseChannelScrollView.svelte @@ -14,17 +14,23 @@ -->
@@ -641,7 +681,15 @@ {/if} {#if !fixedInput && withInput && !readonly} - + {/if} {#if !isThread && isLatestMessageButtonVisible} @@ -658,7 +706,15 @@
{#if fixedInput && withInput && !readonly} - + {/if} {#if readonly} diff --git a/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte b/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte index 6245426a01..de11b4982f 100644 --- a/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte +++ b/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte @@ -23,8 +23,9 @@ import { createEventDispatcher } from 'svelte' import { getObjectId } from '@hcengineering/view-resources' import { ThrottledCaller } from '@hcengineering/ui' - import { getSpace } from '@hcengineering/activity-resources' + import { getSpace, editingMessageStore } from '@hcengineering/activity-resources' import { getCurrentEmployee } from '@hcengineering/contact' + import { getChannelSpace } from '../../utils' import ChannelTypingInfo from '../ChannelTypingInfo.svelte' @@ -37,6 +38,7 @@ export let collection: string = 'comments' export let autofocus = false export let withTypingInfo = false + export let onKeyDown: ((e: KeyboardEvent) => void) | undefined = undefined import { setTyping, clearTyping } from '@hcengineering/presence-resources' @@ -102,14 +104,14 @@ async function deleteTypingInfo (): Promise { if (!withTypingInfo) return - clearTyping(me, object._id) + void clearTyping(me, object._id) } async function updateTypingInfo (): Promise { if (!withTypingInfo) return throttle.call(() => { - setTyping(me, object._id) + void setTyping(me, object._id) }) } @@ -216,6 +218,22 @@ export function submit (): void { inputRef.submit() } + + function handleKeyDown (event: KeyboardEvent): boolean { + if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { + if (inputRef.isEmptyDraft() && chatMessage == null) { + onKeyDown?.(event) + } + } + + if (event.key === 'Escape') { + if ($editingMessageStore === undefined) return false + event.stopPropagation() + event.preventDefault() + editingMessageStore.set(undefined) + } + return false + } {#if withTypingInfo} diff --git a/plugins/chunter-resources/src/components/chat-message/ChatMessagePresenter.svelte b/plugins/chunter-resources/src/components/chat-message/ChatMessagePresenter.svelte index 7f9c9e9b9c..b6615b3127 100644 --- a/plugins/chunter-resources/src/components/chat-message/ChatMessagePresenter.svelte +++ b/plugins/chunter-resources/src/components/chat-message/ChatMessagePresenter.svelte @@ -14,7 +14,12 @@ --> {#if window !== undefined && window.hasPrevPage()} diff --git a/plugins/communication-resources/src/components/ReactionPresenter.svelte b/plugins/communication-resources/src/components/ReactionPresenter.svelte index 899a321547..22b3c99d79 100644 --- a/plugins/communication-resources/src/components/ReactionPresenter.svelte +++ b/plugins/communication-resources/src/components/ReactionPresenter.svelte @@ -13,7 +13,7 @@ @@ -34,7 +34,7 @@ class:selected class:active on:click - use:tooltip={(count ?? 0) > 0 ? { component: ReactionsTooltip, props: { socialIds } } : undefined} + use:tooltip={(count ?? 0) > 0 ? { component: ReactionsTooltip, props: { persons, emoji } } : undefined} >
{#if icon} @@ -43,7 +43,7 @@ {/if}
- {#if count !== undefined} + {#if count !== undefined && count > 0}
{count}
{/if} diff --git a/plugins/communication-resources/src/components/ReactionsList.svelte b/plugins/communication-resources/src/components/ReactionsList.svelte index 6a4f444d9b..da5da03a9b 100644 --- a/plugins/communication-resources/src/components/ReactionsList.svelte +++ b/plugins/communication-resources/src/components/ReactionsList.svelte @@ -14,13 +14,13 @@
- {#each reactionsByEmoji as [emoji, reactions] (emoji)} - me.socialIds.includes(it.creator))} - socialIds={reactions.map((it) => it.creator)} - count={reactions.length} - on:click={() => dispatch('click', emoji)} - /> + {#each Object.entries(reactions) as [emoji, data] (emoji)} + {#if data.length > 0} + it.person === me.uuid)} + persons={data.map((it) => it.person)} + count={data.length} + on:click={() => dispatch('click', emoji)} + /> + {/if} {/each}
diff --git a/plugins/communication-resources/src/components/ReactionsTooltip.svelte b/plugins/communication-resources/src/components/ReactionsTooltip.svelte index 1485a651dc..88c3ec3cd8 100644 --- a/plugins/communication-resources/src/components/ReactionsTooltip.svelte +++ b/plugins/communication-resources/src/components/ReactionsTooltip.svelte @@ -12,21 +12,53 @@
- {#each persons as person (person)} - +
+ + {#if shortCode} + + :{shortCode}: + + {/if} +
+ {#each employees as emp (emp._id)} + {/each}
+ + diff --git a/plugins/communication-resources/src/components/TextInput.svelte b/plugins/communication-resources/src/components/TextInput.svelte index a5d4c3209f..eb1feec2a7 100644 --- a/plugins/communication-resources/src/components/TextInput.svelte +++ b/plugins/communication-resources/src/components/TextInput.svelte @@ -37,6 +37,7 @@ export let autofocus: FocusPosition = false export let onCancel: (() => void) | undefined = undefined export let onPaste: ((view: EditorView, event: ClipboardEvent) => boolean) | undefined = undefined + export let onKeyDown: ((view: EditorView, event: KeyboardEvent) => boolean) | undefined = undefined const dispatch = createEventDispatcher() @@ -140,6 +141,7 @@ {placeholder} {placeholderParams} {onPaste} + {onKeyDown} on:content={(ev) => { if (canSubmit(loading, content, hasChanges)) { dispatch('submit', ev.detail) diff --git a/plugins/communication-resources/src/components/TypingPresenter.svelte b/plugins/communication-resources/src/components/TypingPresenter.svelte index cbf8f74c49..8a804078ef 100644 --- a/plugins/communication-resources/src/components/TypingPresenter.svelte +++ b/plugins/communication-resources/src/components/TypingPresenter.svelte @@ -11,13 +11,12 @@ - + {#if typingPersonsLabel !== ''} {typingPersonsLabel} diff --git a/plugins/communication-resources/src/components/message/MessageBody.svelte b/plugins/communication-resources/src/components/message/MessageBody.svelte index ed2efde337..33f686c1ff 100644 --- a/plugins/communication-resources/src/components/message/MessageBody.svelte +++ b/plugins/communication-resources/src/components/message/MessageBody.svelte @@ -18,7 +18,7 @@ import { formatName, Person } from '@hcengineering/contact' import { Message } from '@hcengineering/communication-types' import { Card } from '@hcengineering/card' - import { IconDelete, Label } from '@hcengineering/ui' + import { Label } from '@hcengineering/ui' import communication from '../../plugin' import MessageInput from './MessageInput.svelte' @@ -34,7 +34,11 @@ export let compact: boolean = false export let hideAvatar: boolean = false export let hideHeader: boolean = false - export let thread: boolean = true + export let showThreads: boolean = true + export let collapsible: boolean = true + export let maxHeight: string = '30rem' + + let isShowMoreActive: boolean = false function formatDate (date: Date): string { return date.toLocaleTimeString('default', { @@ -58,8 +62,8 @@
{#if !isEditing && message.content !== ''} -
- +
+
{:else if isEditing} {/if} {#if !isEditing} - + {/if}
@@ -82,46 +86,40 @@
{#if !hideAvatar}
- {#if !message.removed} - - - - {:else} - - {/if} + + +
{/if}
- {#if !message.removed} - -
- {formatName(author?.name ?? '')} -
-
- {/if} + +
+ {formatName(author?.name ?? '')} +
+
{formatDate(message.created)}
- {#if message.edited && !message.removed} + {#if message.modified}
(
{/if} - {#if !message.removed && $translateMessagesStore.get(message.id)?.inProgress === true} + {#if $translateMessagesStore.get(message.id)?.inProgress === true}
{/if} - {#if !message.removed && $translateMessagesStore.get(message.id)?.shown === true} + {#if $translateMessagesStore.get(message.id)?.shown === true}
showOriginalMessage(message, card)}>
{/if}
{#if !isEditing} -
- +
+
{:else if isEditing} {/if} {#if !isEditing} - + {/if}
@@ -225,6 +223,11 @@ max-width: 100%; user-select: text; flex: 1; + + &.with-showmore { + position: relative; // This ensures ShowMore button positions relative to this container + margin-bottom: 1rem; + } } .time-container { diff --git a/plugins/communication-resources/src/components/message/MessageContentViewer.svelte b/plugins/communication-resources/src/components/message/MessageContentViewer.svelte index ce287aa779..97fffa5687 100644 --- a/plugins/communication-resources/src/components/message/MessageContentViewer.svelte +++ b/plugins/communication-resources/src/components/message/MessageContentViewer.svelte @@ -17,20 +17,22 @@ import { MessageViewer as MarkupMessageViewer } from '@hcengineering/presentation' import { Markdown, Message, MessageID } from '@hcengineering/communication-types' import { Card } from '@hcengineering/card' - import { Label } from '@hcengineering/ui' import { Person } from '@hcengineering/contact' import { Markup } from '@hcengineering/core' + import { ShowMore } from '@hcengineering/ui' import ActivityMessageViewer from './ActivityMessageViewer.svelte' import { toMarkup } from '../../utils' import { isActivityMessage } from '../../activity' - import communication from '../../plugin' import { isShownTranslatedMessage, TranslateMessagesStatus, translateMessagesStore } from '../../stores' import { translateMessage } from '../../actions' export let card: Card export let message: Message export let author: Person | undefined + export let collapsible: boolean = true + export let maxHeight: string = '30rem' + export let isShowMoreActive: boolean = false let displayMarkup: Markup = toMarkup(message.content) let prevContent: Markdown | undefined = undefined @@ -57,20 +59,20 @@ }) } } + + function getMaxSize (maxHeight: string): number { + const remValue = parseFloat(maxHeight.replace('rem', '')) + if (isNaN(remValue) || remValue <= 0) { + return 480 // 30rem * 16px + } + return remValue * 16 + } -{#if isActivityMessage(message)} - -{:else if message.removed} - - -{:else} - -{/if} - - + + {#if isActivityMessage(message)} + + {:else} + + {/if} + diff --git a/plugins/communication-resources/src/components/message/MessageFooter.svelte b/plugins/communication-resources/src/components/message/MessageFooter.svelte index 734a35b957..1f1714454c 100644 --- a/plugins/communication-resources/src/components/message/MessageFooter.svelte +++ b/plugins/communication-resources/src/components/message/MessageFooter.svelte @@ -15,10 +15,10 @@ -{#if applets.length > 0 && !message.removed} +{#if applets.length > 0}
{#each applets as applet (applet.id)} - {@const appletModel = appletsModels.find((it) => it.type === applet.type)} + {@const appletModel = appletsModels.find((it) => it.type === applet.mimeType)} {#if appletModel} {/if} -{#if blobs.length > 0 && !message.removed} +{#if blobs.length > 0}
{#each blobs as blob (blob.id)} {/if} -{#if links.length > 0 && !message.removed} +{#if links.length > 0}