diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index dbbad43dcc..4a951af8af 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -485,6 +485,11 @@ jobs: cd ./ws-tests export DO_CLEAN=true ./prepare.sh + - name: Run API tests + run: | + cd ./ws-tests/api-tests + node ../../common/scripts/install-run-rush.js validate --to @hcengineering/api-tests + node ../../common/scripts/install-run-rushx.js api-test --verbose - name: Install Playwright run: | cd ./ws-tests/sanity diff --git a/.vscode/launch.json b/.vscode/launch.json index d78c98e888..0100c0e635 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -77,11 +77,11 @@ "args": ["src/__start.ts"], "env": { "FULLTEXT_URL": "http://localhost:4710", - // "DB_URL": "mongodb://localhost:27018", + "DB_URL": "mongodb://localhost:27018", // "DB_URL": "postgresql://postgres:example@localhost:5432", - "DB_URL": "postgresql://root@huly.local:26258/defaultdb?sslmode=disable", + // "DB_URL": "postgresql://root@huly.local:26258/defaultdb?sslmode=disable", // "GREEN_URL": "http://huly.local:6767?token=secret", - "SERVER_PORT": "3335", + "SERVER_PORT": "3334", "METRICS_CONSOLE": "false", "METRICS_FILE": "${workspaceRoot}/metrics.txt", // Show metrics in console evert 30 seconds., "STORAGE_CONFIG": "minio|localhost?accessKey=minioadmin&secretKey=minioadmin", diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index ef6c65ba48..e619e7bfcf 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -109,6 +109,9 @@ importers: '@rush-temp/api-client': specifier: file:./projects/api-client.tgz version: file:projects/api-client.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(bufferutil@4.0.8)(esbuild@0.24.2)(utf-8-validate@6.0.4) + '@rush-temp/api-tests': + specifier: file:./projects/api-tests.tgz + version: file:projects/api-tests.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3)) '@rush-temp/attachment': specifier: file:./projects/attachment.tgz version: file:projects/attachment.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@types/node@20.11.19)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3)) @@ -4012,6 +4015,10 @@ packages: resolution: {integrity: sha512-SMKCkubeb8nE1zsYu0fsutpc9LJKiNOG0bn9NP+FxlSU4NGb8++EpZfb650lGYdvg2MljvrQD+kqYQDt1IRd1A==, tarball: file:projects/api-client.tgz} version: 0.0.0 + '@rush-temp/api-tests@file:projects/api-tests.tgz': + resolution: {integrity: sha512-gleyNro85I24sFWGaNjqqz1AF2c8sugQ4vZX/UD/OVu+It6mI96P2Zd8pDl9FqPaIUvtYng8ePj0ThjwI5uGFw==, tarball: file:projects/api-tests.tgz} + version: 0.0.0 + '@rush-temp/attachment-assets@file:projects/attachment-assets.tgz': resolution: {integrity: sha512-C4ZvrB9y7H0bh1vbkqmKw9tIrwFAHUG7P6kPI+tjRPRKQNGAn61eiVSTpM8BCGNY/mFC5b+uMy0us0UaqvPy+A==, tarball: file:projects/attachment-assets.tgz} version: 0.0.0 @@ -4353,11 +4360,11 @@ packages: version: 0.0.0 '@rush-temp/mail-resources@file:projects/mail-resources.tgz': - resolution: {integrity: sha512-MArPZYwi2jollp/LHmrsx6wqyZzX/2XwjptpOg/10kADjpwo6wShL95ABMx5sCZo3+8paGXU1PipjnikbpNc0w==, tarball: file:projects/mail-resources.tgz} + resolution: {integrity: sha512-495MjItvq3ep8/hMbrw8ITDPZQ8oNC+5h1ZLTU0uCLUAOVgu1BFVpRtBZ8nFAIxK2Aks5JpKg9No2070QDjDaQ==, tarball: file:projects/mail-resources.tgz} version: 0.0.0 '@rush-temp/mail@file:projects/mail.tgz': - resolution: {integrity: sha512-0UzQEN733mDUZKfsGvNq8ikfsbVjQgTFOzP7/+buwIfWgeZBqVCoCDJrk90ZQ38yp8dMgdWZ+OymfTZ4WNlb7g==, tarball: file:projects/mail.tgz} + resolution: {integrity: sha512-Ld099uwXEeUid9s0GYHmgUKkctWnDc6D+yppNRmJhnf7VWtInqIPM2S3vl1kQi7pj0LwUY8ZKQ8B6m7Wxgj1Pw==, tarball: file:projects/mail.tgz} version: 0.0.0 '@rush-temp/middleware@file:projects/middleware.tgz': @@ -4461,7 +4468,7 @@ packages: version: 0.0.0 '@rush-temp/model-mail@file:projects/model-mail.tgz': - resolution: {integrity: sha512-/GxdWziahn6TAFJ4/9V7YPatdGo68lWecWkOyMVdBtPFl9VcPsxYKt3cqWz9qk2G6UxV2k2bmI0qt8kg1anxUA==, tarball: file:projects/model-mail.tgz} + resolution: {integrity: sha512-ZJ0BKaCme1heHlFvWPZO419uFncRPU832X/ce17INounlmwjTR8O/V6DBbeSn+VPJnhVq9LIZurd7REYrP+bjA==, tarball: file:projects/model-mail.tgz} version: 0.0.0 '@rush-temp/model-my-space@file:projects/model-my-space.tgz': @@ -4769,7 +4776,7 @@ packages: version: 0.0.0 '@rush-temp/pod-calendar@file:projects/pod-calendar.tgz': - resolution: {integrity: sha512-gUk3jshnHJ0CUucxZ1Stk8oQ2DTdSVfJzFHIl/EBUISbRv/YySoXesQCnqfn1kVpqOBZdIvI/N4NRAL/yREbWw==, tarball: file:projects/pod-calendar.tgz} + resolution: {integrity: sha512-ZZGuUNNBPQ8AuNUoq5jWopHtN7BExJBYPaEv+CULuyunTRzYli3xe/AKDM6pYQ/gR4ePMrPjnqcOeC3MBCeXUA==, tarball: file:projects/pod-calendar.tgz} version: 0.0.0 '@rush-temp/pod-collaborator@file:projects/pod-collaborator.tgz': @@ -4801,7 +4808,7 @@ packages: version: 0.0.0 '@rush-temp/pod-server@file:projects/pod-server.tgz': - resolution: {integrity: sha512-U7wNYw+h8a6hEV6fwXYJFStTk5noW4LAsL1G4JCJjyQ7PVzlmXZqGncFe+bq5EqgYgbOewZ2tR+T8rB8HWi9Cw==, tarball: file:projects/pod-server.tgz} + resolution: {integrity: sha512-MJ5TQobQy4kv67qy7qpQgz056fhaZw8Zs/NaXniFafG/xjstgWeIJCXNHDkKvdBm+wT6EdHgNLIN3BaRpMSDsA==, tarball: file:projects/pod-server.tgz} version: 0.0.0 '@rush-temp/pod-ses@file:projects/pod-ses.tgz': @@ -4989,7 +4996,7 @@ packages: version: 0.0.0 '@rush-temp/server-calendar-resources@file:projects/server-calendar-resources.tgz': - resolution: {integrity: sha512-92TFFDbAxCmUu4V3IaNDOLhbEEqTCB5dRohzpV1nzmroO8r+JneZjdV1j8OPHJF65ml0uqciDFPOct+kMzx5fg==, tarball: file:projects/server-calendar-resources.tgz} + resolution: {integrity: sha512-DMDh8Zn9OPu/Sg3ChbNu6iCcoVhvlqqd61hCTSpSlxxRJ+IdKQGlsKexccEgB87IgiYqAgM5AQMK05WaOaR+oQ==, tarball: file:projects/server-calendar-resources.tgz} version: 0.0.0 '@rush-temp/server-calendar@file:projects/server-calendar.tgz': @@ -5245,7 +5252,7 @@ packages: version: 0.0.0 '@rush-temp/server-ws@file:projects/server-ws.tgz': - resolution: {integrity: sha512-Sev25LbpscG4MEnofW4g16Wsd4CzBMuFd1WqiVKwCOBPQEFxrH6zQ9wt20gHcjKacPZfn4GMFWuHew0H/ThuqQ==, tarball: file:projects/server-ws.tgz} + resolution: {integrity: sha512-0Jjc3+GOrp80rAP/J2kmp3AXBYF9fHOmISUbnmAorBQaj/I+H2vLmUSGBOunFFmRoU4RBfF2pWNjw3PvVjE9oA==, tarball: file:projects/server-ws.tgz} version: 0.0.0 '@rush-temp/server@file:projects/server.tgz': @@ -5433,7 +5440,7 @@ packages: version: 0.0.0 '@rush-temp/ui@file:projects/ui.tgz': - resolution: {integrity: sha512-RoUDfXdl8E3vT/wG4bVrY9FsfUPeqG9Bck6lQsnKj9kk/Q6gG7a4xl3qEkwk+qLU6qH6QD7lkUiaEbm4PXh5MA==, tarball: file:projects/ui.tgz} + resolution: {integrity: sha512-Qnk2xLA3fSpGq0eNpEGm8DWiuRkDbF0B86v+GDmHbRmNOs9uxIy1sUEEPUIgNx1wmlHPrMh+vP+2v+VFzeUYUQ==, tarball: file:projects/ui.tgz} version: 0.0.0 '@rush-temp/uploader-assets@file:projects/uploader-assets.tgz': @@ -5453,7 +5460,7 @@ packages: version: 0.0.0 '@rush-temp/view-resources@file:projects/view-resources.tgz': - resolution: {integrity: sha512-QbVy5YO1NViW1f65Z7SqHS8XPq5IBtsy1uwVYZK6La5zBXjjJA3BgN960sYc1eWUoKIz3naElQUKzXMC1lYK9w==, tarball: file:projects/view-resources.tgz} + resolution: {integrity: sha512-7AFAEQlszTmRCzi9xfKegIhjzl76W8lLW4/LLygoH7a8JZPqoJhP1/l99QhNwa0tpUsXFTMg1EYMucFMrXhMdQ==, tarball: file:projects/view-resources.tgz} version: 0.0.0 '@rush-temp/view@file:projects/view.tgz': @@ -16490,6 +16497,41 @@ snapshots: - supports-color - utf-8-validate + '@rush-temp/api-tests@file:projects/api-tests.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3))': + dependencies: + '@types/body-parser': 1.19.5 + '@types/compression': 1.7.5 + '@types/cors': 2.8.17 + '@types/express': 4.17.21 + '@types/jest': 29.5.12 + '@types/morgan': 1.9.9 + '@types/node': 20.11.19 + '@types/ws': 8.5.11 + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.3.3))(eslint@8.56.0)(typescript@5.7.3) + '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.7.3) + bufferutil: 4.0.8 + 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.3.3))(eslint@8.56.0)(typescript@5.3.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.7.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@20.11.19)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3)) + prettier: 3.2.5 + snappyjs: 0.7.0 + 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.24.2)(jest@29.7.0(@types/node@20.11.19)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3)))(typescript@5.7.3) + typescript: 5.7.3 + utf-8-validate: 6.0.4 + ws: 8.18.0(bufferutil@4.0.8)(utf-8-validate@6.0.4) + transitivePeerDependencies: + - '@babel/core' + - '@jest/types' + - babel-jest + - babel-plugin-macros + - esbuild + - node-notifier + - supports-color + - ts-node + '@rush-temp/attachment-assets@file:projects/attachment-assets.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3))': dependencies: '@types/jest': 29.5.12 @@ -24623,6 +24665,7 @@ snapshots: '@types/cors': 2.8.17 '@types/express': 4.17.21 '@types/jest': 29.5.12 + '@types/morgan': 1.9.9 '@types/node': 20.11.19 '@types/ws': 8.5.11 '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.3.3))(eslint@8.56.0)(typescript@5.3.3) @@ -24637,6 +24680,7 @@ snapshots: eslint-plugin-promise: 6.1.1(eslint@8.56.0) express: 4.21.2 jest: 29.7.0(@types/node@20.11.19)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3)) + morgan: 1.10.0 prettier: 3.2.5 snappy: 7.2.2 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.24.2)(jest@29.7.0(@types/node@20.11.19)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3)))(typescript@5.3.3) @@ -26324,7 +26368,6 @@ snapshots: eslint-plugin-promise: 6.1.1(eslint@8.56.0) eslint-plugin-svelte: 2.35.1(eslint@8.56.0)(svelte@4.2.19)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3)) fast-equals: 5.2.2 - hls.js: 1.5.20 jest: 29.7.0(@types/node@20.11.19)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3)) prettier: 3.2.5 prettier-plugin-svelte: 3.2.2(prettier@3.2.5)(svelte@4.2.19) diff --git a/dev/tool/src/benchmark.ts b/dev/tool/src/benchmark.ts index 3f0108d1c9..74cce54c54 100644 --- a/dev/tool/src/benchmark.ts +++ b/dev/tool/src/benchmark.ts @@ -138,6 +138,9 @@ export async function benchmark ( } } }) + worker.on('error', (err) => { + console.error('worker error', err) + }) }) const m = newMetrics() @@ -150,6 +153,9 @@ export async function benchmark ( moment: number mem: number memTotal: number + memRSS: number + memFree: number + memArrays: number cpu: number requestTime: number operations: number @@ -161,6 +167,9 @@ export async function benchmark ( moment: 'Moment Time', mem: 'Mem', memTotal: 'Mem total', + memRSS: 'Mem RSS', + memFree: 'Mem Free', + memArrays: 'Mem Arrays', cpu: 'CPU', requestTime: 'Request time', operations: 'OPS', @@ -173,6 +182,9 @@ export async function benchmark ( let cpu: number = 0 let memUsed: number = 0 let memTotal: number = 0 + let memRSS: number = 0 + const memFree: number = 0 + let memArrays: number = 0 let elapsed = 0 let requestTime: number = 0 let operations = 0 @@ -207,6 +219,7 @@ export async function benchmark ( } } if (!found) { + console.log('no measurements found for path', path, p) return null } } @@ -214,47 +227,60 @@ export async function benchmark ( } let timer: any + let p: Promise | undefined if (isMainThread && monitorConnection !== undefined) { timer = setInterval(() => { const st = platformNow() try { - const fetchUrl = endpoint.replace('ws:/', 'http:/') + '/api/v1/statistics?token=' + token - void fetch(fetchUrl) - .then((res) => { - void res - .json() - .then((json) => { - memUsed = json.statistics.memoryUsed - memTotal = json.statistics.memoryTotal - cpu = json.statistics.cpuUsage - // operations = 0 - requestTime = 0 - // transfer = 0 - const r = extract( - json.metrics as Metrics, - '🧲 session', - 'client', - 'handleRequest', - 'process', - 'find-all' - ) - operations = (r?.operations ?? 0) - oldOperations - oldOperations = r?.operations ?? 0 - - requestTime = (r?.value ?? 0) / (((r?.operations as number) ?? 0) + 1) - - const tr = extract(json.metrics as Metrics, '🧲 session', '#send-data') - transfer = (tr?.value ?? 0) - oldTransfer - oldTransfer = tr?.value ?? 0 - }) - .catch((err) => { - console.log(err) - }) - }) - .catch((err) => { - console.log(err) + const fetchUrl = endpoint.replace('ws:/', 'http:/') + '/api/v1/statistics' + if (p === undefined) { + p = fetch(fetchUrl, { + headers: { + Authorization: 'Bearer ' + token + }, + keepalive: true }) + .then((res) => { + void res + .json() + .then((json) => { + memUsed = json.statistics.memoryUsed + memTotal = json.statistics.memoryTotal + memRSS = json.statistics.memoryRSS + memArrays = json.statistics.memoryArrayBuffers + cpu = json.statistics.cpuUsage + // operations = 0 + requestTime = 0 + // transfer = 0 + const r = extract( + json.metrics as Metrics, + '🧲 session', + 'client', + 'handleRequest', + 'process', + 'find-all' + ) + operations = (r?.operations ?? 0) - oldOperations + oldOperations = r?.operations ?? 0 + + requestTime = (r?.value ?? 0) / (((r?.operations as number) ?? 0) + 1) + + const tr = extract(json.metrics as Metrics, '🧲 session', 'client', '#send-data') + transfer = (tr?.value ?? 0) - oldTransfer + oldTransfer = tr?.value ?? 0 + p = undefined + }) + .catch((err) => { + console.log(err) + p = undefined + }) + }) + .catch((err) => { + console.log(err) + p = undefined + }) + } } catch (err) { console.log(err) } @@ -288,7 +314,10 @@ export async function benchmark ( moment, average: Math.round(opTime / (ops + 1)), mem: memUsed, + memRSS, memTotal, + memFree, + memArrays, cpu, requestTime, operations, @@ -363,7 +392,9 @@ export function benchmarkWorker (): void { if (!isMainThread) { parentPort?.on('message', (msg: StartMessage) => { console.log('starting worker', msg.workId) - void perform(msg) + void perform(msg).catch((err) => { + console.error('failed to perform', err) + }) }) } diff --git a/models/controlled-documents/src/index.ts b/models/controlled-documents/src/index.ts index 1b15efadc0..a29e1b8488 100644 --- a/models/controlled-documents/src/index.ts +++ b/models/controlled-documents/src/index.ts @@ -105,10 +105,6 @@ export function createModel (builder: Builder): void { titleProvider: documents.function.ControlledDocumentTitleProvider }) - builder.mixin(documents.class.DocumentMeta, core.class.Class, view.mixin.ObjectTitle, { - titleProvider: documents.function.ControlledDocumentTitleProvider - }) - builder.mixin(documents.class.DocumentApprovalRequest, core.class.Class, view.mixin.ObjectPresenter, { presenter: documents.component.DocumentApprovalRequestPresenter }) @@ -406,6 +402,10 @@ export function createModel (builder: Builder): void { presenter: documents.component.DocumentPresenter }) + builder.mixin(documents.class.ControlledDocument, core.class.Class, view.mixin.LinkProvider, { + encode: documents.function.GetControlledDocumentLinkFragment + }) + builder.mixin(documents.class.Document, core.class.Class, view.mixin.IgnoreActions, { actions: [view.action.Delete] }) @@ -741,6 +741,14 @@ export function createModel (builder: Builder): void { provider: documents.function.DocumentIdentifierProvider }) + builder.mixin(documents.class.ControlledDocument, core.class.Class, view.mixin.ReferenceObjectProvider, { + provider: documents.function.ControlledDocumentReferenceObjectProvider + }) + + builder.mixin(documents.class.ProjectDocument, core.class.Class, view.mixin.ReferenceObjectProvider, { + provider: documents.function.ProjectDocumentReferenceObjectProvider + }) + createAction( builder, { diff --git a/models/controlled-documents/src/migration.ts b/models/controlled-documents/src/migration.ts index aef92e4049..4059cb7832 100644 --- a/models/controlled-documents/src/migration.ts +++ b/models/controlled-documents/src/migration.ts @@ -26,6 +26,8 @@ import { type Class, type Data, type Doc, + type DocIndexState, + DOMAIN_DOC_INDEX_STATE, DOMAIN_SEQUENCE, DOMAIN_TX, generateId, @@ -51,6 +53,7 @@ import { DOMAIN_ATTACHMENT } from '@hcengineering/model-attachment' import core from '@hcengineering/model-core' import tags from '@hcengineering/tags' +import { compareDocumentVersions } from '@hcengineering/controlled-documents/src' import { makeRank } from '@hcengineering/rank' import documents, { DOMAIN_DOCUMENTS } from './index' @@ -402,6 +405,57 @@ async function migrateProjectMetaRank (client: MigrationClient): Promise { await client.bulk(DOMAIN_DOCUMENTS, operations) } +async function migrateDocumentMetaInternalCode (client: MigrationClient): Promise { + const docMetas = await client.find(DOMAIN_DOCUMENTS, { + _class: documents.class.DocumentMeta + }) + + let docs = await client.find(DOMAIN_DOCUMENTS, { + _class: documents.class.ControlledDocument + }) + + docs = docs.slice().sort(compareDocumentVersions).reverse() + const docMap = new Map, ControlledDocument>() + + for (const doc of docs) { + const curr = docMap.get(doc.attachedTo) + const metaId = doc.attachedTo + + const shouldBind = + curr === undefined || + doc.state === DocumentState.Effective || + (doc.state === DocumentState.Archived && curr.state !== DocumentState.Effective) + + if (shouldBind) docMap.set(metaId, doc) + } + + const operations: { filter: MigrationDocumentQuery, update: MigrateUpdate }[] = [] + const updatedIds = new Set>() + for (const meta of docMetas) { + const doc = docMap.get(meta._id) + if (doc === undefined) continue + + const title = `${doc.code} ${doc.title}` + if (meta.title === title) continue + + operations.push({ + filter: { _id: meta._id }, + update: { $set: { title } } + }) + updatedIds.add(meta._id) + } + + await client.bulk(DOMAIN_DOCUMENTS, operations) + await client.update( + DOMAIN_DOC_INDEX_STATE, + { + _id: { $in: Array.from(updatedIds) as any }, + objectClass: documents.class.DocumentMeta + }, + { $set: { needIndex: true } } + ) +} + export const documentsOperation: MigrateOperation = { async migrate (client: MigrationClient): Promise { await tryMigrate(client, documentsId, [ @@ -427,6 +481,10 @@ export const documentsOperation: MigrateOperation = { ) await client.move(DOMAIN_DOCUMENTS, { _class: core.class.Sequence }, DOMAIN_SEQUENCE) } + }, + { + state: 'migrateDocumentMetaInternalCode', + func: migrateDocumentMetaInternalCode } ]) }, diff --git a/models/controlled-documents/src/plugin.ts b/models/controlled-documents/src/plugin.ts index fdde0039ed..e0b5eca013 100644 --- a/models/controlled-documents/src/plugin.ts +++ b/models/controlled-documents/src/plugin.ts @@ -53,6 +53,12 @@ export default mergeIds(documentsId, documents, { DocumentIdentifierProvider: '' as Resource< (client: Client, ref: Ref, doc?: T) => Promise >, + ControlledDocumentReferenceObjectProvider: '' as Resource< + (client: Client, ref: Ref, doc?: T) => Promise + >, + ProjectDocumentReferenceObjectProvider: '' as Resource< + (client: Client, ref: Ref, doc?: T) => Promise + >, Comment: '' as Resource, IsCommentVisible: '' as Resource }, diff --git a/models/view/src/index.ts b/models/view/src/index.ts index 05f7c0b8e0..6c26dcc62d 100644 --- a/models/view/src/index.ts +++ b/models/view/src/index.ts @@ -89,6 +89,7 @@ import { type ViewletDescriptor, type ViewletPreference, type ObjectIdentifier, + type ReferenceObjectProvider, type ObjectIcon, type ObjectTooltip, type AttrPresenter, @@ -253,6 +254,11 @@ export class TObjectIdentifier extends TClass implements ObjectIdentifier { provider!: Resource<(client: Client, ref: Ref, doc?: T) => Promise> } +@Mixin(view.mixin.ReferenceObjectProvider, core.class.Class) +export class TReferenceObjectProvider extends TClass implements ReferenceObjectProvider { + provider!: Resource<(client: Client, ref: Ref, doc?: T) => Promise> +} + @Mixin(view.mixin.ObjectTooltip, core.class.Class) export class TObjectTooltip extends TClass implements ObjectTooltip { provider!: Resource<(client: Client, doc?: Doc | null) => Promise> @@ -465,6 +471,7 @@ export function createModel (builder: Builder): void { TAggregation, TGroupping, TObjectIdentifier, + TReferenceObjectProvider, TObjectTooltip, TObjectIcon, TAttrPresenter, diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts index 6b311eaeca..cb0fb8abe0 100644 --- a/packages/api-client/src/client.ts +++ b/packages/api-client/src/client.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. // -import { getClient as getAccountClient } from '@hcengineering/account-client' +import { type WorkspaceLoginInfo, getClient as getAccountClient } from '@hcengineering/account-client' import client, { clientId } from '@hcengineering/client' import { type Class, @@ -288,11 +288,18 @@ class PlatformClientImpl implements PlatformClient { } } -async function getWorkspaceToken ( +export interface WorkspaceToken { + endpoint: string + token: string + workspaceId: WorkspaceUuid + info: WorkspaceLoginInfo +} + +export async function getWorkspaceToken ( url: string, options: ConnectOptions, config?: ServerConfig -): Promise<{ endpoint: string, token: string, workspaceId: string }> { +): Promise { config ??= await loadServerConfig(url) let token: string | undefined @@ -314,5 +321,5 @@ async function getWorkspaceToken ( throw new Error('Workspace not found') } - return { endpoint: ws.endpoint, token: ws.token, workspaceId: ws.workspace } + return { endpoint: ws.endpoint, token: ws.token, workspaceId: ws.workspace, info: ws } } diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index 911f155e01..e6ee3bebb6 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -18,3 +18,4 @@ export * from './markup/types' export * from './socket' export * from './types' export * from './rest' +export * from './config' diff --git a/packages/api-client/src/rest.ts b/packages/api-client/src/rest.ts deleted file mode 100644 index 23648ca2d1..0000000000 --- a/packages/api-client/src/rest.ts +++ /dev/null @@ -1,130 +0,0 @@ -// -// 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 Account, - type Class, - type Doc, - type DocumentQuery, - type FindOptions, - type FindResult, - type Ref, - type Storage, - type Tx, - type TxResult, - type WithLookup, - concatLink -} from '@hcengineering/core' - -import { PlatformError, unknownError } from '@hcengineering/platform' - -import { uncompress } from 'snappyjs' - -export interface RestClient extends Storage { - getAccount: () => Promise - - findOne: ( - _class: Ref>, - query: DocumentQuery, - options?: FindOptions - ) => Promise | undefined> -} - -export async function createRestClient (endpoint: string, workspaceId: string, token: string): Promise { - return new RestClientImpl(endpoint, workspaceId, token) -} - -class RestClientImpl implements RestClient { - constructor ( - readonly endpoint: string, - readonly workspace: string, - readonly token: string - ) {} - - async findAll( - _class: Ref>, - query: DocumentQuery, - options?: FindOptions - ): Promise> { - const params = new URLSearchParams() - params.append('class', _class) - if (query !== undefined && Object.keys(query).length > 0) { - params.append('query', JSON.stringify(query)) - } - if (options !== undefined && Object.keys(options).length > 0) { - params.append('options', JSON.stringify(options)) - } - const response = await fetch(concatLink(this.endpoint, `/api/v1/find-all/${this.workspace}?${params.toString()}`), { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - Authorization: 'Bearer ' + this.token, - 'accept-encoding': 'snappy, gzip' - }, - keepalive: true - }) - if (!response.ok) { - throw new PlatformError(unknownError(response.statusText)) - } - const encoding = response.headers.get('content-encoding') - if (encoding === 'snappy') { - const buffer = await response.arrayBuffer() - const decompressed = uncompress(buffer) - const decoder = new TextDecoder() - const jsonString = decoder.decode(decompressed) - return JSON.parse(jsonString) as FindResult - } - return (await response.json()) as FindResult - } - - async getAccount (): Promise { - const response = await fetch(concatLink(this.endpoint, `/api/v1/account/${this.workspace}`), { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - Authorization: 'Bearer ' + this.token - }, - keepalive: true - }) - if (!response.ok) { - throw new PlatformError(unknownError(response.statusText)) - } - return (await response.json()) as Account - } - - async findOne( - _class: Ref>, - query: DocumentQuery, - options?: FindOptions - ): Promise | undefined> { - return (await this.findAll(_class, query, { ...options, limit: 1 })).shift() - } - - async tx (tx: Tx): Promise { - const response = await fetch(concatLink(this.endpoint, `/api/v1/tx/${this.workspace}`), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: 'Bearer ' + this.token - }, - keepalive: true, - body: JSON.stringify(tx) - }) - if (!response.ok) { - throw new PlatformError(unknownError(response.statusText)) - } - return (await response.json()) as TxResult - } -} diff --git a/packages/api-client/src/rest/index.ts b/packages/api-client/src/rest/index.ts new file mode 100644 index 0000000000..4397c92ecd --- /dev/null +++ b/packages/api-client/src/rest/index.ts @@ -0,0 +1,18 @@ +// +// 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. +// + +export { createRestClient } from './rest' +export { createRestTxOperations } from './tx' +export * from './types' diff --git a/packages/api-client/src/rest/rest.ts b/packages/api-client/src/rest/rest.ts new file mode 100644 index 0000000000..fb2995eb9c --- /dev/null +++ b/packages/api-client/src/rest/rest.ts @@ -0,0 +1,199 @@ +// +// 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 Account, + type Class, + type Doc, + type DocumentQuery, + type FindOptions, + type FindResult, + Hierarchy, + MeasureMetricsContext, + ModelDb, + type Ref, + type SearchOptions, + type SearchQuery, + type SearchResult, + type Tx, + type TxResult, + type WithLookup, + buildModel, + concatLink +} from '@hcengineering/core' + +import { PlatformError, unknownError } from '@hcengineering/platform' + +import type { RestClient } from './types' +import { extractJson, withRetry } from './utils' + +export function createRestClient (endpoint: string, workspaceId: string, token: string): RestClient { + return new RestClientImpl(endpoint, workspaceId, token) +} + +export class RestClientImpl implements RestClient { + endpoint: string + constructor ( + endpoint: string, + readonly workspace: string, + readonly token: string + ) { + this.endpoint = endpoint.replace('ws', 'http') + } + + jsonHeaders (): Record { + return { + 'Content-Type': 'application/json', + Authorization: 'Bearer ' + this.token, + 'accept-encoding': 'snappy, gzip' + } + } + + requestInit (): RequestInit { + return { + method: 'GET', + keepalive: true, + headers: this.jsonHeaders() + } + } + + async findAll( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise> { + const params = new URLSearchParams() + params.append('class', _class) + if (query !== undefined && Object.keys(query).length > 0) { + params.append('query', JSON.stringify(query)) + } + if (options !== undefined && Object.keys(options).length > 0) { + params.append('options', JSON.stringify(options)) + } + const requestUrl = concatLink(this.endpoint, `/api/v1/find-all/${this.workspace}?${params.toString()}`) + const result = await withRetry(async () => { + const response = await fetch(requestUrl, this.requestInit()) + if (!response.ok) { + throw new PlatformError(unknownError(response.statusText)) + } + return await extractJson>(response) + }) + + if (result.lookupMap !== undefined) { + // We need to extract lookup map to document lookups + for (const d of result) { + if (d.$lookup !== undefined) { + for (const [k, v] of Object.entries(d.$lookup)) { + if (!Array.isArray(v)) { + d.$lookup[k] = result.lookupMap[v as any] + } else { + d.$lookup[k] = v.map((it) => result.lookupMap?.[it]) + } + } + } + } + delete result.lookupMap + } + + // We need to revert deleted query simple values. + // We need to get rid of simple query parameters matched in documents + for (const doc of result) { + if (doc._class == null) { + doc._class = _class + } + for (const [k, v] of Object.entries(query)) { + if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') { + if (doc[k] == null) { + doc[k] = v + } + } + } + } + + return result + } + + async getAccount (): Promise { + const requestUrl = concatLink(this.endpoint, `/api/v1/account/${this.workspace}`) + const response = await fetch(requestUrl, this.requestInit()) + if (!response.ok) { + throw new PlatformError(unknownError(response.statusText)) + } + return await extractJson(response) + } + + async getModel (): Promise<{ hierarchy: Hierarchy, model: ModelDb }> { + const requestUrl = concatLink(this.endpoint, `/api/v1/load-model/${this.workspace}`) + const response = await fetch(requestUrl, this.requestInit()) + if (!response.ok) { + throw new PlatformError(unknownError(response.statusText)) + } + const modelResponse: Tx[] = await extractJson(response) + + const hierarchy = new Hierarchy() + const model = new ModelDb(hierarchy) + + const ctx = new MeasureMetricsContext('loadModel', {}) + buildModel(ctx, modelResponse, (txes: Tx[]) => txes, hierarchy, model) + + return { hierarchy, model } + } + + async findOne( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise | undefined> { + return (await this.findAll(_class, query, { ...options, limit: 1 })).shift() + } + + async tx (tx: Tx): Promise { + const requestUrl = concatLink(this.endpoint, `/api/v1/tx/${this.workspace}`) + const response = await fetch(requestUrl, { + method: 'POST', + headers: this.jsonHeaders(), + keepalive: true, + body: JSON.stringify(tx) + }) + if (!response.ok) { + throw new PlatformError(unknownError(response.statusText)) + } + return await extractJson(response) + } + + async searchFulltext (query: SearchQuery, options: SearchOptions): Promise { + const params = new URLSearchParams() + params.append('query', query.query) + if (query.classes != null && Object.keys(query.classes).length > 0) { + params.append('classes', JSON.stringify(query.classes)) + } + if (query.spaces != null && Object.keys(query.spaces).length > 0) { + params.append('spaces', JSON.stringify(query.spaces)) + } + if (options.limit != null) { + params.append('limit', `${options.limit}`) + } + const requestUrl = concatLink(this.endpoint, `/api/v1/search-fulltext/${this.workspace}`) + const response = await fetch(requestUrl, { + method: 'GET', + headers: this.jsonHeaders(), + keepalive: true + }) + if (!response.ok) { + throw new PlatformError(unknownError(response.statusText)) + } + return await extractJson(response) + } +} diff --git a/packages/api-client/src/rest/tx.ts b/packages/api-client/src/rest/tx.ts new file mode 100644 index 0000000000..46f365c059 --- /dev/null +++ b/packages/api-client/src/rest/tx.ts @@ -0,0 +1,101 @@ +// +// 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 Account, + type Class, + type Client, + type Doc, + type DocumentQuery, + type FindOptions, + type FindResult, + Hierarchy, + ModelDb, + type Ref, + type SearchOptions, + type SearchQuery, + type SearchResult, + toFindResult, + type Tx, + TxOperations, + type TxResult, + type WithLookup +} from '@hcengineering/core' +import { RestClientImpl } from './rest' + +export async function createRestTxOperations ( + endpoint: string, + workspaceId: string, + token: string +): Promise { + const restClient = new RestClientImpl(endpoint, workspaceId, token) + + const account = await restClient.getAccount() + const { hierarchy, model } = await restClient.getModel() + + return new TxOperations(new RestTxClient(restClient, hierarchy, model, account), account.socialIds[0]) +} + +class RestTxClient implements Client { + constructor ( + readonly client: RestClientImpl, + readonly hierarchy: Hierarchy, + readonly model: ModelDb, + readonly account: Account + ) {} + + close (): Promise { + return Promise.resolve() + } + + async findAll( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise> { + const data = await this.client.findAll(_class, query, options) + const result = data.map((v) => { + return this.hierarchy.updateLookupMixin(_class, v, options) + }) + return toFindResult(result, data.total) + } + + async findOne( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise | undefined> { + const v = await this.client.findOne(_class, query, options) + if (v === undefined) { + return + } + return this.hierarchy.updateLookupMixin(_class, v, options) + } + + getHierarchy: () => Hierarchy = () => this.hierarchy + getModel: () => ModelDb = () => this.model + + async getAccount (): Promise { + return this.account + } + + async tx (tx: Tx): Promise { + return await this.client.tx(tx) + } + + async searchFulltext (query: SearchQuery, options: SearchOptions): Promise { + return await this.client.searchFulltext(query, options) + } +} diff --git a/packages/api-client/src/rest/types.ts b/packages/api-client/src/rest/types.ts new file mode 100644 index 0000000000..b117d90c60 --- /dev/null +++ b/packages/api-client/src/rest/types.ts @@ -0,0 +1,39 @@ +// +// 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 Account, + type Class, + type Doc, + type DocumentQuery, + type FindOptions, + type Hierarchy, + type ModelDb, + type Ref, + type Storage, + type WithLookup +} from '@hcengineering/core' + +export interface RestClient extends Storage { + getAccount: () => Promise + + findOne: ( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ) => Promise | undefined> + + getModel: () => Promise<{ hierarchy: Hierarchy, model: ModelDb }> +} diff --git a/packages/api-client/src/rest/utils.ts b/packages/api-client/src/rest/utils.ts new file mode 100644 index 0000000000..d6b87cbc58 --- /dev/null +++ b/packages/api-client/src/rest/utils.ts @@ -0,0 +1,42 @@ +import { uncompress } from 'snappyjs' + +export async function withRetry (fn: () => Promise): Promise { + const maxRetries = 3 + let lastError: any + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + return await fn() + } catch (err: any) { + lastError = err + if (attempt === maxRetries - 1) { + console.error('Failed to execute query', err) + throw lastError + } + await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempt) * 100)) + } + } + throw lastError +} + +function rpcJSONReceiver (key: string, value: any): any { + if (typeof value === 'object' && value !== null) { + if (value.dataType === 'TotalArray') { + return Object.assign(value.value, { total: value.total, lookupMap: value.lookupMap }) + } + } + return value +} + +export async function extractJson (response: Response): Promise { + const encoding = response.headers.get('content-encoding') + if (encoding === 'snappy') { + const buffer = await response.arrayBuffer() + const decompressed = uncompress(buffer) + const decoder = new TextDecoder() + const jsonString = decoder.decode(decompressed) + return JSON.parse(jsonString, rpcJSONReceiver) as T + } + const jsonString = await response.text() + return JSON.parse(jsonString, rpcJSONReceiver) as T +} diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 7c15731353..5136173e66 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -393,7 +393,7 @@ async function loadModel ( return { mode: 'addition', current: current.transactions, addition: result.transactions } } -function buildModel ( +export function buildModel ( ctx: MeasureContext, transactions: Tx[], modelFilter: ModelFilter | undefined, diff --git a/plugins/contact-resources/src/components/CreateEmployee.svelte b/plugins/contact-resources/src/components/CreateEmployee.svelte index 0ee91b5b63..73535fd3ba 100644 --- a/plugins/contact-resources/src/components/CreateEmployee.svelte +++ b/plugins/contact-resources/src/components/CreateEmployee.svelte @@ -90,7 +90,7 @@ const employeeRef = (existingPerson?._id as Ref) ?? id await client.createMixin(id, contact.class.Person, contact.space.Contacts, contact.mixin.Employee, { - active: false + active: true }) await client.addCollection( diff --git a/plugins/contact-resources/src/components/EditPerson.svelte b/plugins/contact-resources/src/components/EditPerson.svelte index 9ef53c75ac..7991b609a7 100644 --- a/plugins/contact-resources/src/components/EditPerson.svelte +++ b/plugins/contact-resources/src/components/EditPerson.svelte @@ -42,7 +42,7 @@ function isEditable (owner: boolean, object: Person): boolean { if (owner) return true if (!h.hasMixin(object, contact.mixin.Employee)) return true - return hasAccountRole(account, AccountRole.Maintainer) && !h.as(object, contact.mixin.Employee).active + return hasAccountRole(account, AccountRole.Maintainer) } $: editable = !readonly && isEditable(owner, object) diff --git a/plugins/contact/src/utils.ts b/plugins/contact/src/utils.ts index 2247873777..01159090f4 100644 --- a/plugins/contact/src/utils.ts +++ b/plugins/contact/src/utils.ts @@ -381,7 +381,7 @@ export async function getAllEmployeesPrimarySocialStrings (client: Client): Prom export async function ensureEmployee ( ctx: MeasureContext, me: Account, - client: Client, + client: Pick, socialIds: SocialId[], getGlobalPerson: () => Promise ): Promise | null> { @@ -439,11 +439,7 @@ export async function ensureEmployee ( if (me.role !== AccountRole.Guest) { const employee = await client.findOne(contact.mixin.Employee, { _id: personRef as Ref }) - if ( - employee === undefined || - !client.getHierarchy().hasMixin(employee, contact.mixin.Employee) || - !employee.active - ) { + if (employee === undefined || !Hierarchy.hasMixin(employee, contact.mixin.Employee) || !employee.active) { await ctx.with('create-employee', {}, async () => { if (personRef === undefined) { // something went wrong diff --git a/plugins/controlled-documents-assets/lang/cs.json b/plugins/controlled-documents-assets/lang/cs.json index 34f47aca5f..4241ef4869 100644 --- a/plugins/controlled-documents-assets/lang/cs.json +++ b/plugins/controlled-documents-assets/lang/cs.json @@ -300,7 +300,9 @@ "Obsolete": "Zastaralé", "MakeDocumentObsolete": "Označit jako zastaralé", "MakeDocumentObsoleteDialog": "Označit {count, plural, one {dokument jako zastaralý} other {dokumenty jako zastaralé}}", - "MakeDocumentObsoleteConfirm": "Opravdu chcete označit následující dokumenty jako zastaralé: {titles}?" + "MakeDocumentObsoleteConfirm": "Opravdu chcete označit následující dokumenty jako zastaralé: {titles}?", + + "LatestVersionHint": "nejnovější" }, "controlledDocStates": { "Empty": "", diff --git a/plugins/controlled-documents-assets/lang/de.json b/plugins/controlled-documents-assets/lang/de.json index 0653e4b4c0..f1866edaf9 100644 --- a/plugins/controlled-documents-assets/lang/de.json +++ b/plugins/controlled-documents-assets/lang/de.json @@ -307,7 +307,9 @@ "Obsolete": "Veraltet", "MakeDocumentObsolete": "Als veraltet markieren", "MakeDocumentObsoleteDialog": "{count, plural, one {Dokument als veraltet markieren} other {Dokumente als veraltet markieren}}", - "MakeDocumentObsoleteConfirm": "Möchten Sie die folgenden Dokumente wirklich als veraltet markieren: {titles}?" + "MakeDocumentObsoleteConfirm": "Möchten Sie die folgenden Dokumente wirklich als veraltet markieren: {titles}?", + + "LatestVersionHint": "neueste" }, "controlledDocStates": { "Empty": "", diff --git a/plugins/controlled-documents-assets/lang/en.json b/plugins/controlled-documents-assets/lang/en.json index dbd49df4b7..a98bc2d854 100644 --- a/plugins/controlled-documents-assets/lang/en.json +++ b/plugins/controlled-documents-assets/lang/en.json @@ -309,7 +309,9 @@ "CreateFolder": "Create new folder", "RenameFolder": "Rename folder", - "CreateChildFolder": "Create child folder" + "CreateChildFolder": "Create child folder", + + "LatestVersionHint": "latest" }, "controlledDocStates": { "Empty": "", diff --git a/plugins/controlled-documents-assets/lang/fr.json b/plugins/controlled-documents-assets/lang/fr.json index 9b2ee9fe3f..82b93dbd9b 100644 --- a/plugins/controlled-documents-assets/lang/fr.json +++ b/plugins/controlled-documents-assets/lang/fr.json @@ -267,7 +267,9 @@ "Obsolete": "Obsolète", "MakeDocumentObsolete": "Marquer comme obsolète", "MakeDocumentObsoleteDialog": "Marquer {count, plural, one {le document comme obsolète} other {les documents comme obsolètes}}", - "MakeDocumentObsoleteConfirm": "Voulez-vous vraiment marquer les documents suivants comme obsolètes : {titles} ?" + "MakeDocumentObsoleteConfirm": "Voulez-vous vraiment marquer les documents suivants comme obsolètes : {titles} ?", + + "LatestVersionHint": "dernier" }, "controlledDocStates": { "Empty": "", diff --git a/plugins/controlled-documents-assets/lang/it.json b/plugins/controlled-documents-assets/lang/it.json index 1ec19f9672..79b28decd0 100644 --- a/plugins/controlled-documents-assets/lang/it.json +++ b/plugins/controlled-documents-assets/lang/it.json @@ -265,7 +265,9 @@ "Obsolete": "Obsoleto", "MakeDocumentObsolete": "Segna come obsoleto", "MakeDocumentObsoleteDialog": "Segna {count, plural, one {il documento come obsoleto} other {i documenti come obsoleti}}", - "MakeDocumentObsoleteConfirm": "Vuoi davvero segnare i seguenti documenti come obsoleti: {titles}?" + "MakeDocumentObsoleteConfirm": "Vuoi davvero segnare i seguenti documenti come obsoleti: {titles}?", + + "LatestVersionHint": "ultimo" }, "controlledDocStates": { "Empty": "", diff --git a/plugins/controlled-documents-assets/lang/ru.json b/plugins/controlled-documents-assets/lang/ru.json index dd99112ec0..37fc54813b 100644 --- a/plugins/controlled-documents-assets/lang/ru.json +++ b/plugins/controlled-documents-assets/lang/ru.json @@ -309,7 +309,9 @@ "Obsolete": "Устаревший", "MakeDocumentObsolete": "Пометить как устаревшее", "MakeDocumentObsoleteDialog": "Пометить {count, plural, one {документ как устаревший} other {документы как устаревшие}}", - "MakeDocumentObsoleteConfirm": "Вы действительно хотите пометить следующие документы как устаревшие: {titles}?" + "MakeDocumentObsoleteConfirm": "Вы действительно хотите пометить следующие документы как устаревшие: {titles}?", + + "LatestVersionHint": "последняя" }, "controlledDocStates": { "Empty": "", diff --git a/plugins/controlled-documents-assets/lang/zh.json b/plugins/controlled-documents-assets/lang/zh.json index d0b07eddbe..7b4c2f584e 100644 --- a/plugins/controlled-documents-assets/lang/zh.json +++ b/plugins/controlled-documents-assets/lang/zh.json @@ -306,7 +306,9 @@ "Obsolete": "已过时", "MakeDocumentObsolete": "标记为过时", "MakeDocumentObsoleteDialog": "标记 {count, plural, one {文档为过时} other {文档为过时}}", - "MakeDocumentObsoleteConfirm": "您确定要将以下文档标记为过时吗:{titles}?" + "MakeDocumentObsoleteConfirm": "您确定要将以下文档标记为过时吗:{titles}?", + + "LatestVersionHint": "最新" }, "controlledDocStates": { "Empty": "", diff --git a/plugins/controlled-documents-resources/src/components/document/EditDocContent.svelte b/plugins/controlled-documents-resources/src/components/document/EditDocContent.svelte index 5e404a3980..fe2e87ccfd 100644 --- a/plugins/controlled-documents-resources/src/components/document/EditDocContent.svelte +++ b/plugins/controlled-documents-resources/src/components/document/EditDocContent.svelte @@ -49,10 +49,12 @@ $documentComments as documentComments, documentCommentsDisplayRequested, documentCommentsHighlightUpdated, - documentCommentsLocationNavigateRequested + documentCommentsLocationNavigateRequested, + $documentReleasedVersions as documentReleasedVersions } from '../../stores/editors/document' import DocumentTitle from './DocumentTitle.svelte' import DocumentPrintTitlePage from '../print/DocumentPrintTitlePage.svelte' + import { syncDocumentMetaTitle } from '../../utils' const client = getClient() const hierarchy = client.getHierarchy() @@ -111,14 +113,17 @@ unsubscribeNavigateToLocation() }) - const handleUpdateTitle = () => { + const handleUpdateTitle = async () => { if (!$controlledDocument || !title) { return } const titleTrimmed = title.trim() if (titleTrimmed.length > 0 && titleTrimmed !== $controlledDocument.title) { - client.update($controlledDocument, { title: titleTrimmed }) + await client.update($controlledDocument, { title: titleTrimmed }) + if ($documentReleasedVersions.length === 0 && $controlledDocument.state === DocumentState.Draft) { + await syncDocumentMetaTitle(client, $controlledDocument.attachedTo, $controlledDocument.code, titleTrimmed) + } } } diff --git a/plugins/controlled-documents-resources/src/components/document/popups/ChangeDocCodePopup.svelte b/plugins/controlled-documents-resources/src/components/document/popups/ChangeDocCodePopup.svelte index 8459e97d04..aeea262934 100644 --- a/plugins/controlled-documents-resources/src/components/document/popups/ChangeDocCodePopup.svelte +++ b/plugins/controlled-documents-resources/src/components/document/popups/ChangeDocCodePopup.svelte @@ -15,14 +15,15 @@ diff --git a/plugins/controlled-documents-resources/src/index.ts b/plugins/controlled-documents-resources/src/index.ts index e2fc0e8505..debf8871f0 100644 --- a/plugins/controlled-documents-resources/src/index.ts +++ b/plugins/controlled-documents-resources/src/index.ts @@ -105,7 +105,10 @@ import { createTemplate, deleteFolder, documentIdentifierProvider, + controlledDocumentReferenceObjectProvider, + projectDocumentReferenceObjectProvider, getAllDocumentStates, + getControlledDocumentLinkFragment, getControlledDocumentTitle, getDocumentMetaLinkFragment, getVisibleFilters, @@ -448,6 +451,7 @@ export default async (): Promise => ({ DocumentStateSort: sortDocumentStates, GetAllDocumentStates: getAllDocumentStates, GetDocumentMetaLinkFragment: getDocumentMetaLinkFragment, + GetControlledDocumentLinkFragment: getControlledDocumentLinkFragment, CanDeleteDocument: canDeleteDocument, CanArchiveDocument: canArchiveDocument, CanMakeDocumentObsolete: canMakeDocumentObsolete, @@ -455,6 +459,8 @@ export default async (): Promise => ({ CanOpenDocument: canOpenDocument, CanPrintDocument: canPrintDocument, DocumentIdentifierProvider: documentIdentifierProvider, + ControlledDocumentReferenceObjectProvider: controlledDocumentReferenceObjectProvider, + ProjectDocumentReferenceObjectProvider: projectDocumentReferenceObjectProvider, ControlledDocumentTitleProvider: getControlledDocumentTitle, Comment: comment, IsCommentVisible: isCommentVisible diff --git a/plugins/controlled-documents-resources/src/plugin.ts b/plugins/controlled-documents-resources/src/plugin.ts index bf16bd4b75..4a7826ebbf 100644 --- a/plugins/controlled-documents-resources/src/plugin.ts +++ b/plugins/controlled-documents-resources/src/plugin.ts @@ -212,7 +212,9 @@ export default mergeIds(documentsId, documents, { CreateDocumentFailed: '' as IntlString, CreateDocumentTemplateFailed: '' as IntlString, - TryAgain: '' as IntlString + TryAgain: '' as IntlString, + + LatestVersionHint: '' as IntlString }, controlledDocStates: { Empty: '' as IntlString, @@ -240,6 +242,7 @@ export default mergeIds(documentsId, documents, { GetAllDocumentStates: '' as Resource<() => Promise>, GetVisibleFilters: '' as Resource<(filters: KeyFilter[], space?: Ref) => Promise>, GetDocumentMetaLinkFragment: '' as Resource<(doc: Doc, props: Record) => Promise>, + GetControlledDocumentLinkFragment: '' as Resource<(doc: Doc, props: Record) => Promise>, CanDeleteDocument: '' as Resource<(doc?: Doc | Doc[]) => Promise>, CanArchiveDocument: '' as Resource<(doc?: Doc | Doc[]) => Promise>, CanMakeDocumentObsolete: '' as Resource<(doc?: Doc | Doc[]) => Promise>, diff --git a/plugins/controlled-documents-resources/src/utils.ts b/plugins/controlled-documents-resources/src/utils.ts index 19b5a92f98..0f93881a79 100644 --- a/plugins/controlled-documents-resources/src/utils.ts +++ b/plugins/controlled-documents-resources/src/utils.ts @@ -17,6 +17,7 @@ import { type Employee, type Person, getCurrentEmployee } from '@hcengineering/c import documents, { type ControlledDocument, type Document, + type DocumentBundle, type DocumentCategory, type DocumentComment, type DocumentMeta, @@ -28,12 +29,12 @@ import documents, { type ProjectDocument, type ProjectMeta, ControlledDocumentState, - type DocumentBundle, DocumentState, + ProjectDocumentTree, + compareDocumentVersions, emptyBundle, getDocumentName, - getFirstRank, - ProjectDocumentTree + getFirstRank } from '@hcengineering/controlled-documents' import core, { type Class, @@ -191,6 +192,18 @@ export async function getDocumentMetaLinkFragment (document: Doc): Promise { + const client = getClient() + const targetDocument = await client.findOne(documents.class.ProjectDocument, { document: document._id }) + + if (targetDocument === undefined) { + throw new Error('Cannot resolve a ProjectDocument for document ' + document._id) + } + + const project = targetDocument.project ?? documents.ids.NoProject + return getProjectDocumentLink(document, project) +} + export interface TeamPopupData { controlledDoc: ControlledDocument requestClass: Ref> @@ -703,6 +716,41 @@ export async function documentIdentifierProvider (client: Client, ref: Ref, + doc?: ControlledDocument +): Promise { + const document = doc ?? (await client.findOne(documents.class.ControlledDocument, { _id: ref })) + if (document === undefined) return + + const meta = await client.findOne(documents.class.DocumentMeta, { _id: document.attachedTo }) + if (meta === undefined) return + + let documentSeq: ControlledDocument[] = await client.findAll(documents.class.ControlledDocument, { + attachedTo: meta._id + }) + + const allowStates = [DocumentState.Draft, DocumentState.Effective] + documentSeq = documentSeq.filter((d) => allowStates.includes(d.state)).sort(compareDocumentVersions) + + const effIndex = documentSeq.findIndex((d) => d.state === DocumentState.Effective) + const docIndex = documentSeq.findIndex((d) => d._id === document._id) + + return docIndex >= 0 && (docIndex <= effIndex || effIndex < 0) ? meta : document +} + +export async function projectDocumentReferenceObjectProvider ( + client: Client, + ref: Ref, + doc?: ProjectDocument +): Promise { + const prjdoc = doc ?? (await client.findOne(documents.class.ProjectDocument, { _id: ref })) + if (prjdoc === undefined) return + + return await controlledDocumentReferenceObjectProvider(client, prjdoc.document as Ref) +} + export function documentCompareFn (doc1: Document, doc2: Document): number { return doc1.major - doc2.major !== 0 ? doc1.major - doc2.major : doc1.minor - doc2.minor } @@ -720,7 +768,7 @@ export async function getControlledDocumentTitle ( if (object === undefined) return '' - return object.title + return object.title + ` (${getDocumentVersionString(object)})` } export async function createChildDocument (doc: ProjectDocument): Promise { @@ -907,3 +955,15 @@ export class DocumentHiearchyQuery { export function createDocumentHierarchyQuery (): DocumentHiearchyQuery { return new DocumentHiearchyQuery() } + +export async function syncDocumentMetaTitle ( + client: Client & TxOperations, + _id: Ref, + code: string, + title: string +): Promise { + const meta = await client.findOne(documents.class.DocumentMeta, { _id }) + if (meta !== undefined) { + await client.update(meta, { title: `${code} ${title}` }) + } +} diff --git a/plugins/controlled-documents/src/docutils.ts b/plugins/controlled-documents/src/docutils.ts index d1346c0356..9a13d9fb9f 100644 --- a/plugins/controlled-documents/src/docutils.ts +++ b/plugins/controlled-documents/src/docutils.ts @@ -166,7 +166,7 @@ export async function createControlledDocMetadata ( space, { documents: 0, - title: `${prefix}-${seqNumber} ${specTitle}` + title: `${specCode} ${specTitle}` }, metaId ) diff --git a/plugins/controlled-documents/src/utils.ts b/plugins/controlled-documents/src/utils.ts index 65c1f7bbaa..22c912662b 100644 --- a/plugins/controlled-documents/src/utils.ts +++ b/plugins/controlled-documents/src/utils.ts @@ -156,17 +156,25 @@ export function isFolder (doc: ProjectDocument | undefined): boolean { return doc !== undefined && doc.document === documents.ids.Folder } +function getDocumentSortSequence (doc: ControlledDocument | undefined): number[] { + return doc !== undefined ? [doc.seqNumber, doc.major, doc.minor, doc.createdOn ?? 0] : [0, 0, 0, 0] +} + +export function compareDocumentVersions ( + doc1: ControlledDocument | undefined, + doc2: ControlledDocument | undefined +): number { + const s0 = getDocumentSortSequence(doc1) + const s1 = getDocumentSortSequence(doc2) + return s0.reduce((r, v, i) => (r !== 0 ? r : s1[i] - v), 0) +} + function extractPresentableStateFromDocumentBundle (bundle: DocumentBundle, prjmeta: ProjectMeta): DocumentBundle { bundle = { ...bundle } const person = getCurrentEmployee() const documentById = toIdMap(bundle.ControlledDocument) - const getSortSequence = (prjdoc: ProjectDocument): number[] => { - const doc = documentById.get(prjdoc.document as Ref) - return doc !== undefined ? [doc.seqNumber, doc.major, doc.minor, doc.createdOn ?? 0] : [0, 0, 0, 0] - } - const prjdoc = bundle.ProjectDocument.filter((prjdoc) => { if (prjdoc.attachedTo !== prjmeta._id) return false if (isFolder(prjdoc)) return true @@ -174,9 +182,10 @@ function extractPresentableStateFromDocumentBundle (bundle: DocumentBundle, prjm const isPublicState = doc?.state === DocumentState.Effective || doc?.state === DocumentState.Archived return doc !== undefined && (isPublicState || isCollaborator(doc, person)) }).sort((a, b) => { - const s0 = getSortSequence(a) - const s1 = getSortSequence(b) - return s0.reduce((r, v, i) => (r !== 0 ? r : s1[i] - v), 0) + return compareDocumentVersions( + documentById.get(a.document as Ref), + documentById.get(b.document as Ref) + ) })[0] const doc = prjdoc !== undefined ? documentById.get(prjdoc.document as Ref) : undefined diff --git a/plugins/login-assets/lang/cs.json b/plugins/login-assets/lang/cs.json index 2db38da02d..65afc5160b 100644 --- a/plugins/login-assets/lang/cs.json +++ b/plugins/login-assets/lang/cs.json @@ -65,6 +65,7 @@ "PasswordMinDigits": "{count, plural, =1 {Heslo musí obsahovat alespoň # číslo} other {Heslo musí obsahovat alespoň # čísel}}", "PasswordMinUpperChars": "{count, plural, =1 {Heslo musí obsahovat alespoň # velké písmeno} other {Heslo musí obsahovat alespoň # velkých písmen}}", "PasswordMinLowerChars": "{count, plural, =1 {Heslo musí obsahovat alespoň # malé písmeno} other {Heslo musí obsahovat alespoň # malých písmen}}", - "WorkspaceIsArchived": "Pracovní prostor je archivován kvůli nečinnosti. Kontaktujte nás prosím pro obnovení..." + "WorkspaceIsArchived": "Pracovní prostor je archivován kvůli nečinnosti.", + "RestoreArchivedWorkspace": "Obnovit pracovní prostor." } } diff --git a/plugins/login-assets/lang/de.json b/plugins/login-assets/lang/de.json index 626419f5dd..674dcf80f5 100644 --- a/plugins/login-assets/lang/de.json +++ b/plugins/login-assets/lang/de.json @@ -65,6 +65,7 @@ "PasswordMinDigits": "{count, plural, =1 {Das Passwort muss mindestens # Zahl enthalten} other {Das Passwort muss mindestens # Zahlen enthalten}}", "PasswordMinUpperChars": "{count, plural, =1 {Das Passwort muss mindestens # Großbuchstaben enthalten} other {Das Passwort muss mindestens # Großbuchstaben enthalten}}", "PasswordMinLowerChars": "{count, plural, =1 {Das Passwort muss mindestens # Kleinbuchstaben enthalten} other {Das Passwort muss mindestens # Kleinbuchstaben enthalten}}", - "WorkspaceArchivedDesc": "Workspace wurde wegen Inaktivität archiviert. Bitte kontaktieren Sie uns zur Wiederherstellung..." + "WorkspaceArchivedDesc": "Workspace wurde wegen Inaktivität archiviert.", + "RestoreArchivedWorkspace": "Workspace wiederherstellen." } } diff --git a/plugins/login-assets/lang/en.json b/plugins/login-assets/lang/en.json index ba230573ec..9da4ce69cc 100644 --- a/plugins/login-assets/lang/en.json +++ b/plugins/login-assets/lang/en.json @@ -64,6 +64,7 @@ "PasswordMinDigits": "{count, plural, =1 {Password must contain at least # number} other {Password must contain at least # numbers}}", "PasswordMinUpperChars": "{count, plural, =1 {Password must contain at least # uppercase letter} other {Password must contain at least # uppercase letters}}", "PasswordMinLowerChars": "{count, plural, =1 {Password must contain at least # lowercase letter} other {Password must contain at least # lowercase letters}}", - "WorkspaceArchivedDesc": "Workspace is archived because of being unused, Please contact us to restore..." + "WorkspaceArchivedDesc": "Workspace is archived because of being unused.", + "RestoreArchivedWorkspace": "Unarchive" } } diff --git a/plugins/login-assets/lang/es.json b/plugins/login-assets/lang/es.json index 7a051528c0..ed7701d8e9 100644 --- a/plugins/login-assets/lang/es.json +++ b/plugins/login-assets/lang/es.json @@ -64,6 +64,7 @@ "PasswordMinDigits": "{count, plural, =1 {La contraseña debe contener al menos # número} other {La contraseña debe contener al menos # números}}", "PasswordMinUpperChars": "{count, plural, =1 {La contraseña debe contener al menos # letra mayúscula} other {La contraseña debe contener al menos # letras mayúsculas}}", "PasswordMinLowerChars": "{count, plural, =1 {La contraseña debe contener al menos # letra minúscula} other {La contraseña debe contener al menos # letras minúsculas}}", - "WorkspaceArchivedDesc": "El espacio de trabajo está archivado por no estar en uso, por favor contáctenos para restaurarlo..." + "WorkspaceArchivedDesc": "El espacio de trabajo está archivado por no estar en uso.", + "RestoreArchivedWorkspace": "Restaurar" } } diff --git a/plugins/login-assets/lang/fr.json b/plugins/login-assets/lang/fr.json index 7e3e9876be..800ed464c2 100644 --- a/plugins/login-assets/lang/fr.json +++ b/plugins/login-assets/lang/fr.json @@ -64,6 +64,7 @@ "PasswordMinDigits": "{count, plural, =1 {Le mot de passe doit contenir au moins # chiffre} other {Le mot de passe doit contenir au moins # chiffres}}", "PasswordMinUpperChars": "{count, plural, =1 {Le mot de passe doit contenir au moins # lettre majuscule} other {Le mot de passe doit contenir au moins # lettres majuscules}}", "PasswordMinLowerChars": "{count, plural, =1 {Le mot de passe doit contenir au moins # lettre minuscule} other {Le mot de passe doit contenir au moins # lettres minuscules}}", - "WorkspaceArchivedDesc": "L'espace de travail est archivé en raison de son inactivité, veuillez nous contacter pour le restaurer..." + "WorkspaceArchivedDesc": "L'espace de travail est archivé en raison de son inactivité.", + "RestoreArchivedWorkspace": "Restaurer" } } diff --git a/plugins/login-assets/lang/it.json b/plugins/login-assets/lang/it.json index 05048983a4..58457f1d24 100644 --- a/plugins/login-assets/lang/it.json +++ b/plugins/login-assets/lang/it.json @@ -64,6 +64,7 @@ "PasswordMinDigits": "{count, plural, =1 {La password deve contenere almeno # numero} other {La password deve contenere almeno # numeri}}", "PasswordMinUpperChars": "{count, plural, =1 {La password deve contenere almeno # lettera maiuscola} other {La password deve contenere almeno # lettere maiuscole}}", "PasswordMinLowerChars": "{count, plural, =1 {La password deve contenere almeno # lettera minuscola} other {La password deve contenere almeno # lettere minuscole}}", - "WorkspaceArchivedDesc": "Il workspace è stato archiviato perché inutilizzato. Si prega di contattarci per ripristinarlo..." + "WorkspaceArchivedDesc": "Il workspace è stato archiviato perché inutilizzato.", + "RestoreArchivedWorkspace": "Un archiviato workspace" } } diff --git a/plugins/login-assets/lang/pt.json b/plugins/login-assets/lang/pt.json index 490dea877b..78b5d9db07 100644 --- a/plugins/login-assets/lang/pt.json +++ b/plugins/login-assets/lang/pt.json @@ -64,6 +64,7 @@ "PasswordMinDigits": "{count, plural, =1 {A senha deve conter pelo menos # número} other {A senha deve conter pelo menos # números}}", "PasswordMinUpperChars": "{count, plural, =1 {A senha deve conter pelo menos # letra maiúscula} other {A senha deve conter pelo menos # letras maiúsculas}}", "PasswordMinLowerChars": "{count, plural, =1 {A senha deve conter pelo menos # letra minúscula} other {A senha deve conter pelo menos # letras minúsculas}}", - "WorkspaceArchivedDesc": "O espaço de trabalho está arquivado por estar inativo, por favor, entre em contato conosco para restaurá-lo..." + "WorkspaceArchivedDesc": "O espaço de trabalho está arquivado por estar inativo.", + "RestoreArchivedWorkspace": "Restaurar" } } diff --git a/plugins/login-assets/lang/ru.json b/plugins/login-assets/lang/ru.json index 32462c77e8..ab71181c31 100644 --- a/plugins/login-assets/lang/ru.json +++ b/plugins/login-assets/lang/ru.json @@ -64,6 +64,7 @@ "PasswordMinDigits": "{count, plural, =1 {Пароль должен содержать минимум # цифру} other {Пароль должен содержать минимум # цифр}}", "PasswordMinUpperChars": "{count, plural, =1 {Пароль должен содержать минимум # заглавную букву} other {Пароль должен содержать минимум # заглавных букв}}", "PasswordMinLowerChars": "{count, plural, =1 {Пароль должен содержать минимум # строчную букву} other {Пароль должен содержать минимум # строчных букв}}", - "WorkspaceArchivedDesc": "Рабочее пространство архивировано из-за неиспользования, пожалуйста, свяжитесь с нами для восстановления..." + "WorkspaceArchivedDesc": "Рабочее пространство архивировано из-за неиспользования.", + "RestoreArchivedWorkspace": "Восстановить" } } diff --git a/plugins/login-assets/lang/zh.json b/plugins/login-assets/lang/zh.json index c76526186e..4e927e9df5 100644 --- a/plugins/login-assets/lang/zh.json +++ b/plugins/login-assets/lang/zh.json @@ -64,6 +64,7 @@ "PasswordMinDigits": "{count, plural, =1 {密码至少需要 # 个数字} other {密码至少需要 # 个数字}}", "PasswordMinUpperChars": "{count, plural, =1 {密码至少需要 # 个大写字母} other {密码至少需要 # 个大写字母}}", "PasswordMinLowerChars": "{count, plural, =1 {密码至少需要 # 个小写字母} other {密码至少需要 # 个小写字母}}", - "WorkspaceArchivedDesc": "工作区因未使用而归档,请与我们联系以恢复..." + "WorkspaceArchivedDesc": "工作区已被归档,因为未使用。", + "RestoreArchivedWorkspace": "解包" } } diff --git a/plugins/login-resources/src/components/SelectWorkspace.svelte b/plugins/login-resources/src/components/SelectWorkspace.svelte index 6e5e7a7c98..6762741362 100644 --- a/plugins/login-resources/src/components/SelectWorkspace.svelte +++ b/plugins/login-resources/src/components/SelectWorkspace.svelte @@ -14,8 +14,8 @@ // limitations under the License. -->