diff --git a/dev/import-tool/docs/huly/README.md b/dev/import-tool/docs/huly/README.md index d6178295359..e5a4539924d 100644 --- a/dev/import-tool/docs/huly/README.md +++ b/dev/import-tool/docs/huly/README.md @@ -31,12 +31,24 @@ workspace/ │ └── files/ │ └── diagram.png # Can be referenced in markdown content └── Project Alpha.yaml # Project configuration +├── QMS Documents/ # QMS documentation +│ ├── [SOP-001] Document Control.md # Document template +│ ├── [SOP-001] Document Control/ # Template implementations +│ │ └── [SOP-002] Document Review.md # Controlled document +│ └── [WI-001] Document Template Usage.md # Standalone controlled document +└── QMS Documents.yaml # QMS space configuration ``` ### File Format Requirements +* All spaces files must be in YAML format +* All document/issue files must include YAML frontmatter followed by Markdown content +* Children documents/issues are located in the folder with the same name as the parent document/issue -#### Space Configuration (*.yaml) -Project space (`Project Alpha.yaml`): + +#### Tracker Issues + +##### 1. Project Configuration (*.yaml) +Example: `Project Alpha.yaml`: ```yaml class: tracker:class:Project # Required title: Project Alpha # Required @@ -51,32 +63,8 @@ description: string # Optional defaultIssueStatus: Todo # Optional ``` -Teamspace (`Documentation.yaml`): -```yaml -class: document:class:Teamspace # Required -title: Documentation # Required -private: false # Optional, default: false -autoJoin: true # Optional, default: true -owners: # Optional, list of email addresses - - john.doe@example.com -members: # Optional, list of email addresses - - joe.shmoe@example.com -description: string # Optional -``` - -#### Documents and Issues (*.md) -All files must include YAML frontmatter followed by Markdown content: - -Document (`Getting Started.md`): -```yaml ---- -class: document:class:Document # Required -title: Getting Started Guide # Required ---- -# Content in Markdown format -``` - -Issue (`1.Project Setup.md`): +##### 2. Issue (*.md) +Example: `1.Project Setup.md`: ```yaml --- class: tracker:class:Issue # Required @@ -90,11 +78,11 @@ remainingTime: 4 # Optional, in hours Task description in Markdown... ``` -### Task Identification -* Human-readable task ID is formed by combining project's identifier and task number from filename -* Example: For project with identifier "ALPHA" and task "1.Setup Project.md", the task ID will be "ALPHA-1" +##### Issue Identification +* Human-readable issue ID is formed by combining project's identifier and issue number from filename +* Example: For project with identifier `ALPHA` and issue `1.Setup Project.md`, the issue ID will be `ALPHA-1` -### Allowed Values +##### Allowed Values Issue status values: * `Backlog` @@ -109,6 +97,99 @@ Issue priority values: * `High` * `Urgent` +#### Documents + +##### 1. Teamspace Configuration (*.yaml) +Example: `Documentation.yaml`: +```yaml +class: document:class:Teamspace # Required +title: Documentation # Required +private: false # Optional, default: false +autoJoin: true # Optional, default: true +owners: # Optional, list of email addresses + - john.doe@example.com +members: # Optional, list of email addresses + - joe.shmoe@example.com +description: string # Optional +``` + +##### 2. Document (*.md) +Example: `Getting Started.md`: +```yaml +--- +class: document:class:Document # Required +title: Getting Started Guide # Required +--- +# Content in Markdown format +``` + +#### Controlled Documents +##### 1. Space Configuration (*.yaml) +QMS Document Space: `QMS Documents.yaml`: +```yaml +class: documents:class:OrgSpace # Required +title: QMS Documents # Required +private: false # Optional, default: false +owners: # Optional, list of email addresses + - john.doe@example.com +members: # Optional, list of email addresses + - joe.shmoe@example.com +description: string # Optional +qualified: john.doe@example.com # Optional, qualified user +manager: jane.doe@example.com # Optional, QMS manager +qara: bob.smith@example.com # Optional, QA/RA specialist +``` + +##### 2. Document Template (*.md) +Example: `[SOP-001] Document Control.md`: +```yaml +--- +class: documents:mixin:DocumentTemplate # Required +title: SOP Template # Required +docPrefix: SOP # Required, document code prefix +category: documents:category:Procedures # Required +author: John Smith # Required +owner: Jane Wilson # Required +abstract: Template description # Optional +reviewers: # Optional + - alice.cooper@example.com +approvers: # Optional + - david.brown@example.com +coAuthors: # Optional + - bob.dylan@example.com +--- +Template content in Markdown... +``` + +##### 3. Controlled Document (*.md) +Example: `[SOP-002] Document Review.md`: +```yaml +--- +class: documents:class:ControlledDocument # Required +title: Document Review Procedure # Required +template: [SOP-001] Document Control.md # Required, path to template +author: John Smith # Required +owner: Jane Wilson # Required +abstract: Document description # Optional +reviewers: # Optional + - alice.cooper@example.com +approvers: # Optional + - david.brown@example.com +coAuthors: # Optional + - bob.dylan@example.com +changeControl: # Optional + description: Initial document creation + reason: Need for standardized process + impact: Improved document control +--- +Document content in Markdown... +``` +##### Controlled Document Code Format +* Document code must be specified in file name: `[CODE] Any File Name.md` +* If code is not specified for controlled document, it will be generated automatically using template's docPrefix and sequential number (e.g. `SOP-99`) +* If code is not specified for template, it will be generated automatically as `TMPL-seqNumber`, where `seqNumber` is the sequence number of the template in the space + + ### Run Import Tool ```bash docker run \ @@ -125,3 +206,6 @@ docker run \ * All users must exist in the system before import * Assignees are mapped by full name * Files in space directories can be used as attachments when referenced in markdown content +* Document codes (in square brackets) must be unique across all document spaces +* Controlled documents must be created in the same space as their templates +* Controlled documents can be imported only with `Draft` status diff --git a/dev/import-tool/docs/huly/example-workspace/QMS Documents.yaml b/dev/import-tool/docs/huly/example-workspace/QMS Documents.yaml new file mode 100644 index 00000000000..3074c615960 --- /dev/null +++ b/dev/import-tool/docs/huly/example-workspace/QMS Documents.yaml @@ -0,0 +1,11 @@ +class: documents:class:OrgSpace +title: QMS Documents +description: Quality Management System Documentation +private: false +owners: + - user1 +members: + - user1 +qualified: user1 +manager: user1 +qara: user1 diff --git a/dev/import-tool/docs/huly/example-workspace/QMS Documents/[SOP-001] Document Control.md b/dev/import-tool/docs/huly/example-workspace/QMS Documents/[SOP-001] Document Control.md new file mode 100644 index 00000000000..8daf8101ade --- /dev/null +++ b/dev/import-tool/docs/huly/example-workspace/QMS Documents/[SOP-001] Document Control.md @@ -0,0 +1,32 @@ +--- +class: documents:mixin:DocumentTemplate +title: 'Standard Operating Procedure Template' +docPrefix: SOP +category: DOC +author: John Appleseed +owner: John Appleseed +abstract: Template for Standard Operating Procedures +reviewers: + - John Appleseed +approvers: + - John Appleseed +--- +# Standard Operating Procedure + +## 1. Purpose +[Describe the purpose of the procedure] + +## 2. Scope +[Define the scope and applicability] + +## 3. Responsibilities +[List key roles and responsibilities] + +## 4. Procedure +[Detail the step-by-step procedure] + +## 5. References +[List related documents] + +## 6. Revision History +[Document revision history] diff --git a/dev/import-tool/docs/huly/example-workspace/QMS Documents/[SOP-001] Document Control/[SOP-002] Document Review.md b/dev/import-tool/docs/huly/example-workspace/QMS Documents/[SOP-001] Document Control/[SOP-002] Document Review.md new file mode 100644 index 00000000000..3741298f42c --- /dev/null +++ b/dev/import-tool/docs/huly/example-workspace/QMS Documents/[SOP-001] Document Control/[SOP-002] Document Review.md @@ -0,0 +1,42 @@ +--- +class: documents:class:ControlledDocument +title: Document Review Procedure +template: '../[SOP-001] Document Control.md' +author: John Appleseed +owner: John Appleseed +abstract: Procedure for document review and approval process +reviewers: + - John Appleseed +approvers: + - John Appleseed +changeControl: + description: Initial document creation + reason: Need for standardized review process + impact: Improved document quality control +--- +# Document Review Procedure + +## 1. Purpose +This procedure defines the process for reviewing quality management system documents. + +## 2. Scope +Applies to all controlled documents within the QMS. + +## 3. Responsibilities +- Document Owner: Responsible for content +- Reviewers: Technical review +- QA Manager: Final approval + +## 4. Procedure +1. Author prepares document +2. Technical review +3. QA review +4. Final approval +5. Document release + +## 5. References +- Quality Manual +- Document Control Procedure + +## 6. Revision History +Rev 0.1 - Initial draft diff --git a/dev/import-tool/docs/huly/example-workspace/QMS Documents/[WI-001] Document Template Usage.md b/dev/import-tool/docs/huly/example-workspace/QMS Documents/[WI-001] Document Template Usage.md new file mode 100644 index 00000000000..b8963023acd --- /dev/null +++ b/dev/import-tool/docs/huly/example-workspace/QMS Documents/[WI-001] Document Template Usage.md @@ -0,0 +1,37 @@ +--- +class: documents:class:ControlledDocument +title: Document Template Usage Guide +template: '[SOP-001] Document Control.md' +author: John Appleseed +owner: John Appleseed +abstract: Work instruction for using document templates +reviewers: + - John Appleseed +approvers: + - John Appleseed +--- +# Document Template Usage Guide + +## 1. Purpose +Guide users in proper usage of QMS document templates. + +## 2. Scope +All personnel creating QMS documentation. + +## 3. Procedure +1. Select appropriate template +2. Fill in required sections +3. Submit for review + +## 4. Document Review Changes + +| Step | Current Text | Updated Text | Comments | +| --- | --- | --- | --- | +| Initial Review | Select a template from library | Select a template that matches your document type | Clarified selection criteria | +| Metadata | Fill in required fields | Complete all required metadata and content fields | Added metadata specification | +| Review Process | Submit for review | Submit document for review according to procedure | Added reference to procedure | +| Approval | Wait for approval | Submit for approval after receiving all reviews | Process detail added | + +## 5. References +- [Document Control SOP](./[SOP-001]%20Document%20Control.md) +- [Document Review Procedure](./[SOP-001]%20Document%20Control/[SOP-002]%20Document%20Review.md) diff --git a/models/controlled-documents/src/plugin.ts b/models/controlled-documents/src/plugin.ts index f2123222507..cf8dcbb92f7 100644 --- a/models/controlled-documents/src/plugin.ts +++ b/models/controlled-documents/src/plugin.ts @@ -15,7 +15,7 @@ import { documentsId } from '@hcengineering/controlled-documents' import documents from '@hcengineering/controlled-documents-resources/src/plugin' -import type { Client, Doc, Ref, Role } from '@hcengineering/core' +import type { Client, Doc, Ref } from '@hcengineering/core' import { type ObjectSearchCategory, type ObjectSearchFactory } from '@hcengineering/model-presentation' import { mergeIds, type Resource } from '@hcengineering/platform' import { type TagCategory } from '@hcengineering/tags' @@ -71,11 +71,6 @@ export default mergeIds(documentsId, documents, { TableDocumentTemplate: '' as Ref, TableDocumentDomain: '' as Ref }, - role: { - QARA: '' as Ref, - Manager: '' as Ref, - QualifiedUser: '' as Ref - }, notification: { DocumentsNotificationGroup: '' as Ref, ContentNotification: '' as Ref, diff --git a/packages/importer/package.json b/packages/importer/package.json index 35424c21744..9ac60f7e2eb 100644 --- a/packages/importer/package.json +++ b/packages/importer/package.json @@ -45,6 +45,7 @@ "@hcengineering/chunter": "^0.6.20", "@hcengineering/collaboration": "^0.6.0", "@hcengineering/contact": "^0.6.24", + "@hcengineering/controlled-documents": "^0.1.0", "@hcengineering/core": "^0.6.32", "@hcengineering/document": "^0.6.0", "@hcengineering/model-attachment": "^0.6.0", diff --git a/packages/importer/src/huly/unified.ts b/packages/importer/src/huly/unified.ts index 264ed772fdb..a07892931db 100644 --- a/packages/importer/src/huly/unified.ts +++ b/packages/importer/src/huly/unified.ts @@ -14,7 +14,7 @@ // import { type Attachment } from '@hcengineering/attachment' -import contact, { type Person, type PersonAccount } from '@hcengineering/contact' +import contact, { Employee, type Person, type PersonAccount } from '@hcengineering/contact' import { type Class, type Doc, generateId, type Ref, type Space, type TxOperations } from '@hcengineering/core' import document, { type Document } from '@hcengineering/document' import { MarkupMarkType, type MarkupNode, MarkupNodeType, traverseNode, traverseNodeMarks } from '@hcengineering/text' @@ -28,6 +28,8 @@ import { ImportWorkspaceBuilder } from '../importer/builder' import { type ImportAttachment, type ImportComment, + ImportControlledDocument, + ImportControlledDocumentTemplate, type ImportDocument, ImportDrawing, type ImportIssue, @@ -35,11 +37,18 @@ import { type ImportProjectType, type ImportTeamspace, type ImportWorkspace, - WorkspaceImporter + WorkspaceImporter, + ImportOrgSpace } from '../importer/importer' import { type Logger } from '../importer/logger' import { BaseMarkdownPreprocessor } from '../importer/preprocessor' import { type FileUploader } from '../importer/uploader' +import documents, { + DocumentState, + DocumentCategory, + ControlledDocument, + DocumentMeta +} from '@hcengineering/controlled-documents' interface UnifiedComment { author: string @@ -59,7 +68,7 @@ interface UnifiedIssueHeader { } interface UnifiedSpaceSettings { - class: 'tracker:class:Project' | 'document:class:Teamspace' + class: 'tracker:class:Project' | 'document:class:Teamspace' | 'documents:class:OrgSpace' title: string private?: boolean autoJoin?: boolean @@ -101,13 +110,53 @@ interface UnifiedWorkspaceSettings { }> } +interface UnifiedChangeControlHeader { + description?: string + reason?: string + impact?: string +} + +interface UnifiedControlledDocumentHeader { + class: 'documents:class:ControlledDocument' + title: string + template: string + author: string + owner: string + abstract?: string + reviewers?: string[] + approvers?: string[] + coAuthors?: string[] + changeControl?: UnifiedChangeControlHeader +} + +interface UnifiedDocumentTemplateHeader { + class: 'documents:mixin:DocumentTemplate' + title: string + category: string + docPrefix: string + author: string + owner: string + abstract?: string + reviewers?: string[] + approvers?: string[] + coAuthors?: string[] + changeControl?: UnifiedChangeControlHeader +} + +interface UnifiedOrgSpaceSettings extends UnifiedSpaceSettings { + class: 'documents:class:OrgSpace' + qualified?: string + manager?: string + qara?: string +} + class HulyMarkdownPreprocessor extends BaseMarkdownPreprocessor { constructor ( private readonly urlProvider: (id: string) => string, private readonly logger: Logger, - private readonly metadataByFilePath: Map, - private readonly metadataById: Map, DocMetadata>, - private readonly attachMetadataByPath: Map, + private readonly pathById: Map, string>, + private readonly refMetaByPath: Map, + private readonly attachMetaByPath: Map, personsByName: Map> ) { super(personsByName) @@ -130,18 +179,24 @@ class HulyMarkdownPreprocessor extends BaseMarkdownPreprocessor { const src = node.attrs?.src if (src === undefined) return - const sourceMeta = this.getSourceMetadata(id) - if (sourceMeta == null) return + const sourcePath = this.getSourcePath(id) + if (sourcePath == null) return const href = decodeURI(src as string) - const fullPath = path.resolve(path.dirname(sourceMeta.path), href) - const attachmentMeta = this.attachMetadataByPath.get(fullPath) + const fullPath = path.resolve(path.dirname(sourcePath), href) + const attachmentMeta = this.attachMetaByPath.get(fullPath) if (attachmentMeta === undefined) { this.logger.error(`Attachment image not found for ${fullPath}`) return } + const sourceMeta = this.refMetaByPath.get(sourcePath) + if (sourceMeta === undefined) { + this.logger.error(`Source metadata not found for ${sourcePath}`) + return + } + this.updateAttachmentMetadata(fullPath, attachmentMeta, id, spaceId, sourceMeta) this.alterImageNode(node, attachmentMeta.id, attachmentMeta.name) } @@ -150,22 +205,25 @@ class HulyMarkdownPreprocessor extends BaseMarkdownPreprocessor { traverseNodeMarks(node, (mark) => { if (mark.type !== MarkupMarkType.link) return - const sourceMeta = this.getSourceMetadata(id) - if (sourceMeta == null) return + const sourcePath = this.getSourcePath(id) + if (sourcePath == null) return const href = decodeURI(mark.attrs.href) - const fullPath = path.resolve(path.dirname(sourceMeta.path), href) + const fullPath = path.resolve(path.dirname(sourcePath), href) - if (this.metadataByFilePath.has(fullPath)) { - const targetDocMeta = this.metadataByFilePath.get(fullPath) + if (this.refMetaByPath.has(fullPath)) { + const targetDocMeta = this.refMetaByPath.get(fullPath) if (targetDocMeta !== undefined) { this.alterInternalLinkNode(node, targetDocMeta) } - } else if (this.attachMetadataByPath.has(fullPath)) { - const attachmentMeta = this.attachMetadataByPath.get(fullPath) + } else if (this.attachMetaByPath.has(fullPath)) { + const attachmentMeta = this.attachMetaByPath.get(fullPath) if (attachmentMeta !== undefined) { this.alterAttachmentLinkNode(node, attachmentMeta) - this.updateAttachmentMetadata(fullPath, attachmentMeta, id, spaceId, sourceMeta) + const sourceMeta = this.refMetaByPath.get(sourcePath) + if (sourceMeta !== undefined) { + this.updateAttachmentMetadata(fullPath, attachmentMeta, id, spaceId, sourceMeta) + } } } else { this.logger.log('Unknown link type, leave it as is: ' + href) @@ -192,7 +250,7 @@ class HulyMarkdownPreprocessor extends BaseMarkdownPreprocessor { } } - private alterInternalLinkNode (node: MarkupNode, targetMeta: DocMetadata): void { + private alterInternalLinkNode (node: MarkupNode, targetMeta: ReferenceMetadata): void { node.type = MarkupNodeType.reference node.attrs = { id: targetMeta.id, @@ -223,13 +281,13 @@ class HulyMarkdownPreprocessor extends BaseMarkdownPreprocessor { return mimeType !== false ? mimeType : undefined } - private getSourceMetadata (id: Ref): DocMetadata | null { - const sourceMeta = this.metadataById.get(id) - if (sourceMeta == null) { - this.logger.error(`Source metadata not found for ${id}`) + private getSourcePath (id: Ref): string | null { + const sourcePath = this.pathById.get(id) + if (sourcePath == null) { + this.logger.error(`Source file path not found for ${id}`) return null } - return sourceMeta + return sourcePath } private updateAttachmentMetadata ( @@ -237,9 +295,9 @@ class HulyMarkdownPreprocessor extends BaseMarkdownPreprocessor { attachmentMeta: AttachmentMetadata, id: Ref, spaceId: Ref, - sourceMeta: DocMetadata + sourceMeta: ReferenceMetadata ): void { - this.attachMetadataByPath.set(fullPath, { + this.attachMetaByPath.set(fullPath, { ...attachmentMeta, spaceId, parentId: id, @@ -248,10 +306,9 @@ class HulyMarkdownPreprocessor extends BaseMarkdownPreprocessor { } } -interface DocMetadata { +interface ReferenceMetadata { id: Ref class: string - path: string refTitle: string } @@ -265,12 +322,14 @@ interface AttachmentMetadata { } export class UnifiedFormatImporter { - private readonly metadataById = new Map, DocMetadata>() - private readonly metadataByFilePath = new Map() - private readonly fileMetadataByPath = new Map() + private readonly pathById = new Map, string>() + private readonly refMetaByPath = new Map() + private readonly fileMetaByPath = new Map() + private readonly ctrlDocTemplateIdByPath = new Map>() private personsByName = new Map>() private accountsByEmail = new Map>() + private employeesByName = new Map>() constructor ( private readonly client: TxOperations, @@ -278,14 +337,18 @@ export class UnifiedFormatImporter { private readonly logger: Logger ) {} - async importFolder (folderPath: string): Promise { + private async initCaches (): Promise { await this.cachePersonsByNames() await this.cacheAccountsByEmails() + await this.cacheEmployeesByName() + } - await this.collectFileMetadata(folderPath) - + async importFolder (folderPath: string): Promise { + await this.initCaches() const workspaceData = await this.processImportFolder(folderPath) + await this.collectFileMetadata(folderPath) + this.logger.log('========================================') this.logger.log('IMPORT DATA STRUCTURE: ' + JSON.stringify(workspaceData)) this.logger.log('========================================') @@ -294,9 +357,9 @@ export class UnifiedFormatImporter { const preprocessor = new HulyMarkdownPreprocessor( this.fileUploader.getFileUrl, this.logger, - this.metadataByFilePath, - this.metadataById, - this.fileMetadataByPath, + this.pathById, + this.refMetaByPath, + this.fileMetaByPath, this.personsByName ) await new WorkspaceImporter( @@ -310,7 +373,7 @@ export class UnifiedFormatImporter { this.logger.log('Importing attachments...') const attachments: ImportAttachment[] = await Promise.all( - Array.from(this.fileMetadataByPath.values()) + Array.from(this.fileMetaByPath.values()) .filter((attachMeta) => attachMeta.parentId !== undefined) .map(async (attachMeta: AttachmentMetadata) => await this.processAttachment(attachMeta)) ) @@ -433,6 +496,15 @@ export class UnifiedFormatImporter { break } + case documents.class.OrgSpace: { + const orgSpace = await this.processOrgSpace(spaceConfig as UnifiedOrgSpaceSettings) + builder.addOrgSpace(spacePath, orgSpace) + if (fs.existsSync(spacePath) && fs.statSync(spacePath).isDirectory()) { + await this.processControlledDocumentsRecursively(builder, spacePath, spacePath) + } + break + } + default: { throw new Error(`Unknown space class ${spaceConfig.class} in ${spaceName}`) } @@ -468,15 +540,13 @@ export class UnifiedFormatImporter { const numberMatch = issueFile.match(/^(\d+)\./) const issueNumber = numberMatch?.[1] - const meta: DocMetadata = { + const meta: ReferenceMetadata = { id: generateId(), class: tracker.class.Issue, - path: issuePath, refTitle: `${projectIdentifier}-${issueNumber}` } - - this.metadataById.set(meta.id, meta) - this.metadataByFilePath.set(issuePath, meta) + this.pathById.set(meta.id, issuePath) + this.refMetaByPath.set(issuePath, meta) const issue: ImportIssue = { id: meta.id as Ref, @@ -525,6 +595,14 @@ export class UnifiedFormatImporter { return account } + private findEmployeeByName (name: string): Ref { + const employee = this.employeesByName.get(name) + if (employee === undefined) { + throw new Error(`Employee not found: ${name}`) + } + return employee + } + private async processDocumentsRecursively ( builder: ImportWorkspaceBuilder, teamspacePath: string, @@ -543,15 +621,14 @@ export class UnifiedFormatImporter { } if (docHeader.class === document.class.Document) { - const docMeta: DocMetadata = { + const docMeta: ReferenceMetadata = { id: generateId(), class: document.class.Document, - path: docPath, refTitle: docHeader.title } - this.metadataById.set(docMeta.id, docMeta) - this.metadataByFilePath.set(docPath, docMeta) + this.pathById.set(docMeta.id, docPath) + this.refMetaByPath.set(docPath, docMeta) const doc: ImportDocument = { id: docMeta.id as Ref, @@ -574,6 +651,79 @@ export class UnifiedFormatImporter { } } + private async processControlledDocumentsRecursively ( + builder: ImportWorkspaceBuilder, + spacePath: string, + currentPath: string, + parentDocPath?: string + ): Promise { + const docFiles = fs.readdirSync(currentPath).filter((f) => f.endsWith('.md')) + + for (const docFile of docFiles) { + const docPath = path.join(currentPath, docFile) + const docHeader = (await this.readYamlHeader(docPath)) as + | UnifiedControlledDocumentHeader + | UnifiedDocumentTemplateHeader + + if (docHeader.class === undefined) { + this.logger.error(`Skipping ${docFile}: not a document`) + continue + } + + if ( + docHeader.class !== documents.class.ControlledDocument && + docHeader.class !== documents.mixin.DocumentTemplate + ) { + throw new Error(`Unknown document class ${docHeader.class} in ${docFile}`) + } + + const documentMetaId = generateId() + const refMeta: ReferenceMetadata = { + id: documentMetaId, + class: documents.class.DocumentMeta, + refTitle: docHeader.title + } + this.refMetaByPath.set(docPath, refMeta) + + if (docHeader.class === documents.class.ControlledDocument) { + const docId = generateId() + this.pathById.set(docId, docPath) + + const doc = await this.processControlledDocument( + docHeader as UnifiedControlledDocumentHeader, + docPath, + docId, + documentMetaId + ) + builder.addControlledDocument(spacePath, docPath, doc, parentDocPath) + } else { + if (!this.ctrlDocTemplateIdByPath.has(docPath)) { + const templateId = generateId() + this.ctrlDocTemplateIdByPath.set(docPath, templateId) + this.pathById.set(templateId, docPath) + } + + const templateId = this.ctrlDocTemplateIdByPath.get(docPath) + if (templateId === undefined) { + throw new Error(`Template ID not found: ${docPath}`) + } + + const template = await this.processControlledDocumentTemplate( + docHeader as UnifiedDocumentTemplateHeader, + docPath, + templateId, + documentMetaId + ) + builder.addControlledDocumentTemplate(spacePath, docPath, template, parentDocPath) + } + + const subDir = path.join(currentPath, docFile.replace('.md', '')) + if (fs.existsSync(subDir) && fs.statSync(subDir).isDirectory()) { + await this.processControlledDocumentsRecursively(builder, spacePath, subDir, docPath) + } + } + } + private processComments (currentPath: string, comments: UnifiedComment[] = []): Promise { return Promise.all( comments.map(async (comment) => { @@ -581,7 +731,7 @@ export class UnifiedFormatImporter { if (comment.attachments !== undefined) { for (const attachmentPath of comment.attachments) { const fullPath = path.resolve(currentPath, attachmentPath) - const attachmentMeta = this.fileMetadataByPath.get(fullPath) + const attachmentMeta = this.fileMetaByPath.get(fullPath) if (attachmentMeta !== undefined) { const importAttachment = await this.processAttachment(attachmentMeta) attachments.push(importAttachment) @@ -650,6 +800,114 @@ export class UnifiedFormatImporter { } } + private async processOrgSpace (spaceHeader: UnifiedOrgSpaceSettings): Promise { + return { + class: documents.class.OrgSpace, + title: spaceHeader.title, + private: spaceHeader.private ?? false, + archived: spaceHeader.archived ?? false, + description: spaceHeader.description, + owners: spaceHeader.owners?.map((email) => this.findAccountByEmail(email)) ?? [], + members: spaceHeader.members?.map((email) => this.findAccountByEmail(email)) ?? [], + qualified: spaceHeader.qualified !== undefined ? this.findAccountByEmail(spaceHeader.qualified) : undefined, + manager: spaceHeader.manager !== undefined ? this.findAccountByEmail(spaceHeader.manager) : undefined, + qara: spaceHeader.qara !== undefined ? this.findAccountByEmail(spaceHeader.qara) : undefined, + docs: [] + } + } + + private async processControlledDocument ( + header: UnifiedControlledDocumentHeader, + docPath: string, + id: Ref, + metaId: Ref + ): Promise { + const codeMatch = path.basename(docPath).match(/^\[([^\]]+)\]/) + + const author = this.findEmployeeByName(header.author) + const owner = this.findEmployeeByName(header.owner) + if (author === undefined || owner === undefined) { + throw new Error(`Author or owner not found: ${header.author} or ${header.owner}`) + } + + const templatePath = path.resolve(path.dirname(docPath), header.template) + if (!fs.existsSync(templatePath)) { + throw new Error(`Template file not found: ${templatePath}`) + } + + if (!this.ctrlDocTemplateIdByPath.has(templatePath)) { + const templateId = generateId() + this.ctrlDocTemplateIdByPath.set(templatePath, templateId) + this.pathById.set(templateId, templatePath) + } + + const templateId = this.ctrlDocTemplateIdByPath.get(templatePath) + if (templateId === undefined) { + throw new Error(`Template ID not found: ${templatePath}`) + } + + return { + id, + metaId, + class: documents.class.ControlledDocument, + title: header.title, + template: templateId, + code: codeMatch?.[1], + major: 0, + minor: 1, + state: DocumentState.Draft, + author, + owner, + abstract: header.abstract, + reviewers: header.reviewers?.map((email) => this.findEmployeeByName(email)) ?? [], + approvers: header.approvers?.map((email) => this.findEmployeeByName(email)) ?? [], + coAuthors: header.coAuthors?.map((email) => this.findEmployeeByName(email)) ?? [], + descrProvider: async () => await this.readMarkdownContent(docPath), + ccReason: header.changeControl?.reason, + ccImpact: header.changeControl?.impact, + ccDescription: header.changeControl?.description, + subdocs: [] + } + } + + private async processControlledDocumentTemplate ( + header: UnifiedDocumentTemplateHeader, + docPath: string, + id: Ref, + metaId: Ref + ): Promise { + const author = this.findEmployeeByName(header.author) + const owner = this.findEmployeeByName(header.owner) + if (author === undefined || owner === undefined) { + throw new Error(`Author or owner not found: ${header.author} or ${header.owner}`) + } + + const codeMatch = path.basename(docPath).match(/^\[([^\]]+)\]/) + return { + id, + metaId, + class: documents.mixin.DocumentTemplate, + title: header.title, + docPrefix: header.docPrefix, + code: codeMatch?.[1], + major: 0, + minor: 1, + state: DocumentState.Draft, + category: header.category as Ref, + author, + owner, + abstract: header.abstract, + reviewers: header.reviewers?.map((email) => this.findEmployeeByName(email)) ?? [], + approvers: header.approvers?.map((email) => this.findEmployeeByName(email)) ?? [], + coAuthors: header.coAuthors?.map((email) => this.findEmployeeByName(email)) ?? [], + descrProvider: async () => await this.readMarkdownContent(docPath), + ccReason: header.changeControl?.reason, + ccImpact: header.changeControl?.impact, + ccDescription: header.changeControl?.description, + subdocs: [] + } + } + private async readYamlHeader (filePath: string): Promise { this.logger.log('Read YAML header from: ' + filePath) const content = fs.readFileSync(filePath, 'utf8') @@ -688,6 +946,20 @@ export class UnifiedFormatImporter { }, new Map()) } + private async cacheEmployeesByName (): Promise { + this.employeesByName = (await this.client.findAll(contact.mixin.Employee, {})) + .map((employee) => { + return { + _id: employee._id, + name: employee.name.split(',').reverse().join(' ') + } + }) + .reduce((refByName, employee) => { + refByName.set(employee.name, employee._id) + return refByName + }, new Map()) + } + private async collectFileMetadata (folderPath: string): Promise { const processDir = async (dir: string): Promise => { const entries = fs.readdirSync(dir, { withFileTypes: true }) @@ -699,7 +971,7 @@ export class UnifiedFormatImporter { await processDir(fullPath) } else if (entry.isFile()) { const attachmentId = generateId() - this.fileMetadataByPath.set(fullPath, { id: attachmentId, name: entry.name, path: fullPath }) + this.fileMetaByPath.set(fullPath, { id: attachmentId, name: entry.name, path: fullPath }) } } } diff --git a/packages/importer/src/importer/builder.ts b/packages/importer/src/importer/builder.ts index c9f0ccbc48d..3442468f51c 100644 --- a/packages/importer/src/importer/builder.ts +++ b/packages/importer/src/importer/builder.ts @@ -12,10 +12,15 @@ // See the License for the specific language governing permissions and // limitations under the License. // +import documents, { ControlledDocument, DocumentState } from '@hcengineering/controlled-documents' import { type DocumentQuery, type Ref, type Status, type TxOperations } from '@hcengineering/core' import document from '@hcengineering/document' import tracker, { IssuePriority, type IssueStatus } from '@hcengineering/tracker' import { + ImportControlledDocument, + ImportControlledDocumentTemplate, + ImportOrgSpace, + type ImportControlledDoc, type ImportDocument, type ImportIssue, type ImportProject, @@ -39,15 +44,21 @@ const PROJECT_IDENTIFIER_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/ export class ImportWorkspaceBuilder { private readonly projects = new Map() - private readonly teamspaces = new Map() - private readonly projectTypes = new Map() private readonly issuesByProject = new Map>() private readonly issueParents = new Map() + + private readonly teamspaces = new Map() private readonly documentsByTeamspace = new Map>() private readonly documentParents = new Map() - private readonly errors = new Map() + private readonly qmsSpaces = new Map() + private readonly qmsTemplates = new Map, string>() + private readonly qmsDocsBySpace = new Map>() + private readonly qmsDocsParents = new Map() + + private readonly projectTypes = new Map() private readonly issueStatusCache = new Map>() + private readonly errors = new Map() constructor ( private readonly client: TxOperations, @@ -125,10 +136,95 @@ export class ImportWorkspaceBuilder { return this } + addOrgSpace (path: string, space: ImportOrgSpace): this { + this.validateAndAdd('documentSpace', path, space, (s) => this.validateOrgSpace(s), this.qmsSpaces, path) + return this + } + + addControlledDocument ( + spacePath: string, + docPath: string, + doc: ImportControlledDocument, + parentDocPath?: string + ): this { + if (!this.qmsDocsBySpace.has(spacePath)) { + this.qmsDocsBySpace.set(spacePath, new Map()) + } + + const docs = this.qmsDocsBySpace.get(spacePath) + if (docs === undefined) { + throw new Error(`Document space ${spacePath} not found`) + } + + if (doc.code !== undefined) { + const duplicateDoc = Array.from(docs.values()).find((existingDoc) => existingDoc.code === doc.code) + if (duplicateDoc !== undefined) { + throw new Error(`Duplicate document code ${doc.code} in space ${spacePath}`) + } + } + + this.validateAndAdd( + 'controlledDocument', + docPath, + doc, + (d) => this.validateControlledDocument(d as ImportControlledDocument), + docs, + docPath + ) + + if (parentDocPath !== undefined) { + this.qmsDocsParents.set(docPath, parentDocPath) + } + + return this + } + + addControlledDocumentTemplate ( + spacePath: string, + templatePath: string, + template: ImportControlledDocumentTemplate, + parentTemplatePath?: string + ): this { + if (!this.qmsDocsBySpace.has(spacePath)) { + this.qmsDocsBySpace.set(spacePath, new Map()) + } + + const qmsDocs = this.qmsDocsBySpace.get(spacePath) + if (qmsDocs === undefined) { + throw new Error(`Document space ${spacePath} not found`) + } + + if (template.code !== undefined) { + const duplicate = Array.from(qmsDocs.values()).find((existingDoc) => existingDoc.code === template.code) + if (duplicate !== undefined) { + throw new Error(`Duplicate document code ${template.code} in space ${spacePath}`) + } + } + + this.validateAndAdd( + 'documentTemplate', + templatePath, + template, + (t) => this.validateControlledDocumentTemplate(t as ImportControlledDocumentTemplate), + qmsDocs, + templatePath + ) + + if (parentTemplatePath !== undefined) { + this.qmsDocsParents.set(templatePath, parentTemplatePath) + } + + if (template.id !== undefined) { + this.qmsTemplates.set(template.id, templatePath) + } + + return this + } + validate (): ValidationResult { // Perform cross-entity validation - this.validateProjectReferences() - this.validateSpaceDocuments() + this.validateSpacesReferences() + this.validateDocumentsReferences() return { isValid: this.errors.size === 0, @@ -173,9 +269,27 @@ export class ImportWorkspaceBuilder { } } + for (const [spacePath, qmsDocs] of this.qmsDocsBySpace) { + const space = this.qmsSpaces.get(spacePath) + if (space !== undefined) { + const rootDocPaths = Array.from(qmsDocs.keys()).filter((docPath) => !this.qmsDocsParents.has(docPath)) + + for (const rootPath of rootDocPaths) { + this.buildControlledDocumentHierarchy(rootPath, qmsDocs) + } + + space.docs = rootDocPaths.map((path) => qmsDocs.get(path)).filter(Boolean) as ImportControlledDocument[] + } + } + return { projectTypes: Array.from(this.projectTypes.values()), - spaces: [...Array.from(this.projects.values()), ...Array.from(this.teamspaces.values())] + spaces: [ + ...Array.from(this.projects.values()), + ...Array.from(this.teamspaces.values()), + ...Array.from(this.qmsSpaces.values()) + ], + attachments: [] } } @@ -415,7 +529,7 @@ export class ImportWorkspaceBuilder { return errors } - private validateProjectReferences (): void { + private validateSpacesReferences (): void { // Validate project type references for (const project of this.projects.values()) { if (project.projectType !== undefined && !this.projectTypes.has(project.projectType.name)) { @@ -424,7 +538,7 @@ export class ImportWorkspaceBuilder { } } - private validateSpaceDocuments (): void { + private validateDocumentsReferences (): void { // Validate that issues belong to projects and documents to teamspaces for (const projectPath of this.issuesByProject.keys()) { if (!this.projects.has(projectPath)) { @@ -437,6 +551,23 @@ export class ImportWorkspaceBuilder { this.addError(teamspacePath, 'Documents reference non-existent teamspace') } } + + for (const [orgSpacePath, docs] of this.qmsDocsBySpace) { + if (!this.qmsSpaces.has(orgSpacePath)) { + this.addError(orgSpacePath, 'Controlled document reference non-existent orgSpace') + } + for (const [docPath, doc] of docs) { + if (doc.class === documents.class.ControlledDocument) { + const templateRef = (doc as ImportControlledDocument).template + const templatePath = this.qmsTemplates.get(templateRef) + if (templatePath === undefined) { + this.addError(docPath, 'Controlled document reference non-existent template') + } else if (!docs.has(templatePath)) { + this.addError(docPath, 'Controlled document reference not in space') + } + } + } + } } private addError (path: string, error: string): void { @@ -471,6 +602,20 @@ export class ImportWorkspaceBuilder { issue.subdocs = childIssues } + private buildControlledDocumentHierarchy (docPath: string, allDocs: Map): void { + const doc = allDocs.get(docPath) + if (doc === undefined) return + + const childDocs = Array.from(allDocs.entries()) + .filter(([childPath]) => this.qmsDocsParents.get(childPath) === docPath) + .map(([childPath, childDoc]) => { + this.buildControlledDocumentHierarchy(childPath, allDocs) + return childDoc + }) + + doc.subdocs = childDocs + } + private validateEmoji (emoji: string): string[] { const errors: string[] = [] if (typeof emoji === 'string' && emoji.codePointAt(0) == null) { @@ -529,4 +674,165 @@ export class ImportWorkspaceBuilder { } return errors } + + private validateOrgSpace (space: ImportOrgSpace): string[] { + const errors: string[] = [] + + if (space.class !== documents.class.OrgSpace) { + errors.push('Invalid space class: ' + space.class) + } + + errors.push(...this.validateType(space.title, 'string', 'title')) + + if (space.emoji !== undefined) { + errors.push(...this.validateEmoji(space.emoji)) + } + + if (space.owners !== undefined) { + errors.push(...this.validateArray(space.owners, 'string', 'owners')) + } + + if (space.members !== undefined) { + errors.push(...this.validateArray(space.members, 'string', 'members')) + } + + return errors + } + + private validateControlledDocument (doc: ImportControlledDocument): string[] { + const errors: string[] = [] + + // Validate required fields presence and types + errors.push(...this.validateType(doc.title, 'string', 'title')) + errors.push(...this.validateType(doc.class, 'string', 'class')) + errors.push(...this.validateType(doc.template, 'string', 'template')) + errors.push(...this.validateType(doc.state, 'string', 'state')) + if (doc.code !== undefined) { + errors.push(...this.validateType(doc.code, 'string', 'code')) + } + + // Validate required string fields are defined + if (!this.validateStringDefined(doc.title)) errors.push('title is required') + if (!this.validateStringDefined(doc.template)) errors.push('template is required') + + // Validate numbers are positive + if (!this.validatePossitiveNumber(doc.major)) errors.push('invalid value for field "major"') + if (!this.validatePossitiveNumber(doc.minor)) errors.push('invalid value for field "minor"') + + // Validate arrays + errors.push(...this.validateArray(doc.reviewers, 'string', 'reviewers')) + errors.push(...this.validateArray(doc.approvers, 'string', 'approvers')) + errors.push(...this.validateArray(doc.coAuthors, 'string', 'coAuthors')) + + // Validate optional fields if present + if (doc.author !== undefined) { + errors.push(...this.validateType(doc.author, 'string', 'author')) + } + if (doc.owner !== undefined) { + errors.push(...this.validateType(doc.owner, 'string', 'owner')) + } + if (doc.abstract !== undefined) { + errors.push(...this.validateType(doc.abstract, 'string', 'abstract')) + } + if (doc.ccDescription !== undefined) { + errors.push(...this.validateType(doc.ccDescription, 'string', 'ccDescription')) + } + if (doc.ccImpact !== undefined) { + errors.push(...this.validateType(doc.ccImpact, 'string', 'ccImpact')) + } + if (doc.ccReason !== undefined) { + errors.push(...this.validateType(doc.ccReason, 'string', 'ccReason')) + } + + // Validate class + if (doc.class !== documents.class.ControlledDocument) { + errors.push('invalid class: ' + doc.class) + } + + // Validate state values + if (doc.state !== DocumentState.Draft) { + errors.push('invalid state: ' + doc.state) + } + + // todo: validate seqNumber is not duplicated (unique prefix? code?) + + return errors + } + + private validateControlledDocumentTemplate (template: ImportControlledDocumentTemplate): string[] { + const errors: string[] = [] + + // Validate required fields presence and types + errors.push(...this.validateType(template.title, 'string', 'title')) + errors.push(...this.validateType(template.class, 'string', 'class')) + errors.push(...this.validateType(template.docPrefix, 'string', 'docPrefix')) + errors.push(...this.validateType(template.state, 'string', 'state')) + if (template.code !== undefined) { + errors.push(...this.validateType(template.code, 'string', 'code')) + } + + // Validate required string fields are defined + if (!this.validateStringDefined(template.title)) errors.push('title is required') + if (!this.validateStringDefined(template.docPrefix)) errors.push('docPrefix is required') + + // Validate numbers are positive + if (!this.validatePossitiveNumber(template.major)) errors.push('invalid value for field "major"') + if (!this.validatePossitiveNumber(template.minor)) errors.push('invalid value for field "minor"') + + // Validate arrays + errors.push(...this.validateArray(template.reviewers, 'string', 'reviewers')) + errors.push(...this.validateArray(template.approvers, 'string', 'approvers')) + errors.push(...this.validateArray(template.coAuthors, 'string', 'coAuthors')) + + // Validate optional fields if present + if (template.author !== undefined) { + errors.push(...this.validateType(template.author, 'string', 'author')) + } + if (template.owner !== undefined) { + errors.push(...this.validateType(template.owner, 'string', 'owner')) + } + if (template.abstract !== undefined) { + errors.push(...this.validateType(template.abstract, 'string', 'abstract')) + } + if (template.ccDescription !== undefined) { + errors.push(...this.validateType(template.ccDescription, 'string', 'ccDescription')) + } + if (template.ccImpact !== undefined) { + errors.push(...this.validateType(template.ccImpact, 'string', 'ccImpact')) + } + if (template.ccReason !== undefined) { + errors.push(...this.validateType(template.ccReason, 'string', 'ccReason')) + } + + // Validate class + if (template.class !== documents.mixin.DocumentTemplate) { + errors.push('invalid class: ' + template.class) + } + + // Validate state values + if (template.state !== DocumentState.Draft) { + errors.push('invalid state: ' + template.state) + } + + // todo: validate seqNumber no duplicated + return errors + } + + private validateControlledDocumentSpaces (): void { + // Validate document spaces + for (const [spacePath] of this.qmsSpaces) { + // Validate controlled documents + const docs = this.qmsDocsBySpace.get(spacePath) + if (docs !== undefined) { + // for (const [docPath, doc] of docs) { + for (const docPath of docs.keys()) { + // Check parent document exists + const parentPath = this.documentParents.get(docPath) + if (parentPath !== undefined && !docs.has(parentPath)) { + this.addError(docPath, `Parent document not found: ${parentPath}`) + } + } + } + } + } } diff --git a/packages/importer/src/importer/importer.ts b/packages/importer/src/importer/importer.ts index a7483a07be4..34b8eadf689 100644 --- a/packages/importer/src/importer/importer.ts +++ b/packages/importer/src/importer/importer.ts @@ -14,12 +14,25 @@ // import attachment, { Drawing, type Attachment } from '@hcengineering/attachment' import chunter, { type ChatMessage } from '@hcengineering/chunter' -import { type Person } from '@hcengineering/contact' +import { Employee, type Person } from '@hcengineering/contact' +import documents, { + ChangeControl, + type ControlledDocument, + createControlledDocMetadata, + createDocumentTemplateMetadata, + DocumentCategory, + DocumentMeta, + type DocumentSpace, + DocumentState, + DocumentTemplate, + OrgSpace, + ProjectDocument, + useDocumentTemplate +} from '@hcengineering/controlled-documents' import core, { type Account, type AttachedData, type Class, - type Blob as PlatformBlob, type CollaborativeDoc, type Data, type Doc, @@ -27,7 +40,9 @@ import core, { generateId, makeCollabId, type Mixin, + type Blob as PlatformBlob, type Ref, + RolesAssignment, SortingOrder, type Space, type Status, @@ -157,6 +172,57 @@ export interface ImportDrawing { contentProvider: () => Promise } +export type ImportControlledDoc = ImportControlledDocument | ImportControlledDocumentTemplate // todo: rename +export interface ImportOrgSpace extends ImportSpace { + class: Ref> + qualified?: Ref + manager?: Ref + qara?: Ref +} + +export interface ImportControlledDocumentTemplate extends ImportDoc { + id: Ref + metaId: Ref + class: Ref> + docPrefix: string + code?: string + major: number + minor: number + state: DocumentState + category?: Ref + author?: Ref + owner?: Ref + abstract?: string + reviewers?: Ref[] + approvers?: Ref[] + coAuthors?: Ref[] + ccReason?: string + ccImpact?: string + ccDescription?: string + subdocs: ImportControlledDoc[] +} + +export interface ImportControlledDocument extends ImportDoc { + id: Ref + metaId: Ref + class: Ref> + template: Ref // todo: test (it was Ref) + code?: string + major: number + minor: number + state: DocumentState + reviewers?: Ref[] + approvers?: Ref[] + coAuthors?: Ref[] + author?: Ref + owner?: Ref + abstract?: string + ccReason?: string + ccImpact?: string + ccDescription?: string + subdocs: ImportControlledDoc[] +} + export class WorkspaceImporter { private readonly issueStatusByName = new Map>() private readonly projectTypeByName = new Map>() @@ -192,6 +258,8 @@ export class WorkspaceImporter { await this.importTeamspace(space as ImportTeamspace) } else if (space.class === tracker.class.Project) { await this.importProject(space as ImportProject) + } else if (space.class === documents.class.OrgSpace) { + await this.importOrgSpace(space as ImportOrgSpace) } } } @@ -730,4 +798,333 @@ export class WorkspaceImporter { } return identifier } + + async importOrgSpace (space: ImportOrgSpace): Promise> { + this.logger.log('Creating document space: ' + space.title) + const spaceId = await this.createOrgSpace(space) + this.logger.log('Document space created: ' + spaceId) + + // Create hierarchy meta + const templateMetaMap = new Map, { seqNumber: number, code: string }>() + for (const doc of space.docs) { + if (this.isDocumentTemplate(doc)) { + await this.createDocTemplateMetaHierarhy(doc as ImportControlledDocumentTemplate, templateMetaMap, spaceId) + } else { + await this.createControlledDocMetaHierarhy(doc as ImportControlledDocument, templateMetaMap, spaceId) + } + } + + // Partition templates and documents + const templateMap = new Map, ImportControlledDocumentTemplate>() + const documentMap = new Map, ImportControlledDocument>() + for (const doc of space.docs) { + this.partitionTemplatesFromDocuments(doc, documentMap, templateMap) + } + + // Create attached docs for templates + for (const template of templateMap.values()) { + const meta = templateMetaMap.get(template.id) + if (meta === undefined) { + throw new Error('Template meta not found: ' + template.id) + } + await this.createDocTemplateAttachedDoc(template, meta.seqNumber, meta.code, spaceId) + } + + // Create attached docs for documents + for (const document of documentMap.values()) { + await this.createControlledDocAttachedDoc(document, spaceId) + } + + return spaceId + } + + private partitionTemplatesFromDocuments ( + doc: ImportControlledDoc, + documentMap: Map, ImportControlledDocument>, + templateMap: Map, ImportControlledDocumentTemplate> + ): void { + if (this.isDocumentTemplate(doc)) { + templateMap.set(doc.id, doc as ImportControlledDocumentTemplate) + } else { + documentMap.set(doc.id, doc as ImportControlledDocument) + } + + for (const subdoc of doc.subdocs) { + this.partitionTemplatesFromDocuments(subdoc, documentMap, templateMap) + } + } + + private isDocumentTemplate (doc: ImportDoc): boolean { + return doc.class === documents.mixin.DocumentTemplate + } + + private async createOrgSpace (space: ImportOrgSpace): Promise> { + const spaceId = generateId() + const data: Data = { + type: documents.spaceType.DocumentSpaceType, + description: space.description ?? '', + name: space.title, + private: space.private, + owners: space.owners ?? [], + members: space.members ?? [], + archived: space.archived ?? false + } + await this.client.createDoc(documents.class.OrgSpace, core.space.Space, data, spaceId) + + const rolesAssignment: RolesAssignment = {} + if (space.qualified !== undefined) { + rolesAssignment[documents.role.QualifiedUser] = [space.qualified] + } + if (space.manager !== undefined) { + rolesAssignment[documents.role.Manager] = [space.manager] + } + if (space.qara !== undefined) { + rolesAssignment[documents.role.QARA] = [space.qara] + } + if (Object.keys(rolesAssignment).length > 0) { + await this.client.createMixin( + spaceId, + documents.class.OrgSpace, + core.space.Space, + documents.mixin.DocumentSpaceTypeData, + rolesAssignment + ) + } + return spaceId + } + + private async createDocTemplateMetaHierarhy ( + template: ImportControlledDocumentTemplate, + templateMetaMap: Map, { seqNumber: number, code: string }>, + spaceId: Ref, + parentProjectDocumentId?: Ref + ): Promise> { + this.logger.log('Creating document template: ' + template.title) + const templateId = template.id ?? generateId() + + const { seqNumber, code, projectDocumentId } = await createDocumentTemplateMetadata( + this.client, + documents.class.Document, + spaceId, + documents.mixin.DocumentTemplate, + undefined, + parentProjectDocumentId, + templateId as unknown as Ref, // todo: suspisios place + template.docPrefix, + template.code ?? '', + template.title, + template.metaId + ) + + templateMetaMap.set(templateId, { seqNumber, code }) + + for (const subdoc of template.subdocs) { + if (this.isDocumentTemplate(subdoc)) { + await this.createDocTemplateMetaHierarhy( + subdoc as ImportControlledDocumentTemplate, + templateMetaMap, + spaceId, + projectDocumentId + ) + } else { + await this.createControlledDocMetaHierarhy( + subdoc as ImportControlledDocument, + templateMetaMap, + spaceId, + projectDocumentId + ) + } + } + + return templateId + } + + private async createDocTemplateAttachedDoc ( + template: ImportControlledDocumentTemplate, + seqNumber: number, + code: string, + spaceId: Ref + ): Promise> { + const content = await template.descrProvider() + + this.logger.log('Creating document template attached doc: ' + template.title) + + const collabId = makeCollabId(documents.class.Document, template.id, 'content') + const contentId = await this.createCollaborativeContent(template.id, collabId, content, spaceId) + + const changeControlId = + template.ccReason !== undefined || template.ccImpact !== undefined || template.ccDescription !== undefined + ? await this.createChangeControl(spaceId, template.ccDescription, template.ccReason, template.ccImpact) + : ('' as Ref) + + const ops = this.client.apply() + const result = await ops.addCollection( + documents.class.ControlledDocument, + spaceId, + template.metaId, + documents.class.DocumentMeta, + 'documents', + { + title: template.title, + major: template.major, + minor: template.minor, + state: template.state, + author: template.author, + owner: template.owner, + abstract: template.abstract, + reviewers: template.reviewers ?? [], + approvers: template.approvers ?? [], + coAuthors: template.coAuthors ?? [], + code, + seqNumber, + prefix: template.docPrefix, // todo: or TEMPLATE_PREFIX?s + content: contentId, + changeControl: changeControlId, + commentSequence: 0, + requests: 0, + labels: 0 + }, + template.id as unknown as Ref // todo: make sure it's not used anywhere as mixin id + ) + + await ops.createMixin(template.id, documents.class.Document, spaceId, documents.mixin.DocumentTemplate, { + sequence: 0, + docPrefix: template.docPrefix + }) + + const commit = await ops.commit() + if (!commit.result) { + throw new Error('Failed to create document template attached doc: ' + template.title) + } + + this.logger.log('Document template attached doc created: ' + result) + return result + } + + private async createControlledDocMetaHierarhy ( + doc: ImportControlledDocument, + templateMetaMap: Map, { seqNumber: number, code: string }>, + spaceId: Ref, + parentProjectDocumentId?: Ref + ): Promise> { + this.logger.log('Creating controlled document: ' + doc.title) + const documentId = doc.id ?? generateId() + + // const { seqNumber, prefix, category } = await useDocumentTemplate(this.client, doc.template as unknown as Ref) + const result = await createControlledDocMetadata( + this.client, + documents.template.ProductChangeControl, // todo: make it dynamic - wtf, commit missed? + documentId, + spaceId, + undefined, // project + parentProjectDocumentId, // parent + 'prefix', + 0, + doc.code ?? '', + doc.title, + doc.metaId + ) + + // Process subdocs recursively + for (const subdoc of doc.subdocs) { + if (this.isDocumentTemplate(subdoc)) { + await this.createDocTemplateMetaHierarhy( + subdoc as ImportControlledDocumentTemplate, + templateMetaMap, + spaceId, + result.projectDocumentId + ) + } else { + await this.createControlledDocMetaHierarhy( + subdoc as ImportControlledDocument, + templateMetaMap, + spaceId, + result.projectDocumentId + ) + } + } + + return documentId + } + + private async createControlledDocAttachedDoc ( + document: ImportControlledDocument, + spaceId: Ref + ): Promise> { + this.logger.log('Creating controlled document attached doc: ' + document.title) + + const content = await document.descrProvider() + const collabId = makeCollabId(documents.class.Document, document.id, 'content') + const contentId = await this.createCollaborativeContent(document.id, collabId, content, spaceId) + + const templateId = document.template + const { seqNumber, prefix, category } = await useDocumentTemplate( + this.client, + templateId as unknown as Ref + ) + + const ops = this.client.apply() + + const changeControlId = + document.ccReason !== undefined || document.ccImpact !== undefined + ? await this.createChangeControl(spaceId, document.ccDescription, document.ccReason, document.ccImpact) + : ('' as Ref) + + const result = await ops.addCollection( + documents.class.ControlledDocument, + spaceId, + document.metaId, + documents.class.DocumentMeta, + 'documents', + { + title: document.title, + major: document.major, + minor: document.minor, + state: document.state, + author: document.author, + owner: document.owner, + abstract: document.abstract, + reviewers: document.reviewers ?? [], + approvers: document.approvers ?? [], + coAuthors: document.coAuthors ?? [], + changeControl: changeControlId, + code: document.code ?? `${prefix}-${seqNumber}`, + prefix, + category, + seqNumber, + content: contentId, + template: templateId as unknown as Ref, + commentSequence: 0, + requests: 0 + }, + document.id + ) + + await ops.updateDoc(documents.class.DocumentMeta, spaceId, document.metaId, { + documents: 0, + title: `${prefix}-${seqNumber} ${document.title}` + }) + + await ops.commit() + + this.logger.log('Controlled document attached doc created: ' + result) + + return result + } + + private async createChangeControl ( + spaceId: Ref, + description?: string, + reason?: string, + impact?: string + ): Promise> { + const changeControlData: Data = { + reason: reason ?? '', + impact: impact ?? '', + description: description ?? '', + impactedDocuments: [] + } + + return await this.client.createDoc(documents.class.ChangeControl, spaceId, changeControlData) + } } diff --git a/plugins/controlled-documents/src/docutils.ts b/plugins/controlled-documents/src/docutils.ts index 1e7afaad44c..0de1ae6c3f5 100644 --- a/plugins/controlled-documents/src/docutils.ts +++ b/plugins/controlled-documents/src/docutils.ts @@ -16,16 +16,16 @@ import { type Employee } from '@hcengineering/contact' import { type AttachedData, type Class, type Ref, type TxOperations, Blob, Mixin } from '@hcengineering/core' import { - type Document, - type DocumentTemplate, type ControlledDocument, + type Document, type DocumentCategory, - type DocumentSpace, type DocumentMeta, + type DocumentSpace, + type DocumentTemplate, + type HierarchyDocument, type Project, - DocumentState, - HierarchyDocument, - ProjectDocument + type ProjectDocument, + DocumentState } from './types' import documents from './plugin' @@ -67,18 +67,55 @@ export async function createControlledDocFromTemplate ( return { seqNumber: -1, success: false } } - const template = await client.findOne(documents.mixin.DocumentTemplate, { - _id: templateId - }) + const { seqNumber, prefix, content, category } = await useDocumentTemplate(client, templateId) + const { success, documentMetaId } = await createControlledDocMetadata( + client, + templateId, + documentId, + space, + project, + parent, + prefix, + seqNumber, + spec.code, + spec.title + ) - if (template === undefined) { + if (!success) { return { seqNumber: -1, success: false } } - let path: Array> = [] + await client.addCollection( + docClass, + space, + documentMetaId, + documents.class.DocumentMeta, + 'documents', + { + ...spec, + category, + template: templateId, + seqNumber, + prefix, + state: DocumentState.Draft, + content + }, + documentId + ) - if (parent !== undefined) { - path = await getParentPath(client, parent) + return { seqNumber, success: true } +} + +export async function useDocumentTemplate ( + client: TxOperations, + templateId: Ref +): Promise<{ seqNumber: number, prefix: string, content: Ref | null, category: Ref }> { + const template = await client.findOne(documents.mixin.DocumentTemplate, { + _id: templateId + }) + + if (template === undefined) { + return { seqNumber: -1, prefix: '', content: null, category: '' as Ref } } await client.updateMixin(templateId, documents.class.Document, template.space, documents.mixin.DocumentTemplate, { @@ -89,34 +126,27 @@ export async function createControlledDocFromTemplate ( const seqNumber = template.sequence + 1 const prefix = template.docPrefix - return await createControlledDoc( - client, - templateId, - documentId, - { ...spec, category: template.category }, - space, - project, - prefix, - seqNumber, - path, - docClass, - template.content - ) + return { seqNumber, prefix, content: template.content, category: template.category as Ref } } -async function createControlledDoc ( +export async function createControlledDocMetadata ( client: TxOperations, templateId: Ref, documentId: Ref, - spec: AttachedData, space: Ref, project: Ref | undefined, + parent: Ref | undefined, prefix: string, seqNumber: number, - path: Ref[] = [], - docClass: Ref> = documents.class.ControlledDocument, - content: Ref | null -): Promise<{ seqNumber: number, success: boolean }> { + specCode: string, + specTitle: string, + metaId?: Ref +): Promise<{ + success: boolean + seqNumber: number + documentMetaId: Ref + projectDocumentId: Ref + }> { const projectId = project ?? documents.ids.NoProject const ops = client.apply() @@ -127,23 +157,33 @@ async function createControlledDoc ( }) ops.notMatch(documents.class.Document, { - code: spec.code + code: specCode }) - const metaId = await ops.createDoc(documents.class.DocumentMeta, space, { - documents: 0, - title: `${prefix}-${seqNumber} ${spec.title}` - }) + const documentMetaId = await ops.createDoc( + documents.class.DocumentMeta, + space, + { + documents: 0, + title: `${prefix}-${seqNumber} ${specTitle}` + }, + metaId + ) + + let path: Array> = [] + if (parent !== undefined) { + path = await getParentPath(client, parent) + } const projectMetaId = await ops.createDoc(documents.class.ProjectMeta, space, { project: projectId, - meta: metaId, + meta: documentMetaId, path, parent: path[0] ?? documents.ids.NoParent, documents: 0 }) - await client.addCollection( + const projectDocumentId = await client.addCollection( documents.class.ProjectDocument, space, projectMetaId, @@ -156,28 +196,70 @@ async function createControlledDoc ( } ) - await ops.addCollection( - docClass, + const success = await ops.commit() + + return { success: success.result, seqNumber, documentMetaId, projectDocumentId } +} + +export async function createDocumentTemplate ( + client: TxOperations, + _class: Ref>, + space: Ref, + _mixin: Ref>, + project: Ref | undefined, + parent: Ref | undefined, + templateId: Ref, + prefix: string, + spec: Omit, 'prefix'>, + category: Ref, + author?: Ref +): Promise<{ seqNumber: number, success: boolean }> { + const { success, seqNumber, code, documentMetaId } = await createDocumentTemplateMetadata( + client, + _class, + space, + _mixin, + project, + parent, + templateId, + prefix, + spec.code ?? '', + spec.title + ) + + if (!success) { + return { seqNumber: -1, success: false } + } + + const ops = client.apply() + await ops.addCollection( + _class, space, - metaId, + documentMetaId, documents.class.DocumentMeta, 'documents', { ...spec, - template: templateId, + code, seqNumber, - prefix, - state: DocumentState.Draft, - content + category, + prefix: TEMPLATE_PREFIX, + author, + owner: author, + content: spec.content ?? null }, - documentId + templateId ) + await ops.createMixin(templateId, documents.class.Document, space, _mixin, { + sequence: 0, + docPrefix: prefix + }) + const commit = await ops.commit() - const success = await ops.commit() - return { seqNumber, success: success.result } + return { seqNumber, success: commit.result } } -export async function createDocumentTemplate ( +export async function createDocumentTemplateMetadata ( client: TxOperations, _class: Ref>, space: Ref, @@ -186,10 +268,16 @@ export async function createDocumentTemplate ( parent: Ref | undefined, templateId: Ref, prefix: string, - spec: Omit, 'prefix'>, - category: Ref, - author?: Ref -): Promise<{ seqNumber: number, success: boolean }> { + specCode: string, + specTitle: string, + metaId?: Ref +): Promise<{ + success: boolean + seqNumber: number + code: string + documentMetaId: Ref + projectDocumentId: Ref + }> { const projectId = project ?? documents.ids.NoProject const incResult = await client.updateDoc( @@ -202,7 +290,7 @@ export async function createDocumentTemplate ( true ) const seqNumber = (incResult as any).object.sequence as number - const code = spec.code === '' ? `${TEMPLATE_PREFIX}-${seqNumber}` : spec.code + const code = specCode === '' ? `${TEMPLATE_PREFIX}-${seqNumber}` : specCode let path: Array> = [] @@ -225,20 +313,25 @@ export async function createDocumentTemplate ( docPrefix: prefix }) - const metaId = await ops.createDoc(documents.class.DocumentMeta, space, { - documents: 0, - title: `${TEMPLATE_PREFIX}-${seqNumber} ${spec.title}` - }) + const documentMetaId = await ops.createDoc( + documents.class.DocumentMeta, + space, + { + documents: 0, + title: `${TEMPLATE_PREFIX}-${seqNumber} ${specTitle}` + }, + metaId + ) const projectMetaId = await ops.createDoc(documents.class.ProjectMeta, space, { project: projectId, - meta: metaId, + meta: documentMetaId, path, parent: path[0] ?? documents.ids.NoParent, documents: 0 }) - await client.addCollection( + const projectDocumentId = await client.addCollection( documents.class.ProjectDocument, space, projectMetaId, @@ -251,31 +344,7 @@ export async function createDocumentTemplate ( } ) - await ops.addCollection( - _class, - space, - metaId, - documents.class.DocumentMeta, - 'documents', - { - ...spec, - code, - seqNumber, - category, - prefix: TEMPLATE_PREFIX, - author, - owner: author, - content: null - }, - templateId - ) - - await ops.createMixin(templateId, documents.class.Document, space, _mixin, { - sequence: 0, - docPrefix: prefix - }) - const success = await ops.commit() - return { seqNumber, success: success.result } + return { success: success.result, seqNumber, code, documentMetaId, projectDocumentId } } diff --git a/plugins/controlled-documents/src/plugin.ts b/plugins/controlled-documents/src/plugin.ts index c18d0e1c2fa..e35c614ff06 100644 --- a/plugins/controlled-documents/src/plugin.ts +++ b/plugins/controlled-documents/src/plugin.ts @@ -6,7 +6,8 @@ import { type Type, type Space, type SpaceTypeDescriptor, - type Permission + type Permission, + Role } from '@hcengineering/core' import type { Asset, Plugin, Resource } from '@hcengineering/platform' import { IntlString, plugin } from '@hcengineering/platform' @@ -276,6 +277,11 @@ export const documentsPlugin = plugin(documentsId, { CA: '' as Ref, CC: '' as Ref }, + role: { + QARA: '' as Ref, + Manager: '' as Ref, + QualifiedUser: '' as Ref + }, resolver: { Location: '' as Resource<(loc: Location) => Promise> },