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/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 */