import { saveCollabJson } from '@hcengineering/collaboration' import core, { AttachedDoc, Class, Data, Doc, generateId, makeCollabId, MeasureContext, Mixin, parseSocialIdString, type PersonId, type PersonInfo, Ref, SocialIdType, Space, TxOperations, type WorkspaceIds } from '@hcengineering/core' import { ModelLogger } from '@hcengineering/model' import { makeRank } from '@hcengineering/rank' import { HulyFormatImporter, StorageFileUploader } from '@hcengineering/importer' import type { StorageAdapter } from '@hcengineering/server-core' import { jsonToMarkup } from '@hcengineering/text' import { markdownToMarkup } from '@hcengineering/text-markdown' import { pickPrimarySocialId } from '@hcengineering/contact' import { v4 as uuid } from 'uuid' import path from 'path' const fieldRegexp = /\${\S+?}/ export interface InitScript { name: string lang?: string default: boolean steps: InitStep[] } export type InitStep = | CreateStep | DefaultStep | MixinStep | UpdateStep | FindStep | UploadStep | ImportStep export interface CreateStep { type: 'create' _class: Ref> data: Props markdownFields?: string[] collabFields?: string[] resultVariable?: string } export interface DefaultStep { type: 'default' _class: Ref> data: Props } export interface MixinStep { type: 'mixin' _class: Ref> mixin: Ref> markdownFields?: string[] collabFields?: string[] data: Props } export interface UpdateStep { type: 'update' _class: Ref> markdownFields?: string[] collabFields?: string[] data: Props } export interface FindStep { type: 'find' _class: Ref> query: Partial resultVariable: string } export interface UploadStep { type: 'upload' fromUrl: string contentType: string resultVariable?: string } export interface ImportStep { type: 'import' path: string } export type Props = Data & Partial & { space: Ref } export class WorkspaceInitializer { private readonly imageUrl = 'image://' private readonly nextRank = '#nextRank' private readonly now = '#now' private readonly creatorPersonVar = 'creatorPerson' private readonly socialKey: PersonId private readonly socialType: SocialIdType private readonly socialValue: string constructor ( private readonly ctx: MeasureContext, private readonly storageAdapter: StorageAdapter, private readonly wsIds: WorkspaceIds, private readonly client: TxOperations, private readonly initRepoDir: string, private readonly creator: PersonInfo ) { this.socialKey = pickPrimarySocialId(creator.socialIds) const socialKeyObj = parseSocialIdString(this.socialKey) this.socialType = socialKeyObj.type this.socialValue = socialKeyObj.value } async processScript ( script: InitScript, logger: ModelLogger, progress: (value: number) => Promise ): Promise { const vars: Record = { '${creatorName@global}': this.creator.name, // eslint-disable-line no-template-curly-in-string '${creatorUuid@global}': this.creator.personUuid, // eslint-disable-line no-template-curly-in-string '${creatorSocialKey@global}': this.socialKey, // eslint-disable-line no-template-curly-in-string '${creatorSocialType@global}': this.socialType, // eslint-disable-line no-template-curly-in-string '${creatorSocialValue@global}': this.socialValue // eslint-disable-line no-template-curly-in-string } const defaults = new Map>, Props>() for (let index = 0; index < script.steps.length; index++) { try { const step = script.steps[index] if (step.type === 'default') { await this.processDefault(step, defaults) } else if (step.type === 'create') { await this.processCreate(step, vars, defaults) } else if (step.type === 'update') { await this.processUpdate(step, vars) } else if (step.type === 'mixin') { await this.processMixin(step, vars) } else if (step.type === 'find') { await this.processFind(step, vars) } else if (step.type === 'upload') { await this.processUpload(step, vars, logger) } else if (step.type === 'import') { await this.processImport(step, vars, logger) } await progress(Math.round(((index + 1) * 100) / script.steps.length)) } catch (error) { logger.error(`Error in script on step ${index}`, error) throw error } } } private async processDefault( step: DefaultStep, defaults: Map>, Props> ): Promise { const obj = defaults.get(step._class) ?? {} defaults.set(step._class, { ...obj, ...step.data }) } private async processUpload (step: UploadStep, vars: Record, logger: ModelLogger): Promise { try { const id = uuid() const resp = await fetch(step.fromUrl) const buffer = Buffer.from(await resp.arrayBuffer()) await this.storageAdapter.put(this.ctx, this.wsIds, id, buffer, step.contentType, buffer.length) if (step.resultVariable !== undefined) { vars[`\${${step.resultVariable}}`] = id vars[`\${${step.resultVariable}_size}`] = buffer.length } } catch (error) { logger.error('Upload failed', error) throw error } } private async processImport (step: ImportStep, vars: Record, logger: ModelLogger): Promise { 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.socialKey, initPerson) await importer.importFolder(initPath) } catch (error) { logger.error('Import failed', error) throw error } } private async processFind(step: FindStep, vars: Record): Promise { const query = this.fillProps(step.query, vars) const res = await this.client.findOne(step._class, { ...(query as any) }) if (res === undefined) { throw new Error(`Document not found: ${JSON.stringify(query)}`) } if (step.resultVariable !== undefined) { vars[`\${${step.resultVariable}}`] = res } } private async processMixin(step: MixinStep, vars: Record): Promise { const data = await this.fillPropsWithMarkdown(step.data, vars, step.markdownFields) const { _id, space, ...props } = data if (_id === undefined || space === undefined) { throw new Error('Mixin step must have _id and space') } await this.client.createMixin(_id, step._class, space, step.mixin, props) } private async processUpdate(step: UpdateStep, vars: Record): Promise { const data = await this.fillPropsWithMarkdown(step.data, vars, step.markdownFields) const { _id, space, ...props } = data if (_id === undefined || space === undefined) { throw new Error('Update step must have _id and space') } await this.client.updateDoc(step._class, space, _id as Ref, props) } private async processCreate( step: CreateStep, vars: Record, defaults: Map>, Props> ): Promise { const _id = generateId() if (step.resultVariable !== undefined) { vars[`\${${step.resultVariable}}`] = _id } const data = await this.fillPropsWithMarkdown( { ...(defaults.get(step._class) ?? {}), ...step.data }, vars, step.markdownFields ) if (step.collabFields !== undefined) { for (const field of step.collabFields) { if ((data as any)[field] !== undefined) { const res = await this.createCollab((data as any)[field], step._class, _id, field) ;(data as any)[field] = res } } } await this.create(step._class, data, _id) } private parseMarkdown (text: string): string { const json = markdownToMarkup(text ?? '', { imageUrl: this.imageUrl }) return JSON.stringify(json) } private async create(_class: Ref>, data: Props, _id?: Ref): Promise> { const hierarchy = this.client.getHierarchy() if (hierarchy.isDerived(_class, core.class.AttachedDoc)) { const { space, attachedTo, attachedToClass, collection, ...props } = data as unknown as Props if ( attachedTo === undefined || space === undefined || attachedToClass === undefined || collection === undefined ) { throw new Error('Add collection step must have attachedTo, attachedToClass, collection and space') } return (await this.client.addCollection( _class, space, attachedTo, attachedToClass, collection, props, _id as Ref | undefined )) as unknown as Ref } else { const { space, ...props } = data if (space === undefined) { throw new Error('Create step must have space') } return await this.client.createDoc(_class, space, props as Data, _id) } } private async fillPropsWithMarkdown | Props>( data: P, vars: Record, markdownFields?: string[] ): Promise

{ data = await this.fillProps(data, vars) if (markdownFields !== undefined) { for (const field of markdownFields) { if ((data as any)[field] !== undefined) { try { const res = this.parseMarkdown((data as any)[field]) ;(data as any)[field] = res } catch (error) { console.log(error) } } } } return data } private async createCollab ( data: string, objectClass: Ref>, objectId: Ref, objectAttr: string ): Promise { const doc = makeCollabId(objectClass, objectId, objectAttr) const json = markdownToMarkup(data ?? '', { imageUrl: this.imageUrl }) const markup = jsonToMarkup(json) return await saveCollabJson(this.ctx, this.storageAdapter, this.wsIds, doc, markup) } private async fillProps | Props>( data: P, vars: Record ): Promise

{ for (const key in data) { const value = (data as any)[key] ;(data as any)[key] = await this.fillValue(value, vars) } return data } private async fillValue (value: any, vars: Record): Promise { if (typeof value === 'object') { if (Array.isArray(value)) { return await Promise.all(value.map(async (v) => await this.fillValue(v, vars))) } else { return await this.fillProps(value, vars) } } else if (typeof value === 'string') { if (value === this.nextRank) { const rank = makeRank(vars[this.nextRank], undefined) vars[this.nextRank] = rank return rank } else if (value === this.now) { return new Date().getTime() } else { while (true) { const matched = fieldRegexp.exec(value) if (matched === null) break const result = vars[matched[0]] if (result === undefined) { throw new Error(`Variable ${matched[0]} not found`) } else { value = value.replaceAll(matched[0], result) fieldRegexp.lastIndex = 0 } } return value } } return value } }