From bcf78574774d2abae781a8920d426384452760ca Mon Sep 17 00:00:00 2001 From: Anna Khismatullina Date: Wed, 20 Nov 2024 14:58:30 +0700 Subject: [PATCH] Support unified Huly data import format (#7191) Support unified Huly data import format Signed-off-by: Anna Khismatullina --- .vscode/launch.json | 16 +- common/config/rush/pnpm-lock.yaml | 4 +- dev/import-tool/README.md | 22 +- dev/import-tool/package.json | 6 +- dev/import-tool/src/clickup/clickup.ts | 79 +-- dev/import-tool/src/huly/README.md | 127 ++++ .../huly/example-workspace/Documentation.yaml | 9 + .../Documentation/Getting Started.md | 19 + .../Documentation/User Guide.md | 16 + .../Documentation/User Guide/Installation.md | 19 + .../Documentation/files/architecture.png | Bin 0 -> 40292 bytes .../huly/example-workspace/Project Alpha.yaml | 11 + .../Project Alpha/1.Project Setup.md | 28 + .../1.Project Setup/2.Configure CI.md | 13 + .../Project Alpha/4.Update Docs.md | 11 + .../Project Alpha/files/config.yaml | 18 + dev/import-tool/src/huly/unified.ts | 625 ++++++++++++++++++ dev/import-tool/src/importer/builder.ts | 499 ++++++++++++++ dev/import-tool/src/importer/dowloader.ts | 14 + dev/import-tool/src/importer/importer.ts | 262 ++++---- dev/import-tool/src/importer/preprocessor.ts | 119 ++++ dev/import-tool/src/index.ts | 16 + models/tracker/src/index.ts | 4 +- models/tracker/src/migration.ts | 9 +- models/tracker/src/plugin.ts | 1 - plugins/tracker/src/index.ts | 4 +- 26 files changed, 1757 insertions(+), 194 deletions(-) create mode 100644 dev/import-tool/src/huly/README.md create mode 100644 dev/import-tool/src/huly/example-workspace/Documentation.yaml create mode 100644 dev/import-tool/src/huly/example-workspace/Documentation/Getting Started.md create mode 100644 dev/import-tool/src/huly/example-workspace/Documentation/User Guide.md create mode 100644 dev/import-tool/src/huly/example-workspace/Documentation/User Guide/Installation.md create mode 100644 dev/import-tool/src/huly/example-workspace/Documentation/files/architecture.png create mode 100644 dev/import-tool/src/huly/example-workspace/Project Alpha.yaml create mode 100644 dev/import-tool/src/huly/example-workspace/Project Alpha/1.Project Setup.md create mode 100644 dev/import-tool/src/huly/example-workspace/Project Alpha/1.Project Setup/2.Configure CI.md create mode 100644 dev/import-tool/src/huly/example-workspace/Project Alpha/4.Update Docs.md create mode 100644 dev/import-tool/src/huly/example-workspace/Project Alpha/files/config.yaml create mode 100644 dev/import-tool/src/huly/unified.ts create mode 100644 dev/import-tool/src/importer/builder.ts create mode 100644 dev/import-tool/src/importer/preprocessor.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 2c8afa3370..3c84e267dd 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -582,7 +582,21 @@ "name": "Debug ClickUp import", "type": "node", "request": "launch", - "args": ["src/__start.ts", "import-clickup-tasks", "/home/anna/work/clickup/aleksandr/debug/mentions.csv", "-u", "user1", "-pw", "1234", "-ws", "ws5"], + "args": ["src/__start.ts", "import-clickup-tasks", "/home/anna/work/clickup/aleksandr/debug/mentions.csv", "-u", "user1", "-pw", "1234", "-ws", "ws10"], + "env": { + "FRONT_URL": "http://localhost:8087", + }, + "runtimeVersion": "20", + "runtimeArgs": ["--nolazy", "-r", "ts-node/register"], + "sourceMaps": true, + "outputCapture": "std", + "cwd": "${workspaceRoot}/dev/import-tool" + }, + { + "name": "Debug Huly import", + "type": "node", + "request": "launch", + "args": ["src/__start.ts", "import", "/home/anna/xored/huly/platform/dev/import-tool/src/huly/example-workspace", "-u", "user1", "-pw", "1234", "-ws", "ws12"], "env": { "FRONT_URL": "http://localhost:8087" }, diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index a705578206..cf86325d85 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -23868,7 +23868,7 @@ packages: dev: false file:projects/import-tool.tgz: - resolution: {integrity: sha512-KD9EB2XXo43QYnx8xp4j9z62JGValTBx2uTS4Egw11rsdAoENIxNxI+68CIKASfY8DMKMMuTmUZ6N6nK/6XSiw==, tarball: file:projects/import-tool.tgz} + resolution: {integrity: sha512-0Q1/hHZxEdYFPr2qqfovlVJRA8JyvWsKtY3ubHrffnaAMbbN6BNWt6Jf+GzwyGeIwImLI2Oud2x/WqOFb/USdg==, tarball: file:projects/import-tool.tgz} name: '@rush-temp/import-tool' version: 0.0.0 dependencies: @@ -23876,6 +23876,7 @@ packages: '@types/domhandler': 2.4.5 '@types/htmlparser2': 3.10.7 '@types/jest': 29.5.12 + '@types/js-yaml': 4.0.9 '@types/mime-types': 2.1.4 '@types/minio': 7.0.18 '@types/node': 20.11.19 @@ -23895,6 +23896,7 @@ packages: eslint-plugin-promise: 6.1.1(eslint@8.56.0) htmlparser2: 9.1.0 jest: 29.7.0(@types/node@20.11.19)(ts-node@10.9.2) + js-yaml: 4.1.0 mammoth: 1.8.0 mime-types: 2.1.35 prettier: 3.2.5 diff --git a/dev/import-tool/README.md b/dev/import-tool/README.md index cb0df79d5d..4e1c0d56b8 100644 --- a/dev/import-tool/README.md +++ b/dev/import-tool/README.md @@ -1,5 +1,25 @@ -### Supported Import Options +# Huly Import Tool + +Tool for importing data into Huly workspace. + +## Recommended Import Method + +### Unified Format Import +The recommended way to import data into Huly is using our [Unified Import Format](./src/huly/README.md). This format provides a straightforward way to migrate data from any system by converting it into an intermediate, human-readable structure. + +See the [complete guide](./src/huly/README.md) and [example workspace](./src/huly/example-workspace) to get started. + +### Why Use Unified Format? +- Simple, human-readable format using YAML and Markdown +- Flexible structure that can represent data from any system +- Easy to validate and fix data before import +- Can be generated automatically by script or prepared manually + +## Direct Import Options + +We also support direct import from some platforms: 1. **Notion**: see [Import from Notion Guide](./src/notion/README.md) 2. **ClickUp**: see [Import from ClickUp Guide](./src/clickup/README.md) +These direct imports are suitable for simple migrations, but for complex cases or systems not listed above, please use the Unified Format. \ No newline at end of file diff --git a/dev/import-tool/package.json b/dev/import-tool/package.json index df3e9123ad..336f00f6fd 100644 --- a/dev/import-tool/package.json +++ b/dev/import-tool/package.json @@ -49,7 +49,8 @@ "jest": "^29.7.0", "ts-jest": "^29.1.1", "@types/jest": "^29.5.5", - "@types/csvtojson": "^2.0.0" + "@types/csvtojson": "^2.0.0", + "@types/js-yaml": "^4.0.9" }, "dependencies": { "@hcengineering/attachment": "^0.6.14", @@ -69,6 +70,7 @@ "csvtojson": "^2.0.10", "@hcengineering/task": "^0.6.20", "@hcengineering/contact": "^0.6.24", - "@hcengineering/chunter": "^0.6.20" + "@hcengineering/chunter": "^0.6.20", + "js-yaml": "^4.1.0" } } diff --git a/dev/import-tool/src/clickup/clickup.ts b/dev/import-tool/src/clickup/clickup.ts index f210de84b4..b39d00a67b 100644 --- a/dev/import-tool/src/clickup/clickup.ts +++ b/dev/import-tool/src/clickup/clickup.ts @@ -15,6 +15,7 @@ import contact, { type Person, type PersonAccount } from '@hcengineering/contact' import { type Ref, type Timestamp, type TxOperations } from '@hcengineering/core' import { MarkupNodeType, traverseNode, type MarkupNode } from '@hcengineering/text' +import tracker from '@hcengineering/tracker' import csv from 'csvtojson' import { download } from '../importer/dowloader' import { @@ -22,10 +23,10 @@ import { type ImportComment, type ImportIssue, type ImportProject, - type ImportProjectType, - type MarkdownPreprocessor + type ImportProjectType } from '../importer/importer' import { type FileUploader } from '../importer/uploader' +import { BaseMarkdownPreprocessor } from '../importer/preprocessor' interface ClickupTask { 'Task ID': string @@ -61,77 +62,15 @@ interface ImportIssueEx extends ImportIssue { clickupProjectName?: string } -class ClickupMarkdownPreprocessor implements MarkdownPreprocessor { - private readonly MENTION_REGEX = /@([\p{L}\p{M}]+ [\p{L}\p{M}]+)/gu - constructor (private readonly personsByName: Map>) {} - +class ClickupMarkdownPreprocessor extends BaseMarkdownPreprocessor { process (json: MarkupNode): MarkupNode { traverseNode(json, (node) => { - if (node.type === MarkupNodeType.paragraph && node.content !== undefined) { - const newContent: MarkupNode[] = [] - for (const childNode of node.content) { - if (childNode.type === MarkupNodeType.text && childNode.text !== undefined) { - let match - let lastIndex = 0 - let hasMentions = false - - while ((match = this.MENTION_REGEX.exec(childNode.text)) !== null) { - hasMentions = true - if (match.index > lastIndex) { - newContent.push({ - type: MarkupNodeType.text, - text: childNode.text.slice(lastIndex, match.index), - marks: childNode.marks, - attrs: childNode.attrs - }) - } - - const name = match[1] - const personRef = this.personsByName.get(name) - if (personRef !== undefined) { - newContent.push({ - type: MarkupNodeType.reference, - attrs: { - id: personRef, - label: name, - objectclass: contact.class.Person - } - }) - } else { - newContent.push({ - type: MarkupNodeType.text, - text: match[0], - marks: childNode.marks, - attrs: childNode.attrs - }) - } - - lastIndex = this.MENTION_REGEX.lastIndex - } - - if (hasMentions) { - if (lastIndex < childNode.text.length) { - newContent.push({ - type: MarkupNodeType.text, - text: childNode.text.slice(lastIndex), - marks: childNode.marks, - attrs: childNode.attrs - }) - } - } else { - newContent.push(childNode) - } - } else { - newContent.push(childNode) - } - } - - node.content = newContent + if (node.type === MarkupNodeType.paragraph) { + this.processMentions(node) return false } return true }) - return json } } @@ -206,8 +145,8 @@ class ClickupImporter { for (const projectName of projects) { const identifier = this.getProjectIdentifier(projectName) importProjectsByName.set(projectName, { - class: 'tracker.class.Project', - name: projectName, + class: tracker.class.Project, + title: projectName, identifier, private: false, autoJoin: false, @@ -280,7 +219,7 @@ class ClickupImporter { } return { - class: 'tracker.class.Issue', + class: tracker.class.Issue, title: clickup['Task Name'], descrProvider: () => { return Promise.resolve(description) diff --git a/dev/import-tool/src/huly/README.md b/dev/import-tool/src/huly/README.md new file mode 100644 index 0000000000..d617829535 --- /dev/null +++ b/dev/import-tool/src/huly/README.md @@ -0,0 +1,127 @@ +## Import from Unified Format Guide + +### Overview +The unified format represents workspace data in a hierarchical folder structure where: +* Root directory contains space configurations (*.yaml) and their corresponding folders +* Each space folder contains documents/issues (*.md) and their subdocuments/subissues +* Documents/issues can have child items in similarly-named folders +* File named `settings.yaml` is reserved and should not be used for spaces configuration +* Files without required `class` property in frontmatter will be skipped + +See the complete working example in the [example workspace](./example-workspace). + +### File Structure Example +``` +workspace/ +├── Documentation/ +│ ├── Getting Started.md # Standalone document +│ ├── User Guide.md # Document with children +│ ├── User Guide/ # Child documents folder +│ │ ├── Installation.md +│ │ └── Configuration.md +│ └── files/ # Attachments +│ └── architecture.png +├── Documentation.yaml # Space configuration +├── Project Alpha/ +│ ├── 1.Project Setup.md # Issue with subtasks +│ ├── 1.Project Setup/ # Subtasks folder +│ │ ├── 2.Configure CI.md +│ │ └── 3.Setup Tests.md +│ ├── 4.Update Docs.md # Standalone issue +│ └── files/ +│ └── diagram.png # Can be referenced in markdown content +└── Project Alpha.yaml # Project configuration +``` + +### File Format Requirements + +#### Space Configuration (*.yaml) +Project space (`Project Alpha.yaml`): +```yaml +class: tracker:class:Project # Required +title: Project Alpha # Required +identifier: ALPHA # Required, max 5 uppercase letters/numbers, must start with a letter +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 +defaultIssueStatus: Todo # Optional +``` + +Teamspace (`Documentation.yaml`): +```yaml +class: document:class:Teamspace # Required +title: Documentation # Required +private: false # Optional, default: false +autoJoin: true # Optional, default: true +owners: # Optional, list of email addresses + - john.doe@example.com +members: # Optional, list of email addresses + - joe.shmoe@example.com +description: string # Optional +``` + +#### Documents and Issues (*.md) +All files must include YAML frontmatter followed by Markdown content: + +Document (`Getting Started.md`): +```yaml +--- +class: document:class:Document # Required +title: Getting Started Guide # Required +--- +# Content in Markdown format +``` + +Issue (`1.Project Setup.md`): +```yaml +--- +class: tracker:class:Issue # Required +title: Project Setup # Required +status: In Progress # Required +priority: High # Optional +assignee: John Smith # Optional +estimation: 8 # Optional, in hours +remainingTime: 4 # Optional, in hours +--- +Task description in Markdown... +``` + +### Task Identification +* Human-readable task ID is formed by combining project's identifier and task number from filename +* Example: For project with identifier "ALPHA" and task "1.Setup Project.md", the task ID will be "ALPHA-1" + +### Allowed Values + +Issue status values: +* `Backlog` +* `Todo` +* `In Progress` +* `Done` +* `Canceled` + +Issue priority values: +* `Low` +* `Medium` +* `High` +* `Urgent` + +### Run Import Tool +```bash +docker run \ + -e FRONT_URL="https://huly.app" \ + -v /path/to/workspace:/data \ + hardcoreeng/import-tool:latest \ + -- bundle.js import /data \ + --user your.email@company.com \ + --password yourpassword \ + --workspace workspace-id +``` + +### Limitations +* All users must exist in the system before import +* Assignees are mapped by full name +* Files in space directories can be used as attachments when referenced in markdown content diff --git a/dev/import-tool/src/huly/example-workspace/Documentation.yaml b/dev/import-tool/src/huly/example-workspace/Documentation.yaml new file mode 100644 index 0000000000..3279e182fb --- /dev/null +++ b/dev/import-tool/src/huly/example-workspace/Documentation.yaml @@ -0,0 +1,9 @@ +class: document:class:Teamspace +title: Documentation +private: false +autoJoin: true +owners: + - john.doe@example.com +members: + - joe.shmoe@example.com +description: Technical documentation and guides diff --git a/dev/import-tool/src/huly/example-workspace/Documentation/Getting Started.md b/dev/import-tool/src/huly/example-workspace/Documentation/Getting Started.md new file mode 100644 index 0000000000..4b0f5dab76 --- /dev/null +++ b/dev/import-tool/src/huly/example-workspace/Documentation/Getting Started.md @@ -0,0 +1,19 @@ +--- +class: document:class:Document +title: Getting Started Guide +--- +# Getting Started + +Welcome to our project! This guide will help you get started with development. + +## Setup Steps + +1. Clone the repository +2. Install dependencies +3. Set up your environment + +## Project Communication +We use Huly for all project communication: +- Team discussions in Virtual Office +- Technical discussions in issue comments +- Documentation in Huly Documents \ No newline at end of file diff --git a/dev/import-tool/src/huly/example-workspace/Documentation/User Guide.md b/dev/import-tool/src/huly/example-workspace/Documentation/User Guide.md new file mode 100644 index 0000000000..5cbe18d034 --- /dev/null +++ b/dev/import-tool/src/huly/example-workspace/Documentation/User Guide.md @@ -0,0 +1,16 @@ +--- +class: document:class:Document +title: User Guide +--- +# User Guide + +Our platform architecture and key components. + +## System Overview + + + +## Development Workflow +- Code reviews via GitHub integration +- CI/CD status in Huly Activity Feed +- Team sync-ups in Huly Virtual Office diff --git a/dev/import-tool/src/huly/example-workspace/Documentation/User Guide/Installation.md b/dev/import-tool/src/huly/example-workspace/Documentation/User Guide/Installation.md new file mode 100644 index 0000000000..0c39873c05 --- /dev/null +++ b/dev/import-tool/src/huly/example-workspace/Documentation/User Guide/Installation.md @@ -0,0 +1,19 @@ +--- +class: document:class:Document +title: Installation Guide +--- +# Installation + +## System Requirements +- Node.js 18 or higher +- Docker Desktop +- Git + +## Setup Steps + +1. Clone the repository +2. Install dependencies +3. Configure your environment + +## Need Help? +Contact @Joe Shmoe for technical support \ No newline at end of file diff --git a/dev/import-tool/src/huly/example-workspace/Documentation/files/architecture.png b/dev/import-tool/src/huly/example-workspace/Documentation/files/architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..131b4072aa97c69e9075cc05b4a91b95eea3a54e GIT binary patch literal 40292 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 VNc#M!*-5xl(=2CMoE3X)`# + }> + }> +} + +class HulyMarkdownPreprocessor extends BaseMarkdownPreprocessor { + constructor ( + private readonly urlProvider: (id: string) => string, + private readonly metadataByFilePath: Map, + private readonly metadataById: Map, DocMetadata>, + private readonly attachMetadataByPath: Map, + personsByName: Map> + ) { + super(personsByName) + } + + process (json: MarkupNode, id: Ref, spaceId: Ref): MarkupNode { + traverseNode(json, (node) => { + if (node.type === MarkupNodeType.image) { + this.processImageNode(node, id, spaceId) + } else { + this.processLinkMarks(node, id, spaceId) + this.processMentions(node) + } + return true + }) + return json + } + + private processImageNode (node: MarkupNode, id: Ref, spaceId: Ref): void { + const src = node.attrs?.src + if (src === undefined) return + + const sourceMeta = this.getSourceMetadata(id) + if (sourceMeta == null) return + + const href = decodeURI(src as string) + const fullPath = path.resolve(path.dirname(sourceMeta.path), href) + const attachmentMeta = this.attachMetadataByPath.get(fullPath) + + if (attachmentMeta === undefined) { + console.warn(`Attachment image not found for ${fullPath}`) + return + } + + this.updateAttachmentMetadata(fullPath, attachmentMeta, id, spaceId, sourceMeta) + this.alterImageNode(node, attachmentMeta.id, attachmentMeta.name) + } + + private processLinkMarks (node: MarkupNode, id: Ref, spaceId: Ref): void { + traverseNodeMarks(node, (mark) => { + if (mark.type !== MarkupMarkType.link) return + + const sourceMeta = this.getSourceMetadata(id) + if (sourceMeta == null) return + + const href = decodeURI(mark.attrs.href) + const fullPath = path.resolve(path.dirname(sourceMeta.path), href) + + if (this.metadataByFilePath.has(fullPath)) { + const targetDocMeta = this.metadataByFilePath.get(fullPath) + if (targetDocMeta !== undefined) { + this.alterInternalLinkNode(node, targetDocMeta) + } + } else if (this.attachMetadataByPath.has(fullPath)) { + const attachmentMeta = this.attachMetadataByPath.get(fullPath) + if (attachmentMeta !== undefined) { + this.alterAttachmentLinkNode(node, attachmentMeta) + this.updateAttachmentMetadata(fullPath, attachmentMeta, id, spaceId, sourceMeta) + } + } else { + console.log('Unknown link type, leave it as is:', href) + } + }) + } + + private alterImageNode (node: MarkupNode, id: string, name: string): void { + node.type = MarkupNodeType.image + if (node.attrs !== undefined) { + node.attrs = { + 'file-id': id, + src: this.urlProvider(id), + width: node.attrs.width ?? null, + height: node.attrs.height ?? null, + align: node.attrs.align ?? null, + alt: name, + title: name + } + const mimeType = this.getContentType(name) + if (mimeType !== undefined) { + node.attrs['data-file-type'] = mimeType + } + } + } + + private alterInternalLinkNode (node: MarkupNode, targetMeta: DocMetadata): void { + node.type = MarkupNodeType.reference + node.attrs = { + id: targetMeta.id, + label: targetMeta.refTitle, + objectclass: targetMeta.class, + text: '', + content: '' + } + } + + private alterAttachmentLinkNode (node: MarkupNode, targetMeta: AttachmentMetadata): void { + const stats = fs.statSync(targetMeta.path) + node.type = MarkupNodeType.file + node.attrs = { + 'file-id': targetMeta.id, + 'data-file-name': targetMeta.name, + 'data-file-size': stats.size, + 'data-file-href': targetMeta.path + } + const mimeType = this.getContentType(targetMeta.name) + if (mimeType !== undefined) { + node.attrs['data-file-type'] = mimeType + } + } + + private getContentType (fileName: string): string | undefined { + const mimeType = contentType(fileName) + return mimeType !== false ? mimeType : undefined + } + + private getSourceMetadata (id: Ref): DocMetadata | null { + const sourceMeta = this.metadataById.get(id) + if (sourceMeta == null) { + console.warn(`Source metadata not found for ${id}`) + return null + } + return sourceMeta + } + + private updateAttachmentMetadata ( + fullPath: string, + attachmentMeta: AttachmentMetadata, + id: Ref, + spaceId: Ref, + sourceMeta: DocMetadata + ): void { + this.attachMetadataByPath.set(fullPath, { + ...attachmentMeta, + spaceId, + parentId: id, + parentClass: sourceMeta.class as Ref>> + }) + } +} + +interface DocMetadata { + id: Ref + class: string + path: string + refTitle: string +} + +interface AttachmentMetadata { + id: Ref + name: string + path: string + parentId?: Ref + parentClass?: Ref>> + spaceId?: Ref +} + +export class UnifiedFormatImporter { + private readonly metadataById = new Map, DocMetadata>() + private readonly metadataByFilePath = new Map() + private readonly attachMetadataByPath = new Map() + + private personsByName = new Map>() + private accountsByEmail = new Map>() + + constructor ( + private readonly client: TxOperations, + private readonly fileUploader: FileUploader + ) {} + + async importFolder (folderPath: string): Promise { + await this.cachePersonsByNames() + await this.cacheAccountsByEmails() + + const workspaceData = await this.processImportFolder(folderPath) + + console.log('========================================') + console.log('IMPORT DATA STRUCTURE: ', JSON.stringify(workspaceData, null, 4)) + console.log('========================================') + + console.log('Importing documents...') + const preprocessor = new HulyMarkdownPreprocessor( + this.fileUploader.getFileUrl, + this.metadataByFilePath, + this.metadataById, + this.attachMetadataByPath, + this.personsByName + ) + await new WorkspaceImporter(this.client, this.fileUploader, workspaceData, preprocessor).performImport() + + console.log('Importing attachments...') + const attachments: ImportAttachment[] = Array.from(this.attachMetadataByPath.values()) + .filter((attachment) => attachment.parentId !== undefined) + .map((attachment) => { + return { + id: attachment.id, + title: path.basename(attachment.path), + blobProvider: async () => { + const data = fs.readFileSync(attachment.path) + return new Blob([data]) + }, + parentId: attachment.parentId, + parentClass: attachment.parentClass, + spaceId: attachment.spaceId + } + }) + await new WorkspaceImporter(this.client, this.fileUploader, { attachments }).performImport() + + console.log('========================================') + console.log('IMPORT SUCCESS') + } + + private async processImportFolder (folderPath: string): Promise { + const builder = new ImportWorkspaceBuilder(this.client) + await builder.initCache() + + // Load workspace settings if exists + const wsSettingsPath = path.join(folderPath, 'settings.yaml') + if (fs.existsSync(wsSettingsPath)) { + const wsSettingsFile = fs.readFileSync(wsSettingsPath, 'utf8') + const wsSettings = yaml.load(wsSettingsFile) as UnifiedWorkspaceSettings + + // Add project types + for (const pt of this.processProjectTypes(wsSettings)) { + builder.addProjectType(pt) + } + } + + // Process all yaml files first + const yamlFiles = fs.readdirSync(folderPath).filter((f) => f.endsWith('.yaml') && f !== 'settings.yaml') + + for (const yamlFile of yamlFiles) { + const yamlPath = path.join(folderPath, yamlFile) + const spaceName = path.basename(yamlFile, '.yaml') + const spacePath = path.join(folderPath, spaceName) + + try { + console.log(`Processing ${spaceName}...`) + const spaceConfig = yaml.load(fs.readFileSync(yamlPath, 'utf8')) as UnifiedSpaceSettings + + if (spaceConfig.class === undefined) { + console.warn(`Skipping ${spaceName}: not a space - no class specified`) + continue + } + + switch (spaceConfig.class) { + case tracker.class.Project: { + const project = await this.processProject(spaceConfig as UnifiedProjectSettings) + builder.addProject(spacePath, project) + if (fs.existsSync(spacePath) && fs.statSync(spacePath).isDirectory()) { + await this.processIssuesRecursively(builder, project.identifier, spacePath, spacePath) + } + break + } + + case document.class.Teamspace: { + const teamspace = await this.processTeamspace(spaceConfig as UnifiedTeamspaceSettings) + builder.addTeamspace(spacePath, teamspace) + if (fs.existsSync(spacePath) && fs.statSync(spacePath).isDirectory()) { + await this.processDocumentsRecursively(builder, spacePath, spacePath) + } + break + } + + default: { + throw new Error(`Unknown space class ${spaceConfig.class} in ${spaceName}`) + } + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Invalid space configuration in ${spaceName}: ${message}`) + } + } + + await this.processAttachments(folderPath) + + return builder.build() + } + + private async processIssuesRecursively ( + builder: ImportWorkspaceBuilder, + projectIdentifier: string, + projectPath: string, + currentPath: string, + parentIssuePath?: string + ): Promise { + const issueFiles = fs.readdirSync(currentPath).filter((f) => f.endsWith('.md')) + + for (const issueFile of issueFiles) { + const issuePath = path.join(currentPath, issueFile) + const issueHeader = (await this.readYamlHeader(issuePath)) as UnifiedIssueHeader + + if (issueHeader.class === undefined) { + console.warn(`Skipping ${issueFile}: not an issue`) + continue + } + + if (issueHeader.class === tracker.class.Issue) { + const numberMatch = issueFile.match(/^(\d+)\./) + const issueNumber = numberMatch?.[1] + + const meta: DocMetadata = { + id: generateId(), + class: tracker.class.Issue, + path: issuePath, + refTitle: `${projectIdentifier}-${issueNumber}` + } + + this.metadataById.set(meta.id, meta) + this.metadataByFilePath.set(issuePath, meta) + + const issue: ImportIssue = { + id: meta.id as Ref, + class: tracker.class.Issue, + title: issueHeader.title, + number: parseInt(issueNumber ?? 'NaN'), + descrProvider: async () => await this.readMarkdownContent(issuePath), + status: { name: issueHeader.status }, + priority: issueHeader.priority, + estimation: issueHeader.estimation, + remainingTime: issueHeader.remainingTime, + comments: this.processComments(issueHeader.comments), + subdocs: [], // Will be added via builder + assignee: this.findPersonByName(issueHeader.assignee) + } + + builder.addIssue(projectPath, issuePath, issue, parentIssuePath) + + // Process sub-issues if they exist + const subDir = path.join(currentPath, issueFile.replace('.md', '')) + if (fs.existsSync(subDir) && fs.statSync(subDir).isDirectory()) { + await this.processIssuesRecursively(builder, projectIdentifier, projectPath, subDir, issuePath) + } + } else { + throw new Error(`Unknown issue class ${issueHeader.class} in ${issueFile}`) + } + } + } + + private findPersonByName (name?: string): Ref | undefined { + if (name === undefined) { + return undefined + } + const person = this.personsByName.get(name) + if (person === undefined) { + throw new Error(`Person not found: ${name}`) + } + return person + } + + private findAccountByEmail (email: string): Ref { + const account = this.accountsByEmail.get(email) + if (account === undefined) { + throw new Error(`Account not found: ${email}`) + } + return account + } + + private async processDocumentsRecursively ( + builder: ImportWorkspaceBuilder, + teamspacePath: string, + currentPath: string, + parentDocPath?: string + ): Promise { + const docFiles = fs.readdirSync(currentPath).filter((f) => f.endsWith('.md')) + + for (const docFile of docFiles) { + const docPath = path.join(currentPath, docFile) + const docHeader = (await this.readYamlHeader(docPath)) as UnifiedDocumentHeader + + if (docHeader.class === undefined) { + console.warn(`Skipping ${docFile}: not a document`) + continue + } + + if (docHeader.class === document.class.Document) { + const docMeta: DocMetadata = { + id: generateId(), + class: document.class.Document, + path: docPath, + refTitle: docHeader.title + } + + this.metadataById.set(docMeta.id, docMeta) + this.metadataByFilePath.set(docPath, docMeta) + + const doc: ImportDocument = { + id: docMeta.id as Ref, + class: document.class.Document, + title: docHeader.title, + descrProvider: async () => await this.readMarkdownContent(docPath), + subdocs: [] // Will be added via builder + } + + builder.addDocument(teamspacePath, docPath, doc, parentDocPath) + + // Process subdocuments if they exist + const subDir = path.join(currentPath, docFile.replace('.md', '')) + if (fs.existsSync(subDir) && fs.statSync(subDir).isDirectory()) { + await this.processDocumentsRecursively(builder, teamspacePath, subDir, docPath) + } + } else { + throw new Error(`Unknown document class ${docHeader.class} in ${docFile}`) + } + } + } + + private processComments (comments: UnifiedComment[] = []): ImportComment[] { + return comments.map((comment) => { + return { + text: comment.text, + author: this.findAccountByEmail(comment.author) + } + }) + } + + private processProjectTypes (wsHeader: UnifiedWorkspaceSettings): ImportProjectType[] { + return ( + wsHeader.projectTypes?.map((pt) => ({ + name: pt.name, + taskTypes: pt.taskTypes?.map((tt) => ({ + name: tt.name, + description: tt.description, + statuses: tt.statuses.map((st) => ({ + name: st.name, + description: st.description + })) + })) + })) ?? [] + ) + } + + private async processProject (projectHeader: UnifiedProjectSettings): Promise { + return { + class: tracker.class.Project, + title: projectHeader.title, + identifier: projectHeader.identifier, + private: projectHeader.private ?? false, + autoJoin: projectHeader.autoJoin ?? true, + description: projectHeader.description, + defaultIssueStatus: + projectHeader.defaultIssueStatus !== undefined ? { name: projectHeader.defaultIssueStatus } : undefined, + owners: + projectHeader.owners !== undefined ? projectHeader.owners.map((email) => this.findAccountByEmail(email)) : [], + members: + projectHeader.members !== undefined ? projectHeader.members.map((email) => this.findAccountByEmail(email)) : [], + docs: [] + } + } + + private async processTeamspace (spaceHeader: UnifiedTeamspaceSettings): Promise { + return { + class: document.class.Teamspace, + title: spaceHeader.title, + private: spaceHeader.private ?? false, + autoJoin: spaceHeader.autoJoin ?? true, + description: spaceHeader.description, + owners: spaceHeader.owners !== undefined ? spaceHeader.owners.map((email) => this.findAccountByEmail(email)) : [], + members: + spaceHeader.members !== undefined ? spaceHeader.members.map((email) => this.findAccountByEmail(email)) : [], + docs: [] + } + } + + private async readYamlHeader (filePath: string): Promise { + console.log('Read YAML header from: ', filePath) + const content = fs.readFileSync(filePath, 'utf8') + const match = content.match(/^---\n([\s\S]*?)\n---/) + if (match != null) { + return yaml.load(match[1]) + } + return {} + } + + private async readMarkdownContent (filePath: string): Promise { + const content = fs.readFileSync(filePath, 'utf8') + const match = content.match(/^---\n[\s\S]*?\n---\n(.*)$/s) + return match != null ? match[1] : content + } + + private async cachePersonsByNames (): Promise { + this.personsByName = (await this.client.findAll(contact.class.Person, {})) + .map((person) => { + return { + _id: person._id, + name: person.name.split(',').reverse().join(' ') + } + }) + .reduce((refByName, person) => { + refByName.set(person.name, person._id) + return refByName + }, new Map()) + } + + private async cacheAccountsByEmails (): Promise { + const accounts = await this.client.findAll(contact.class.PersonAccount, {}) + this.accountsByEmail = accounts.reduce((map, account) => { + map.set(account.email, account._id) + return map + }, new Map()) + } + + private async processAttachments (folderPath: string): Promise { + const processDir = async (dir: string): Promise => { + const entries = fs.readdirSync(dir, { withFileTypes: true }) + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name) + + if (entry.isDirectory()) { + await processDir(fullPath) + } else if (entry.isFile()) { + // Skip files that are already processed as documents or issues + if (!this.metadataByFilePath.has(fullPath)) { + const attachmentId = generateId() + this.attachMetadataByPath.set(fullPath, { id: attachmentId, name: entry.name, path: fullPath }) + } + } + } + } + + await processDir(folderPath) + } +} diff --git a/dev/import-tool/src/importer/builder.ts b/dev/import-tool/src/importer/builder.ts new file mode 100644 index 0000000000..d47a84a5f6 --- /dev/null +++ b/dev/import-tool/src/importer/builder.ts @@ -0,0 +1,499 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// +import { type DocumentQuery, type Ref, type Status, type TxOperations } from '@hcengineering/core' +import document from '@hcengineering/document' +import tracker, { IssuePriority, type IssueStatus } from '@hcengineering/tracker' +import { + type ImportDocument, + type ImportIssue, + type ImportProject, + type ImportProjectType, + type ImportTeamspace, + type ImportWorkspace +} from './importer' + +export interface ValidationError { + path: string + error: string +} + +export interface ValidationResult { + isValid: boolean + errors: Map +} + +const MAX_PROJECT_IDENTIFIER_LENGTH = 5 +const PROJECT_IDENTIFIER_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/ + +export class ImportWorkspaceBuilder { + private readonly projects = new Map() + private readonly teamspaces = new Map() + private readonly projectTypes = new Map() + private readonly issuesByProject = new Map>() + private readonly issueParents = new Map() + private readonly documentsByTeamspace = new Map>() + private readonly documentParents = new Map() + private readonly errors = new Map() + + private readonly issueStatusCache = new Map>() + + constructor ( + private readonly client: TxOperations, + private readonly strictMode: boolean = true + ) {} + + async initCache (): Promise { + await this.cacheIssueStatuses() + return this + } + + addProjectType (projectType: ImportProjectType): this { + this.validateAndAdd( + 'projectType', + projectType.name, + projectType, + (pt) => this.validateProjectType(pt), + this.projectTypes + ) + return this + } + + addProject (path: string, project: ImportProject): this { + this.validateAndAdd('project', path, project, (p) => this.validateProject(p), this.projects, path) + return this + } + + addTeamspace (path: string, teamspace: ImportTeamspace): this { + this.validateAndAdd('teamspace', path, teamspace, (t) => this.validateTeamspace(t), this.teamspaces, path) + return this + } + + addIssue (projectPath: string, issuePath: string, issue: ImportIssue, parentIssuePath?: string): this { + if (!this.issuesByProject.has(projectPath)) { + this.issuesByProject.set(projectPath, new Map()) + } + + const projectIssues = this.issuesByProject.get(projectPath) + if (projectIssues === undefined) { + throw new Error(`Project ${projectPath} not found`) + } + + const duplicateIssue = Array.from(projectIssues.values()).find( + (existingIssue) => existingIssue.number === issue.number + ) + + if (duplicateIssue !== undefined) { + this.addError(issuePath, `Duplicate issue number ${issue.number} in project ${projectPath}`) + } else { + this.validateAndAdd('issue', issuePath, issue, (i) => this.validateIssue(i), projectIssues, issuePath) + + if (parentIssuePath !== undefined) { + this.issueParents.set(issuePath, parentIssuePath) + } + } + return this + } + + addDocument (teamspacePath: string, docPath: string, doc: ImportDocument, parentDocPath?: string): this { + if (!this.documentsByTeamspace.has(teamspacePath)) { + this.documentsByTeamspace.set(teamspacePath, new Map()) + } + + const docs = this.documentsByTeamspace.get(teamspacePath) + if (docs === undefined) { + throw new Error(`Teamspace ${teamspacePath} not found`) + } + + this.validateAndAdd('document', docPath, doc, (d) => this.validateDocument(d), docs, docPath) + + if (parentDocPath !== undefined) { + this.documentParents.set(docPath, parentDocPath) + } + + return this + } + + validate (): ValidationResult { + // Perform cross-entity validation + this.validateProjectReferences() + this.validateSpaceDocuments() + + return { + isValid: this.errors.size === 0, + errors: this.errors + } + } + + build (): ImportWorkspace { + const validation = this.validate() + if (this.strictMode && !validation.isValid) { + throw new Error( + 'Invalid workspace: \n' + + Array.from(validation.errors.values()) + .map((e) => ` * ${e.path}: ${e.error}`) + .join(';\n') + ) + } + + for (const [teamspacePath, docs] of this.documentsByTeamspace) { + const teamspace = this.teamspaces.get(teamspacePath) + if (teamspace !== undefined) { + const rootDocPaths = Array.from(docs.keys()).filter((docPath) => !this.documentParents.has(docPath)) + + for (const rootPath of rootDocPaths) { + this.buildDocumentHierarchy(rootPath, docs) + } + + teamspace.docs = rootDocPaths.map((path) => docs.get(path)).filter(Boolean) as ImportDocument[] + } + } + + for (const [projectPath, issues] of this.issuesByProject) { + const project = this.projects.get(projectPath) + if (project !== undefined) { + const rootIssuePaths = Array.from(issues.keys()).filter((issuePath) => !this.issueParents.has(issuePath)) + + for (const rootPath of rootIssuePaths) { + this.buildIssueHierarchy(rootPath, issues) + } + + project.docs = rootIssuePaths.map((path) => issues.get(path)).filter(Boolean) as ImportIssue[] + } + } + + return { + projectTypes: Array.from(this.projectTypes.values()), + spaces: [...Array.from(this.projects.values()), ...Array.from(this.teamspaces.values())] + } + } + + async cacheIssueStatuses (): Promise { + const query: DocumentQuery = { + ofAttribute: tracker.attribute.IssueStatus + } + + const statuses = await this.client.findAll(tracker.class.IssueStatus, query) + for (const status of statuses) { + this.issueStatusCache.set(status.name, status._id) + } + } + + private validateAndAdd( + type: string, + path: string, + item: T, + validator: (item: T) => string[], + collection: Map, + key?: K + ): void { + const errors = validator(item) + if (errors.length > 0) { + this.addError(path, `Invalid ${type} at ${path}: \n${errors.map((e) => ` * ${e}`).join('\n')}`) + if (this.strictMode) { + throw new Error(`Invalid ${type} at ${path}: \n${errors.map((e) => ` * ${e}`).join('\n')}`) + } + } else { + collection.set((key ?? path) as K, item) + } + } + + private validateProjectType (projectType: ImportProjectType): string[] { + const errors: string[] = [] + if (!this.validateStringDefined(projectType.name)) { + errors.push('name is required') + } + return errors + } + + private validateProject (project: ImportProject): string[] { + const errors: string[] = [] + + errors.push(...this.validateType(project.title, 'string', 'title')) + errors.push(...this.validateType(project.identifier, 'string', 'identifier')) + errors.push(...this.validateType(project.class, 'string', 'class')) + + if (project.private !== undefined) { + errors.push(...this.validateType(project.private, 'boolean', 'private')) + } + + if (project.autoJoin !== undefined) { + errors.push(...this.validateType(project.autoJoin, 'boolean', 'autoJoin')) + } + + if (project.owners !== undefined) { + errors.push(...this.validateArray(project.owners, 'string', 'owners')) + } + + if (project.members !== undefined) { + errors.push(...this.validateArray(project.members, 'string', 'members')) + } + + if (project.description !== undefined) { + errors.push(...this.validateType(project.description, 'string', 'description')) + } + + if (!this.validateStringDefined(project.title)) { + errors.push('title is required') + } + + if (project.class !== tracker.class.Project) { + errors.push('invalid class: ' + project.class) + } + + if (project.defaultIssueStatus !== undefined && !this.issueStatusCache.has(project.defaultIssueStatus.name)) { + errors.push('defaultIssueStatus not found: ' + project.defaultIssueStatus.name) + } + + errors.push(...this.validateProjectIdentifier(project.identifier)) + return errors + } + + private validateProjectIdentifier (identifier: string): string[] { + const errors: string[] = [] + if (!this.validateStringDefined(identifier)) { + errors.push('identifier is required') + return errors + } + if (identifier.length > MAX_PROJECT_IDENTIFIER_LENGTH) { + errors.push(`identifier must be no longer than ${MAX_PROJECT_IDENTIFIER_LENGTH} characters`) + } + if (!PROJECT_IDENTIFIER_REGEX.test(identifier)) { + errors.push( + 'identifier must contain only Latin letters, numbers, and underscores, and must not start with a number' + ) + } + return errors + } + + private validateTeamspace (teamspace: ImportTeamspace): string[] { + const errors: string[] = [] + + errors.push(...this.validateType(teamspace.title, 'string', 'title')) + errors.push(...this.validateType(teamspace.class, 'string', 'class')) + + if (teamspace.private !== undefined) { + errors.push(...this.validateType(teamspace.private, 'boolean', 'private')) + } + + if (teamspace.autoJoin !== undefined) { + errors.push(...this.validateType(teamspace.autoJoin, 'boolean', 'autoJoin')) + } + + if (teamspace.owners !== undefined) { + errors.push(...this.validateArray(teamspace.owners, 'string', 'owners')) + } + + if (teamspace.members !== undefined) { + errors.push(...this.validateArray(teamspace.members, 'string', 'members')) + } + + if (teamspace.description !== undefined) { + errors.push(...this.validateType(teamspace.description, 'string', 'description')) + } + + if (!this.validateStringDefined(teamspace.title)) { + errors.push('title is required') + } + if (teamspace.class !== document.class.Teamspace) { + errors.push('invalid class: ' + teamspace.class) + } + return errors + } + + private validateIssue (issue: ImportIssue): string[] { + const errors: string[] = [] + + errors.push(...this.validateType(issue.title, 'string', 'title')) + errors.push(...this.validateType(issue.class, 'string', 'class')) + + if (issue.number !== undefined) { + errors.push(...this.validateType(issue.number, 'number', 'number')) + } + + if (issue.estimation !== undefined) { + errors.push(...this.validateType(issue.estimation, 'number', 'estimation')) + } + + if (issue.remainingTime !== undefined) { + errors.push(...this.validateType(issue.remainingTime, 'number', 'remainingTime')) + } + + if (issue.priority !== undefined) { + errors.push(...this.validateType(issue.priority, 'string', 'priority')) + } + + if (issue.assignee !== undefined) { + errors.push(...this.validateType(issue.assignee, 'string', 'assignee')) + } + + if (issue.status == null) { + errors.push('status is required: ') + } else if (!this.issueStatusCache.has(issue.status.name)) { + errors.push('status not found: ' + issue.status.name) + } + if (issue.priority != null && IssuePriority[issue.priority as keyof typeof IssuePriority] === undefined) { + errors.push('priority not found: ' + issue.priority) + } + if (issue.class !== tracker.class.Issue) { + errors.push('invalid class: ' + issue.class) + } + if (issue.number !== undefined && !this.validatePossitiveNumber(issue.number)) { + errors.push('invalid issue number: ' + issue.number) + } + if (issue.estimation != null && !this.validatePossitiveNumber(issue.estimation)) { + errors.push('invalid estimation: ' + issue.estimation) + } + if (issue.remainingTime != null && !this.validatePossitiveNumber(issue.remainingTime)) { + errors.push('invalid remaining time: ' + issue.remainingTime) + } + if (issue.comments != null && issue.comments.length > 0) { + for (const comment of issue.comments) { + if (comment.author == null) { + errors.push('comment author is required') + } + if (!this.validateStringDefined(comment.text)) { + errors.push('comment text is required') + } + } + } + return errors + } + + private validatePossitiveNumber (value: any): boolean { + return typeof value === 'number' && !Number.isNaN(value) && value >= 0 + } + + private validateStringDefined (value: string | null | undefined): boolean { + return typeof value === 'string' && value !== '' && value !== null && value !== undefined + } + + private validateDocument (doc: ImportDocument): string[] { + const errors: string[] = [] + if (!this.validateStringDefined(doc.title)) { + errors.push('title is required') + } + if (doc.class !== document.class.Document) { + errors.push('invalid class: ' + doc.class) + } + return errors + } + + private validateProjectReferences (): void { + // Validate project type references + for (const project of this.projects.values()) { + if (project.projectType !== undefined && !this.projectTypes.has(project.projectType.name)) { + this.addError(project.title, `Referenced project type ${project.projectType.name} not found`) + } + } + } + + private validateSpaceDocuments (): void { + // Validate that issues belong to projects and documents to teamspaces + for (const projectPath of this.issuesByProject.keys()) { + if (!this.projects.has(projectPath)) { + this.addError(projectPath, 'Issues reference non-existent project') + } + } + + for (const [teamspacePath] of this.documentsByTeamspace) { + if (!this.teamspaces.has(teamspacePath)) { + this.addError(teamspacePath, 'Documents reference non-existent teamspace') + } + } + } + + private addError (path: string, error: string): void { + this.errors.set(path, { path, error }) + } + + private buildDocumentHierarchy (docPath: string, allDocs: Map): void { + const doc = allDocs.get(docPath) + if (doc === undefined) return + + const childDocs = Array.from(allDocs.entries()) + .filter(([childPath]) => this.documentParents.get(childPath) === docPath) + .map(([childPath, childDoc]) => { + this.buildDocumentHierarchy(childPath, allDocs) + return childDoc + }) + + doc.subdocs = childDocs + } + + private buildIssueHierarchy (issuePath: string, allIssues: Map): void { + const issue = allIssues.get(issuePath) + if (issue === undefined) return + + const childIssues = Array.from(allIssues.entries()) + .filter(([childPath]) => this.issueParents.get(childPath) === issuePath) + .map(([childPath, childIssue]) => { + this.buildIssueHierarchy(childPath, allIssues) + return childIssue + }) + + issue.subdocs = childIssues + } + + private validateType (value: unknown, type: 'string' | 'number' | 'boolean', fieldName: string): string[] { + const errors: string[] = [] + switch (type) { + case 'string': + if (typeof value !== 'string') { + errors.push(`${fieldName} must be string, got ${typeof value}`) + } + break + case 'number': + if (typeof value !== 'number') { + errors.push(`${fieldName} must be number, got ${typeof value}`) + } + break + case 'boolean': + if (typeof value !== 'boolean') { + errors.push(`${fieldName} must be boolean, got ${typeof value}`) + } + break + } + return errors + } + + private validateArray (value: unknown, itemType: 'string' | 'number' | 'boolean', fieldName: string): string[] { + const errors: string[] = [] + if (!Array.isArray(value)) { + errors.push(`${fieldName} must be an array`) + return errors + } + + for (let i = 0; i < value.length; i++) { + switch (itemType) { + case 'string': + if (typeof value[i] !== 'string') { + errors.push(`${fieldName}[${i}] must be string, got ${typeof value[i]}`) + } + break + case 'number': + if (typeof value[i] !== 'number') { + errors.push(`${fieldName}[${i}] must be number, got ${typeof value[i]}`) + } + break + case 'boolean': + if (typeof value[i] !== 'boolean') { + errors.push(`${fieldName}[${i}] must be boolean, got ${typeof value[i]}`) + } + break + } + } + return errors + } +} diff --git a/dev/import-tool/src/importer/dowloader.ts b/dev/import-tool/src/importer/dowloader.ts index 30a97187d9..e31b271079 100644 --- a/dev/import-tool/src/importer/dowloader.ts +++ b/dev/import-tool/src/importer/dowloader.ts @@ -1,3 +1,17 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// export async function download (url: string): Promise { try { const response = await fetch(url) diff --git a/dev/import-tool/src/importer/importer.ts b/dev/import-tool/src/importer/importer.ts index c3e6b91294..13d2363f08 100644 --- a/dev/import-tool/src/importer/importer.ts +++ b/dev/import-tool/src/importer/importer.ts @@ -19,6 +19,7 @@ import { type Person } from '@hcengineering/contact' import core, { type Account, type AttachedData, + type Class, type CollaborativeDoc, type Data, type Doc, @@ -28,6 +29,7 @@ import core, { type Mixin, type Ref, SortingOrder, + type Space, type Status, type Timestamp, type TxOperations @@ -40,7 +42,7 @@ import task, { type TaskType, type TaskTypeWithFactory } from '@hcengineering/task' -import { jsonToMarkup, jsonToYDocNoSchema, type MarkupNode, parseMessageMarkdown } from '@hcengineering/text' +import { jsonToMarkup, jsonToYDocNoSchema, parseMessageMarkdown } from '@hcengineering/text' import tracker, { type Issue, type IssueParentInfo, @@ -49,17 +51,13 @@ import tracker, { type Project, TimeReportDayType } from '@hcengineering/tracker' +import { type MarkdownPreprocessor, NoopMarkdownPreprocessor } from './preprocessor' import { type FileUploader, type UploadResult } from './uploader' export interface ImportWorkspace { - persons?: ImportPerson[] projectTypes?: ImportProjectType[] spaces?: ImportSpace[] -} - -export interface ImportPerson { - name: string - email: string + attachments?: ImportAttachment[] } export interface ImportProjectType { @@ -80,45 +78,47 @@ export interface ImportStatus { } export interface ImportSpace { - class: string - name: string + class: Ref> + title: string + private: boolean + autoJoin?: boolean description?: string - + owners?: Ref[] + members?: Ref[] docs: T[] } export interface ImportDoc { - class: string + id?: Ref + class: Ref>> title: string descrProvider: () => Promise - subdocs: ImportDoc[] } export interface ImportTeamspace extends ImportSpace { - class: 'document.class.TeamSpace' + class: Ref> } export interface ImportDocument extends ImportDoc { - class: 'document.class.Document' + id?: Ref + class: Ref> subdocs: ImportDocument[] } export interface ImportProject extends ImportSpace { - class: 'tracker.class.Project' + class: Ref> identifier: string - private: boolean - autoJoin: boolean - projectType: ImportProjectType - defaultAssignee?: ImportPerson + projectType?: ImportProjectType defaultIssueStatus?: ImportStatus - owners?: ImportPerson[] - members?: ImportPerson[] description?: string } export interface ImportIssue extends ImportDoc { - class: 'tracker.class.Issue' + id?: Ref + class: Ref> status: ImportStatus + priority?: string + number?: number assignee?: Ref estimation?: number remainingTime?: number @@ -133,16 +133,15 @@ export interface ImportComment { } export interface ImportAttachment { + id?: Ref title: string blobProvider: () => Promise -} - -export interface MarkdownPreprocessor { - process: (json: MarkupNode) => MarkupNode + parentId?: Ref + parentClass?: Ref>> + spaceId?: Ref } export class WorkspaceImporter { - private readonly personsByName = new Map>() private readonly issueStatusByName = new Map>() private readonly projectTypeByName = new Map>() @@ -150,23 +149,13 @@ export class WorkspaceImporter { private readonly client: TxOperations, private readonly fileUploader: FileUploader, private readonly workspaceData: ImportWorkspace, - private readonly preprocessor: MarkdownPreprocessor + private readonly preprocessor: MarkdownPreprocessor = new NoopMarkdownPreprocessor() ) {} public async performImport (): Promise { - await this.importPersons() await this.importProjectTypes() await this.importSpaces() - } - - private async importPersons (): Promise { - if (this.workspaceData.persons === undefined) return - - for (const person of this.workspaceData.persons) { - const personId = generateId() - this.personsByName.set(person.name, personId) - // TODO: Implement person creation - } + await this.importAttachments() } private async importProjectTypes (): Promise { @@ -182,14 +171,29 @@ export class WorkspaceImporter { if (this.workspaceData.spaces === undefined) return for (const space of this.workspaceData.spaces) { - if (space.class === 'document.class.TeamSpace') { + if (space.class === document.class.Teamspace) { 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) } } } + private async importAttachments (): Promise { + if (this.workspaceData.attachments === undefined) return + + for (const attachment of this.workspaceData.attachments) { + if ( + attachment.parentId === undefined || + attachment.parentClass === undefined || + attachment.spaceId === undefined + ) { + throw new Error('Attachment is missing parentId, parentClass or spaceId') + } + await this.importAttachment(attachment.parentId, attachment.parentClass, attachment, attachment.spaceId) + } + } + async createProjectTypeWithTaskTypes (projectType: ImportProjectType): Promise> { const taskTypes: TaskTypeWithFactory[] = [] if (projectType.taskTypes !== undefined) { @@ -230,7 +234,9 @@ export class WorkspaceImporter { } async importTeamspace (space: ImportTeamspace): Promise> { + console.log('Creating teamspace: ', space.title) const teamspaceId = await this.createTeamspace(space) + console.log('Teamspace created: ', teamspaceId) for (const doc of space.docs) { await this.createDocumentWithSubdocs(doc, document.ids.NoParent, teamspaceId) } @@ -242,7 +248,9 @@ export class WorkspaceImporter { parentId: Ref, teamspaceId: Ref ): Promise> { + console.log('Creating document: ', doc.title) const documentId = await this.createDocument(doc, parentId, teamspaceId) + console.log('Document created: ', documentId) for (const child of doc.subdocs) { await this.createDocumentWithSubdocs(child, documentId, teamspaceId) } @@ -254,12 +262,12 @@ export class WorkspaceImporter { const data = { type: document.spaceType.DefaultTeamspaceType, description: space.description ?? '', - title: space.name, - name: space.name, - private: false, - members: [], - owners: [], - autoJoin: false, + title: space.title, + name: space.title, + private: space.private, + owners: space.owners ?? [], + members: space.members ?? [], + autoJoin: space.autoJoin, archived: false } await this.client.createDoc(document.class.Teamspace, core.space.Space, data, teamspaceId) @@ -271,9 +279,9 @@ export class WorkspaceImporter { parentId: Ref, teamspaceId: Ref ): Promise> { - const id = generateId() + const id = doc.id ?? generateId() const content = await doc.descrProvider() - const collabId = await this.createCollaborativeContent(id, 'content', content) + const collabId = await this.createCollaborativeContent(id, 'content', content, teamspaceId) const lastRank = await getFirstRank(this.client, teamspaceId, parentId) const rank = makeRank(lastRank, undefined) @@ -295,7 +303,7 @@ export class WorkspaceImporter { } async importProject (project: ImportProject): Promise> { - console.log('Create project: ', project.name) + console.log('Creating project: ', project.title) const projectId = await this.createProject(project) console.log('Project created: ' + projectId) @@ -316,7 +324,7 @@ export class WorkspaceImporter { project: Project, parentsInfo: IssueParentInfo[] ): Promise<{ id: Ref, identifier: string }> { - console.log('Create issue: ', issue.title) + console.log('Creating issue: ', issue.title) const issueResult = await this.createIssue(issue, project, parentId, parentsInfo) console.log('Issue created: ', issueResult) @@ -341,23 +349,29 @@ export class WorkspaceImporter { async createProject (project: ImportProject): Promise> { const projectId = generateId() - const projectType = this.projectTypeByName.get(project.projectType.name) + + const projectType = + project.projectType !== undefined + ? this.projectTypeByName.get(project.projectType.name) + : tracker.ids.ClassingProjectType + const defaultIssueStatus = project.defaultIssueStatus !== undefined ? this.issueStatusByName.get(project.defaultIssueStatus.name) : tracker.status.Backlog + const identifier = await this.uniqueProjectIdentifier(project.identifier) const projectData = { - name: project.name, + name: project.title, description: project.description ?? '', private: project.private, - members: [], - owners: [], + members: project.members ?? [], + owners: project.owners ?? [], archived: false, autoJoin: project.autoJoin, identifier, sequence: 0, - defaultIssueStatus: defaultIssueStatus ?? tracker.status.Backlog, // todo: test with no status + defaultIssueStatus: defaultIssueStatus ?? tracker.status.Backlog, defaultTimeReportDay: TimeReportDayType.PreviousWorkDay, type: projectType as Ref } @@ -375,14 +389,22 @@ export class WorkspaceImporter { parentId: Ref, parentsInfo: IssueParentInfo[] ): Promise<{ id: Ref, identifier: string }> { - const issueId = generateId() + const issueId = issue.id ?? generateId() const content = await issue.descrProvider() - const collabId = await this.createCollaborativeContent(issueId, 'description', content) + const collabId = await this.createCollaborativeContent(issueId, 'description', content, project._id) + + const { number, identifier } = + issue.number !== undefined + ? { number: issue.number, identifier: `${project.identifier}-${issue.number}` } + : await this.getNextIssueIdentifier(project) - const { number, identifier } = await this.getNextIssueIdentifier(project) const kind = await this.getIssueKind(project) const rank = await this.getIssueRank(project) const status = await this.findIssueStatusByName(issue.status.name) + const priority = + issue.priority !== undefined + ? IssuePriority[issue.priority as keyof typeof IssuePriority] + : IssuePriority.NoPriority const estimation = issue.estimation ?? 0 const remainingTime = issue.remainingTime ?? 0 @@ -395,7 +417,7 @@ export class WorkspaceImporter { component: null, number, status, - priority: IssuePriority.NoPriority, + priority, rank, comments: issue.comments?.length ?? 0, subIssues: issue.subdocs.length, @@ -469,7 +491,7 @@ export class WorkspaceImporter { async createComment (issueId: Ref, comment: ImportComment, projectId: Ref): Promise { const json = parseMessageMarkdown(comment.text ?? '', 'image://') - const processedJson = this.preprocessor.process(json) + const processedJson = this.preprocessor.process(json, issueId, projectId) const markup = jsonToMarkup(processedJson) const value: AttachedData = { @@ -491,71 +513,86 @@ export class WorkspaceImporter { ) if (comment.attachments !== undefined) { - await this.importAttachments(commentId, comment.attachments, projectId) + for (const attachment of comment.attachments) { + await this.importAttachment(commentId, chunter.class.ChatMessage, attachment, projectId) + } } } - private async importAttachments ( - commentId: Ref, - attachments: ImportAttachment[], - projectId: Ref + private async importAttachment ( + parentId: Ref, + parentClass: Ref>>, + attachment: ImportAttachment, + spaceId: Ref ): Promise { - for (const attach of attachments) { - const blob = await attach.blobProvider() - if (blob === null) { - console.warn('Failed to download attachment file: ', attach.title) - continue - } + const blob = await attachment.blobProvider() + if (blob === null) { + console.warn('Failed to read attachment file: ', attachment.title) + return + } - const attachmentId = await this.createAttachment(blob, attach, projectId, commentId) - if (attachmentId === null) { - console.warn('Failed to upload attachment file: ', attach.title) - } + const file = new File([blob], attachment.title) + const attachmentId = await this.createAttachment( + attachment.id ?? generateId(), + file, + spaceId, + parentId, + parentClass + ) + if (attachmentId === null) { + console.warn('Failed to upload attachment file: ', attachment.title) } } private async createAttachment ( - blob: Blob, - attach: ImportAttachment, - projectId: Ref, - commentId: Ref + id: Ref, + file: File, + spaceId: Ref, + parentId: Ref, + parentClass: Ref>> ): Promise | null> { - const attachmentId = generateId() - const file = new File([blob], attach.title) - - const response = await this.fileUploader.uploadFile(attachmentId, attach.title, file) - if (response.status === 200) { - const responseText = await response.text() - if (responseText !== undefined) { - const uploadResult = JSON.parse(responseText) as UploadResult[] - if (!Array.isArray(uploadResult) || uploadResult.length === 0) { - return null - } - - await this.client.addCollection( - attachment.class.Attachment, - projectId, - commentId, - chunter.class.ChatMessage, - 'attachments', - { - file: uploadResult[0].id, - lastModified: Date.now(), - name: file.name, - size: file.size, - type: file.type - }, - attachmentId - ) - } + const response = await this.fileUploader.uploadFile(id, id, file) + if (response.status !== 200) { + return null } - return attachmentId + + const responseText = await response.text() + if (responseText === undefined) { + return null + } + + const uploadResult = JSON.parse(responseText) as UploadResult[] + if (!Array.isArray(uploadResult) || uploadResult.length === 0) { + return null + } + + await this.client.addCollection( + attachment.class.Attachment, + spaceId, + parentId, + parentClass, + 'attachments', + { + file: uploadResult[0].id, + lastModified: Date.now(), + name: file.name, + size: file.size, + type: file.type + }, + id + ) + return id } // Collaborative content handling - private async createCollaborativeContent (id: Ref, field: string, content: string): Promise { + private async createCollaborativeContent ( + id: Ref, + field: string, + content: string, + spaceId: Ref + ): Promise { const json = parseMessageMarkdown(content ?? '', 'image://') - const processedJson = this.preprocessor.process(json) + const processedJson = this.preprocessor.process(json, id, spaceId) const collabId = makeCollaborativeDoc(id, 'description') const yDoc = jsonToYDocNoSchema(processedJson, field) @@ -568,8 +605,7 @@ export class WorkspaceImporter { async findIssueStatusByName (name: string): Promise> { const query: DocumentQuery = { name, - ofAttribute: tracker.attribute.IssueStatus, - category: task.statusCategory.Active + ofAttribute: tracker.attribute.IssueStatus } const status = await this.client.findOne(tracker.class.IssueStatus, query) diff --git a/dev/import-tool/src/importer/preprocessor.ts b/dev/import-tool/src/importer/preprocessor.ts new file mode 100644 index 0000000000..7275a7ca9f --- /dev/null +++ b/dev/import-tool/src/importer/preprocessor.ts @@ -0,0 +1,119 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// +import contact, { type Person } from '@hcengineering/contact' +import { type Doc, type Space, type Ref } from '@hcengineering/core' +import { type MarkupNode, MarkupNodeType } from '@hcengineering/text' + +export interface MarkdownPreprocessor { + process: (json: MarkupNode, id: Ref, spaceId: Ref) => MarkupNode +} + +export class NoopMarkdownPreprocessor implements MarkdownPreprocessor { + process (json: MarkupNode, id: Ref, spaceId: Ref): MarkupNode { + return json + } +} + +export abstract class BaseMarkdownPreprocessor implements MarkdownPreprocessor { + protected readonly MENTION_REGEX = /@([\p{L}\p{M}]+ [\p{L}\p{M}]+)/gu + + constructor (protected readonly personsByName: Map>) {} + + abstract process (json: MarkupNode, id: Ref, spaceId: Ref): MarkupNode + + protected processMentions (node: MarkupNode): void { + if (node.type !== MarkupNodeType.paragraph || node.content === undefined) return + + const newContent: MarkupNode[] = [] + for (const childNode of node.content) { + if (childNode.type === MarkupNodeType.text && childNode.text !== undefined) { + this.processMentionTextNode(childNode, newContent) + } else { + newContent.push(childNode) + } + } + node.content = newContent + } + + protected processMentionTextNode (node: MarkupNode, newContent: MarkupNode[]): void { + if (node.text === undefined) return + + let match + let lastIndex = 0 + let hasMentions = false + + while ((match = this.MENTION_REGEX.exec(node.text)) !== null) { + hasMentions = true + this.addTextBeforeMention(newContent, node, lastIndex, match.index) + this.addMentionNode(newContent, match[1], node) + lastIndex = this.MENTION_REGEX.lastIndex + } + + if (hasMentions) { + this.addRemainingText(newContent, node, lastIndex) + } else { + newContent.push(node) + } + } + + protected addTextBeforeMention ( + newContent: MarkupNode[], + node: MarkupNode, + lastIndex: number, + matchIndex: number + ): void { + if (node.text === undefined) return + if (matchIndex > lastIndex) { + newContent.push({ + type: MarkupNodeType.text, + text: node.text.slice(lastIndex, matchIndex), + marks: node.marks, + attrs: node.attrs + }) + } + } + + protected addMentionNode (newContent: MarkupNode[], name: string, originalNode: MarkupNode): void { + const personRef = this.personsByName.get(name) + if (personRef !== undefined) { + newContent.push({ + type: MarkupNodeType.reference, + attrs: { + id: personRef, + label: name, + objectclass: contact.class.Person + } + }) + } else { + newContent.push({ + type: MarkupNodeType.text, + text: `@${name}`, + marks: originalNode.marks, + attrs: originalNode.attrs + }) + } + } + + protected addRemainingText (newContent: MarkupNode[], node: MarkupNode, lastIndex: number): void { + if (node.text !== undefined && lastIndex < node.text.length) { + newContent.push({ + type: MarkupNodeType.text, + text: node.text.slice(lastIndex), + marks: node.marks, + attrs: node.attrs + }) + } + } +} diff --git a/dev/import-tool/src/index.ts b/dev/import-tool/src/index.ts index df3759fa5d..3ca141dd71 100644 --- a/dev/import-tool/src/index.ts +++ b/dev/import-tool/src/index.ts @@ -24,6 +24,7 @@ import { importNotion } from './notion/notion' import { setMetadata } from '@hcengineering/platform' import { FrontFileUploader, type FileUploader } from './importer/uploader' import { ClickupImporter } from './clickup/clickup' +import { UnifiedFormatImporter } from './huly/unified' /** * @public @@ -131,5 +132,20 @@ export function importTool (): void { }) }) + // import /home/anna/xored/huly/platform/dev/import-tool/src/huly/example-workspace --workspace ws1 --user user1 --password 1234 + program + .command('import ') + .description('import issues in Unified Huly Format') + .requiredOption('-u, --user ', 'user') + .requiredOption('-pw, --password ', 'password') + .requiredOption('-ws, --workspace ', 'workspace url where the documents should be imported to') + .action(async (dir: string, cmd) => { + const { workspace, user, password } = cmd + await authorize(user, password, workspace, async (client, uploader) => { + const importer = new UnifiedFormatImporter(client, uploader) + await importer.importFolder(dir) + }) + }) + program.parse(process.argv) } diff --git a/models/tracker/src/index.ts b/models/tracker/src/index.ts index df061fcb3e..b765a8479f 100644 --- a/models/tracker/src/index.ts +++ b/models/tracker/src/index.ts @@ -772,7 +772,7 @@ function defineSpaceType (builder: Builder): void { task.class.TaskType, core.space.Model, { - parent: tracker.ids.ClassingProjectType, + parent: pluginState.ids.ClassingProjectType, statuses: classicStatuses, descriptor: tracker.descriptors.Issue, name: 'Issue', @@ -800,6 +800,6 @@ function defineSpaceType (builder: Builder): void { statuses: classicStatuses.map((s) => ({ _id: s, taskType: tracker.taskTypes.Issue })), targetClass: tracker.mixin.ClassicProjectTypeData }, - tracker.ids.ClassingProjectType + pluginState.ids.ClassingProjectType ) } diff --git a/models/tracker/src/migration.ts b/models/tracker/src/migration.ts index 060ac13f44..cf9da14fc6 100644 --- a/models/tracker/src/migration.ts +++ b/models/tracker/src/migration.ts @@ -39,11 +39,16 @@ import { DOMAIN_SPACE } from '@hcengineering/model-core' import { DOMAIN_TASK, migrateDefaultStatusesBase } from '@hcengineering/model-task' import tags from '@hcengineering/tags' import task from '@hcengineering/task' -import { type Issue, type IssueStatus, type Project, TimeReportDayType, trackerId } from '@hcengineering/tracker' +import tracker, { + type Issue, + type IssueStatus, + type Project, + TimeReportDayType, + trackerId +} from '@hcengineering/tracker' import contact from '@hcengineering/model-contact' import { classicIssueTaskStatuses } from '.' -import tracker from './plugin' async function createDefaultProject (tx: TxOperations): Promise { const current = await tx.findOne(tracker.class.Project, { diff --git a/models/tracker/src/plugin.ts b/models/tracker/src/plugin.ts index e7449c1113..3f62f5a3c9 100644 --- a/models/tracker/src/plugin.ts +++ b/models/tracker/src/plugin.ts @@ -85,7 +85,6 @@ export default mergeIds(trackerId, tracker, { IssueTemplateChatMessageViewlet: '' as Ref, ComponentChatMessageViewlet: '' as Ref, MilestoneChatMessageViewlet: '' as Ref, - ClassingProjectType: '' as Ref, DefaultProjectType: '' as Ref }, completion: { diff --git a/plugins/tracker/src/index.ts b/plugins/tracker/src/index.ts index 6904acaf50..3ba06ba655 100644 --- a/plugins/tracker/src/index.ts +++ b/plugins/tracker/src/index.ts @@ -36,6 +36,7 @@ import { Preference } from '@hcengineering/preference' import { TagCategory, TagElement, TagReference } from '@hcengineering/tags' import { ToDo } from '@hcengineering/time' import { + ProjectType, ProjectTypeDescriptor, Task, Project as TaskProject, @@ -384,7 +385,8 @@ const pluginState = plugin(trackerId, { ids: { NoParent: '' as Ref, IssueDraft: '', - IssueDraftChild: '' + IssueDraftChild: '', + ClassingProjectType: '' as Ref }, status: { Backlog: '' as Ref,