diff --git a/dev/tool/src/index.ts b/dev/tool/src/index.ts index cbffad5fc0..0ac46cfe95 100644 --- a/dev/tool/src/index.ts +++ b/dev/tool/src/index.ts @@ -1193,12 +1193,14 @@ export function devTool ( program .command('copy-s3-datalake') - .description('migrate files from s3 to datalake') + .description('copy files from s3 to datalake') .option('-w, --workspace ', 'Selected workspace only', '') .option('-c, --concurrency ', 'Number of files being processed concurrently', '10') - .action(async (cmd: { workspace: string, concurrency: string }) => { + .option('-e, --existing', 'Copy existing blobs', false) + .action(async (cmd: { workspace: string, concurrency: string, existing: boolean }) => { const params = { - concurrency: parseInt(cmd.concurrency) + concurrency: parseInt(cmd.concurrency), + existing: cmd.existing } const storageConfig = storageConfigFromEnv(process.env.STORAGE) @@ -1222,14 +1224,32 @@ export function devTool ( workspaces = workspaces .filter((p) => isActiveMode(p.mode) || isArchivingMode(p.mode)) .filter((p) => cmd.workspace === '' || p.workspace === cmd.workspace) - .sort((a, b) => b.lastVisit - a.lastVisit) + // .sort((a, b) => b.lastVisit - a.lastVisit) + .sort((a, b) => { + if (a.backupInfo !== undefined && b.backupInfo !== undefined) { + return b.backupInfo.blobsSize - a.backupInfo.blobsSize + } else if (b.backupInfo !== undefined) { + return 1 + } else if (a.backupInfo !== undefined) { + return -1 + } else { + return b.lastVisit - a.lastVisit + } + }) }) const count = workspaces.length + console.log('found workspaces', count) + let index = 0 for (const workspace of workspaces) { index++ - toolCtx.info('processing workspace', { workspace: workspace.workspace, index, count }) + toolCtx.info('processing workspace', { + workspace: workspace.workspace, + index, + count, + blobsSize: workspace.backupInfo?.blobsSize ?? 0 + }) const workspaceId = getWorkspaceId(workspace.workspace) for (const config of storages) { diff --git a/dev/tool/src/storage.ts b/dev/tool/src/storage.ts index eb9582b8dd..e4afd9bb51 100644 --- a/dev/tool/src/storage.ts +++ b/dev/tool/src/storage.ts @@ -261,6 +261,7 @@ async function retryOnFailure ( export interface CopyDatalakeParams { concurrency: number + existing: boolean } export async function copyToDatalake ( @@ -281,7 +282,9 @@ export async function copyToDatalake ( let time = Date.now() let processedCnt = 0 + let processedSize = 0 let skippedCnt = 0 + let existingCnt = 0 let failedCnt = 0 function printStats (): void { @@ -291,14 +294,32 @@ export async function copyToDatalake ( processedCnt, 'skipped', skippedCnt, + 'existing', + existingCnt, 'failed', failedCnt, - Math.round(duration / 1000) + 's' + Math.round(duration / 1000) + 's', + formatSize(processedSize) ) time = Date.now() } + const existing = new Set() + + let cursor: string | undefined = '' + let hasMore = true + while (hasMore) { + const res = await datalake.listObjects(ctx, workspaceId, cursor, 1000) + cursor = res.cursor + hasMore = res.cursor !== undefined + for (const blob of res.blobs) { + existing.add(blob.name) + } + } + + console.info('found blobs in datalake:', existing.size) + const rateLimiter = new RateLimiter(params.concurrency) const iterator = await adapter.listStream(ctx, workspaceId) @@ -315,6 +336,12 @@ export async function copyToDatalake ( continue } + if (!params.existing && existing.has(objectName)) { + // TODO handle mutable blobs + existingCnt++ + continue + } + await rateLimiter.add(async () => { try { await retryOnFailure( @@ -323,6 +350,7 @@ export async function copyToDatalake ( async () => { await copyBlobToDatalake(ctx, workspaceId, blob, config, adapter, datalake) processedCnt += 1 + processedSize += blob.size }, 50 ) @@ -352,11 +380,6 @@ export async function copyBlobToDatalake ( datalake: DatalakeClient ): Promise { const objectName = blob._id - const stat = await datalake.statObject(ctx, workspaceId, objectName) - if (stat !== undefined) { - return - } - if (blob.size < 1024 * 1024 * 64) { // Handle small file const { endpoint, accessKey: accessKeyId, secretKey: secretAccessKey, region } = config @@ -392,3 +415,10 @@ export async function copyBlobToDatalake ( } } } + +export function formatSize (size: number): string { + const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + const pow = size === 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024)) + const val = (1.0 * size) / Math.pow(1024, pow) + return `${val.toFixed(2)} ${units[pow]}` +} diff --git a/models/contact/src/index.ts b/models/contact/src/index.ts index b417998587..60e26b083d 100644 --- a/models/contact/src/index.ts +++ b/models/contact/src/index.ts @@ -696,6 +696,17 @@ export function createModel (builder: Builder): void { contact.channelProvider.Profile ) + builder.createDoc( + contact.class.ChannelProvider, + core.space.Model, + { + label: contact.string.Viber, + icon: contact.icon.Viber, + placeholder: contact.string.ViberPlaceholder + }, + contact.channelProvider.Viber + ) + builder.createDoc( contact.class.AvatarProvider, core.space.Model, diff --git a/models/contact/src/plugin.ts b/models/contact/src/plugin.ts index e3189451f8..cbb7e1b4a8 100644 --- a/models/contact/src/plugin.ts +++ b/models/contact/src/plugin.ts @@ -91,6 +91,8 @@ export default mergeIds(contactId, contact, { SkypePlaceholder: '' as IntlString, Profile: '' as IntlString, ProfilePlaceholder: '' as IntlString, + Viber: '' as IntlString, + ViberPlaceholder: '' as IntlString, CurrentEmployee: '' as IntlString, diff --git a/models/controlled-documents/src/index.ts b/models/controlled-documents/src/index.ts index 20231c6ce5..f7659ab469 100644 --- a/models/controlled-documents/src/index.ts +++ b/models/controlled-documents/src/index.ts @@ -676,6 +676,24 @@ export function createModel (builder: Builder): void { provider: documents.function.DocumentIdentifierProvider }) + createAction( + builder, + { + action: documents.actionImpl.TransferDocument, + label: documents.string.Transfer, + icon: view.icon.Move, + input: 'any', + category: view.category.General, + target: documents.class.ProjectDocument, + visibilityTester: documents.function.CanTransferDocument, + context: { + mode: ['context', 'browser'], + group: 'copy' + } + }, + documents.action.TransferDocument + ) + createAction( builder, { diff --git a/models/controlled-documents/src/plugin.ts b/models/controlled-documents/src/plugin.ts index cf8dcbb92f..f155e919ec 100644 --- a/models/controlled-documents/src/plugin.ts +++ b/models/controlled-documents/src/plugin.ts @@ -61,8 +61,10 @@ export default mergeIds(documentsId, documents, { CreateChildTemplate: '' as ViewAction, CreateDocument: '' as ViewAction, CreateTemplate: '' as ViewAction, + TransferTemplate: '' as ViewAction, DeleteDocument: '' as ViewAction, ArchiveDocument: '' as ViewAction, + TransferDocument: '' as ViewAction, EditDocSpace: '' as ViewAction }, viewlet: { diff --git a/packages/presentation/src/utils.ts b/packages/presentation/src/utils.ts index 787882bb06..0906e9daab 100644 --- a/packages/presentation/src/utils.ts +++ b/packages/presentation/src/utils.ts @@ -549,6 +549,23 @@ export async function getBlobURL (blob: Blob): Promise { }) } +/** + * @public + */ +export function copyTextToClipboardOldBrowser (text: string): void { + const textarea = document.createElement('textarea') + textarea.value = text + textarea.classList.add('hulyClipboardArea') + document.body.appendChild(textarea) + textarea.select() + try { + document.execCommand('copy') + } catch (err) { + console.error(err) + } + document.body.removeChild(textarea) +} + /** * @public */ @@ -562,7 +579,9 @@ export async function copyTextToClipboard (text: string | Promise): Prom await navigator.clipboard.write([clipboardItem]) } catch { // Fallback to default clipboard API implementation - await navigator.clipboard.writeText(text instanceof Promise ? await text : text) + if (navigator.clipboard != null && typeof navigator.clipboard.writeText === 'function') { + await navigator.clipboard.writeText(text instanceof Promise ? await text : text) + } else copyTextToClipboardOldBrowser(text instanceof Promise ? await text : text) } } diff --git a/packages/text/src/index.ts b/packages/text/src/index.ts index d543c4f264..b3601a4b99 100644 --- a/packages/text/src/index.ts +++ b/packages/text/src/index.ts @@ -24,6 +24,7 @@ export * from './nodes' export * from './marks/code' export * from './marks/colors' export * from './marks/noteBase' +export * from './marks/inlineComment' export * from './markdown' export * from './markdown/serializer' export * from './markdown/parser' diff --git a/packages/text/src/kits/server-kit.ts b/packages/text/src/kits/server-kit.ts index e5366ba7b6..21ca67c146 100644 --- a/packages/text/src/kits/server-kit.ts +++ b/packages/text/src/kits/server-kit.ts @@ -37,6 +37,7 @@ import { MermaidExtension, mermaidOptions } from '../nodes/mermaid' import TextAlign from '@tiptap/extension-text-align' import TextStyle from '@tiptap/extension-text-style' import { BackgroundColor, TextColor } from '../marks/colors' +import { InlineCommentMark } from '../marks/inlineComment' const headingLevels: Level[] = [1, 2, 3, 4, 5, 6] @@ -84,6 +85,7 @@ export const ServerKit = Extension.create({ levels: headingLevels } }), + InlineCommentMark.configure({}), CodeBlockExtension.configure(codeBlockOptions), CodeExtension.configure(codeOptions), MermaidExtension.configure(mermaidOptions), diff --git a/packages/text/src/marks/inlineComment.ts b/packages/text/src/marks/inlineComment.ts new file mode 100644 index 0000000000..a2d942fc57 --- /dev/null +++ b/packages/text/src/marks/inlineComment.ts @@ -0,0 +1,87 @@ +// +// 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 { Mark } from '@tiptap/core' +import { Fragment, Node, Slice } from '@tiptap/pm/model' +import { Plugin, PluginKey } from '@tiptap/pm/state' + +export const InlineCommentMark = Mark.create({ + name: 'inline-comment', + excludes: '', + + inclusive: false, + + parseHTML () { + return [ + { + tag: 'span.proseInlineComment[data-inline-comment-thread]' + } + ] + }, + + renderHTML ({ HTMLAttributes, mark }) { + return ['span', { ...HTMLAttributes, class: 'proseInlineComment' }, 0] + }, + + addAttributes () { + const name = 'data-inline-comment-thread-id' + return { + thread: { + default: undefined, + parseHTML: (element) => { + return element.getAttribute(name) + }, + renderHTML: (attributes) => { + return { [name]: attributes.thread } + } + } + } + }, + + addProseMirrorPlugins () { + return [...(this.parent?.() ?? []), InlineCommentPasteFixPlugin()] + } +}) + +function removeMarkFromNode (node: Node, name: string): Node { + if (node.isText) { + return node.mark(node.marks.filter((mark) => mark.type.name !== name)) + } + + if (node.content.size > 0) { + const nodes: Node[] = [] + node.content.forEach((child) => { + nodes.push(removeMarkFromNode(child, name)) + }) + return node.copy(Fragment.fromArray(nodes)) + } + + return node +} + +export function InlineCommentPasteFixPlugin (): Plugin { + return new Plugin({ + key: new PluginKey('inline-comment-paste-fix-plugin'), + props: { + transformPasted: (slice) => { + const nodes: Node[] = [] + slice.content.forEach((node) => { + nodes.push(removeMarkFromNode(node, 'inline-comment')) + }) + return new Slice(Fragment.fromArray(nodes), slice.openStart, slice.openEnd) + } + } + }) +} diff --git a/packages/text/src/nodes/codeblock.ts b/packages/text/src/nodes/codeblock.ts index 4b438d24e9..29e686ee29 100644 --- a/packages/text/src/nodes/codeblock.ts +++ b/packages/text/src/nodes/codeblock.ts @@ -37,6 +37,8 @@ export const backtickInputRegex = /^```$/ export const tildeInputRegex = /^~~~$/ export const CodeBlockExtension = CodeBlock.extend({ + marks: 'inline-comment', + addAttributes () { return { language: { diff --git a/packages/text/src/nodes/mermaid.ts b/packages/text/src/nodes/mermaid.ts index dd128740d7..4290c60864 100644 --- a/packages/text/src/nodes/mermaid.ts +++ b/packages/text/src/nodes/mermaid.ts @@ -24,6 +24,7 @@ export const mermaidOptions: CodeBlockOptions = { export const MermaidExtension = CodeBlock.extend({ name: 'mermaid', group: 'block', + marks: 'inline-comment', parseHTML () { return [ diff --git a/packages/theme/styles/_colors.scss b/packages/theme/styles/_colors.scss index d4881a9ee9..a6f571b0dd 100644 --- a/packages/theme/styles/_colors.scss +++ b/packages/theme/styles/_colors.scss @@ -250,6 +250,7 @@ --theme-text-editor-note-anchor-bg-primary-light: #747C81; --text-editor-table-border-color: hsl(220, 6%, 40%); + --text-editor-color-picker-outline: rgba(250, 222, 201, 0.3); --theme-text-editor-palette-text-gray: rgba(155, 155, 155, 1); --theme-text-editor-palette-text-brown: rgba(186, 133, 111, 1); @@ -531,6 +532,7 @@ --theme-text-editor-note-anchor-bg-primary-light: #D5E5F5; --text-editor-table-border-color: #c9cbcd; + --text-editor-color-picker-outline: rgb(227, 226, 224); --theme-text-editor-palette-text-gray: rgba(120, 119, 116, 1); --theme-text-editor-palette-text-brown: rgba(159, 107, 83, 1); diff --git a/packages/theme/styles/_layouts.scss b/packages/theme/styles/_layouts.scss index 0002c6d0e6..ff4842d656 100644 --- a/packages/theme/styles/_layouts.scss +++ b/packages/theme/styles/_layouts.scss @@ -917,6 +917,11 @@ a.no-line { .text-line-through { text-decoration: line-through; } +.hulyClipboardArea { + width: 0; + height: 0; + opacity: 0; +} .hidden-text { position: absolute; visibility: hidden; diff --git a/packages/ui/src/components/calendar/DueDatePresenter.svelte b/packages/ui/src/components/calendar/DueDatePresenter.svelte index 4190131e72..fa1dba5047 100644 --- a/packages/ui/src/components/calendar/DueDatePresenter.svelte +++ b/packages/ui/src/components/calendar/DueDatePresenter.svelte @@ -16,6 +16,7 @@ import { Timestamp } from '@hcengineering/core' import DueDatePopup from './DueDatePopup.svelte' import { tooltip } from '../../tooltips' + import ui from '../../plugin' import DatePresenter from './DatePresenter.svelte' import { getDaysDifference, getDueDateIconModifier, getFormattedDate } from './internal/DateUtils' import { ButtonKind, ButtonSize } from '../../types' @@ -67,6 +68,7 @@ : undefined} > ({ isPortrait: false, isMobile: false, navigator: { visible: true, float: false, direction: 'vertical' }, - aside: { visible: true, float: false }, fontSize: 0, size: null, sizes: { xs: false, sm: false, md: false, lg: false, xl: false, xxl: false }, diff --git a/packages/ui/src/popups.ts b/packages/ui/src/popups.ts index b2e53371fa..512d86aace 100644 --- a/packages/ui/src/popups.ts +++ b/packages/ui/src/popups.ts @@ -375,9 +375,9 @@ export function fitPopupElement ( } else if (element === 'full-centered') { const rect = contentPanel !== undefined ? contentPanel.getBoundingClientRect() : { top: 0 } newProps.top = `${Math.max(20, rect.top + 1)}px` - newProps.bottom = '20px' - newProps.left = '20px' - newProps.right = '20px' + newProps.bottom = '.5rem' + newProps.left = '.5rem' + newProps.right = '.5rem' show = true } else if (element === 'content' && contentPanel !== undefined) { const rect = contentPanel.getBoundingClientRect() diff --git a/packages/ui/src/types.ts b/packages/ui/src/types.ts index 77121dea8d..9b66828ae6 100644 --- a/packages/ui/src/types.ts +++ b/packages/ui/src/types.ts @@ -381,7 +381,6 @@ export interface DeviceOptions { isPortrait: boolean isMobile: boolean navigator: { visible: boolean, float: boolean, direction: 'vertical' | 'horizontal' } - aside: { visible: boolean, float: boolean } fontSize: number size: WidthType | null sizes: Record diff --git a/plugins/chunter-resources/src/navigation.ts b/plugins/chunter-resources/src/navigation.ts index 6dc72ed16e..8e13ecae37 100644 --- a/plugins/chunter-resources/src/navigation.ts +++ b/plugins/chunter-resources/src/navigation.ts @@ -5,8 +5,7 @@ import { getLocation, type Location, navigate, - languageStore, - deviceOptionsStore as deviceInfo + languageStore } from '@hcengineering/ui' import { type Ref, type Doc, type Class, generateId } from '@hcengineering/core' import activity, { type ActivityMessage } from '@hcengineering/activity' @@ -180,10 +179,6 @@ export async function replyToThread (message: ActivityMessage, e: Event): Promis const fromSidebar = isElementFromSidebar(e.target as HTMLElement) const loc = getCurrentLocation() - const dev = get(deviceInfo) - dev.aside.visible = true - deviceInfo.set(dev) - threadMessagesStore.set(message) if (fromSidebar) { diff --git a/plugins/contact-assets/assets/icons.svg b/plugins/contact-assets/assets/icons.svg index 7e0f0c61c0..d66d763cf2 100644 --- a/plugins/contact-assets/assets/icons.svg +++ b/plugins/contact-assets/assets/icons.svg @@ -95,4 +95,30 @@ + + + + + + + diff --git a/plugins/contact-assets/lang/cs.json b/plugins/contact-assets/lang/cs.json index 0337643477..3c68f91b41 100644 --- a/plugins/contact-assets/lang/cs.json +++ b/plugins/contact-assets/lang/cs.json @@ -105,6 +105,8 @@ "For": "Pro", "SelectUsers": "Vyberte uživatele", "AddGuest": "Přidat hosta", - "ViewProfile": "Zobrazit profil" + "ViewProfile": "Zobrazit profil", + "Viber": "Viber", + "ViberPlaceholder": "Viber" } } diff --git a/plugins/contact-assets/lang/de.json b/plugins/contact-assets/lang/de.json index bfa05c591f..2d1c7467bd 100644 --- a/plugins/contact-assets/lang/de.json +++ b/plugins/contact-assets/lang/de.json @@ -105,6 +105,8 @@ "For": "Für", "SelectUsers": "Benutzer auswählen", "AddGuest": "Gast hinzufügen", - "ViewProfile": "Profil anzeigen" + "ViewProfile": "Profil anzeigen", + "Viber": "Viber", + "ViberPlaceholder": "Viber" } } diff --git a/plugins/contact-assets/lang/en.json b/plugins/contact-assets/lang/en.json index ac4c99f0dd..c0823fd5eb 100644 --- a/plugins/contact-assets/lang/en.json +++ b/plugins/contact-assets/lang/en.json @@ -105,6 +105,8 @@ "For": "For", "SelectUsers": "Select users", "AddGuest": "Add guest", - "ViewProfile": "View profile" + "ViewProfile": "View profile", + "Viber": "Viber", + "ViberPlaceholder": "Viber" } } diff --git a/plugins/contact-assets/lang/es.json b/plugins/contact-assets/lang/es.json index a8c491f331..5e3f8a6835 100644 --- a/plugins/contact-assets/lang/es.json +++ b/plugins/contact-assets/lang/es.json @@ -105,6 +105,8 @@ "For": "Para", "SelectUsers": "Seleccionar usuarios", "AddGuest": "Añadir invitado", - "ViewProfile": "Ver perfil" + "ViewProfile": "Ver perfil", + "Viber": "Viber", + "ViberPlaceholder": "Viber" } } diff --git a/plugins/contact-assets/lang/fr.json b/plugins/contact-assets/lang/fr.json index 090d7e8421..e6bcf4546f 100644 --- a/plugins/contact-assets/lang/fr.json +++ b/plugins/contact-assets/lang/fr.json @@ -105,6 +105,8 @@ "For": "Pour", "SelectUsers": "Sélectionner des utilisateurs", "AddGuest": "Ajouter un invité", - "ViewProfile": "Voir le profil" + "ViewProfile": "Voir le profil", + "Viber": "Viber", + "ViberPlaceholder": "Viber" } } diff --git a/plugins/contact-assets/lang/it.json b/plugins/contact-assets/lang/it.json index 6be24cb527..3be4148a48 100644 --- a/plugins/contact-assets/lang/it.json +++ b/plugins/contact-assets/lang/it.json @@ -105,6 +105,8 @@ "For": "Per", "SelectUsers": "Seleziona utenti", "AddGuest": "Aggiungi ospite", - "ViewProfile": "Visualizza profilo" + "ViewProfile": "Visualizza profilo", + "Viber": "Viber", + "ViberPlaceholder": "Viber" } } diff --git a/plugins/contact-assets/lang/pt.json b/plugins/contact-assets/lang/pt.json index eaefaf537f..ca1f70e0bc 100644 --- a/plugins/contact-assets/lang/pt.json +++ b/plugins/contact-assets/lang/pt.json @@ -105,6 +105,8 @@ "For": "Para", "SelectUsers": "Selecionar utilizadores", "AddGuest": "Adicionar convidado", - "ViewProfile": "Ver perfil" + "ViewProfile": "Ver perfil", + "Viber": "Viber", + "ViberPlaceholder": "Viber" } } diff --git a/plugins/contact-assets/lang/ru.json b/plugins/contact-assets/lang/ru.json index 8e063ae76b..15c2c3231d 100644 --- a/plugins/contact-assets/lang/ru.json +++ b/plugins/contact-assets/lang/ru.json @@ -105,6 +105,8 @@ "For": "Для", "SelectUsers": "Выберите пользователей", "AddGuest": "Добавить гостя", - "ViewProfile": "Посмотреть профиль" + "ViewProfile": "Посмотреть профиль", + "Viber": "Viber", + "ViberPlaceholder": "Viber" } } diff --git a/plugins/contact-assets/lang/zh.json b/plugins/contact-assets/lang/zh.json index a06acb8acc..65f6de469d 100644 --- a/plugins/contact-assets/lang/zh.json +++ b/plugins/contact-assets/lang/zh.json @@ -105,6 +105,8 @@ "For": "为", "SelectUsers": "选择用户", "AddGuest": "添加访客", - "ViewProfile": "查看资料" + "ViewProfile": "查看资料", + "Viber": "Viber", + "ViberPlaceholder": "Viber" } } diff --git a/plugins/contact-assets/src/index.ts b/plugins/contact-assets/src/index.ts index 6cc90fb578..37d0347d9a 100644 --- a/plugins/contact-assets/src/index.ts +++ b/plugins/contact-assets/src/index.ts @@ -28,6 +28,7 @@ loadMetadata(contact.icon, { Telegram: `${icons}#telegram`, Twitter: `${icons}#twitter`, VK: `${icons}#vk`, + Viber: `${icons}#viber`, WhatsApp: `${icons}#whatsapp`, Skype: `${icons}#skype`, Youtube: `${icons}#youtube`, diff --git a/plugins/contact/src/index.ts b/plugins/contact/src/index.ts index b19b6ff83d..9dd17c0d4c 100644 --- a/plugins/contact/src/index.ts +++ b/plugins/contact/src/index.ts @@ -235,7 +235,8 @@ export const contactPlugin = plugin(contactId, { Homepage: '' as Ref, Whatsapp: '' as Ref, Skype: '' as Ref, - Profile: '' as Ref + Profile: '' as Ref, + Viber: '' as Ref }, avatarProvider: { Color: '' as Ref, @@ -273,7 +274,8 @@ export const contactPlugin = plugin(contactId, { ComponentMembers: '' as Asset, Profile: '' as Asset, KickUser: '' as Asset, - Contacts: '' as Asset + Contacts: '' as Asset, + Viber: '' as Asset }, space: { Contacts: '' as Ref diff --git a/plugins/controlled-documents-assets/lang/cs.json b/plugins/controlled-documents-assets/lang/cs.json index 242304159a..54a802f60c 100644 --- a/plugins/controlled-documents-assets/lang/cs.json +++ b/plugins/controlled-documents-assets/lang/cs.json @@ -129,7 +129,12 @@ "Copy": "kopírovat", "ConfigLabel": "Řízené dokumenty", - "ConfigDescription": "Rozšíření pro správu řízených dokumentů" + "ConfigDescription": "Rozšíření pro správu řízených dokumentů", + + "Transfer": "Přenos", + "TransferWarning": "Někteří členové týmu mohou po této akci ztratit možnost prohlížet nebo upravovat tento dokument.", + "TransferDocuments": "Přenos řízených dokumentů", + "TransferDocumentsHint": "Dokumenty, které mají být přeneseny do vybraného prostoru:" }, "controlledDocStates": { "Empty": "", diff --git a/plugins/controlled-documents-assets/lang/de.json b/plugins/controlled-documents-assets/lang/de.json index a6687b5a0e..0298bad71a 100644 --- a/plugins/controlled-documents-assets/lang/de.json +++ b/plugins/controlled-documents-assets/lang/de.json @@ -293,7 +293,12 @@ "DeleteDocumentCategoryPermission": "Dokumentenkategorie löschen", "DeleteDocumentCategoryDescription": "Gewährt Benutzern die Möglichkeit, eine Dokumentenkategorie zu löschen", "ConfigLabel": "Kontrollierte Dokumente", - "ConfigDescription": "Erweiterung zur Verwaltung kontrollierter Dokumente" + "ConfigDescription": "Erweiterung zur Verwaltung kontrollierter Dokumente", + + "Transfer": "Übertragung", + "TransferWarning": "Einige Teammitglieder können dieses Dokument nach dieser Aktion möglicherweise nicht mehr anzeigen oder bearbeiten.", + "TransferDocuments": "Übertragung kontrollierter Dokumente", + "TransferDocumentsHint": "Dokumente, die in den ausgewählten Bereich übertragen werden sollen:" }, "controlledDocStates": { "Empty": "", diff --git a/plugins/controlled-documents-assets/lang/en.json b/plugins/controlled-documents-assets/lang/en.json index 6bd79621ab..9f981bf2ee 100644 --- a/plugins/controlled-documents-assets/lang/en.json +++ b/plugins/controlled-documents-assets/lang/en.json @@ -295,7 +295,12 @@ "DeleteDocumentCategoryPermission": "Delete document category", "DeleteDocumentCategoryDescription": "Grants users ability to delete a document category", "ConfigLabel": "Controlled Documents", - "ConfigDescription": "Extension to manage controlled documents" + "ConfigDescription": "Extension to manage controlled documents", + + "Transfer": "Transfer", + "TransferWarning": "Some team members may lose the ability to view or edit this document after this action.", + "TransferDocuments": "Transfer controlled documents", + "TransferDocumentsHint": "Documents to be transferred to the selected space:" }, "controlledDocStates": { "Empty": "", diff --git a/plugins/controlled-documents-assets/lang/fr.json b/plugins/controlled-documents-assets/lang/fr.json index cf9023ab24..0378c61382 100644 --- a/plugins/controlled-documents-assets/lang/fr.json +++ b/plugins/controlled-documents-assets/lang/fr.json @@ -253,7 +253,12 @@ "DeleteDocumentCategoryPermission": "Supprimer la catégorie de document", "DeleteDocumentCategoryDescription": "Accorde aux utilisateurs la capacité de supprimer une catégorie de document", "ConfigLabel": "Documents contrôlés", - "ConfigDescription": "Extension pour gérer les documents contrôlés" + "ConfigDescription": "Extension pour gérer les documents contrôlés", + + "Transfer": "Transfert", + "TransferWarning": "Certains membres de l'équipe peuvent perdre la possibilité de visualiser ou de modifier ce document après cette action.", + "TransferDocuments": "Transférer des documents contrôlés", + "TransferDocumentsHint": "Documents à transférer dans l'espace sélectionné:" }, "controlledDocStates": { "Empty": "", diff --git a/plugins/controlled-documents-assets/lang/it.json b/plugins/controlled-documents-assets/lang/it.json index 56bdc91d64..561f79624a 100644 --- a/plugins/controlled-documents-assets/lang/it.json +++ b/plugins/controlled-documents-assets/lang/it.json @@ -251,7 +251,12 @@ "DeleteDocumentCategoryPermission": "Elimina categoria documento", "DeleteDocumentCategoryDescription": "Concede agli utenti la possibilità di eliminare una categoria di documento", "ConfigLabel": "Documenti controllati", - "ConfigDescription": "Estensione per gestire documenti controllati" + "ConfigDescription": "Estensione per gestire documenti controllati", + + "Transfer": "Trasferimento", + "TransferWarning": "Alcuni membri del team potrebbero perdere la possibilità di visualizzare o modificare il documento dopo questa azione.", + "TransferDocuments": "Trasferimento di documenti controllati", + "TransferDocumentsHint": "Documenti da trasferire nello spazio selezionato:" }, "controlledDocStates": { "Empty": "", diff --git a/plugins/controlled-documents-assets/lang/ru.json b/plugins/controlled-documents-assets/lang/ru.json index cfd3485676..1b4864e0cb 100644 --- a/plugins/controlled-documents-assets/lang/ru.json +++ b/plugins/controlled-documents-assets/lang/ru.json @@ -295,7 +295,12 @@ "DeleteDocumentCategoryPermission": "Удалять категорию", "DeleteDocumentCategoryDescription": "Предоставляет пользователям разрешение удалять категорию", "ConfigLabel": "Управляемые Документы", - "ConfigDescription": "Расширение для управления управляемыми документами" + "ConfigDescription": "Расширение для управления управляемыми документами", + + "Transfer": "Трансфер", + "TransferWarning": "После этого действия некоторые члены команды могут потерять возможность просматривать или редактировать этот документ.", + "TransferDocuments": "Трансфер управляемых документов", + "TransferDocumentsHint": "Документы, которые будут перенесены в выбранное пространство:" }, "controlledDocStates": { "Empty": "", diff --git a/plugins/controlled-documents-assets/lang/zh.json b/plugins/controlled-documents-assets/lang/zh.json index 4bb386924b..930b82681f 100644 --- a/plugins/controlled-documents-assets/lang/zh.json +++ b/plugins/controlled-documents-assets/lang/zh.json @@ -292,7 +292,12 @@ "DeleteDocumentCategoryPermission": "删除文档类别", "DeleteDocumentCategoryDescription": "授予用户删除文档类别的权限", "ConfigLabel": "受控文档", - "ConfigDescription": "用于管理受控文档的扩展" + "ConfigDescription": "用于管理受控文档的扩展", + + "Transfer": "转让", + "TransferWarning": "执行此操作后,某些团队成员可能会失去查看或编辑此文档的能力", + "TransferDocuments": "移交受控文件", + "TransferDocumentsHint": "要转移到所选空间的文件:" }, "controlledDocStates": { "Empty": "", diff --git a/plugins/controlled-documents-resources/src/components/document/popups/TransferDocumentPopup.svelte b/plugins/controlled-documents-resources/src/components/document/popups/TransferDocumentPopup.svelte new file mode 100644 index 0000000000..1f77c9637a --- /dev/null +++ b/plugins/controlled-documents-resources/src/components/document/popups/TransferDocumentPopup.svelte @@ -0,0 +1,284 @@ + + + + + + + diff --git a/plugins/controlled-documents-resources/src/index.ts b/plugins/controlled-documents-resources/src/index.ts index 1ee72b010b..0a7cb8032b 100644 --- a/plugins/controlled-documents-resources/src/index.ts +++ b/plugins/controlled-documents-resources/src/index.ts @@ -28,7 +28,9 @@ import { type Document, type DocumentSpace, DocumentState, - type DocumentMeta + type DocumentMeta, + type ProjectDocument, + type Project } from '@hcengineering/controlled-documents' import { type Resources } from '@hcengineering/platform' import { type ObjectSearchResult, getClient, MessageBox } from '@hcengineering/presentation' @@ -101,6 +103,7 @@ import { createTemplate } from './utils' import { comment, isCommentVisible } from './text' +import TransferDocumentPopup from './components/document/popups/TransferDocumentPopup.svelte' export { DocumentStatusTag, DocumentTitle, DocumentVersionPresenter, StatePresenter } @@ -207,6 +210,46 @@ async function canArchiveDocument (obj?: Doc | Doc[]): Promise { ).then((res) => res.every((r) => r)) } +async function canTransferDocument (obj?: Doc | Doc[]): Promise { + if (obj == null) { + return false + } + + const objs = (Array.isArray(obj) ? obj : [obj]) as Document[] + const spaces = new Set(objs.map((doc) => doc.space)) + + return await Promise.all( + Array.from(spaces).map( + async (space) => await checkPermission(getClient(), documents.permission.ArchiveDocument, space) + ) + ).then((res) => res.every((r) => r)) +} + +async function transferDocuments (selection: Document | Document[]): Promise { + const objects = Array.isArray(selection) ? selection : [selection] + + const client = getClient() + const h = client.getHierarchy() + + let sourceDocumentIds: Array> = [] + let sourceSpaceId: Ref | undefined + let sourceProjectId: Ref> | undefined + + if (objects.length < 1) return + if (h.isDerived(objects[0]._class, documents.class.ProjectDocument)) { + const pjDocs = objects as unknown as ProjectDocument[] + const pjMeta = await client.findAll(documents.class.ProjectMeta, { _id: { $in: pjDocs.map((d) => d.attachedTo) } }) + const docMeta = await client.findAll(documents.class.DocumentMeta, { _id: { $in: pjMeta.map((d) => d.meta) } }) + sourceDocumentIds = docMeta.map((d) => d._id) + sourceSpaceId = pjDocs[0].space + sourceProjectId = pjDocs[0].project + } + + if (sourceDocumentIds.length < 1) return + + showPopup(TransferDocumentPopup, { sourceDocumentIds, sourceSpaceId, sourceProjectId }) +} + async function isLatestDraftDoc (obj?: Doc | Doc[]): Promise { if (obj == null) { return false @@ -322,6 +365,7 @@ export default async (): Promise => ({ GetDocumentMetaLinkFragment: getDocumentMetaLinkFragment, CanDeleteDocument: canDeleteDocument, CanArchiveDocument: canArchiveDocument, + CanTransferDocument: canTransferDocument, DocumentIdentifierProvider: documentIdentifierProvider, ControlledDocumentTitleProvider: getControlledDocumentTitle, Comment: comment, @@ -334,6 +378,7 @@ export default async (): Promise => ({ CreateTemplate: createTemplate, DeleteDocument: deleteDocuments, ArchiveDocument: archiveDocuments, + TransferDocument: transferDocuments, EditDocSpace: editDocSpace }, resolver: { diff --git a/plugins/controlled-documents-resources/src/plugin.ts b/plugins/controlled-documents-resources/src/plugin.ts index 7ee46767e1..0f63086ecf 100644 --- a/plugins/controlled-documents-resources/src/plugin.ts +++ b/plugins/controlled-documents-resources/src/plugin.ts @@ -238,6 +238,7 @@ export default mergeIds(documentsId, documents, { GetDocumentMetaLinkFragment: '' as Resource<(doc: Doc, props: Record) => Promise>, CanDeleteDocument: '' as Resource<(doc?: Doc | Doc[]) => Promise>, CanArchiveDocument: '' as Resource<(doc?: Doc | Doc[]) => Promise>, + CanTransferDocument: '' as Resource<(doc?: Doc | Doc[]) => Promise>, ControlledDocumentTitleProvider: '' as Resource<(client: Client, ref: Ref, doc?: Doc) => Promise> } }) diff --git a/plugins/controlled-documents/src/plugin.ts b/plugins/controlled-documents/src/plugin.ts index e35c614ff0..49aaaef79f 100644 --- a/plugins/controlled-documents/src/plugin.ts +++ b/plugins/controlled-documents/src/plugin.ts @@ -118,6 +118,7 @@ export const documentsPlugin = plugin(documentsId, { DeleteDocument: '' as Ref, ArchiveDocument: '' as Ref, EditDocSpace: '' as Ref, + TransferDocument: '' as Ref, Print: '' as Ref> }, function: { @@ -259,7 +260,12 @@ export const documentsPlugin = plugin(documentsId, { DeleteDocumentCategoryPermission: '' as IntlString, DeleteDocumentCategoryDescription: '' as IntlString, ConfigLabel: '' as IntlString, - ConfigDescription: '' as IntlString + ConfigDescription: '' as IntlString, + + Transfer: '' as IntlString, + TransferWarning: '' as IntlString, + TransferDocuments: '' as IntlString, + TransferDocumentsHint: '' as IntlString }, ids: { NoParent: '' as Ref, diff --git a/plugins/controlled-documents/src/utils.ts b/plugins/controlled-documents/src/utils.ts index 7ae9ad4d13..7acbb4f229 100644 --- a/plugins/controlled-documents/src/utils.ts +++ b/plugins/controlled-documents/src/utils.ts @@ -14,7 +14,10 @@ // import { ApplyOperations, + checkPermission, + Class, Data, + Doc, DocumentQuery, DocumentUpdate, Rank, @@ -28,17 +31,23 @@ import LexoRankBucket from 'lexorank/lib/lexoRank/lexoRankBucket' import documents from './plugin' +import attachment, { Attachment } from '@hcengineering/attachment' +import chunter, { ChatMessage } from '@hcengineering/chunter' +import tags, { TagReference } from '@hcengineering/tags' import { ChangeControl, ControlledDocument, Document, DocumentMeta, + DocumentRequest, + DocumentSnapshot, DocumentSpace, DocumentState, Project, ProjectDocument, ProjectMeta } from './types' +import { makeRank } from '@hcengineering/rank' /** * @public @@ -129,6 +138,364 @@ export async function deleteProjectDrafts (client: ApplyOperations, source: Ref< } } +class ProjectDocumentTree { + rootDocs: ProjectMeta[] + childrenByParent: Map, ProjectMeta[]> + + constructor (pjMeta: ProjectMeta[]) { + this.rootDocs = [] + this.childrenByParent = new Map, Array>() + + for (const meta of pjMeta) { + const parentId = meta.path[0] ?? documents.ids.NoParent + + if (!this.childrenByParent.has(parentId)) { + this.childrenByParent.set(parentId, []) + } + + this.childrenByParent.get(parentId)?.push(meta) + + if (parentId === documents.ids.NoParent) { + this.rootDocs.push(meta) + } + } + } + + getDescendants (parent: Ref): Ref[] { + const result: Ref[] = [] + const queue: Ref[] = [parent] + + while (queue.length > 0) { + const next = queue.pop() + if (next === undefined) break + + const children = this.childrenByParent.get(next) ?? [] + const childrenRefs = children.map((p) => p.meta) + result.push(...childrenRefs) + queue.push(...childrenRefs) + } + + return result + } +} + +export async function findProjectDocsHierarchy ( + client: TxOperations, + space: Ref, + project?: Ref> +): Promise { + const pjMeta = await client.findAll(documents.class.ProjectMeta, { space, project }) + return new ProjectDocumentTree(pjMeta) +} + +export interface DocumentBundle { + DocumentMeta: DocumentMeta[] + ProjectMeta: ProjectMeta[] + ProjectDocument: ProjectDocument[] + ControlledDocument: ControlledDocument[] + ChangeControl: ChangeControl[] + DocumentRequest: DocumentRequest[] + DocumentSnapshot: DocumentSnapshot[] + ChatMessage: ChatMessage[] + TagReference: TagReference[] + Attachment: Attachment[] +} + +function emptyBundle (): DocumentBundle { + return { + DocumentMeta: [], + ProjectMeta: [], + ProjectDocument: [], + ControlledDocument: [], + ChangeControl: [], + DocumentRequest: [], + DocumentSnapshot: [], + ChatMessage: [], + TagReference: [], + Attachment: [] + } +} + +export async function findAllDocumentBundles ( + client: TxOperations, + ids: Ref[] +): Promise { + const all: DocumentBundle = { ...emptyBundle() } + + async function crawl ( + _class: Ref>, + bkey: keyof DocumentBundle, + prop: P, + ids: T[P][] + ): Promise { + const data = await client.findAll(_class, { [prop]: { $in: ids } } as any) + all[bkey].push(...(data as any)) + return data + } + + await crawl(documents.class.DocumentMeta, 'DocumentMeta', '_id', ids) + await crawl( + documents.class.ProjectMeta, + 'ProjectMeta', + 'meta', + all.DocumentMeta.map((m) => m._id) + ) + await crawl( + documents.class.ProjectDocument, + 'ProjectDocument', + 'attachedTo', + all.ProjectMeta.map((m) => m._id) + ) + await crawl( + documents.class.ControlledDocument, + 'ControlledDocument', + 'attachedTo', + all.DocumentMeta.map((m) => m._id) + ) + await crawl( + documents.class.ChangeControl, + 'ChangeControl', + '_id', + all.ControlledDocument.map((p) => p.changeControl) + ) + await crawl( + documents.class.DocumentRequest, + 'DocumentRequest', + 'attachedTo', + all.ControlledDocument.map((p) => p._id) + ) + await crawl( + documents.class.DocumentSnapshot, + 'DocumentSnapshot', + 'attachedTo', + all.ControlledDocument.map((p) => p._id) + ) + await crawl( + documents.class.DocumentComment, + 'ChatMessage', + 'attachedTo', + all.ControlledDocument.map((p) => p._id) + ) + await crawl( + chunter.class.ThreadMessage, + 'ChatMessage', + 'attachedTo', + all.ChatMessage.map((p) => p._id) + ) + await crawl( + tags.class.TagReference, + 'TagReference', + 'attachedTo', + all.ControlledDocument.map((p) => p._id) + ) + await crawl(attachment.class.Attachment, 'Attachment', 'attachedTo', [ + ...all.ChatMessage.map((p) => p._id), + ...all.ControlledDocument.map((p) => p._id) + ]) + + const bundles = new Map, DocumentBundle>(all.DocumentMeta.map((m) => [m._id, { ...emptyBundle() }])) + const links = new Map, Ref>() + + const link = (ref: Ref, lookup: Ref): void => { + const meta = links.get(lookup) + if (meta !== undefined) links.set(ref, meta) + } + + const relink = (ref: Ref, prop: keyof DocumentBundle, obj: DocumentBundle[typeof prop][0]): void => { + const meta = links.get(ref) + if (meta !== undefined) bundles.get(meta)?.[prop].push(obj as any) + } + + for (const m of all.DocumentMeta) links.set(m._id, m._id) // DocumentMeta -> DocumentMeta + for (const m of all.ProjectMeta) links.set(m._id, m.meta) // ProjectMeta -> DocumentMeta + for (const m of all.ProjectDocument) { + link(m._id, m.attachedTo) // ProjectDocument -> ProjectMeta + link(m.document, m.attachedTo) // ControlledDocument -> ProjectMeta + } + for (const m of all.ControlledDocument) link(m.changeControl, m.attachedTo) // ChangeControl -> ControlledDocument + for (const m of all.DocumentRequest) link(m._id, m.attachedTo) // DocumentRequest -> ControlledDocument + for (const m of all.DocumentSnapshot) link(m._id, m.attachedTo) // DocumentSnapshot -> ControlledDocument + for (const m of all.ChatMessage) link(m._id, m.attachedTo) // ChatMessage -> (ControlledDocument | ChatMessage) + for (const m of all.TagReference) link(m._id, m.attachedTo) // TagReference -> ControlledDocument + for (const m of all.Attachment) link(m._id, m.attachedTo) // Attachment -> (ControlledDocument | ChatMessage) + + let key: keyof DocumentBundle + for (key in all) { + all[key].forEach((value) => { + relink(value._id, key, value) + }) + } + + return Array.from(bundles.values()) +} + +export async function findOneDocumentBundle ( + client: TxOperations, + id: Ref +): Promise { + const bundles = await findAllDocumentBundles(client, [id]) + return bundles[0] +} + +export interface DocumentTransferRequest { + sourceDocumentIds: Ref[] + sourceSpaceId: Ref + sourceProjectId?: Ref> + + targetSpaceId: Ref + targetParentId?: Ref + targetProjectId?: Ref> +} + +interface DocumentTransferContext { + request: DocumentTransferRequest + bundles: DocumentBundle[] + + sourceTree: ProjectDocumentTree + targetTree: ProjectDocumentTree + + sourceSpace: DocumentSpace + targetSpace: DocumentSpace + + targetParentBundle?: DocumentBundle +} + +async function _buildDocumentTransferContext ( + client: TxOperations, + request: DocumentTransferRequest +): Promise { + const sourceTree = await findProjectDocsHierarchy(client, request.sourceSpaceId, request.sourceProjectId) + const targetTree = await findProjectDocsHierarchy(client, request.targetSpaceId, request.targetProjectId) + + const docIds = new Set>(request.sourceDocumentIds) + for (const id of request.sourceDocumentIds) { + sourceTree.getDescendants(id).forEach((d) => docIds.add(d)) + } + + const bundles = await findAllDocumentBundles(client, Array.from(docIds)) + const targetParentBundle = + request.targetParentId !== undefined ? await findOneDocumentBundle(client, request.targetParentId) : undefined + + const sourceSpace = await client.findOne(documents.class.DocumentSpace, { _id: request.sourceSpaceId }) + const targetSpace = await client.findOne(documents.class.DocumentSpace, { _id: request.targetSpaceId }) + + if (sourceSpace === undefined || targetSpace === undefined) return + + return { + request, + bundles, + sourceTree, + targetTree, + sourceSpace, + targetSpace, + targetParentBundle + } +} + +export async function listDocumentsAffectedByTransfer ( + client: TxOperations, + req: DocumentTransferRequest +): Promise { + const cx = await _buildDocumentTransferContext(client, req) + return cx?.bundles.map((b) => b.DocumentMeta[0]) ?? [] +} + +/** + * @public + */ +export async function canTransferDocuments (client: TxOperations, req: DocumentTransferRequest): Promise { + const cx = await _buildDocumentTransferContext(client, req) + return cx !== undefined ? await _transferDocuments(client, cx, 'check') : false +} + +/** + * @public + */ +export async function transferDocuments (client: TxOperations, req: DocumentTransferRequest): Promise { + const cx = await _buildDocumentTransferContext(client, req) + return cx !== undefined ? await _transferDocuments(client, cx) : false +} + +async function _transferDocuments ( + client: TxOperations, + cx: DocumentTransferContext, + mode: 'default' | 'check' = 'default' +): Promise { + if (cx.bundles.length < 1) return false + if (cx.targetSpace._id === cx.sourceSpace._id) return false + + const hierarchy = client.getHierarchy() + + const canArchiveInSourceSpace = await checkPermission( + client, + documents.permission.ArchiveDocument, + cx.request.sourceSpaceId + ) + const canCreateInTargetSpace = await checkPermission( + client, + documents.permission.CreateDocument, + cx.request.targetSpaceId + ) + + if (!canArchiveInSourceSpace || !canCreateInTargetSpace) return false + + for (const bundle of cx.bundles) { + if (bundle.DocumentMeta.length !== 1) return false + if (bundle.ProjectMeta.length !== 1) return false + if (bundle.DocumentMeta[0].space !== cx.request.sourceSpaceId) return false + if (bundle.ControlledDocument.length < 1) return false + + const isTemplate = hierarchy.hasMixin(bundle.ControlledDocument[0], documents.mixin.DocumentTemplate) + if (isTemplate && hierarchy.isDerived(cx.targetSpace._class, documents.class.ExternalSpace)) return false + } + + const roots = new Set(cx.request.sourceDocumentIds) + const updates = new Map>() + + function update (document: T, update: Partial): void { + updates.set(document, { ...updates.get(document), ...update }) + } + + const parentMeta = cx.targetParentBundle?.ProjectMeta[0] + const project = cx.request.targetProjectId ?? documents.ids.NoProject + + if (cx.targetParentBundle !== undefined && parentMeta === undefined) return false + + let lastRank: Rank | undefined + if (parentMeta !== undefined) { + lastRank = await getFirstRank(client, cx.targetSpace._id, project, parentMeta.meta) + } + + for (const bundle of cx.bundles) { + const projectMeta = bundle.ProjectMeta[0] + + if (roots.has(projectMeta.meta)) { + const path = parentMeta?.path !== undefined ? [parentMeta.meta, ...parentMeta.path] : [] + const parent = path[0] ?? documents.ids.NoParent + const rank = makeRank(lastRank, undefined) + update(projectMeta, { parent, path, rank }) + } + + let key: keyof DocumentBundle + for (key in bundle) { + bundle[key].forEach((doc) => { + update(doc, { space: cx.targetSpace._id }) + }) + } + + for (const m of bundle.ProjectMeta) update(m, { project }) + for (const m of bundle.ProjectDocument) update(m, { project }) + } + + if (mode === 'check') return true + + const ops = client.apply() + for (const u of updates) await ops.update(u[0], u[1]) + + const commit = await ops.commit() + return commit.result +} + /** * @public */ diff --git a/plugins/love-resources/src/components/Room.svelte b/plugins/love-resources/src/components/Room.svelte index 867cb4edb2..0cf8e39a78 100644 --- a/plugins/love-resources/src/components/Room.svelte +++ b/plugins/love-resources/src/components/Room.svelte @@ -387,7 +387,7 @@ $: activeParticipants = getActiveParticipants(participants) -
+
{#if $isConnected && !$isCurrentInstanceConnected}