import core, { AttachedDoc, Class, Client, Doc, DocumentUpdate, Hierarchy, Ref, SortingOrder, Tx, TxCollectionCUD, TxCreateDoc, TxCUD, 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 // Document in case it is required. doc?: Doc updated: 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 txes1: Array> = [] private txes2: Array> = [] constructor (readonly client: Client) { 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] } }, (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>() const results: DisplayTx[] = [] for (const tx of txCUD) { const { collectionCUD, updateCUD, 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, ntx, object)) { // Combine previous update transaction for same field and if same operation and time treshold is ok this.checkIntegratePreviousTx(results, result) results.push(result) this.updateRemovedState(result, results) } } console.log('DISPLAY TX', 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, updateCUD: boolean, ntx: TxCUD, object: Doc): boolean { return !(collectionCUD && updateCUD) || ntx.objectId === object._id } filterTxCUD (allTx: Array>, hierarchy: Hierarchy): Array> { return allTx.filter((tx) => hierarchy.isDerived(tx._class, core.class.TxCUD)) } createDisplayTx ( tx: TxCUD, parents: Map, DisplayTx> ): { collectionCUD: boolean, updateCUD: boolean, result: DisplayTx, tx: TxCUD } { let collectionCUD = false let updateCUD = 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) this.checkRemoveState(hierarchy, tx, firstTx, result) return { collectionCUD, updateCUD, 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 } 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, doc: createTx !== undefined ? TxProcessor.createDoc2Doc(createTx) : undefined } } checkIntegratePreviousTx (results: DisplayTx[], result: DisplayTx): void { if (results.length > 0) { const prevTx = results[results.length - 1] if (this.isSameKindTx(prevTx, result)) { const prevUpdate = prevTx.tx as unknown as TxUpdateDoc const curUpdate = result.tx as unknown as TxUpdateDoc if ( isEqualOps(prevUpdate.operations, curUpdate.operations) && result.tx.modifiedOn - prevUpdate.modifiedOn < combineThreshold ) { // we have same keys, l // Remember previous transactions result.txes.push(...prevTx.txes, prevTx.tx) // Remove last item results.splice(results.length - 1, 1) } } } } isSameKindTx (prevTx: DisplayTx, result: DisplayTx): boolean { return ( prevTx.tx.objectId === result.tx.objectId && // Same document id prevTx.tx._class === result.tx._class && // Same transaction class result.tx._class === core.class.TxUpdateDoc && 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): Activity { return new ActivityImpl(client) }