mirror of
https://github.com/hcengineering/platform.git
synced 2025-01-07 04:11:17 +03:00
Support Controlled Documents import (#7541)
Some checks failed
CI / build (push) Has been cancelled
CI / uitest (push) Has been cancelled
CI / uitest-pg (push) Has been cancelled
CI / uitest-qms (push) Has been cancelled
CI / svelte-check (push) Has been cancelled
CI / formatting (push) Has been cancelled
CI / test (push) Has been cancelled
CI / docker-build (push) Has been cancelled
CI / dist-build (push) Has been cancelled
Some checks failed
CI / build (push) Has been cancelled
CI / uitest (push) Has been cancelled
CI / uitest-pg (push) Has been cancelled
CI / uitest-qms (push) Has been cancelled
CI / svelte-check (push) Has been cancelled
CI / formatting (push) Has been cancelled
CI / test (push) Has been cancelled
CI / docker-build (push) Has been cancelled
CI / dist-build (push) Has been cancelled
* Support Controlled Documents import Signed-off-by: Anna Khismatullina <anna.khismatullina@gmail.com> * Add example table Signed-off-by: Anna Khismatullina <anna.khismatullina@gmail.com> --------- Signed-off-by: Anna Khismatullina <anna.khismatullina@gmail.com>
This commit is contained in:
parent
d9604b7e0e
commit
ffbd356cc2
@ -31,12 +31,24 @@ workspace/
|
|||||||
│ └── files/
|
│ └── files/
|
||||||
│ └── diagram.png # Can be referenced in markdown content
|
│ └── diagram.png # Can be referenced in markdown content
|
||||||
└── Project Alpha.yaml # Project configuration
|
└── 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
|
### 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
|
```yaml
|
||||||
class: tracker:class:Project # Required
|
class: tracker:class:Project # Required
|
||||||
title: Project Alpha # Required
|
title: Project Alpha # Required
|
||||||
@ -51,32 +63,8 @@ description: string # Optional
|
|||||||
defaultIssueStatus: Todo # Optional
|
defaultIssueStatus: Todo # Optional
|
||||||
```
|
```
|
||||||
|
|
||||||
Teamspace (`Documentation.yaml`):
|
##### 2. Issue (*.md)
|
||||||
```yaml
|
Example: `1.Project Setup.md`:
|
||||||
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`):
|
|
||||||
```yaml
|
```yaml
|
||||||
---
|
---
|
||||||
class: tracker:class:Issue # Required
|
class: tracker:class:Issue # Required
|
||||||
@ -90,11 +78,11 @@ remainingTime: 4 # Optional, in hours
|
|||||||
Task description in Markdown...
|
Task description in Markdown...
|
||||||
```
|
```
|
||||||
|
|
||||||
### Task Identification
|
##### Issue Identification
|
||||||
* Human-readable task ID is formed by combining project's identifier and task number from filename
|
* Human-readable issue ID is formed by combining project's identifier and issue number from filename
|
||||||
* Example: For project with identifier "ALPHA" and task "1.Setup Project.md", the task ID will be "ALPHA-1"
|
* 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:
|
Issue status values:
|
||||||
* `Backlog`
|
* `Backlog`
|
||||||
@ -109,6 +97,99 @@ Issue priority values:
|
|||||||
* `High`
|
* `High`
|
||||||
* `Urgent`
|
* `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
|
### Run Import Tool
|
||||||
```bash
|
```bash
|
||||||
docker run \
|
docker run \
|
||||||
@ -125,3 +206,6 @@ docker run \
|
|||||||
* All users must exist in the system before import
|
* All users must exist in the system before import
|
||||||
* Assignees are mapped by full name
|
* Assignees are mapped by full name
|
||||||
* Files in space directories can be used as attachments when referenced in markdown content
|
* 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
|
||||||
|
@ -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
|
@ -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]
|
@ -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
|
@ -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)
|
@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
import { documentsId } from '@hcengineering/controlled-documents'
|
import { documentsId } from '@hcengineering/controlled-documents'
|
||||||
import documents from '@hcengineering/controlled-documents-resources/src/plugin'
|
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 { type ObjectSearchCategory, type ObjectSearchFactory } from '@hcengineering/model-presentation'
|
||||||
import { mergeIds, type Resource } from '@hcengineering/platform'
|
import { mergeIds, type Resource } from '@hcengineering/platform'
|
||||||
import { type TagCategory } from '@hcengineering/tags'
|
import { type TagCategory } from '@hcengineering/tags'
|
||||||
@ -71,11 +71,6 @@ export default mergeIds(documentsId, documents, {
|
|||||||
TableDocumentTemplate: '' as Ref<Doc>,
|
TableDocumentTemplate: '' as Ref<Doc>,
|
||||||
TableDocumentDomain: '' as Ref<Doc>
|
TableDocumentDomain: '' as Ref<Doc>
|
||||||
},
|
},
|
||||||
role: {
|
|
||||||
QARA: '' as Ref<Role>,
|
|
||||||
Manager: '' as Ref<Role>,
|
|
||||||
QualifiedUser: '' as Ref<Role>
|
|
||||||
},
|
|
||||||
notification: {
|
notification: {
|
||||||
DocumentsNotificationGroup: '' as Ref<NotificationGroup>,
|
DocumentsNotificationGroup: '' as Ref<NotificationGroup>,
|
||||||
ContentNotification: '' as Ref<NotificationType>,
|
ContentNotification: '' as Ref<NotificationType>,
|
||||||
|
@ -45,6 +45,7 @@
|
|||||||
"@hcengineering/chunter": "^0.6.20",
|
"@hcengineering/chunter": "^0.6.20",
|
||||||
"@hcengineering/collaboration": "^0.6.0",
|
"@hcengineering/collaboration": "^0.6.0",
|
||||||
"@hcengineering/contact": "^0.6.24",
|
"@hcengineering/contact": "^0.6.24",
|
||||||
|
"@hcengineering/controlled-documents": "^0.1.0",
|
||||||
"@hcengineering/core": "^0.6.32",
|
"@hcengineering/core": "^0.6.32",
|
||||||
"@hcengineering/document": "^0.6.0",
|
"@hcengineering/document": "^0.6.0",
|
||||||
"@hcengineering/model-attachment": "^0.6.0",
|
"@hcengineering/model-attachment": "^0.6.0",
|
||||||
|
@ -14,7 +14,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import { type Attachment } from '@hcengineering/attachment'
|
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 { type Class, type Doc, generateId, type Ref, type Space, type TxOperations } from '@hcengineering/core'
|
||||||
import document, { type Document } from '@hcengineering/document'
|
import document, { type Document } from '@hcengineering/document'
|
||||||
import { MarkupMarkType, type MarkupNode, MarkupNodeType, traverseNode, traverseNodeMarks } from '@hcengineering/text'
|
import { MarkupMarkType, type MarkupNode, MarkupNodeType, traverseNode, traverseNodeMarks } from '@hcengineering/text'
|
||||||
@ -28,6 +28,8 @@ import { ImportWorkspaceBuilder } from '../importer/builder'
|
|||||||
import {
|
import {
|
||||||
type ImportAttachment,
|
type ImportAttachment,
|
||||||
type ImportComment,
|
type ImportComment,
|
||||||
|
ImportControlledDocument,
|
||||||
|
ImportControlledDocumentTemplate,
|
||||||
type ImportDocument,
|
type ImportDocument,
|
||||||
ImportDrawing,
|
ImportDrawing,
|
||||||
type ImportIssue,
|
type ImportIssue,
|
||||||
@ -35,11 +37,18 @@ import {
|
|||||||
type ImportProjectType,
|
type ImportProjectType,
|
||||||
type ImportTeamspace,
|
type ImportTeamspace,
|
||||||
type ImportWorkspace,
|
type ImportWorkspace,
|
||||||
WorkspaceImporter
|
WorkspaceImporter,
|
||||||
|
ImportOrgSpace
|
||||||
} from '../importer/importer'
|
} from '../importer/importer'
|
||||||
import { type Logger } from '../importer/logger'
|
import { type Logger } from '../importer/logger'
|
||||||
import { BaseMarkdownPreprocessor } from '../importer/preprocessor'
|
import { BaseMarkdownPreprocessor } from '../importer/preprocessor'
|
||||||
import { type FileUploader } from '../importer/uploader'
|
import { type FileUploader } from '../importer/uploader'
|
||||||
|
import documents, {
|
||||||
|
DocumentState,
|
||||||
|
DocumentCategory,
|
||||||
|
ControlledDocument,
|
||||||
|
DocumentMeta
|
||||||
|
} from '@hcengineering/controlled-documents'
|
||||||
|
|
||||||
interface UnifiedComment {
|
interface UnifiedComment {
|
||||||
author: string
|
author: string
|
||||||
@ -59,7 +68,7 @@ interface UnifiedIssueHeader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface UnifiedSpaceSettings {
|
interface UnifiedSpaceSettings {
|
||||||
class: 'tracker:class:Project' | 'document:class:Teamspace'
|
class: 'tracker:class:Project' | 'document:class:Teamspace' | 'documents:class:OrgSpace'
|
||||||
title: string
|
title: string
|
||||||
private?: boolean
|
private?: boolean
|
||||||
autoJoin?: 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 {
|
class HulyMarkdownPreprocessor extends BaseMarkdownPreprocessor {
|
||||||
constructor (
|
constructor (
|
||||||
private readonly urlProvider: (id: string) => string,
|
private readonly urlProvider: (id: string) => string,
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
private readonly metadataByFilePath: Map<string, DocMetadata>,
|
private readonly pathById: Map<Ref<Doc>, string>,
|
||||||
private readonly metadataById: Map<Ref<Doc>, DocMetadata>,
|
private readonly refMetaByPath: Map<string, ReferenceMetadata>,
|
||||||
private readonly attachMetadataByPath: Map<string, AttachmentMetadata>,
|
private readonly attachMetaByPath: Map<string, AttachmentMetadata>,
|
||||||
personsByName: Map<string, Ref<Person>>
|
personsByName: Map<string, Ref<Person>>
|
||||||
) {
|
) {
|
||||||
super(personsByName)
|
super(personsByName)
|
||||||
@ -130,18 +179,24 @@ class HulyMarkdownPreprocessor extends BaseMarkdownPreprocessor {
|
|||||||
const src = node.attrs?.src
|
const src = node.attrs?.src
|
||||||
if (src === undefined) return
|
if (src === undefined) return
|
||||||
|
|
||||||
const sourceMeta = this.getSourceMetadata(id)
|
const sourcePath = this.getSourcePath(id)
|
||||||
if (sourceMeta == null) return
|
if (sourcePath == null) return
|
||||||
|
|
||||||
const href = decodeURI(src as string)
|
const href = decodeURI(src as string)
|
||||||
const fullPath = path.resolve(path.dirname(sourceMeta.path), href)
|
const fullPath = path.resolve(path.dirname(sourcePath), href)
|
||||||
const attachmentMeta = this.attachMetadataByPath.get(fullPath)
|
const attachmentMeta = this.attachMetaByPath.get(fullPath)
|
||||||
|
|
||||||
if (attachmentMeta === undefined) {
|
if (attachmentMeta === undefined) {
|
||||||
this.logger.error(`Attachment image not found for ${fullPath}`)
|
this.logger.error(`Attachment image not found for ${fullPath}`)
|
||||||
return
|
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.updateAttachmentMetadata(fullPath, attachmentMeta, id, spaceId, sourceMeta)
|
||||||
this.alterImageNode(node, attachmentMeta.id, attachmentMeta.name)
|
this.alterImageNode(node, attachmentMeta.id, attachmentMeta.name)
|
||||||
}
|
}
|
||||||
@ -150,23 +205,26 @@ class HulyMarkdownPreprocessor extends BaseMarkdownPreprocessor {
|
|||||||
traverseNodeMarks(node, (mark) => {
|
traverseNodeMarks(node, (mark) => {
|
||||||
if (mark.type !== MarkupMarkType.link) return
|
if (mark.type !== MarkupMarkType.link) return
|
||||||
|
|
||||||
const sourceMeta = this.getSourceMetadata(id)
|
const sourcePath = this.getSourcePath(id)
|
||||||
if (sourceMeta == null) return
|
if (sourcePath == null) return
|
||||||
|
|
||||||
const href = decodeURI(mark.attrs.href)
|
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)) {
|
if (this.refMetaByPath.has(fullPath)) {
|
||||||
const targetDocMeta = this.metadataByFilePath.get(fullPath)
|
const targetDocMeta = this.refMetaByPath.get(fullPath)
|
||||||
if (targetDocMeta !== undefined) {
|
if (targetDocMeta !== undefined) {
|
||||||
this.alterInternalLinkNode(node, targetDocMeta)
|
this.alterInternalLinkNode(node, targetDocMeta)
|
||||||
}
|
}
|
||||||
} else if (this.attachMetadataByPath.has(fullPath)) {
|
} else if (this.attachMetaByPath.has(fullPath)) {
|
||||||
const attachmentMeta = this.attachMetadataByPath.get(fullPath)
|
const attachmentMeta = this.attachMetaByPath.get(fullPath)
|
||||||
if (attachmentMeta !== undefined) {
|
if (attachmentMeta !== undefined) {
|
||||||
this.alterAttachmentLinkNode(node, attachmentMeta)
|
this.alterAttachmentLinkNode(node, attachmentMeta)
|
||||||
|
const sourceMeta = this.refMetaByPath.get(sourcePath)
|
||||||
|
if (sourceMeta !== undefined) {
|
||||||
this.updateAttachmentMetadata(fullPath, attachmentMeta, id, spaceId, sourceMeta)
|
this.updateAttachmentMetadata(fullPath, attachmentMeta, id, spaceId, sourceMeta)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.logger.log('Unknown link type, leave it as is: ' + href)
|
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.type = MarkupNodeType.reference
|
||||||
node.attrs = {
|
node.attrs = {
|
||||||
id: targetMeta.id,
|
id: targetMeta.id,
|
||||||
@ -223,13 +281,13 @@ class HulyMarkdownPreprocessor extends BaseMarkdownPreprocessor {
|
|||||||
return mimeType !== false ? mimeType : undefined
|
return mimeType !== false ? mimeType : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
private getSourceMetadata (id: Ref<Doc>): DocMetadata | null {
|
private getSourcePath (id: Ref<Doc>): string | null {
|
||||||
const sourceMeta = this.metadataById.get(id)
|
const sourcePath = this.pathById.get(id)
|
||||||
if (sourceMeta == null) {
|
if (sourcePath == null) {
|
||||||
this.logger.error(`Source metadata not found for ${id}`)
|
this.logger.error(`Source file path not found for ${id}`)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return sourceMeta
|
return sourcePath
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateAttachmentMetadata (
|
private updateAttachmentMetadata (
|
||||||
@ -237,9 +295,9 @@ class HulyMarkdownPreprocessor extends BaseMarkdownPreprocessor {
|
|||||||
attachmentMeta: AttachmentMetadata,
|
attachmentMeta: AttachmentMetadata,
|
||||||
id: Ref<Doc>,
|
id: Ref<Doc>,
|
||||||
spaceId: Ref<Space>,
|
spaceId: Ref<Space>,
|
||||||
sourceMeta: DocMetadata
|
sourceMeta: ReferenceMetadata
|
||||||
): void {
|
): void {
|
||||||
this.attachMetadataByPath.set(fullPath, {
|
this.attachMetaByPath.set(fullPath, {
|
||||||
...attachmentMeta,
|
...attachmentMeta,
|
||||||
spaceId,
|
spaceId,
|
||||||
parentId: id,
|
parentId: id,
|
||||||
@ -248,10 +306,9 @@ class HulyMarkdownPreprocessor extends BaseMarkdownPreprocessor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DocMetadata {
|
interface ReferenceMetadata {
|
||||||
id: Ref<Doc>
|
id: Ref<Doc>
|
||||||
class: string
|
class: string
|
||||||
path: string
|
|
||||||
refTitle: string
|
refTitle: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -265,12 +322,14 @@ interface AttachmentMetadata {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class UnifiedFormatImporter {
|
export class UnifiedFormatImporter {
|
||||||
private readonly metadataById = new Map<Ref<Doc>, DocMetadata>()
|
private readonly pathById = new Map<Ref<Doc>, string>()
|
||||||
private readonly metadataByFilePath = new Map<string, DocMetadata>()
|
private readonly refMetaByPath = new Map<string, ReferenceMetadata>()
|
||||||
private readonly fileMetadataByPath = new Map<string, AttachmentMetadata>()
|
private readonly fileMetaByPath = new Map<string, AttachmentMetadata>()
|
||||||
|
private readonly ctrlDocTemplateIdByPath = new Map<string, Ref<ControlledDocument>>()
|
||||||
|
|
||||||
private personsByName = new Map<string, Ref<Person>>()
|
private personsByName = new Map<string, Ref<Person>>()
|
||||||
private accountsByEmail = new Map<string, Ref<PersonAccount>>()
|
private accountsByEmail = new Map<string, Ref<PersonAccount>>()
|
||||||
|
private employeesByName = new Map<string, Ref<Employee>>()
|
||||||
|
|
||||||
constructor (
|
constructor (
|
||||||
private readonly client: TxOperations,
|
private readonly client: TxOperations,
|
||||||
@ -278,14 +337,18 @@ export class UnifiedFormatImporter {
|
|||||||
private readonly logger: Logger
|
private readonly logger: Logger
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async importFolder (folderPath: string): Promise<void> {
|
private async initCaches (): Promise<void> {
|
||||||
await this.cachePersonsByNames()
|
await this.cachePersonsByNames()
|
||||||
await this.cacheAccountsByEmails()
|
await this.cacheAccountsByEmails()
|
||||||
|
await this.cacheEmployeesByName()
|
||||||
|
}
|
||||||
|
|
||||||
|
async importFolder (folderPath: string): Promise<void> {
|
||||||
|
await this.initCaches()
|
||||||
|
const workspaceData = await this.processImportFolder(folderPath)
|
||||||
|
|
||||||
await this.collectFileMetadata(folderPath)
|
await this.collectFileMetadata(folderPath)
|
||||||
|
|
||||||
const workspaceData = await this.processImportFolder(folderPath)
|
|
||||||
|
|
||||||
this.logger.log('========================================')
|
this.logger.log('========================================')
|
||||||
this.logger.log('IMPORT DATA STRUCTURE: ' + JSON.stringify(workspaceData))
|
this.logger.log('IMPORT DATA STRUCTURE: ' + JSON.stringify(workspaceData))
|
||||||
this.logger.log('========================================')
|
this.logger.log('========================================')
|
||||||
@ -294,9 +357,9 @@ export class UnifiedFormatImporter {
|
|||||||
const preprocessor = new HulyMarkdownPreprocessor(
|
const preprocessor = new HulyMarkdownPreprocessor(
|
||||||
this.fileUploader.getFileUrl,
|
this.fileUploader.getFileUrl,
|
||||||
this.logger,
|
this.logger,
|
||||||
this.metadataByFilePath,
|
this.pathById,
|
||||||
this.metadataById,
|
this.refMetaByPath,
|
||||||
this.fileMetadataByPath,
|
this.fileMetaByPath,
|
||||||
this.personsByName
|
this.personsByName
|
||||||
)
|
)
|
||||||
await new WorkspaceImporter(
|
await new WorkspaceImporter(
|
||||||
@ -310,7 +373,7 @@ export class UnifiedFormatImporter {
|
|||||||
this.logger.log('Importing attachments...')
|
this.logger.log('Importing attachments...')
|
||||||
|
|
||||||
const attachments: ImportAttachment[] = await Promise.all(
|
const attachments: ImportAttachment[] = await Promise.all(
|
||||||
Array.from(this.fileMetadataByPath.values())
|
Array.from(this.fileMetaByPath.values())
|
||||||
.filter((attachMeta) => attachMeta.parentId !== undefined)
|
.filter((attachMeta) => attachMeta.parentId !== undefined)
|
||||||
.map(async (attachMeta: AttachmentMetadata) => await this.processAttachment(attachMeta))
|
.map(async (attachMeta: AttachmentMetadata) => await this.processAttachment(attachMeta))
|
||||||
)
|
)
|
||||||
@ -433,6 +496,15 @@ export class UnifiedFormatImporter {
|
|||||||
break
|
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: {
|
default: {
|
||||||
throw new Error(`Unknown space class ${spaceConfig.class} in ${spaceName}`)
|
throw new Error(`Unknown space class ${spaceConfig.class} in ${spaceName}`)
|
||||||
}
|
}
|
||||||
@ -468,15 +540,13 @@ export class UnifiedFormatImporter {
|
|||||||
const numberMatch = issueFile.match(/^(\d+)\./)
|
const numberMatch = issueFile.match(/^(\d+)\./)
|
||||||
const issueNumber = numberMatch?.[1]
|
const issueNumber = numberMatch?.[1]
|
||||||
|
|
||||||
const meta: DocMetadata = {
|
const meta: ReferenceMetadata = {
|
||||||
id: generateId<Issue>(),
|
id: generateId<Issue>(),
|
||||||
class: tracker.class.Issue,
|
class: tracker.class.Issue,
|
||||||
path: issuePath,
|
|
||||||
refTitle: `${projectIdentifier}-${issueNumber}`
|
refTitle: `${projectIdentifier}-${issueNumber}`
|
||||||
}
|
}
|
||||||
|
this.pathById.set(meta.id, issuePath)
|
||||||
this.metadataById.set(meta.id, meta)
|
this.refMetaByPath.set(issuePath, meta)
|
||||||
this.metadataByFilePath.set(issuePath, meta)
|
|
||||||
|
|
||||||
const issue: ImportIssue = {
|
const issue: ImportIssue = {
|
||||||
id: meta.id as Ref<Issue>,
|
id: meta.id as Ref<Issue>,
|
||||||
@ -525,6 +595,14 @@ export class UnifiedFormatImporter {
|
|||||||
return account
|
return account
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private findEmployeeByName (name: string): Ref<Employee> {
|
||||||
|
const employee = this.employeesByName.get(name)
|
||||||
|
if (employee === undefined) {
|
||||||
|
throw new Error(`Employee not found: ${name}`)
|
||||||
|
}
|
||||||
|
return employee
|
||||||
|
}
|
||||||
|
|
||||||
private async processDocumentsRecursively (
|
private async processDocumentsRecursively (
|
||||||
builder: ImportWorkspaceBuilder,
|
builder: ImportWorkspaceBuilder,
|
||||||
teamspacePath: string,
|
teamspacePath: string,
|
||||||
@ -543,15 +621,14 @@ export class UnifiedFormatImporter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (docHeader.class === document.class.Document) {
|
if (docHeader.class === document.class.Document) {
|
||||||
const docMeta: DocMetadata = {
|
const docMeta: ReferenceMetadata = {
|
||||||
id: generateId<Document>(),
|
id: generateId<Document>(),
|
||||||
class: document.class.Document,
|
class: document.class.Document,
|
||||||
path: docPath,
|
|
||||||
refTitle: docHeader.title
|
refTitle: docHeader.title
|
||||||
}
|
}
|
||||||
|
|
||||||
this.metadataById.set(docMeta.id, docMeta)
|
this.pathById.set(docMeta.id, docPath)
|
||||||
this.metadataByFilePath.set(docPath, docMeta)
|
this.refMetaByPath.set(docPath, docMeta)
|
||||||
|
|
||||||
const doc: ImportDocument = {
|
const doc: ImportDocument = {
|
||||||
id: docMeta.id as Ref<Document>,
|
id: docMeta.id as Ref<Document>,
|
||||||
@ -574,6 +651,79 @@ export class UnifiedFormatImporter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async processControlledDocumentsRecursively (
|
||||||
|
builder: ImportWorkspaceBuilder,
|
||||||
|
spacePath: string,
|
||||||
|
currentPath: string,
|
||||||
|
parentDocPath?: string
|
||||||
|
): Promise<void> {
|
||||||
|
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<DocumentMeta>()
|
||||||
|
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<ControlledDocument>()
|
||||||
|
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<ControlledDocument>()
|
||||||
|
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<ImportComment[]> {
|
private processComments (currentPath: string, comments: UnifiedComment[] = []): Promise<ImportComment[]> {
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
comments.map(async (comment) => {
|
comments.map(async (comment) => {
|
||||||
@ -581,7 +731,7 @@ export class UnifiedFormatImporter {
|
|||||||
if (comment.attachments !== undefined) {
|
if (comment.attachments !== undefined) {
|
||||||
for (const attachmentPath of comment.attachments) {
|
for (const attachmentPath of comment.attachments) {
|
||||||
const fullPath = path.resolve(currentPath, attachmentPath)
|
const fullPath = path.resolve(currentPath, attachmentPath)
|
||||||
const attachmentMeta = this.fileMetadataByPath.get(fullPath)
|
const attachmentMeta = this.fileMetaByPath.get(fullPath)
|
||||||
if (attachmentMeta !== undefined) {
|
if (attachmentMeta !== undefined) {
|
||||||
const importAttachment = await this.processAttachment(attachmentMeta)
|
const importAttachment = await this.processAttachment(attachmentMeta)
|
||||||
attachments.push(importAttachment)
|
attachments.push(importAttachment)
|
||||||
@ -650,6 +800,114 @@ export class UnifiedFormatImporter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async processOrgSpace (spaceHeader: UnifiedOrgSpaceSettings): Promise<ImportOrgSpace> {
|
||||||
|
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<ControlledDocument>,
|
||||||
|
metaId: Ref<DocumentMeta>
|
||||||
|
): Promise<ImportControlledDocument> {
|
||||||
|
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<ControlledDocument>()
|
||||||
|
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<ControlledDocument>,
|
||||||
|
metaId: Ref<DocumentMeta>
|
||||||
|
): Promise<ImportControlledDocumentTemplate> {
|
||||||
|
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<DocumentCategory>,
|
||||||
|
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<any> {
|
private async readYamlHeader (filePath: string): Promise<any> {
|
||||||
this.logger.log('Read YAML header from: ' + filePath)
|
this.logger.log('Read YAML header from: ' + filePath)
|
||||||
const content = fs.readFileSync(filePath, 'utf8')
|
const content = fs.readFileSync(filePath, 'utf8')
|
||||||
@ -688,6 +946,20 @@ export class UnifiedFormatImporter {
|
|||||||
}, new Map())
|
}, new Map())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async cacheEmployeesByName (): Promise<void> {
|
||||||
|
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<void> {
|
private async collectFileMetadata (folderPath: string): Promise<void> {
|
||||||
const processDir = async (dir: string): Promise<void> => {
|
const processDir = async (dir: string): Promise<void> => {
|
||||||
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
||||||
@ -699,7 +971,7 @@ export class UnifiedFormatImporter {
|
|||||||
await processDir(fullPath)
|
await processDir(fullPath)
|
||||||
} else if (entry.isFile()) {
|
} else if (entry.isFile()) {
|
||||||
const attachmentId = generateId<Attachment>()
|
const attachmentId = generateId<Attachment>()
|
||||||
this.fileMetadataByPath.set(fullPath, { id: attachmentId, name: entry.name, path: fullPath })
|
this.fileMetaByPath.set(fullPath, { id: attachmentId, name: entry.name, path: fullPath })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -12,10 +12,15 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// 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 { type DocumentQuery, type Ref, type Status, type TxOperations } from '@hcengineering/core'
|
||||||
import document from '@hcengineering/document'
|
import document from '@hcengineering/document'
|
||||||
import tracker, { IssuePriority, type IssueStatus } from '@hcengineering/tracker'
|
import tracker, { IssuePriority, type IssueStatus } from '@hcengineering/tracker'
|
||||||
import {
|
import {
|
||||||
|
ImportControlledDocument,
|
||||||
|
ImportControlledDocumentTemplate,
|
||||||
|
ImportOrgSpace,
|
||||||
|
type ImportControlledDoc,
|
||||||
type ImportDocument,
|
type ImportDocument,
|
||||||
type ImportIssue,
|
type ImportIssue,
|
||||||
type ImportProject,
|
type ImportProject,
|
||||||
@ -39,15 +44,21 @@ const PROJECT_IDENTIFIER_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/
|
|||||||
|
|
||||||
export class ImportWorkspaceBuilder {
|
export class ImportWorkspaceBuilder {
|
||||||
private readonly projects = new Map<string, ImportProject>()
|
private readonly projects = new Map<string, ImportProject>()
|
||||||
private readonly teamspaces = new Map<string, ImportTeamspace>()
|
|
||||||
private readonly projectTypes = new Map<string, ImportProjectType>()
|
|
||||||
private readonly issuesByProject = new Map<string, Map<string, ImportIssue>>()
|
private readonly issuesByProject = new Map<string, Map<string, ImportIssue>>()
|
||||||
private readonly issueParents = new Map<string, string>()
|
private readonly issueParents = new Map<string, string>()
|
||||||
|
|
||||||
|
private readonly teamspaces = new Map<string, ImportTeamspace>()
|
||||||
private readonly documentsByTeamspace = new Map<string, Map<string, ImportDocument>>()
|
private readonly documentsByTeamspace = new Map<string, Map<string, ImportDocument>>()
|
||||||
private readonly documentParents = new Map<string, string>()
|
private readonly documentParents = new Map<string, string>()
|
||||||
private readonly errors = new Map<string, ValidationError>()
|
|
||||||
|
|
||||||
|
private readonly qmsSpaces = new Map<string, ImportOrgSpace>()
|
||||||
|
private readonly qmsTemplates = new Map<Ref<ControlledDocument>, string>()
|
||||||
|
private readonly qmsDocsBySpace = new Map<string, Map<string, ImportControlledDoc>>()
|
||||||
|
private readonly qmsDocsParents = new Map<string, string>()
|
||||||
|
|
||||||
|
private readonly projectTypes = new Map<string, ImportProjectType>()
|
||||||
private readonly issueStatusCache = new Map<string, Ref<IssueStatus>>()
|
private readonly issueStatusCache = new Map<string, Ref<IssueStatus>>()
|
||||||
|
private readonly errors = new Map<string, ValidationError>()
|
||||||
|
|
||||||
constructor (
|
constructor (
|
||||||
private readonly client: TxOperations,
|
private readonly client: TxOperations,
|
||||||
@ -125,10 +136,95 @@ export class ImportWorkspaceBuilder {
|
|||||||
return this
|
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 {
|
validate (): ValidationResult {
|
||||||
// Perform cross-entity validation
|
// Perform cross-entity validation
|
||||||
this.validateProjectReferences()
|
this.validateSpacesReferences()
|
||||||
this.validateSpaceDocuments()
|
this.validateDocumentsReferences()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isValid: this.errors.size === 0,
|
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 {
|
return {
|
||||||
projectTypes: Array.from(this.projectTypes.values()),
|
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
|
return errors
|
||||||
}
|
}
|
||||||
|
|
||||||
private validateProjectReferences (): void {
|
private validateSpacesReferences (): void {
|
||||||
// Validate project type references
|
// Validate project type references
|
||||||
for (const project of this.projects.values()) {
|
for (const project of this.projects.values()) {
|
||||||
if (project.projectType !== undefined && !this.projectTypes.has(project.projectType.name)) {
|
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
|
// Validate that issues belong to projects and documents to teamspaces
|
||||||
for (const projectPath of this.issuesByProject.keys()) {
|
for (const projectPath of this.issuesByProject.keys()) {
|
||||||
if (!this.projects.has(projectPath)) {
|
if (!this.projects.has(projectPath)) {
|
||||||
@ -437,6 +551,23 @@ export class ImportWorkspaceBuilder {
|
|||||||
this.addError(teamspacePath, 'Documents reference non-existent teamspace')
|
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 {
|
private addError (path: string, error: string): void {
|
||||||
@ -471,6 +602,20 @@ export class ImportWorkspaceBuilder {
|
|||||||
issue.subdocs = childIssues
|
issue.subdocs = childIssues
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildControlledDocumentHierarchy (docPath: string, allDocs: Map<string, ImportControlledDoc>): 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[] {
|
private validateEmoji (emoji: string): string[] {
|
||||||
const errors: string[] = []
|
const errors: string[] = []
|
||||||
if (typeof emoji === 'string' && emoji.codePointAt(0) == null) {
|
if (typeof emoji === 'string' && emoji.codePointAt(0) == null) {
|
||||||
@ -529,4 +674,165 @@ export class ImportWorkspaceBuilder {
|
|||||||
}
|
}
|
||||||
return errors
|
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}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -14,12 +14,25 @@
|
|||||||
//
|
//
|
||||||
import attachment, { Drawing, type Attachment } from '@hcengineering/attachment'
|
import attachment, { Drawing, type Attachment } from '@hcengineering/attachment'
|
||||||
import chunter, { type ChatMessage } from '@hcengineering/chunter'
|
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, {
|
import core, {
|
||||||
type Account,
|
type Account,
|
||||||
type AttachedData,
|
type AttachedData,
|
||||||
type Class,
|
type Class,
|
||||||
type Blob as PlatformBlob,
|
|
||||||
type CollaborativeDoc,
|
type CollaborativeDoc,
|
||||||
type Data,
|
type Data,
|
||||||
type Doc,
|
type Doc,
|
||||||
@ -27,7 +40,9 @@ import core, {
|
|||||||
generateId,
|
generateId,
|
||||||
makeCollabId,
|
makeCollabId,
|
||||||
type Mixin,
|
type Mixin,
|
||||||
|
type Blob as PlatformBlob,
|
||||||
type Ref,
|
type Ref,
|
||||||
|
RolesAssignment,
|
||||||
SortingOrder,
|
SortingOrder,
|
||||||
type Space,
|
type Space,
|
||||||
type Status,
|
type Status,
|
||||||
@ -157,6 +172,57 @@ export interface ImportDrawing {
|
|||||||
contentProvider: () => Promise<string>
|
contentProvider: () => Promise<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ImportControlledDoc = ImportControlledDocument | ImportControlledDocumentTemplate // todo: rename
|
||||||
|
export interface ImportOrgSpace extends ImportSpace<ImportControlledDoc> {
|
||||||
|
class: Ref<Class<DocumentSpace>>
|
||||||
|
qualified?: Ref<Account>
|
||||||
|
manager?: Ref<Account>
|
||||||
|
qara?: Ref<Account>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportControlledDocumentTemplate extends ImportDoc {
|
||||||
|
id: Ref<ControlledDocument>
|
||||||
|
metaId: Ref<DocumentMeta>
|
||||||
|
class: Ref<Class<Document>>
|
||||||
|
docPrefix: string
|
||||||
|
code?: string
|
||||||
|
major: number
|
||||||
|
minor: number
|
||||||
|
state: DocumentState
|
||||||
|
category?: Ref<DocumentCategory>
|
||||||
|
author?: Ref<Employee>
|
||||||
|
owner?: Ref<Employee>
|
||||||
|
abstract?: string
|
||||||
|
reviewers?: Ref<Employee>[]
|
||||||
|
approvers?: Ref<Employee>[]
|
||||||
|
coAuthors?: Ref<Employee>[]
|
||||||
|
ccReason?: string
|
||||||
|
ccImpact?: string
|
||||||
|
ccDescription?: string
|
||||||
|
subdocs: ImportControlledDoc[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportControlledDocument extends ImportDoc {
|
||||||
|
id: Ref<ControlledDocument>
|
||||||
|
metaId: Ref<DocumentMeta>
|
||||||
|
class: Ref<Class<ControlledDocument>>
|
||||||
|
template: Ref<ControlledDocument> // todo: test (it was Ref<DocumentTemplate>)
|
||||||
|
code?: string
|
||||||
|
major: number
|
||||||
|
minor: number
|
||||||
|
state: DocumentState
|
||||||
|
reviewers?: Ref<Employee>[]
|
||||||
|
approvers?: Ref<Employee>[]
|
||||||
|
coAuthors?: Ref<Employee>[]
|
||||||
|
author?: Ref<Employee>
|
||||||
|
owner?: Ref<Employee>
|
||||||
|
abstract?: string
|
||||||
|
ccReason?: string
|
||||||
|
ccImpact?: string
|
||||||
|
ccDescription?: string
|
||||||
|
subdocs: ImportControlledDoc[]
|
||||||
|
}
|
||||||
|
|
||||||
export class WorkspaceImporter {
|
export class WorkspaceImporter {
|
||||||
private readonly issueStatusByName = new Map<string, Ref<IssueStatus>>()
|
private readonly issueStatusByName = new Map<string, Ref<IssueStatus>>()
|
||||||
private readonly projectTypeByName = new Map<string, Ref<ProjectType>>()
|
private readonly projectTypeByName = new Map<string, Ref<ProjectType>>()
|
||||||
@ -192,6 +258,8 @@ export class WorkspaceImporter {
|
|||||||
await this.importTeamspace(space as ImportTeamspace)
|
await this.importTeamspace(space as ImportTeamspace)
|
||||||
} else if (space.class === tracker.class.Project) {
|
} else if (space.class === tracker.class.Project) {
|
||||||
await this.importProject(space as ImportProject)
|
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
|
return identifier
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async importOrgSpace (space: ImportOrgSpace): Promise<Ref<DocumentSpace>> {
|
||||||
|
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<Ref<ControlledDocument>, { 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<Ref<ControlledDocument>, ImportControlledDocumentTemplate>()
|
||||||
|
const documentMap = new Map<Ref<ControlledDocument>, 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<Ref<ControlledDocument>, ImportControlledDocument>,
|
||||||
|
templateMap: Map<Ref<ControlledDocument>, 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<Ref<DocumentSpace>> {
|
||||||
|
const spaceId = generateId<DocumentSpace>()
|
||||||
|
const data: Data<OrgSpace> = {
|
||||||
|
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<Ref<ControlledDocument>, { seqNumber: number, code: string }>,
|
||||||
|
spaceId: Ref<DocumentSpace>,
|
||||||
|
parentProjectDocumentId?: Ref<ProjectDocument>
|
||||||
|
): Promise<Ref<ControlledDocument>> {
|
||||||
|
this.logger.log('Creating document template: ' + template.title)
|
||||||
|
const templateId = template.id ?? generateId<ControlledDocument>()
|
||||||
|
|
||||||
|
const { seqNumber, code, projectDocumentId } = await createDocumentTemplateMetadata(
|
||||||
|
this.client,
|
||||||
|
documents.class.Document,
|
||||||
|
spaceId,
|
||||||
|
documents.mixin.DocumentTemplate,
|
||||||
|
undefined,
|
||||||
|
parentProjectDocumentId,
|
||||||
|
templateId as unknown as Ref<ControlledDocument>, // 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<DocumentSpace>
|
||||||
|
): Promise<Ref<ControlledDocument>> {
|
||||||
|
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<ChangeControl>)
|
||||||
|
|
||||||
|
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<ControlledDocument> // 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<Ref<ControlledDocument>, { seqNumber: number, code: string }>,
|
||||||
|
spaceId: Ref<DocumentSpace>,
|
||||||
|
parentProjectDocumentId?: Ref<ProjectDocument>
|
||||||
|
): Promise<Ref<ControlledDocument>> {
|
||||||
|
this.logger.log('Creating controlled document: ' + doc.title)
|
||||||
|
const documentId = doc.id ?? generateId<ControlledDocument>()
|
||||||
|
|
||||||
|
// const { seqNumber, prefix, category } = await useDocumentTemplate(this.client, doc.template as unknown as Ref<DocumentTemplate>)
|
||||||
|
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<DocumentSpace>
|
||||||
|
): Promise<Ref<ControlledDocument>> {
|
||||||
|
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<DocumentTemplate>
|
||||||
|
)
|
||||||
|
|
||||||
|
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<ChangeControl>)
|
||||||
|
|
||||||
|
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<DocumentTemplate>,
|
||||||
|
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<DocumentSpace>,
|
||||||
|
description?: string,
|
||||||
|
reason?: string,
|
||||||
|
impact?: string
|
||||||
|
): Promise<Ref<ChangeControl>> {
|
||||||
|
const changeControlData: Data<ChangeControl> = {
|
||||||
|
reason: reason ?? '',
|
||||||
|
impact: impact ?? '',
|
||||||
|
description: description ?? '',
|
||||||
|
impactedDocuments: []
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.client.createDoc(documents.class.ChangeControl, spaceId, changeControlData)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -16,16 +16,16 @@
|
|||||||
import { type Employee } from '@hcengineering/contact'
|
import { type Employee } from '@hcengineering/contact'
|
||||||
import { type AttachedData, type Class, type Ref, type TxOperations, Blob, Mixin } from '@hcengineering/core'
|
import { type AttachedData, type Class, type Ref, type TxOperations, Blob, Mixin } from '@hcengineering/core'
|
||||||
import {
|
import {
|
||||||
type Document,
|
|
||||||
type DocumentTemplate,
|
|
||||||
type ControlledDocument,
|
type ControlledDocument,
|
||||||
|
type Document,
|
||||||
type DocumentCategory,
|
type DocumentCategory,
|
||||||
type DocumentSpace,
|
|
||||||
type DocumentMeta,
|
type DocumentMeta,
|
||||||
|
type DocumentSpace,
|
||||||
|
type DocumentTemplate,
|
||||||
|
type HierarchyDocument,
|
||||||
type Project,
|
type Project,
|
||||||
DocumentState,
|
type ProjectDocument,
|
||||||
HierarchyDocument,
|
DocumentState
|
||||||
ProjectDocument
|
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
import documents from './plugin'
|
import documents from './plugin'
|
||||||
@ -67,18 +67,55 @@ export async function createControlledDocFromTemplate (
|
|||||||
return { seqNumber: -1, success: false }
|
return { seqNumber: -1, success: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (!success) {
|
||||||
|
return { seqNumber: -1, success: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.addCollection(
|
||||||
|
docClass,
|
||||||
|
space,
|
||||||
|
documentMetaId,
|
||||||
|
documents.class.DocumentMeta,
|
||||||
|
'documents',
|
||||||
|
{
|
||||||
|
...spec,
|
||||||
|
category,
|
||||||
|
template: templateId,
|
||||||
|
seqNumber,
|
||||||
|
prefix,
|
||||||
|
state: DocumentState.Draft,
|
||||||
|
content
|
||||||
|
},
|
||||||
|
documentId
|
||||||
|
)
|
||||||
|
|
||||||
|
return { seqNumber, success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function useDocumentTemplate (
|
||||||
|
client: TxOperations,
|
||||||
|
templateId: Ref<DocumentTemplate>
|
||||||
|
): Promise<{ seqNumber: number, prefix: string, content: Ref<Blob> | null, category: Ref<DocumentCategory> }> {
|
||||||
const template = await client.findOne(documents.mixin.DocumentTemplate, {
|
const template = await client.findOne(documents.mixin.DocumentTemplate, {
|
||||||
_id: templateId
|
_id: templateId
|
||||||
})
|
})
|
||||||
|
|
||||||
if (template === undefined) {
|
if (template === undefined) {
|
||||||
return { seqNumber: -1, success: false }
|
return { seqNumber: -1, prefix: '', content: null, category: '' as Ref<DocumentCategory> }
|
||||||
}
|
|
||||||
|
|
||||||
let path: Array<Ref<DocumentMeta>> = []
|
|
||||||
|
|
||||||
if (parent !== undefined) {
|
|
||||||
path = await getParentPath(client, parent)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await client.updateMixin(templateId, documents.class.Document, template.space, documents.mixin.DocumentTemplate, {
|
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 seqNumber = template.sequence + 1
|
||||||
const prefix = template.docPrefix
|
const prefix = template.docPrefix
|
||||||
|
|
||||||
return await createControlledDoc(
|
return { seqNumber, prefix, content: template.content, category: template.category as Ref<DocumentCategory> }
|
||||||
client,
|
|
||||||
templateId,
|
|
||||||
documentId,
|
|
||||||
{ ...spec, category: template.category },
|
|
||||||
space,
|
|
||||||
project,
|
|
||||||
prefix,
|
|
||||||
seqNumber,
|
|
||||||
path,
|
|
||||||
docClass,
|
|
||||||
template.content
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createControlledDoc (
|
export async function createControlledDocMetadata (
|
||||||
client: TxOperations,
|
client: TxOperations,
|
||||||
templateId: Ref<DocumentTemplate>,
|
templateId: Ref<DocumentTemplate>,
|
||||||
documentId: Ref<ControlledDocument>,
|
documentId: Ref<ControlledDocument>,
|
||||||
spec: AttachedData<ControlledDocument>,
|
|
||||||
space: Ref<DocumentSpace>,
|
space: Ref<DocumentSpace>,
|
||||||
project: Ref<Project> | undefined,
|
project: Ref<Project> | undefined,
|
||||||
|
parent: Ref<ProjectDocument> | undefined,
|
||||||
prefix: string,
|
prefix: string,
|
||||||
seqNumber: number,
|
seqNumber: number,
|
||||||
path: Ref<DocumentMeta>[] = [],
|
specCode: string,
|
||||||
docClass: Ref<Class<ControlledDocument>> = documents.class.ControlledDocument,
|
specTitle: string,
|
||||||
content: Ref<Blob> | null
|
metaId?: Ref<DocumentMeta>
|
||||||
): Promise<{ seqNumber: number, success: boolean }> {
|
): Promise<{
|
||||||
|
success: boolean
|
||||||
|
seqNumber: number
|
||||||
|
documentMetaId: Ref<DocumentMeta>
|
||||||
|
projectDocumentId: Ref<ProjectDocument>
|
||||||
|
}> {
|
||||||
const projectId = project ?? documents.ids.NoProject
|
const projectId = project ?? documents.ids.NoProject
|
||||||
|
|
||||||
const ops = client.apply()
|
const ops = client.apply()
|
||||||
@ -127,23 +157,33 @@ async function createControlledDoc (
|
|||||||
})
|
})
|
||||||
|
|
||||||
ops.notMatch(documents.class.Document, {
|
ops.notMatch(documents.class.Document, {
|
||||||
code: spec.code
|
code: specCode
|
||||||
})
|
})
|
||||||
|
|
||||||
const metaId = await ops.createDoc(documents.class.DocumentMeta, space, {
|
const documentMetaId = await ops.createDoc(
|
||||||
|
documents.class.DocumentMeta,
|
||||||
|
space,
|
||||||
|
{
|
||||||
documents: 0,
|
documents: 0,
|
||||||
title: `${prefix}-${seqNumber} ${spec.title}`
|
title: `${prefix}-${seqNumber} ${specTitle}`
|
||||||
})
|
},
|
||||||
|
metaId
|
||||||
|
)
|
||||||
|
|
||||||
|
let path: Array<Ref<DocumentMeta>> = []
|
||||||
|
if (parent !== undefined) {
|
||||||
|
path = await getParentPath(client, parent)
|
||||||
|
}
|
||||||
|
|
||||||
const projectMetaId = await ops.createDoc(documents.class.ProjectMeta, space, {
|
const projectMetaId = await ops.createDoc(documents.class.ProjectMeta, space, {
|
||||||
project: projectId,
|
project: projectId,
|
||||||
meta: metaId,
|
meta: documentMetaId,
|
||||||
path,
|
path,
|
||||||
parent: path[0] ?? documents.ids.NoParent,
|
parent: path[0] ?? documents.ids.NoParent,
|
||||||
documents: 0
|
documents: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
await client.addCollection(
|
const projectDocumentId = await client.addCollection(
|
||||||
documents.class.ProjectDocument,
|
documents.class.ProjectDocument,
|
||||||
space,
|
space,
|
||||||
projectMetaId,
|
projectMetaId,
|
||||||
@ -156,25 +196,9 @@ async function createControlledDoc (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
await ops.addCollection(
|
|
||||||
docClass,
|
|
||||||
space,
|
|
||||||
metaId,
|
|
||||||
documents.class.DocumentMeta,
|
|
||||||
'documents',
|
|
||||||
{
|
|
||||||
...spec,
|
|
||||||
template: templateId,
|
|
||||||
seqNumber,
|
|
||||||
prefix,
|
|
||||||
state: DocumentState.Draft,
|
|
||||||
content
|
|
||||||
},
|
|
||||||
documentId
|
|
||||||
)
|
|
||||||
|
|
||||||
const success = await ops.commit()
|
const success = await ops.commit()
|
||||||
return { seqNumber, success: success.result }
|
|
||||||
|
return { success: success.result, seqNumber, documentMetaId, projectDocumentId }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createDocumentTemplate (
|
export async function createDocumentTemplate (
|
||||||
@ -190,6 +214,70 @@ export async function createDocumentTemplate (
|
|||||||
category: Ref<DocumentCategory>,
|
category: Ref<DocumentCategory>,
|
||||||
author?: Ref<Employee>
|
author?: Ref<Employee>
|
||||||
): Promise<{ seqNumber: number, success: boolean }> {
|
): 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<DocumentMeta, HierarchyDocument>(
|
||||||
|
_class,
|
||||||
|
space,
|
||||||
|
documentMetaId,
|
||||||
|
documents.class.DocumentMeta,
|
||||||
|
'documents',
|
||||||
|
{
|
||||||
|
...spec,
|
||||||
|
code,
|
||||||
|
seqNumber,
|
||||||
|
category,
|
||||||
|
prefix: TEMPLATE_PREFIX,
|
||||||
|
author,
|
||||||
|
owner: author,
|
||||||
|
content: spec.content ?? null
|
||||||
|
},
|
||||||
|
templateId
|
||||||
|
)
|
||||||
|
await ops.createMixin(templateId, documents.class.Document, space, _mixin, {
|
||||||
|
sequence: 0,
|
||||||
|
docPrefix: prefix
|
||||||
|
})
|
||||||
|
const commit = await ops.commit()
|
||||||
|
|
||||||
|
return { seqNumber, success: commit.result }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDocumentTemplateMetadata (
|
||||||
|
client: TxOperations,
|
||||||
|
_class: Ref<Class<Document>>,
|
||||||
|
space: Ref<DocumentSpace>,
|
||||||
|
_mixin: Ref<Mixin<DocumentTemplate>>,
|
||||||
|
project: Ref<Project> | undefined,
|
||||||
|
parent: Ref<ProjectDocument> | undefined,
|
||||||
|
templateId: Ref<ControlledDocument>,
|
||||||
|
prefix: string,
|
||||||
|
specCode: string,
|
||||||
|
specTitle: string,
|
||||||
|
metaId?: Ref<DocumentMeta>
|
||||||
|
): Promise<{
|
||||||
|
success: boolean
|
||||||
|
seqNumber: number
|
||||||
|
code: string
|
||||||
|
documentMetaId: Ref<DocumentMeta>
|
||||||
|
projectDocumentId: Ref<ProjectDocument>
|
||||||
|
}> {
|
||||||
const projectId = project ?? documents.ids.NoProject
|
const projectId = project ?? documents.ids.NoProject
|
||||||
|
|
||||||
const incResult = await client.updateDoc(
|
const incResult = await client.updateDoc(
|
||||||
@ -202,7 +290,7 @@ export async function createDocumentTemplate (
|
|||||||
true
|
true
|
||||||
)
|
)
|
||||||
const seqNumber = (incResult as any).object.sequence as number
|
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<Ref<DocumentMeta>> = []
|
let path: Array<Ref<DocumentMeta>> = []
|
||||||
|
|
||||||
@ -225,20 +313,25 @@ export async function createDocumentTemplate (
|
|||||||
docPrefix: prefix
|
docPrefix: prefix
|
||||||
})
|
})
|
||||||
|
|
||||||
const metaId = await ops.createDoc(documents.class.DocumentMeta, space, {
|
const documentMetaId = await ops.createDoc(
|
||||||
|
documents.class.DocumentMeta,
|
||||||
|
space,
|
||||||
|
{
|
||||||
documents: 0,
|
documents: 0,
|
||||||
title: `${TEMPLATE_PREFIX}-${seqNumber} ${spec.title}`
|
title: `${TEMPLATE_PREFIX}-${seqNumber} ${specTitle}`
|
||||||
})
|
},
|
||||||
|
metaId
|
||||||
|
)
|
||||||
|
|
||||||
const projectMetaId = await ops.createDoc(documents.class.ProjectMeta, space, {
|
const projectMetaId = await ops.createDoc(documents.class.ProjectMeta, space, {
|
||||||
project: projectId,
|
project: projectId,
|
||||||
meta: metaId,
|
meta: documentMetaId,
|
||||||
path,
|
path,
|
||||||
parent: path[0] ?? documents.ids.NoParent,
|
parent: path[0] ?? documents.ids.NoParent,
|
||||||
documents: 0
|
documents: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
await client.addCollection(
|
const projectDocumentId = await client.addCollection(
|
||||||
documents.class.ProjectDocument,
|
documents.class.ProjectDocument,
|
||||||
space,
|
space,
|
||||||
projectMetaId,
|
projectMetaId,
|
||||||
@ -251,31 +344,7 @@ export async function createDocumentTemplate (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
await ops.addCollection<DocumentMeta, HierarchyDocument>(
|
|
||||||
_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()
|
const success = await ops.commit()
|
||||||
|
|
||||||
return { seqNumber, success: success.result }
|
return { success: success.result, seqNumber, code, documentMetaId, projectDocumentId }
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,8 @@ import {
|
|||||||
type Type,
|
type Type,
|
||||||
type Space,
|
type Space,
|
||||||
type SpaceTypeDescriptor,
|
type SpaceTypeDescriptor,
|
||||||
type Permission
|
type Permission,
|
||||||
|
Role
|
||||||
} from '@hcengineering/core'
|
} from '@hcengineering/core'
|
||||||
import type { Asset, Plugin, Resource } from '@hcengineering/platform'
|
import type { Asset, Plugin, Resource } from '@hcengineering/platform'
|
||||||
import { IntlString, plugin } from '@hcengineering/platform'
|
import { IntlString, plugin } from '@hcengineering/platform'
|
||||||
@ -276,6 +277,11 @@ export const documentsPlugin = plugin(documentsId, {
|
|||||||
CA: '' as Ref<DocumentCategory>,
|
CA: '' as Ref<DocumentCategory>,
|
||||||
CC: '' as Ref<DocumentCategory>
|
CC: '' as Ref<DocumentCategory>
|
||||||
},
|
},
|
||||||
|
role: {
|
||||||
|
QARA: '' as Ref<Role>,
|
||||||
|
Manager: '' as Ref<Role>,
|
||||||
|
QualifiedUser: '' as Ref<Role>
|
||||||
|
},
|
||||||
resolver: {
|
resolver: {
|
||||||
Location: '' as Resource<(loc: Location) => Promise<ResolvedLocation | undefined>>
|
Location: '' as Resource<(loc: Location) => Promise<ResolvedLocation | undefined>>
|
||||||
},
|
},
|
||||||
|
Loading…
Reference in New Issue
Block a user