import core, { AnyAttribute, AttachedDoc, Class, Client, Doc, DocumentUpdate, Hierarchy, Ref, SortingOrder, Tx, TxCollectionCUD, TxCreateDoc, TxCUD, TxMixin, TxProcessor, TxUpdateDoc } from '@anticrm/core' import { createQuery, LiveQuery } from '@anticrm/presentation' /** * @public */ export type ActivityKey = string /** * @public */ export function activityKey (objectClass: Ref>, txClass: Ref>): ActivityKey { return objectClass + ':' + txClass } function isEqualOps (op1: DocumentUpdate, op2: DocumentUpdate): boolean { const o1 = Object.keys(op1).sort().join('-') const o2 = Object.keys(op2).sort().join('-') return o1 === o2 } /** * Transaction being displayed. * @public */ export interface DisplayTx { // Source tx tx: TxCUD // A set of collapsed transactions. txes: Array> // type check for createTx createTx?: TxCreateDoc // Type check for updateTx updateTx?: TxUpdateDoc // Type check for updateTx mixinTx?: TxMixin // Document in case it is required. doc?: Doc updated: boolean mixin: boolean removed: boolean } /** * @public */ export type DisplayTxListener = (txes: DisplayTx[]) => void // Use 5 minutes to combine similar transactions. const combineThreshold = 5 * 60 * 1000 /** * Define activity. * * Allow to recieve a list of transactions and notify client about it. */ export interface Activity { update: (object: Doc, listener: DisplayTxListener, sort: SortingOrder) => void } class ActivityImpl implements Activity { private readonly txQuery1: LiveQuery private readonly txQuery2: LiveQuery private readonly hiddenAttributes: Set private txes1: Array> = [] private txes2: Array> = [] constructor (readonly client: Client, attributes: Map) { this.hiddenAttributes = new Set([...attributes.entries()] .filter(([, value]) => value.hidden === true) .map(([key]) => key)) this.txQuery1 = createQuery() this.txQuery2 = createQuery() } private notify (object: Doc, listener: DisplayTxListener, sort: SortingOrder): void { this.combineTransactions(object, this.txes1, this.txes2).then( (result) => { const sorted = result.sort((a, b) => (a.tx.modifiedOn - b.tx.modifiedOn) * sort) listener(sorted) }, (err) => { console.error(err) } ) } update (object: Doc, listener: DisplayTxListener, sort: SortingOrder): void { let isAttached = false isAttached = this.client.getHierarchy().isDerived(object._class, core.class.AttachedDoc) this.txQuery1.query>( isAttached ? core.class.TxCollectionCUD : core.class.TxCUD, isAttached ? { 'tx.objectId': object._id as Ref } : { objectId: object._id, _class: { $in: [core.class.TxCreateDoc, core.class.TxUpdateDoc, core.class.TxRemoveDoc, core.class.TxMixin] } }, (result) => { this.txes1 = result this.notify(object, listener, sort) }, { sort: { modifiedOn: SortingOrder.Descending } } ) this.txQuery2.query>( core.class.TxCollectionCUD, { objectId: object._id, 'tx._class': { $in: [core.class.TxCreateDoc, core.class.TxUpdateDoc, core.class.TxRemoveDoc] } }, (result) => { this.txes2 = result this.notify(object, listener, sort) }, { sort: { modifiedOn: SortingOrder.Descending } } ) } async combineTransactions (object: Doc, txes1: Array>, txes2: Array>): Promise { const hierarchy = this.client.getHierarchy() // We need to sort with with natural order, to build a proper doc values. const allTx = Array.from(txes1).concat(txes2).sort(this.sortByLastModified) const txCUD: Array> = this.filterTxCUD(allTx, hierarchy) const parents = new Map, DisplayTx>() let results: DisplayTx[] = [] for (const tx of txCUD) { const { collectionCUD, updateCUD, mixinCUD, result, tx: ntx } = this.createDisplayTx(tx, parents) // We do not need collection object updates, in main list of displayed transactions. if (this.isDisplayTxRequired(collectionCUD, updateCUD || mixinCUD, ntx, object)) { // Combine previous update transaction for same field and if same operation and time treshold is ok results = this.integrateTxWithResults(results, result) this.updateRemovedState(result, results) } } return Array.from(results) } private updateRemovedState (result: DisplayTx, results: DisplayTx[]): void { if (result.removed) { // We need to mark all transactions for same object as removed as well. for (const t of results) { if (t.tx.objectId === result.tx.objectId) { t.removed = true } } } } sortByLastModified (a: TxCUD, b: TxCUD): number { return a.modifiedOn - b.modifiedOn } isDisplayTxRequired (collectionCUD: boolean, cudOp: boolean, ntx: TxCUD, object: Doc): boolean { return !(collectionCUD && cudOp) || ntx.objectId === object._id } private readonly getUpdateTx = (tx: TxCUD): TxUpdateDoc | undefined => { if (tx._class !== core.class.TxCollectionCUD) { return undefined } const colTx = tx as TxCollectionCUD if (colTx.tx._class !== core.class.TxUpdateDoc) { return undefined } return colTx.tx as TxUpdateDoc } filterTxCUD (allTx: Array>, hierarchy: Hierarchy): Array> { return allTx .filter((tx) => hierarchy.isDerived(tx._class, core.class.TxCUD)) .filter((tx) => { const utx = this.getUpdateTx(tx) if (utx === undefined) { return true } const ops = Object.keys(utx.operations) if (ops.length > 1) { return true } return !this.hiddenAttributes.has(ops[0]) }) } createDisplayTx ( tx: TxCUD, parents: Map, DisplayTx> ): { collectionCUD: boolean, updateCUD: boolean, mixinCUD: boolean, result: DisplayTx, tx: TxCUD } { let collectionCUD = false let updateCUD = false let mixinCUD = false const hierarchy = this.client.getHierarchy() if (hierarchy.isDerived(tx._class, core.class.TxCollectionCUD)) { tx = getCollectionTx(tx as TxCollectionCUD) collectionCUD = true } let firstTx = parents.get(tx.objectId) const result: DisplayTx = this.newDisplayTx(tx) result.doc = firstTx?.doc ?? result.doc firstTx = firstTx ?? result parents.set(tx.objectId, firstTx) // If we have updates also apply them all. updateCUD = this.checkUpdateState(result, firstTx) mixinCUD = this.checkMixinState(result, firstTx) this.checkRemoveState(hierarchy, tx, firstTx, result) return { collectionCUD, updateCUD, mixinCUD, result, tx } } private checkRemoveState (hierarchy: Hierarchy, tx: TxCUD, firstTx: DisplayTx, result: DisplayTx): void { if (hierarchy.isDerived(tx._class, core.class.TxRemoveDoc)) { firstTx.removed = true result.removed = true } } checkUpdateState (result: DisplayTx, firstTx: DisplayTx): boolean { if (this.client.getHierarchy().isDerived(result.tx._class, core.class.TxUpdateDoc) && result.doc !== undefined) { firstTx.doc = TxProcessor.updateDoc2Doc(result.doc, result.tx as TxUpdateDoc) firstTx.updated = true result.updated = true return true } return false } checkMixinState (result: DisplayTx, firstTx: DisplayTx): boolean { if (this.client.getHierarchy().isDerived(result.tx._class, core.class.TxMixin) && result.doc !== undefined) { const mix = result.tx as TxMixin firstTx.doc = TxProcessor.updateMixin4Doc(result.doc, mix.mixin, mix.attributes) firstTx.mixin = true result.mixin = true return true } return false } newDisplayTx (tx: TxCUD): DisplayTx { const hierarchy = this.client.getHierarchy() const createTx = hierarchy.isDerived(tx._class, core.class.TxCreateDoc) ? (tx as TxCreateDoc) : undefined return { tx, txes: [], createTx, updateTx: hierarchy.isDerived(tx._class, core.class.TxUpdateDoc) ? (tx as TxUpdateDoc) : undefined, updated: false, removed: false, mixin: false, mixinTx: hierarchy.isDerived(tx._class, core.class.TxMixin) ? (tx as TxMixin) : undefined, doc: createTx !== undefined ? TxProcessor.createDoc2Doc(createTx) : undefined } } integrateTxWithResults (results: DisplayTx[], result: DisplayTx): DisplayTx[] { const curUpdate: any = (result.tx._class === core.class.TxUpdateDoc) ? (result.tx as unknown as TxUpdateDoc).operations : (result.tx as unknown as TxMixin).attributes const newResult = results.filter((prevTx) => { if (this.isSameKindTx(prevTx, result, result.tx._class)) { const prevUpdate: any = (prevTx.tx._class === core.class.TxUpdateDoc) ? (prevTx.tx as unknown as TxUpdateDoc).operations : (prevTx.tx as unknown as TxMixin).attributes if ( result.tx.modifiedOn - prevTx.tx.modifiedOn < combineThreshold && isEqualOps(prevUpdate, curUpdate) ) { // we have same keys, // Remember previous transactions result.txes.push(...prevTx.txes, prevTx.tx) return false } } return true }) newResult.push(result) return newResult } isSameKindTx (prevTx: DisplayTx, result: DisplayTx, _class: Ref>): boolean { return ( prevTx.tx.objectId === result.tx.objectId && // Same document id prevTx.tx._class === result.tx._class && // Same transaction class result.tx._class === _class && prevTx.tx.modifiedBy === result.tx.modifiedBy // Same user ) } } function getCollectionTx (cltx: TxCollectionCUD): TxCUD { if (cltx.tx._class === core.class.TxCreateDoc) { // We need to update tx to contain attachedDoc, attachedClass & collection const create = cltx.tx as TxCreateDoc create.attributes.attachedTo = cltx.objectId create.attributes.attachedToClass = cltx.objectClass create.attributes.collection = cltx.collection return create } return cltx.tx } /** * Construct an new activity, to listend for displayed transactions in UI. * @param client */ export function newActivity (client: Client, attributes: Map): Activity { return new ActivityImpl(client, attributes) }