EZQMS-1234: means for transferring controlled documents between spaces (#7691)

* EZQMS-1234: transfer of controlled documents between spaces

Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com>

* ff

Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com>

* updated translation

Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com>

---------

Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com>
This commit is contained in:
Victor Ilyushchenko 2025-01-17 13:17:07 +03:00 committed by GitHub
parent 8f31c6ec61
commit 8829859d1e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 767 additions and 9 deletions

View File

@ -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,
{

View File

@ -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: {

View File

@ -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": "",

View File

@ -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": "",

View File

@ -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": "",

View File

@ -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": "",

View File

@ -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": "",

View File

@ -295,7 +295,12 @@
"DeleteDocumentCategoryPermission": "Удалять категорию",
"DeleteDocumentCategoryDescription": "Предоставляет пользователям разрешение удалять категорию",
"ConfigLabel": "Управляемые Документы",
"ConfigDescription": "Расширение для управления управляемыми документами"
"ConfigDescription": "Расширение для управления управляемыми документами",
"Transfer": "Трансфер",
"TransferWarning": "После этого действия некоторые члены команды могут потерять возможность просматривать или редактировать этот документ.",
"TransferDocuments": "Трансфер управляемых документов",
"TransferDocumentsHint": "Документы, которые будут перенесены в выбранное пространство:"
},
"controlledDocStates": {
"Empty": "",

View File

@ -292,7 +292,12 @@
"DeleteDocumentCategoryPermission": "删除文档类别",
"DeleteDocumentCategoryDescription": "授予用户删除文档类别的权限",
"ConfigLabel": "受控文档",
"ConfigDescription": "用于管理受控文档的扩展"
"ConfigDescription": "用于管理受控文档的扩展",
"Transfer": "转让",
"TransferWarning": "执行此操作后,某些团队成员可能会失去查看或编辑此文档的能力",
"TransferDocuments": "移交受控文件",
"TransferDocumentsHint": "要转移到所选空间的文件:"
},
"controlledDocStates": {
"Empty": "",

View File

@ -0,0 +1,284 @@
<!--
// 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.
-->
<script lang="ts">
import documents, {
canTransferDocuments,
DocumentMeta,
DocumentTransferRequest,
listDocumentsAffectedByTransfer,
transferDocuments,
type DocumentSpace,
type DocumentSpaceType,
type Project,
type ProjectDocument
} from '@hcengineering/controlled-documents'
import { type Doc, type Ref, type Space } from '@hcengineering/core'
import presentation, { getClient, SpaceSelector } from '@hcengineering/presentation'
import { Button, Label } from '@hcengineering/ui'
import { permissionsStore } from '@hcengineering/view-resources'
import { createEventDispatcher } from 'svelte'
import documentsRes from '../../../plugin'
import { getLatestProjectId } from '../../../utils'
import DocumentParentSelector from '../../hierarchy/DocumentParentSelector.svelte'
import ProjectSelector from '../../project/ProjectSelector.svelte'
import Info from '../../icons/Info.svelte'
export let sourceDocumentIds: Ref<DocumentMeta>[] = []
export let sourceSpaceId: Ref<DocumentSpace> | undefined
export let sourceProjectId: Ref<Project<DocumentSpace>> | undefined
let targetSpaceId: Ref<DocumentSpace> | undefined
let targetParentId: Ref<DocumentMeta> | undefined
let targetSpace: DocumentSpace | undefined
$: void fetchSpace(targetSpaceId)
let targetSpaceType: DocumentSpaceType | undefined
$: void fetchSpaceType(targetSpace?.type)
let targetProjectId: Ref<Project> | undefined
$: void selectProject(targetSpaceId)
let targetParentDocumentId: Ref<ProjectDocument> | undefined
let affectedDocs: DocumentMeta[] = []
let canTransfer = false
$: request =
sourceSpaceId !== undefined && targetSpaceId !== undefined
? ({
sourceDocumentIds,
sourceSpaceId,
sourceProjectId,
targetSpaceId,
targetProjectId,
targetParentId
} satisfies DocumentTransferRequest)
: undefined
const client = getClient()
const hierarchy = client.getHierarchy()
const dispatch = createEventDispatcher()
async function transfer (): Promise<void> {
if (request !== undefined) {
await transferDocuments(client, request)
dispatch('close')
}
}
$: if (request !== undefined) {
void listDocumentsAffectedByTransfer(client, request).then((result) => {
affectedDocs = result
})
}
$: if (request !== undefined) {
void canTransferDocuments(client, request).then((value) => {
canTransfer = value
})
} else {
canTransfer = false
}
async function selectProject (spaceRef: Ref<DocumentSpace> | undefined): Promise<void> {
targetProjectId = spaceRef !== undefined ? await getLatestProjectId(spaceRef) : undefined
}
async function fetchSpace (id: Ref<DocumentSpace> | undefined): Promise<void> {
targetSpace = id === undefined ? undefined : await client.findOne(documents.class.DocumentSpace, { _id: id })
}
async function fetchSpaceType (id: Ref<DocumentSpaceType> | undefined): Promise<void> {
targetSpaceType =
id === undefined ? undefined : await client.findOne(documents.class.DocumentSpaceType, { _id: id }, {})
}
async function handleParentSelected (doc: Doc): Promise<void> {
if (hierarchy.isDerived(doc._class, documents.class.DocumentSpace)) {
targetParentDocumentId = undefined
targetParentId = undefined
} else if (hierarchy.isDerived(doc._class, documents.class.ProjectDocument)) {
const pjDoc = doc as ProjectDocument
targetParentDocumentId = pjDoc._id
const pjMeta = await client.findOne(documents.class.ProjectMeta, { _id: pjDoc.attachedTo })
if (targetParentDocumentId === pjDoc._id) targetParentId = pjMeta?.meta
}
}
function handleProjectSelected (value: Ref<Project> | undefined): void {
targetProjectId = value
}
let haveTemplateObjects: boolean = false
$: void checkForTemplateObjects(affectedDocs)
async function checkForTemplateObjects (docs: DocumentMeta[]): Promise<void> {
const cdocs = await client.findAll(documents.class.ControlledDocument, {
attachedTo: { $in: docs.map((d) => d._id) }
})
haveTemplateObjects = cdocs.some((doc) => hierarchy.hasMixin(doc, documents.mixin.DocumentTemplate))
}
const externalSpaces = hierarchy.getDescendants(documents.class.ExternalSpace)
$: hasParentSelector = targetSpaceId !== documents.space.UnsortedTemplates
$: permissionRestrictedSpaces = Object.entries($permissionsStore.ps)
.filter(([, pss]) => !pss.has(documents.permission.CreateDocument))
.map(([s]) => s) as Ref<Space>[]
$: restrictedSpaces =
sourceSpaceId !== undefined ? permissionRestrictedSpaces.concat(sourceSpaceId) : permissionRestrictedSpaces
$: spaceQuery = haveTemplateObjects
? { _id: { $nin: restrictedSpaces }, archived: false, _class: { $nin: externalSpaces } }
: { _id: { $nin: restrictedSpaces }, archived: false }
</script>
<div class="popup">
<div class="bottom-divider">
<div class="text-xl pr-6 pl-6 pt-4 pb-4 primary-text-color">
<Label label={documents.string.TransferDocuments} />
</div>
</div>
<div class="p-6 bottom-divider popup-body">
<div class="sectionTitle"><Label label={documentsRes.string.Space} /></div>
<div class="flex-row-center flex-no-shrink flex-gap-4">
<div class="space">
<SpaceSelector
_class={documents.class.DocumentSpace}
query={spaceQuery}
bind:space={targetSpaceId}
label={documentsRes.string.Space}
width="100%"
justify="left"
autoSelect={true}
/>
</div>
{#if targetSpace && targetSpaceType && targetSpaceType.projects}
<div class="space">
<ProjectSelector
value={targetProjectId}
space={targetSpace._id}
kind={'no-border'}
size={'small'}
justify="left"
showReadonly={false}
on:change={(e) => {
handleProjectSelected(e.detail)
}}
/>
</div>
{/if}
</div>
<div class="parentText pt-4"><Label label={documents.string.TransferDocumentsHint} /></div>
<ol class="docList">
{#each affectedDocs as object}
<li>{object.title}</li>
{/each}
</ol>
{#if hasParentSelector}
<div class="sectionTitle"><Label label={documents.string.Parent} /></div>
<div class="parentText"><Label label={documentsRes.string.SelectParent} /></div>
<div class="parentSelector">
{#if targetSpace}
<DocumentParentSelector
space={targetSpace}
project={targetProjectId}
selected={targetParentDocumentId}
collapsedPrefix="locationStep"
on:selected={(e) => {
void handleParentSelected(e.detail)
}}
/>
{/if}
</div>
{/if}
</div>
<div class="flex items-center flex-between pr-6 pl-6 pt-4 pb-4">
<div class="flex flex-gap-2 items-center max-w-120 p-1 text-xs pr-4">
<div class="warning-sign">
<Info size="small" />
</div>
<Label label={documents.string.TransferWarning} />
</div>
<div class="flex justify-end items-center flex-gap-2">
<Button kind="regular" label={presentation.string.Cancel} on:click={() => dispatch('close')} />
<Button
kind={canTransfer ? 'primary' : 'ghost'}
disabled={!canTransfer}
label={documents.string.Transfer}
on:click={transfer}
/>
</div>
</div>
</div>
<style lang="scss">
.popup {
width: 58.25rem;
border-radius: 1.25rem;
background-color: var(--theme-dialog-background-color);
}
.docList li {
color: var(--global-primary-TextColor);
}
.popup-body {
height: 60vh;
overflow-y: auto;
}
.hint {
color: var(--theme-dark-color);
}
.warning-sign {
color: var(--theme-docs-warning-icon-color);
}
.primary-text-color {
color: var(--theme-text-primary-color);
}
.sectionTitle {
font-size: 0.875rem;
font-weight: 500;
line-height: 1.25rem;
&:not(:first-child) {
margin-top: 1.5rem;
}
}
.space {
width: 12.5rem;
margin-top: 0.5rem;
}
.parentText {
font-size: 0.6875rem;
line-height: 1rem;
}
.parentSelector {
margin-top: 0.5rem;
}
</style>

View File

@ -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<boolean> {
).then((res) => res.every((r) => r))
}
async function canTransferDocument (obj?: Doc | Doc[]): Promise<boolean> {
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<void> {
const objects = Array.isArray(selection) ? selection : [selection]
const client = getClient()
const h = client.getHierarchy()
let sourceDocumentIds: Array<Ref<DocumentMeta>> = []
let sourceSpaceId: Ref<DocumentSpace> | undefined
let sourceProjectId: Ref<Project<DocumentSpace>> | 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<boolean> {
if (obj == null) {
return false
@ -322,6 +365,7 @@ export default async (): Promise<Resources> => ({
GetDocumentMetaLinkFragment: getDocumentMetaLinkFragment,
CanDeleteDocument: canDeleteDocument,
CanArchiveDocument: canArchiveDocument,
CanTransferDocument: canTransferDocument,
DocumentIdentifierProvider: documentIdentifierProvider,
ControlledDocumentTitleProvider: getControlledDocumentTitle,
Comment: comment,
@ -334,6 +378,7 @@ export default async (): Promise<Resources> => ({
CreateTemplate: createTemplate,
DeleteDocument: deleteDocuments,
ArchiveDocument: archiveDocuments,
TransferDocument: transferDocuments,
EditDocSpace: editDocSpace
},
resolver: {

View File

@ -238,6 +238,7 @@ export default mergeIds(documentsId, documents, {
GetDocumentMetaLinkFragment: '' as Resource<(doc: Doc, props: Record<string, any>) => Promise<Location>>,
CanDeleteDocument: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
CanArchiveDocument: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
CanTransferDocument: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
ControlledDocumentTitleProvider: '' as Resource<(client: Client, ref: Ref<Doc>, doc?: Doc) => Promise<string>>
}
})

View File

@ -118,6 +118,7 @@ export const documentsPlugin = plugin(documentsId, {
DeleteDocument: '' as Ref<Action>,
ArchiveDocument: '' as Ref<Action>,
EditDocSpace: '' as Ref<Action>,
TransferDocument: '' as Ref<Action>,
Print: '' as Ref<Action<Doc, { signed: boolean }>>
},
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<DocumentMeta>,

View File

@ -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<Ref<DocumentMeta>, ProjectMeta[]>
constructor (pjMeta: ProjectMeta[]) {
this.rootDocs = []
this.childrenByParent = new Map<Ref<DocumentMeta>, Array<ProjectMeta>>()
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<DocumentMeta>): Ref<DocumentMeta>[] {
const result: Ref<DocumentMeta>[] = []
const queue: Ref<DocumentMeta>[] = [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<DocumentSpace>,
project?: Ref<Project<DocumentSpace>>
): Promise<ProjectDocumentTree> {
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<DocumentMeta>[]
): Promise<DocumentBundle[]> {
const all: DocumentBundle = { ...emptyBundle() }
async function crawl<T extends Doc, P extends keyof T> (
_class: Ref<Class<T>>,
bkey: keyof DocumentBundle,
prop: P,
ids: T[P][]
): Promise<T[]> {
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<Ref<DocumentMeta>, DocumentBundle>(all.DocumentMeta.map((m) => [m._id, { ...emptyBundle() }]))
const links = new Map<Ref<Doc>, Ref<DocumentMeta>>()
const link = (ref: Ref<Doc>, lookup: Ref<Doc>): void => {
const meta = links.get(lookup)
if (meta !== undefined) links.set(ref, meta)
}
const relink = (ref: Ref<Doc>, 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<DocumentMeta>
): Promise<DocumentBundle | undefined> {
const bundles = await findAllDocumentBundles(client, [id])
return bundles[0]
}
export interface DocumentTransferRequest {
sourceDocumentIds: Ref<DocumentMeta>[]
sourceSpaceId: Ref<DocumentSpace>
sourceProjectId?: Ref<Project<DocumentSpace>>
targetSpaceId: Ref<DocumentSpace>
targetParentId?: Ref<DocumentMeta>
targetProjectId?: Ref<Project<DocumentSpace>>
}
interface DocumentTransferContext {
request: DocumentTransferRequest
bundles: DocumentBundle[]
sourceTree: ProjectDocumentTree
targetTree: ProjectDocumentTree
sourceSpace: DocumentSpace
targetSpace: DocumentSpace
targetParentBundle?: DocumentBundle
}
async function _buildDocumentTransferContext (
client: TxOperations,
request: DocumentTransferRequest
): Promise<DocumentTransferContext | undefined> {
const sourceTree = await findProjectDocsHierarchy(client, request.sourceSpaceId, request.sourceProjectId)
const targetTree = await findProjectDocsHierarchy(client, request.targetSpaceId, request.targetProjectId)
const docIds = new Set<Ref<DocumentMeta>>(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<DocumentMeta[]> {
const cx = await _buildDocumentTransferContext(client, req)
return cx?.bundles.map((b) => b.DocumentMeta[0]) ?? []
}
/**
* @public
*/
export async function canTransferDocuments (client: TxOperations, req: DocumentTransferRequest): Promise<boolean> {
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<boolean> {
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<boolean> {
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<Doc, Partial<Doc>>()
function update<T extends Doc> (document: T, update: Partial<T>): 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
*/