import attachment, { Attachment } from '@hcengineering/attachment' import chunter, { Comment } from '@hcengineering/chunter' import contact, { Channel, combineName, Contact, EmployeeAccount } from '@hcengineering/contact' import core, { Account, AccountRole, ApplyOperations, AttachedDoc, Class, concatLink, Data, Doc, DocumentUpdate, FindResult, generateId, Hierarchy, Mixin, MixinData, MixinUpdate, Ref, Space, Timestamp, TxOperations, TxProcessor, WithLookup } from '@hcengineering/core' import gmail, { Message } from '@hcengineering/gmail' import recruit from '@hcengineering/recruit' import tags, { TagElement } from '@hcengineering/tags' import { deepEqual } from 'fast-equals' import { BitrixClient } from './client' import bitrix from './index' import { BitrixActivity, BitrixEntityMapping, BitrixEntityType, BitrixFieldMapping, BitrixFiles, BitrixOwnerType, BitrixSyncDoc, LoginInfo } from './types' import { convert, ConvertResult } from './utils' async function updateDoc (client: ApplyOperations, doc: Doc, raw: Doc | Data, date: Timestamp): Promise { // We need to update fields if they are different. const documentUpdate: DocumentUpdate = {} for (const [k, v] of Object.entries(raw)) { if (['_class', '_id', 'modifiedBy', 'modifiedOn', 'space', 'attachedTo', 'attachedToClass'].includes(k)) { continue } const dv = (doc as any)[k] if (!deepEqual(dv, v) && v != null) { ;(documentUpdate as any)[k] = v } } if (Object.keys(documentUpdate).length > 0) { await client.update(doc, documentUpdate, false, date, doc.modifiedBy) TxProcessor.applyUpdate(doc, documentUpdate) } return doc } async function updateMixin ( client: ApplyOperations, doc: Doc, raw: Doc | Data, mixin: Ref>>, modifiedBy: Ref, modifiedOn: Timestamp ): Promise { // We need to update fields if they are different. if (!client.getHierarchy().hasMixin(doc, mixin)) { await client.createMixin(doc._id, doc._class, doc.space, mixin, raw as MixinData, modifiedOn, modifiedBy) return doc } const documentUpdate: MixinUpdate = {} for (const [k, v] of Object.entries(raw)) { if (['_class', '_id', 'modifiedBy', 'modifiedOn', 'space', 'attachedTo', 'attachedToClass'].includes(k)) { continue } const dv = (doc as any)[k] if (!deepEqual(dv, v) && v != null) { ;(documentUpdate as any)[k] = v } } if (Object.keys(documentUpdate).length > 0) { await client.updateMixin(doc._id, doc._class, doc.space, mixin, documentUpdate, modifiedOn, modifiedBy) } return doc } /** * @public */ export async function syncDocument ( client: TxOperations, existing: Doc | undefined, resultDoc: ConvertResult, info: LoginInfo, frontUrl: string, ops: SyncOptions, extraDocs: Map>, Doc[]>, monitor?: (doc: ConvertResult) => void ): Promise { const st = Date.now() const hierarchy = client.getHierarchy() try { if (existing !== undefined) { // We need update document id. resultDoc.document._id = existing._id as Ref } const applyOp = client.apply(resultDoc.document._id) // Operations could add more change instructions for (const op of resultDoc.postOperations) { await op(resultDoc, extraDocs, ops, existing) } // const newDoc = existing === undefined existing = await updateMainDoc(applyOp) const mixins = { ...resultDoc.mixins } // Add bitrix sync mixin mixins[bitrix.mixin.BitrixSyncDoc] = { type: resultDoc.document.type, bitrixId: resultDoc.document.bitrixId, syncTime: Date.now() } // Check and update mixins await updateMixins(mixins, hierarchy, existing, applyOp, resultDoc.document) // Just create supplier documents, like TagElements. for (const ed of resultDoc.extraDocs) { const { _class, space, _id, ...data } = ed await applyOp.createDoc(_class, space, data, _id, resultDoc.document.modifiedOn, resultDoc.document.modifiedBy) } const idMapping = new Map, Ref>() // Find all attachment documents to existing. const byClass = new Map>, (AttachedDoc & BitrixSyncDoc)[]>() for (const d of resultDoc.extraSync) { byClass.set(d._class, [...(byClass.get(d._class) ?? []), d]) } for (const [cl, vals] of byClass.entries()) { await syncClass(applyOp, cl, vals, idMapping, resultDoc.document._id) } if (ops.syncAttachments ?? true) { // Sync gmail documents const emailAccount = resultDoc.extraSync.find( (it) => it._class === contact.class.Channel && (it as unknown as Channel).provider === contact.channelProvider.Email ) if (resultDoc.gmailDocuments.length > 0 && emailAccount !== undefined) { const emailReadId = idMapping.get(emailAccount._id) ?? emailAccount._id await syncClass(applyOp, gmail.class.Message, resultDoc.gmailDocuments, idMapping, emailReadId) } const attachIds = Array.from( new Set(resultDoc.blobs.map((it) => idMapping.get(it[0].attachedTo) ?? it[0].attachedTo)).values() ) const existingBlobs = await client.findAll(attachment.class.Attachment, { attachedTo: { $in: [resultDoc.document._id, ...attachIds] } }) for (const [ed, op, upd] of resultDoc.blobs) { let existing = existingBlobs.find((it) => { const bdoc = hierarchy.as(it, bitrix.mixin.BitrixSyncDoc) return bdoc.bitrixId === ed.bitrixId }) // Check attachment document exists in our storage. if (existing !== undefined) { const ex = existing try { const resp = await fetch(concatLink(frontUrl, `/files?file=${existing?.file}&token=${info.token}`), { method: 'GET' }) if (!resp.ok) { // Attachment is broken and need to be re-added. await applyOp.remove(ex) existing = undefined } } catch (err: any) { console.error(err) await applyOp.remove(ex) existing = undefined } } // For Attachments, just do it once per attachment and assume it is not changed. if (existing === undefined) { const attachmentId: Ref = generateId() try { const edData = await op() if (edData === undefined) { console.error('Failed to retrieve document data', ed.name) continue } const data = new FormData() data.append('file', edData) upd(edData, ed) ed.lastModified = edData.lastModified ed.size = edData.size ed.type = edData.type let updated = false for (const existingObj of existingBlobs) { if ( existingObj.name === ed.name && existingObj.size === ed.size && (existingObj.type ?? null) === (ed.type ?? null) ) { if (!updated) { await updateAttachedDoc(existingObj, applyOp, ed) updated = true } else { // Remove duplicate attachment await applyOp.remove(existingObj) } } } if (!updated) { // No attachment, send to server const resp = await fetch(concatLink(frontUrl, '/files'), { method: 'POST', headers: { Authorization: 'Bearer ' + info.token }, body: data }) if (resp.status === 200) { const uuid = await resp.text() ed.file = uuid ed._id = attachmentId as Ref await updateAttachedDoc(undefined, applyOp, ed) } } } catch (err: any) { console.error(err) } } } } console.log('Syncronized before commit', resultDoc.document._class, resultDoc.document.bitrixId, Date.now() - st) await applyOp.commit() const ed = Date.now() console.log('Syncronized', resultDoc.document._class, resultDoc.document.bitrixId, ed - st) } catch (err: any) { console.error(err) } monitor?.(resultDoc) async function syncClass ( applyOp: ApplyOperations, cl: Ref>, vals: (AttachedDoc & BitrixSyncDoc)[], idMapping: Map, Ref>, attachedTo: Ref ): Promise { if (applyOp.getHierarchy().isDerived(cl, core.class.AttachedDoc)) { const existingByClass = await client.findAll(cl, { attachedTo }) for (const valValue of vals) { const id = idMapping.get(valValue.attachedTo) if (id !== undefined) { valValue.attachedTo = id } else { // Update document id, for existing document. valValue.attachedTo = attachedTo } const existingIdx = existingByClass.findIndex((it) => { const bdoc = hierarchy.as(it, bitrix.mixin.BitrixSyncDoc) return bdoc.bitrixId === valValue.bitrixId && (bdoc.type ?? null) === (valValue.type ?? null) }) let existing: Doc | undefined if (existingIdx >= 0) { existing = existingByClass.splice(existingIdx, 1).shift() if (existing !== undefined) { idMapping.set(valValue._id, existing._id) } } await updateAttachedDoc(existing, applyOp, valValue) } // Remove previous merged documents, probable they are deleted in bitrix or wrongly migrated. for (const doc of existingByClass) { await applyOp.remove(doc) } } } async function updateAttachedDoc ( existing: WithLookup | undefined, applyOp: ApplyOperations, valValue: AttachedDoc & BitrixSyncDoc ): Promise { if (existing !== undefined) { // We need to update fields if they are different. existing = await updateDoc(applyOp, existing, valValue, Date.now()) const existingM = hierarchy.as(existing, bitrix.mixin.BitrixSyncDoc) await updateMixin( applyOp, existingM, { type: valValue.type, bitrixId: valValue.bitrixId }, bitrix.mixin.BitrixSyncDoc, valValue.modifiedBy, valValue.modifiedOn ) } else { const { bitrixId, ...data } = valValue await applyOp.addCollection( valValue._class, valValue.space, valValue.attachedTo, valValue.attachedToClass, valValue.collection, data, valValue._id, valValue.modifiedOn, valValue.modifiedBy ) await applyOp.createMixin( valValue._id, valValue._class, valValue.space, bitrix.mixin.BitrixSyncDoc, { type: valValue.type, bitrixId: valValue.bitrixId }, valValue.modifiedOn, valValue.modifiedBy ) } } async function updateMainDoc (applyOp: ApplyOperations): Promise { if (existing !== undefined) { // We need to update fields if they are different. return (await updateDoc(applyOp, existing, resultDoc.document, resultDoc.document.modifiedOn)) as BitrixSyncDoc // Go over extra documents. } else { const { bitrixId, ...data } = resultDoc.document const id = await applyOp.createDoc( resultDoc.document._class, resultDoc.document.space, data, resultDoc.document._id, resultDoc.document.modifiedOn, resultDoc.document.modifiedBy ) resultDoc.document._id = id as Ref return resultDoc.document } } } async function updateMixins ( mixins: Record>, Data>, hierarchy: Hierarchy, existing: Doc, applyOp: ApplyOperations, resultDoc: BitrixSyncDoc ): Promise { for (const [m, mv] of Object.entries(mixins)) { const mRef = m as Ref> if (!hierarchy.hasMixin(existing, mRef)) { await applyOp.createMixin( resultDoc._id, resultDoc._class, resultDoc.space, m as Ref>, mv, resultDoc.modifiedOn, resultDoc.modifiedBy ) } else { const existingM = hierarchy.as(existing, mRef) await updateMixin(applyOp, existingM, mv, mRef, resultDoc.modifiedBy, resultDoc.modifiedOn) } } } /** * @public */ export function processComment (comment: string): string { comment = comment.replaceAll('\n', '\n
') comment = comment.replaceAll(/\[(\/?[^[\]]+)]/gi, (text: string, args: string) => { if (args.startsWith('/URL')) { return '' } if (args.startsWith('URL=')) { return `` } if (args.includes('/FONT')) { return '' } if (args.includes('FONT')) { return `` } if (args.includes('/SIZE')) { return '' } if (args.includes('SIZE')) { return `` } if (args.includes('/COLOR')) { return '' } if (args.includes('COLOR')) { return `` } if (args.includes('/IMG')) { return '"/>' } if (args.includes('IMG')) { return `