mirror of
https://github.com/hcengineering/platform.git
synced 2025-05-09 17:05:01 +00:00
Merge b25bcadf51
into a6e491edf6
This commit is contained in:
commit
cdda9ec451
1
.gitignore
vendored
1
.gitignore
vendored
@ -4,6 +4,7 @@ _api-extractor-temp/
|
||||
temp/
|
||||
.idea
|
||||
pods/workspace/init/
|
||||
pods/workspace/init-scripts/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
8
.vscode/launch.json
vendored
8
.vscode/launch.json
vendored
@ -279,9 +279,11 @@
|
||||
"MODEL_VERSION": "v0.7.1",
|
||||
"WS_OPERATION": "all+backup",
|
||||
"BACKUP_STORAGE": "minio|minio?accessKey=minioadmin&secretKey=minioadmin",
|
||||
"BACKUP_BUCKET": "dev-backups"
|
||||
// "INIT_REPO_DIR": "${workspaceRoot}/pods/workspace/init",
|
||||
// "INIT_WORKSPACE": "staging-dev"
|
||||
"BACKUP_BUCKET": "dev-backups",
|
||||
"INIT_REPO_DIR": "${workspaceRoot}/pods/workspace/init",
|
||||
"INIT_WORKSPACE": "staging-dev",
|
||||
"QUEUE_CONFIG": "huly.local:19092",
|
||||
"QUEUE_REGION": ""
|
||||
},
|
||||
"runtimeVersion": "20",
|
||||
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"],
|
||||
|
@ -32,11 +32,10 @@ import * as yaml from 'js-yaml'
|
||||
import { contentType } from 'mime-types'
|
||||
import * as path from 'path'
|
||||
import { IntlString } from '../../../platform/types'
|
||||
import { Props, UnifiedDoc, UnifiedUpdate, UnifiedFile, UnifiedMixin } from '../types'
|
||||
import { MetadataRegistry, AssociationMetadata } from './registry'
|
||||
import { readMarkdownContent, readYamlHeader } from './parsing'
|
||||
import { Logger } from '../importer/logger'
|
||||
import { validateSchema } from './validation'
|
||||
import { Props, UnifiedDoc, UnifiedFile, UnifiedMixin, UnifiedUpdate } from '../types'
|
||||
import { UnifiedFormatParser } from './parser'
|
||||
import { AssociationMetadata, MetadataRegistry } from './registry'
|
||||
import {
|
||||
AssociationSchema,
|
||||
BaseFieldType,
|
||||
@ -51,6 +50,7 @@ import {
|
||||
StringFieldType,
|
||||
TagSchema
|
||||
} from './schema'
|
||||
import { validateSchema } from './validation'
|
||||
|
||||
export interface UnifiedDocProcessResult {
|
||||
docs: Map<string, Array<UnifiedDoc<Doc>>>
|
||||
@ -62,6 +62,7 @@ export interface UnifiedDocProcessResult {
|
||||
export class CardsProcessor {
|
||||
constructor (
|
||||
private readonly metadataRegistry: MetadataRegistry,
|
||||
private readonly parser: UnifiedFormatParser,
|
||||
private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
@ -199,7 +200,7 @@ export class CardsProcessor {
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
const cardPath = path.join(currentPath, entry.name)
|
||||
const { class: cardType, ...cardProps } = await readYamlHeader(cardPath)
|
||||
const { class: cardType, ...cardProps } = this.parser.readYamlHeader(cardPath)
|
||||
|
||||
if (masterTagId !== undefined) {
|
||||
await this.processCard(result, cardPath, cardProps, masterTagId, masterTagAssociaions, masterTagAttributes)
|
||||
@ -231,7 +232,7 @@ export class CardsProcessor {
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
const cardPath = path.join(currentDir, entry.name)
|
||||
const { class: cardType, ...cardProps } = await readYamlHeader(cardPath)
|
||||
const { class: cardType, ...cardProps } = this.parser.readYamlHeader(cardPath)
|
||||
|
||||
if (cardType !== undefined && cardType.startsWith('card:types:') === false) {
|
||||
throw new Error('Unsupported card type: ' + cardType + ' in ' + cardPath)
|
||||
@ -310,7 +311,7 @@ export class CardsProcessor {
|
||||
|
||||
for (const entry of entries) {
|
||||
const childCardPath = path.join(cardDir, entry.name)
|
||||
const { class: cardClass, ...cardProps } = await readYamlHeader(childCardPath)
|
||||
const { class: cardClass, ...cardProps } = this.parser.readYamlHeader(childCardPath)
|
||||
await this.processCard(
|
||||
result,
|
||||
childCardPath,
|
||||
@ -594,7 +595,7 @@ export class CardsProcessor {
|
||||
{
|
||||
_class: masterTagId,
|
||||
collabField: 'content',
|
||||
contentProvider: () => readMarkdownContent(cardPath),
|
||||
contentProvider: () => Promise.resolve(this.parser.readMarkdownContent(cardPath)),
|
||||
props: cardProps as Props<Card>
|
||||
},
|
||||
...relations
|
||||
|
@ -15,7 +15,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { type Attachment } from '@hcengineering/attachment'
|
||||
import card from '@hcengineering/card'
|
||||
import contact, { Employee, type Person, SocialIdentity } from '@hcengineering/contact'
|
||||
import contact, { Employee, type Person } from '@hcengineering/contact'
|
||||
import documents, {
|
||||
ControlledDocument,
|
||||
DocumentCategory,
|
||||
@ -24,18 +24,14 @@ import documents, {
|
||||
} from '@hcengineering/controlled-documents'
|
||||
import {
|
||||
AccountUuid,
|
||||
type Class,
|
||||
type Doc,
|
||||
generateId,
|
||||
PersonId,
|
||||
type Ref,
|
||||
SocialIdType,
|
||||
type Space,
|
||||
type TxOperations
|
||||
} from '@hcengineering/core'
|
||||
import document, { type Document } from '@hcengineering/document'
|
||||
import core from '@hcengineering/model-core'
|
||||
import { MarkupMarkType, type MarkupNode, MarkupNodeType, traverseNode, traverseNodeMarks } from '@hcengineering/text'
|
||||
import tracker, { type Issue, Project } from '@hcengineering/tracker'
|
||||
import * as fs from 'fs'
|
||||
import sizeOf from 'image-size'
|
||||
@ -59,11 +55,11 @@ import {
|
||||
WorkspaceImporter
|
||||
} from '../importer/importer'
|
||||
import { type Logger } from '../importer/logger'
|
||||
import { BaseMarkdownPreprocessor } from '../importer/preprocessor'
|
||||
import { type FileUploader } from '../importer/uploader'
|
||||
import { CardsProcessor } from './cards'
|
||||
import { MetadataRegistry, MentionMetadata } from './registry'
|
||||
import { readMarkdownContent, readYamlHeader } from './parsing'
|
||||
import { UnifiedFormatParser } from './parser'
|
||||
import { HulyMarkdownPreprocessor, type AttachmentMetadata } from './preprocessor'
|
||||
import { MetadataRegistry } from './registry'
|
||||
export interface HulyComment {
|
||||
author: string
|
||||
text: string
|
||||
@ -165,175 +161,10 @@ export interface HulyOrgSpaceSettings extends HulySpaceSettings {
|
||||
qara?: string
|
||||
}
|
||||
|
||||
class HulyMarkdownPreprocessor extends BaseMarkdownPreprocessor {
|
||||
constructor (
|
||||
private readonly urlProvider: (id: string) => string,
|
||||
private readonly logger: Logger,
|
||||
private readonly metadataRegistry: MetadataRegistry,
|
||||
private readonly attachMetaByPath: Map<string, AttachmentMetadata>,
|
||||
personsByName: Map<string, Ref<Person>>
|
||||
) {
|
||||
super(personsByName)
|
||||
}
|
||||
|
||||
process (json: MarkupNode, id: Ref<Doc>, spaceId: Ref<Space>): 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<Doc>, spaceId: Ref<Space>): void {
|
||||
const src = node.attrs?.src
|
||||
if (src === undefined) return
|
||||
|
||||
const sourcePath = this.getSourcePath(id)
|
||||
if (sourcePath == null) return
|
||||
|
||||
const href = decodeURI(src as string)
|
||||
const fullPath = path.resolve(path.dirname(sourcePath), href)
|
||||
const attachmentMeta = this.attachMetaByPath.get(fullPath)
|
||||
|
||||
if (attachmentMeta === undefined) {
|
||||
this.logger.error(`Attachment image not found for ${fullPath}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.metadataRegistry.hasRefMetadata(sourcePath)) {
|
||||
this.logger.error(`Source metadata not found for ${sourcePath}`)
|
||||
return
|
||||
}
|
||||
|
||||
const sourceMeta = this.metadataRegistry.getRefMetadata(sourcePath)
|
||||
this.updateAttachmentMetadata(fullPath, attachmentMeta, id, spaceId, sourceMeta)
|
||||
this.alterImageNode(node, attachmentMeta.id, attachmentMeta.name)
|
||||
}
|
||||
|
||||
private processLinkMarks (node: MarkupNode, id: Ref<Doc>, spaceId: Ref<Space>): void {
|
||||
traverseNodeMarks(node, (mark) => {
|
||||
if (mark.type !== MarkupMarkType.link) return
|
||||
|
||||
const sourcePath = this.getSourcePath(id)
|
||||
if (sourcePath == null) return
|
||||
|
||||
const href = decodeURI(mark.attrs?.href ?? '')
|
||||
const fullPath = path.resolve(path.dirname(sourcePath), href)
|
||||
|
||||
if (this.metadataRegistry.hasRefMetadata(fullPath)) {
|
||||
const targetDocMeta = this.metadataRegistry.getRefMetadata(fullPath)
|
||||
this.alterMentionNode(node, targetDocMeta)
|
||||
} else if (this.attachMetaByPath.has(fullPath)) {
|
||||
const attachmentMeta = this.attachMetaByPath.get(fullPath)
|
||||
if (attachmentMeta !== undefined) {
|
||||
this.alterAttachmentLinkNode(node, attachmentMeta)
|
||||
if (this.metadataRegistry.hasRefMetadata(sourcePath)) {
|
||||
const sourceMeta = this.metadataRegistry.getRefMetadata(sourcePath)
|
||||
this.updateAttachmentMetadata(fullPath, attachmentMeta, id, spaceId, sourceMeta)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.logger.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 alterMentionNode (node: MarkupNode, targetMeta: MentionMetadata): 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 getSourcePath (id: Ref<Doc>): string | null {
|
||||
const sourcePath = this.metadataRegistry.getPath(id)
|
||||
if (sourcePath === undefined) {
|
||||
this.logger.error(`Source file path not found for ${id}`)
|
||||
return null
|
||||
}
|
||||
return sourcePath
|
||||
}
|
||||
|
||||
private updateAttachmentMetadata (
|
||||
fullPath: string,
|
||||
attachmentMeta: AttachmentMetadata,
|
||||
id: Ref<Doc>,
|
||||
spaceId: Ref<Space>,
|
||||
sourceMeta: MentionMetadata
|
||||
): void {
|
||||
this.attachMetaByPath.set(fullPath, {
|
||||
...attachmentMeta,
|
||||
spaceId,
|
||||
parentId: id,
|
||||
parentClass: sourceMeta.class as Ref<Class<Doc<Space>>>
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
interface AttachmentMetadata {
|
||||
id: Ref<Attachment>
|
||||
name: string
|
||||
path: string
|
||||
parentId?: Ref<Doc>
|
||||
parentClass?: Ref<Class<Doc<Space>>>
|
||||
spaceId?: Ref<Space>
|
||||
}
|
||||
|
||||
export class HulyFormatImporter {
|
||||
private readonly importerEmailPlaceholder = 'newuser@huly.io'
|
||||
private readonly importerNamePlaceholder = 'New User'
|
||||
|
||||
private personsByName = new Map<string, Ref<Person>>()
|
||||
private readonly personsByName = new Map<string, Ref<Person>>()
|
||||
private employeesByName = new Map<string, Ref<Employee>>()
|
||||
private accountsByEmail = new Map<string, AccountUuid>()
|
||||
private readonly accountsByName = new Map<string, AccountUuid>()
|
||||
private readonly personIdByEmail = new Map<string, PersonId>()
|
||||
private controlledDocumentCategories = new Map<string, Ref<DocumentCategory>>()
|
||||
|
||||
@ -341,20 +172,20 @@ export class HulyFormatImporter {
|
||||
|
||||
private readonly metadataRegistry = new MetadataRegistry()
|
||||
private readonly cardsProcessor: CardsProcessor
|
||||
private readonly parser: UnifiedFormatParser
|
||||
|
||||
constructor (
|
||||
private readonly client: TxOperations,
|
||||
private readonly fileUploader: FileUploader,
|
||||
private readonly logger: Logger,
|
||||
private readonly importerSocialId?: PersonId,
|
||||
private readonly importerPerson?: Ref<Person>
|
||||
variables?: Record<string, any>
|
||||
) {
|
||||
this.cardsProcessor = new CardsProcessor(this.metadataRegistry, this.logger)
|
||||
this.parser = new UnifiedFormatParser(variables ?? {})
|
||||
this.cardsProcessor = new CardsProcessor(this.metadataRegistry, this.parser, this.logger)
|
||||
}
|
||||
|
||||
private async initCaches (): Promise<void> {
|
||||
await this.cachePersonsByNames()
|
||||
await this.cacheAccountsByEmails()
|
||||
await this.cacheEmployeesByName()
|
||||
await this.cacheControlledDocumentCategories()
|
||||
}
|
||||
@ -485,7 +316,7 @@ export class HulyFormatImporter {
|
||||
|
||||
try {
|
||||
this.logger.log(`Processing ${spaceName}...`)
|
||||
const spaceConfig = yaml.load(fs.readFileSync(yamlPath, 'utf8')) as HulySpaceSettings
|
||||
const spaceConfig = this.parser.readYaml(yamlPath) as HulySpaceSettings
|
||||
|
||||
if (spaceConfig?.class === undefined) {
|
||||
this.logger.error(`Skipping ${spaceName}: not a space - no class specified`)
|
||||
@ -560,7 +391,7 @@ export class HulyFormatImporter {
|
||||
|
||||
for (const issueFile of issueFiles) {
|
||||
const issuePath = path.join(currentPath, issueFile)
|
||||
const issueHeader = (await readYamlHeader(issuePath)) as HulyIssueHeader
|
||||
const issueHeader = this.parser.readYamlHeader(issuePath) as HulyIssueHeader
|
||||
|
||||
if (issueHeader.class === undefined) {
|
||||
this.logger.error(`Skipping ${issueFile}: not an issue`)
|
||||
@ -578,7 +409,7 @@ export class HulyFormatImporter {
|
||||
class: tracker.class.Issue,
|
||||
title: issueHeader.title,
|
||||
number: parseInt(issueNumber ?? 'NaN'),
|
||||
descrProvider: async () => await readMarkdownContent(issuePath),
|
||||
descrProvider: () => Promise.resolve(this.parser.readMarkdownContent(issuePath)),
|
||||
status: { name: issueHeader.status },
|
||||
priority: issueHeader.priority,
|
||||
estimation: issueHeader.estimation,
|
||||
@ -606,9 +437,6 @@ export class HulyFormatImporter {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (name === this.importerNamePlaceholder && this.importerPerson != null) {
|
||||
return this.importerPerson
|
||||
}
|
||||
const person = this.personsByName.get(name)
|
||||
if (person === undefined) {
|
||||
throw new Error(`Person not found: ${name}`)
|
||||
@ -617,10 +445,6 @@ export class HulyFormatImporter {
|
||||
}
|
||||
|
||||
private async getPersonIdByEmail (email: string): Promise<PersonId> {
|
||||
if (email === this.importerEmailPlaceholder && this.importerSocialId != null) {
|
||||
return this.importerSocialId
|
||||
}
|
||||
|
||||
const personId = this.personIdByEmail.get(email)
|
||||
if (personId !== undefined) {
|
||||
return personId
|
||||
@ -640,10 +464,10 @@ export class HulyFormatImporter {
|
||||
return socialId._id
|
||||
}
|
||||
|
||||
private findAccountByEmail (email: string): AccountUuid {
|
||||
const account = this.accountsByEmail.get(email)
|
||||
private findAccountByName (name: string): AccountUuid {
|
||||
const account = this.accountsByName.get(name)
|
||||
if (account === undefined) {
|
||||
throw new Error(`Account not found: ${email}`)
|
||||
throw new Error(`Account not found: ${name}`)
|
||||
}
|
||||
return account
|
||||
}
|
||||
@ -666,7 +490,7 @@ export class HulyFormatImporter {
|
||||
|
||||
for (const docFile of docFiles) {
|
||||
const docPath = path.join(currentPath, docFile)
|
||||
const docHeader = (await readYamlHeader(docPath)) as HulyDocumentHeader
|
||||
const docHeader = this.parser.readYamlHeader(docPath) as HulyDocumentHeader
|
||||
|
||||
if (docHeader.class === undefined) {
|
||||
this.logger.error(`Skipping ${docFile}: not a document`)
|
||||
@ -680,7 +504,7 @@ export class HulyFormatImporter {
|
||||
id: this.metadataRegistry.getRef(docPath) as Ref<Document>,
|
||||
class: document.class.Document,
|
||||
title: docHeader.title,
|
||||
descrProvider: async () => await readMarkdownContent(docPath),
|
||||
descrProvider: () => Promise.resolve(this.parser.readMarkdownContent(docPath)),
|
||||
subdocs: [] // Will be added via builder
|
||||
}
|
||||
|
||||
@ -707,7 +531,7 @@ export class HulyFormatImporter {
|
||||
|
||||
for (const docFile of docFiles) {
|
||||
const docPath = path.join(currentPath, docFile)
|
||||
const docHeader = (await readYamlHeader(docPath)) as HulyControlledDocumentHeader | HulyDocumentTemplateHeader
|
||||
const docHeader = this.parser.readYamlHeader(docPath) as HulyControlledDocumentHeader | HulyDocumentTemplateHeader
|
||||
|
||||
if (docHeader.class === undefined) {
|
||||
this.logger.error(`Skipping ${docFile}: not a document`)
|
||||
@ -788,55 +612,51 @@ export class HulyFormatImporter {
|
||||
)
|
||||
}
|
||||
|
||||
private async processProject (projectHeader: HulyProjectSettings): Promise<ImportProject> {
|
||||
private async processProject (data: HulyProjectSettings): Promise<ImportProject> {
|
||||
return {
|
||||
class: tracker.class.Project,
|
||||
id: projectHeader.id as Ref<Project>,
|
||||
title: projectHeader.title,
|
||||
identifier: projectHeader.identifier,
|
||||
private: projectHeader.private ?? false,
|
||||
autoJoin: projectHeader.autoJoin ?? true,
|
||||
archived: projectHeader.archived ?? false,
|
||||
description: projectHeader.description,
|
||||
emoji: projectHeader.emoji,
|
||||
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)) : [],
|
||||
id: data.id as Ref<Project>,
|
||||
title: data.title,
|
||||
identifier: data.identifier,
|
||||
private: data.private ?? false,
|
||||
autoJoin: data.autoJoin ?? true,
|
||||
archived: data.archived ?? false,
|
||||
description: data.description,
|
||||
emoji: data.emoji,
|
||||
defaultIssueStatus: data.defaultIssueStatus !== undefined ? { name: data.defaultIssueStatus } : undefined,
|
||||
owners: data.owners !== undefined ? data.owners.map((name) => this.findAccountByName(name)) : [],
|
||||
members: data.members !== undefined ? data.members.map((name) => this.findAccountByName(name)) : [],
|
||||
docs: []
|
||||
}
|
||||
}
|
||||
|
||||
private async processTeamspace (spaceHeader: HulyTeamspaceSettings): Promise<ImportTeamspace> {
|
||||
private async processTeamspace (data: HulyTeamspaceSettings): Promise<ImportTeamspace> {
|
||||
return {
|
||||
class: document.class.Teamspace,
|
||||
title: spaceHeader.title,
|
||||
private: spaceHeader.private ?? false,
|
||||
autoJoin: spaceHeader.autoJoin ?? true,
|
||||
archived: spaceHeader.archived ?? false,
|
||||
description: spaceHeader.description,
|
||||
emoji: spaceHeader.emoji,
|
||||
owners: spaceHeader.owners !== undefined ? spaceHeader.owners.map((email) => this.findAccountByEmail(email)) : [],
|
||||
members:
|
||||
spaceHeader.members !== undefined ? spaceHeader.members.map((email) => this.findAccountByEmail(email)) : [],
|
||||
title: data.title,
|
||||
private: data.private ?? false,
|
||||
autoJoin: data.autoJoin ?? true,
|
||||
archived: data.archived ?? false,
|
||||
description: data.description,
|
||||
emoji: data.emoji,
|
||||
owners: data.owners !== undefined ? data.owners.map((name) => this.findAccountByName(name)) : [],
|
||||
members: data.members !== undefined ? data.members.map((name) => this.findAccountByName(name)) : [],
|
||||
docs: []
|
||||
}
|
||||
}
|
||||
|
||||
private async processOrgSpace (spaceHeader: HulyOrgSpaceSettings): Promise<ImportOrgSpace> {
|
||||
private async processOrgSpace (data: HulyOrgSpaceSettings): 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,
|
||||
title: data.title,
|
||||
private: data.private ?? false,
|
||||
archived: data.archived ?? false,
|
||||
description: data.description,
|
||||
owners: data.owners?.map((name) => this.findAccountByName(name)) ?? [],
|
||||
members: data.members?.map((name) => this.findAccountByName(name)) ?? [],
|
||||
qualified: data.qualified !== undefined ? this.findAccountByName(data.qualified) : undefined,
|
||||
manager: data.manager !== undefined ? this.findAccountByName(data.manager) : undefined,
|
||||
qara: data.qara !== undefined ? this.findAccountByName(data.qara) : undefined,
|
||||
docs: []
|
||||
}
|
||||
}
|
||||
@ -876,10 +696,10 @@ export class HulyFormatImporter {
|
||||
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 readMarkdownContent(docPath),
|
||||
reviewers: header.reviewers?.map((name) => this.findEmployeeByName(name)) ?? [],
|
||||
approvers: header.approvers?.map((name) => this.findEmployeeByName(name)) ?? [],
|
||||
coAuthors: header.coAuthors?.map((name) => this.findEmployeeByName(name)) ?? [],
|
||||
descrProvider: () => Promise.resolve(this.parser.readMarkdownContent(docPath)),
|
||||
ccReason: header.changeControl?.reason,
|
||||
ccImpact: header.changeControl?.impact,
|
||||
ccDescription: header.changeControl?.description,
|
||||
@ -919,10 +739,10 @@ export class HulyFormatImporter {
|
||||
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 readMarkdownContent(docPath),
|
||||
reviewers: header.reviewers?.map((name) => this.findEmployeeByName(name)) ?? [],
|
||||
approvers: header.approvers?.map((name) => this.findEmployeeByName(name)) ?? [],
|
||||
coAuthors: header.coAuthors?.map((name) => this.findEmployeeByName(name)) ?? [],
|
||||
descrProvider: () => Promise.resolve(this.parser.readMarkdownContent(docPath)),
|
||||
ccReason: header.changeControl?.reason,
|
||||
ccImpact: header.changeControl?.impact,
|
||||
ccDescription: header.changeControl?.description,
|
||||
@ -930,54 +750,14 @@ export class HulyFormatImporter {
|
||||
}
|
||||
}
|
||||
|
||||
private async cacheAccountsByEmails (): Promise<void> {
|
||||
const employees = await this.client.findAll(
|
||||
contact.mixin.Employee,
|
||||
{ active: true },
|
||||
{ lookup: { _id: { socialIds: contact.class.SocialIdentity } } }
|
||||
)
|
||||
|
||||
this.accountsByEmail = employees.reduce((map, employee) => {
|
||||
employee.$lookup?.socialIds?.forEach((socialId) => {
|
||||
if ((socialId as SocialIdentity).type === SocialIdType.EMAIL) {
|
||||
map.set((socialId as SocialIdentity).value, employee.personUuid)
|
||||
}
|
||||
})
|
||||
|
||||
return map
|
||||
}, new Map())
|
||||
}
|
||||
|
||||
private async cachePersonIdsByEmails (): Promise<void> {
|
||||
const employees = await this.client.findAll(
|
||||
contact.mixin.Employee,
|
||||
{ active: true },
|
||||
{ lookup: { _id: { socialIds: contact.class.SocialIdentity } } }
|
||||
)
|
||||
|
||||
this.accountsByEmail = employees.reduce((map, employee) => {
|
||||
employee.$lookup?.socialIds?.forEach((socialId) => {
|
||||
if ((socialId as SocialIdentity).type === SocialIdType.EMAIL) {
|
||||
map.set((socialId as SocialIdentity).value, employee.personUuid)
|
||||
}
|
||||
})
|
||||
|
||||
return map
|
||||
}, new Map())
|
||||
}
|
||||
|
||||
private async cachePersonsByNames (): Promise<void> {
|
||||
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())
|
||||
(await this.client.findAll(contact.class.Person, {})).forEach((person) => {
|
||||
const name = person.name.split(',').reverse().join(' ')
|
||||
this.personsByName.set(name, person._id)
|
||||
if (person.personUuid !== undefined) {
|
||||
this.accountsByName.set(name, person.personUuid as AccountUuid)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async cacheEmployeesByName (): Promise<void> {
|
||||
|
79
packages/importer/src/huly/parser.ts
Normal file
79
packages/importer/src/huly/parser.ts
Normal file
@ -0,0 +1,79 @@
|
||||
//
|
||||
// Copyright © 2025 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import * as fs from 'fs'
|
||||
import * as yaml from 'js-yaml'
|
||||
|
||||
const VARIABLE_REGEX = /\${\S+?}/
|
||||
const YAML_HEADER_REGEX = /^---\n([\s\S]*?)\n---/
|
||||
const MARKDOWN_CONTENT_REGEX = /^---\n[\s\S]*?\n---\n(.*)$/s
|
||||
|
||||
export class UnifiedFormatParser {
|
||||
constructor (private readonly variables: Record<string, any>) {}
|
||||
|
||||
public readYaml (filePath: string): Record<string, any> {
|
||||
const content = fs.readFileSync(filePath, 'utf8')
|
||||
const data = yaml.load(content) as Record<string, any>
|
||||
return this.resolveProps(data)
|
||||
}
|
||||
|
||||
public readYamlHeader (filePath: string): Record<string, any> {
|
||||
const content = fs.readFileSync(filePath, 'utf8')
|
||||
const match = content.match(YAML_HEADER_REGEX)
|
||||
if (match != null) {
|
||||
const header = yaml.load(match[1])
|
||||
return this.resolveProps(header as Record<string, any>)
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
public readMarkdownContent (filePath: string): string {
|
||||
const content = fs.readFileSync(filePath, 'utf8')
|
||||
const match = content.match(MARKDOWN_CONTENT_REGEX)
|
||||
return match != null ? match[1] : content
|
||||
}
|
||||
|
||||
public resolveProps (data: Record<string, any>): Record<string, any> {
|
||||
for (const key in data) {
|
||||
const value = (data as any)[key]
|
||||
;(data as any)[key] = this.resolveValue(value)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
private resolveValue (value: any): any {
|
||||
if (typeof value === 'object') {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((v) => this.resolveValue(v))
|
||||
} else {
|
||||
return this.resolveProps(value)
|
||||
}
|
||||
} else if (typeof value === 'string') {
|
||||
while (true) {
|
||||
const matched = VARIABLE_REGEX.exec(value)
|
||||
if (matched === null) break
|
||||
const result = this.variables[matched[0]]
|
||||
if (result === undefined) {
|
||||
throw new Error(`Variable ${matched[0]} not found`)
|
||||
} else {
|
||||
value = value.replaceAll(matched[0], result)
|
||||
VARIABLE_REGEX.lastIndex = 0
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
@ -1,32 +0,0 @@
|
||||
//
|
||||
// Copyright © 2025 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import * as fs from 'fs'
|
||||
import * as yaml from 'js-yaml'
|
||||
|
||||
export async function readYamlHeader (filePath: string): Promise<any> {
|
||||
const content = fs.readFileSync(filePath, 'utf8')
|
||||
const match = content.match(/^---\n([\s\S]*?)\n---/)
|
||||
if (match != null) {
|
||||
return yaml.load(match[1])
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
export async function readMarkdownContent (filePath: string): Promise<string> {
|
||||
const content = fs.readFileSync(filePath, 'utf8')
|
||||
const match = content.match(/^---\n[\s\S]*?\n---\n(.*)$/s)
|
||||
return match != null ? match[1] : content
|
||||
}
|
191
packages/importer/src/huly/preprocessor.ts
Normal file
191
packages/importer/src/huly/preprocessor.ts
Normal file
@ -0,0 +1,191 @@
|
||||
//
|
||||
// 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 { Attachment } from '@hcengineering/attachment'
|
||||
import { type Person } from '@hcengineering/contact'
|
||||
import {
|
||||
type Class,
|
||||
type Doc,
|
||||
type Ref,
|
||||
type Space
|
||||
} from '@hcengineering/core'
|
||||
import { MarkupMarkType, type MarkupNode, MarkupNodeType, traverseNode, traverseNodeMarks } from '@hcengineering/text'
|
||||
import * as fs from 'fs'
|
||||
import { contentType } from 'mime-types'
|
||||
import * as path from 'path'
|
||||
import { type Logger } from '../importer/logger'
|
||||
import { BaseMarkdownPreprocessor } from '../importer/preprocessor'
|
||||
import { MentionMetadata, MetadataRegistry } from './registry'
|
||||
|
||||
export interface AttachmentMetadata {
|
||||
id: Ref<Attachment>
|
||||
name: string
|
||||
path: string
|
||||
parentId?: Ref<Doc>
|
||||
parentClass?: Ref<Class<Doc<Space>>>
|
||||
spaceId?: Ref<Space>
|
||||
}
|
||||
|
||||
export class HulyMarkdownPreprocessor extends BaseMarkdownPreprocessor {
|
||||
constructor (
|
||||
private readonly urlProvider: (id: string) => string,
|
||||
private readonly logger: Logger,
|
||||
private readonly metadataRegistry: MetadataRegistry,
|
||||
private readonly attachMetaByPath: Map<string, AttachmentMetadata>,
|
||||
personsByName: Map<string, Ref<Person>>
|
||||
) {
|
||||
super(personsByName)
|
||||
}
|
||||
|
||||
process (json: MarkupNode, id: Ref<Doc>, spaceId: Ref<Space>): 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<Doc>, spaceId: Ref<Space>): void {
|
||||
const src = node.attrs?.src
|
||||
if (src === undefined) return
|
||||
|
||||
const sourcePath = this.getSourcePath(id)
|
||||
if (sourcePath == null) return
|
||||
|
||||
const href = decodeURI(src as string)
|
||||
const fullPath = path.resolve(path.dirname(sourcePath), href)
|
||||
const attachmentMeta = this.attachMetaByPath.get(fullPath)
|
||||
|
||||
if (attachmentMeta === undefined) {
|
||||
this.logger.error(`Attachment image not found for ${fullPath}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.metadataRegistry.hasRefMetadata(sourcePath)) {
|
||||
this.logger.error(`Source metadata not found for ${sourcePath}`)
|
||||
return
|
||||
}
|
||||
|
||||
const sourceMeta = this.metadataRegistry.getRefMetadata(sourcePath)
|
||||
this.updateAttachmentMetadata(fullPath, attachmentMeta, id, spaceId, sourceMeta)
|
||||
this.alterImageNode(node, attachmentMeta.id, attachmentMeta.name)
|
||||
}
|
||||
|
||||
private processLinkMarks (node: MarkupNode, id: Ref<Doc>, spaceId: Ref<Space>): void {
|
||||
traverseNodeMarks(node, (mark) => {
|
||||
if (mark.type !== MarkupMarkType.link) return
|
||||
|
||||
const sourcePath = this.getSourcePath(id)
|
||||
if (sourcePath == null) return
|
||||
|
||||
const href = decodeURI(mark.attrs?.href ?? '')
|
||||
const fullPath = path.resolve(path.dirname(sourcePath), href)
|
||||
|
||||
if (this.metadataRegistry.hasRefMetadata(fullPath)) {
|
||||
const targetDocMeta = this.metadataRegistry.getRefMetadata(fullPath)
|
||||
this.alterMentionNode(node, targetDocMeta)
|
||||
} else if (this.attachMetaByPath.has(fullPath)) {
|
||||
const attachmentMeta = this.attachMetaByPath.get(fullPath)
|
||||
if (attachmentMeta !== undefined) {
|
||||
this.alterAttachmentLinkNode(node, attachmentMeta)
|
||||
if (this.metadataRegistry.hasRefMetadata(sourcePath)) {
|
||||
const sourceMeta = this.metadataRegistry.getRefMetadata(sourcePath)
|
||||
this.updateAttachmentMetadata(fullPath, attachmentMeta, id, spaceId, sourceMeta)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.logger.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 alterMentionNode (node: MarkupNode, targetMeta: MentionMetadata): 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 getSourcePath (id: Ref<Doc>): string | null {
|
||||
const sourcePath = this.metadataRegistry.getPath(id)
|
||||
if (sourcePath === undefined) {
|
||||
this.logger.error(`Source file path not found for ${id}`)
|
||||
return null
|
||||
}
|
||||
return sourcePath
|
||||
}
|
||||
|
||||
private updateAttachmentMetadata (
|
||||
fullPath: string,
|
||||
attachmentMeta: AttachmentMetadata,
|
||||
id: Ref<Doc>,
|
||||
spaceId: Ref<Space>,
|
||||
sourceMeta: MentionMetadata
|
||||
): void {
|
||||
this.attachMetaByPath.set(fullPath, {
|
||||
...attachmentMeta,
|
||||
spaceId,
|
||||
parentId: id,
|
||||
parentClass: sourceMeta.class as Ref<Class<Doc<Space>>>
|
||||
})
|
||||
}
|
||||
}
|
@ -208,9 +208,9 @@ export async function initializeWorkspace (
|
||||
progress: (value: number) => Promise<void>
|
||||
): Promise<void> {
|
||||
const initWS = branding?.initWorkspace ?? getMetadata(toolPlugin.metadata.InitWorkspace)
|
||||
const initRepoDir = getMetadata(toolPlugin.metadata.InitRepoDir)
|
||||
const initRepoDir = getMetadata(toolPlugin.metadata.InitRepoDir) ?? ''
|
||||
ctx.info('Init script details', { initWS, initRepoDir })
|
||||
if (initWS === undefined || initRepoDir === undefined) return
|
||||
// if (initWS === undefined || initRepoDir === undefined) return
|
||||
|
||||
const initScriptFile = path.resolve(initRepoDir, 'script.yaml')
|
||||
if (!fs.existsSync(initScriptFile)) {
|
||||
|
@ -205,9 +205,7 @@ export class WorkspaceInitializer {
|
||||
try {
|
||||
const uploader = new StorageFileUploader(this.ctx, this.storageAdapter, this.wsIds)
|
||||
const initPath = path.resolve(this.initRepoDir, step.path)
|
||||
// eslint-disable-next-line no-template-curly-in-string
|
||||
const initPerson = vars[`\${${this.creatorPersonVar}}`]
|
||||
const importer = new HulyFormatImporter(this.client, uploader, logger, this.socialId, initPerson)
|
||||
const importer = new HulyFormatImporter(this.client, uploader, logger, vars)
|
||||
await importer.importFolder(initPath)
|
||||
} catch (error) {
|
||||
logger.error('Import failed', error)
|
||||
|
Loading…
Reference in New Issue
Block a user