From 527e382f8ac966e8b09cd02ca516e81d0ffae8e7 Mon Sep 17 00:00:00 2001 From: Kevin Jahns Date: Tue, 29 Apr 2025 22:42:56 +0200 Subject: [PATCH] implement createAttributionsManagerFromDiff that automatically handles gc --- src/index.js | 5 +- src/types/AbstractType.js | 6 +-- src/types/YText.js | 4 +- src/types/YXmlElement.js | 2 +- src/utils/AttributionManager.js | 81 +++++++++++++++++++++++++++++++-- src/utils/IdMap.js | 2 +- src/utils/IdSet.js | 4 +- tests/IdMap.tests.js | 4 +- tests/testHelper.js | 2 +- tests/y-xml.tests.js | 51 +++++++++++++++++++-- 10 files changed, 140 insertions(+), 21 deletions(-) diff --git a/src/index.js b/src/index.js index b7df9103..4dfa0b3c 100644 --- a/src/index.js +++ b/src/index.js @@ -104,7 +104,7 @@ export { createDeleteSetFromStructStore, IdMap, createIdMap, - createAttribution, + createAttributionItem, createInsertionSetFromStructStore, diffIdMap, diffIdSet, @@ -112,7 +112,8 @@ export { encodeIdMap, createIdMapFromIdSet, TwosetAttributionManager, - noAttributionsManager + noAttributionsManager, + createAttributionManagerFromDiff } from './internals.js' const glo = /** @type {any} */ (typeof globalThis !== 'undefined' diff --git a/src/types/AbstractType.js b/src/types/AbstractType.js index 2f1b6f7e..7f4161d7 100644 --- a/src/types/AbstractType.js +++ b/src/types/AbstractType.js @@ -10,7 +10,7 @@ import { ContentAny, ContentBinary, getItemCleanStart, - ContentDoc, YText, YArray, UpdateEncoderV1, UpdateEncoderV2, Doc, Snapshot, Transaction, EventHandler, YEvent, Item, createAttributionFromAttrs, AbstractAttributionManager, // eslint-disable-line + ContentDoc, YText, YArray, UpdateEncoderV1, UpdateEncoderV2, Doc, Snapshot, Transaction, EventHandler, YEvent, Item, createAttributionFromAttributionItems, AbstractAttributionManager, // eslint-disable-line } from '../internals.js' import * as delta from '../utils/Delta.js' @@ -518,7 +518,7 @@ export const typeListGetContent = (type, am) => { } for (let i = 0; i < cs.length; i++) { const { content, deleted, attrs } = cs[i] - const attribution = createAttributionFromAttrs(attrs, deleted) + const attribution = createAttributionFromAttributionItems(attrs, deleted) d.insert(content.getContent(), null, attribution) } } @@ -1006,7 +1006,7 @@ export const typeMapGetContent = (parent, am) => { am.readContent(cs, item) const { deleted, attrs, content } = cs[cs.length - 1] const c = array.last(content.getContent()) - const attribution = createAttributionFromAttrs(attrs, deleted) + const attribution = createAttributionFromAttributionItems(attrs, deleted) if (deleted) { mapcontent[key] = { prevValue: c, value: undefined, attribution } } else { diff --git a/src/types/YText.js b/src/types/YText.js index fa32cc80..6e08563f 100644 --- a/src/types/YText.js +++ b/src/types/YText.js @@ -27,7 +27,7 @@ import { ContentType, warnPrematureAccess, noAttributionsManager, AbstractAttributionManager, ArraySearchMarker, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, ID, Doc, Item, Snapshot, Transaction, // eslint-disable-line - createAttributionFromAttrs + createAttributionFromAttributionItems } from '../internals.js' import * as delta from '../utils/Delta.js' @@ -1038,7 +1038,7 @@ export class YText extends AbstractType { } for (let i = 0; i < cs.length; i++) { const { content, deleted, attrs } = cs[i] - const attribution = createAttributionFromAttrs(attrs, deleted) + const attribution = createAttributionFromAttributionItems(attrs, deleted) switch (content.constructor) { case ContentString: { d.insert(/** @type {ContentString} */ (content).str, null, attribution) diff --git a/src/types/YXmlElement.js b/src/types/YXmlElement.js index aca5fbb0..25e03aa6 100644 --- a/src/types/YXmlElement.js +++ b/src/types/YXmlElement.js @@ -254,7 +254,7 @@ export class YXmlElement extends YXmlFragment { getContent (am = noAttributionsManager) { const attributes = typeMapGetContent(this, am) const { children } = super.getContent(am) - return { children, attributes } + return { nodeName: this.nodeName, children, attributes } } /** diff --git a/src/utils/AttributionManager.js b/src/utils/AttributionManager.js index 7ae17eb5..3373c91c 100644 --- a/src/utils/AttributionManager.js +++ b/src/utils/AttributionManager.js @@ -1,5 +1,12 @@ import { - Item, AbstractContent, IdMap // eslint-disable-line + getItem, + diffIdSet, + createInsertionSetFromStructStore, + createDeleteSetFromStructStore, + createIdMapFromIdSet, + ContentDeleted, + Doc, Item, AbstractContent, IdMap, // eslint-disable-line + findIndexCleanStart } from '../internals.js' import * as error from 'lib0/error' @@ -21,7 +28,7 @@ import * as error from 'lib0/error' * @param {boolean} deleted - whether the attributed item is deleted * @return {Attribution?} */ -export const createAttributionFromAttrs = (attrs, deleted) => { +export const createAttributionFromAttributionItems = (attrs, deleted) => { /** * @type {Attribution?} */ @@ -84,8 +91,6 @@ export class AbstractAttributionManager { } /** - * Abstract class for associating Attributions to content / changes - * * @implements AbstractAttributionManager */ export class TwosetAttributionManager { @@ -136,3 +141,71 @@ export class NoAttributionsManager { } export const noAttributionsManager = new NoAttributionsManager() + +/** + * @implements AbstractAttributionManager + */ +export class DiffAttributionManager { + /** + * @param {IdMap} inserts + * @param {IdMap} deletes + * @param {Doc} prevDoc + * @param {Doc} nextDoc + */ + constructor (inserts, deletes, prevDoc, nextDoc) { + this.inserts = inserts + this.deletes = deletes + this._prevDocStore = prevDoc.store + this._nextDoc = nextDoc + } + + /** + * @param {Array>} contents + * @param {Item} item + */ + readContent (contents, item) { + const deleted = item.deleted || /** @type {any} */ (item.parent).doc !== this._nextDoc + const slice = (deleted ? this.deletes : this.inserts).slice(item.id, item.length) + let content = slice.length === 1 ? item.content : item.content.copy() + if (content instanceof ContentDeleted && slice[0].attrs != null) { + // Retrieved item is never more fragmented than the newer item. + const prevItem = getItem(this._prevDocStore, item.id) + content = prevItem.length > 1 ? prevItem.content.copy() : prevItem.content + // trim itemContent to the correct size. + const diffStart = prevItem.id.clock - item.id.clock + const diffEnd = prevItem.id.clock + prevItem.length - item.id.clock - item.length + if (diffStart > 0) { + content = content.splice(diffStart) + } + if (diffEnd > 0) { + content.splice(content.getLength() - diffEnd) + } + } + slice.forEach(s => { + const c = content + if (s.len < c.getLength()) { + content = c.splice(s.len) + } + if (!deleted || s.attrs != null) { + contents.push(new AttributedContent(c, deleted, s.attrs)) + } + }) + } +} + + + +/** + * Attribute changes from ydoc1 to ydoc2. + * + * @param {Doc} prevDoc + * @param {Doc} nextDoc + */ +export const createAttributionManagerFromDiff = (prevDoc, nextDoc) => { + const inserts = diffIdSet(createInsertionSetFromStructStore(nextDoc.store), createInsertionSetFromStructStore(prevDoc.store)) + const deletes = diffIdSet(createDeleteSetFromStructStore(nextDoc.store), createDeleteSetFromStructStore(prevDoc.store)) + const insertMap = createIdMapFromIdSet(inserts, []) + const deleteMap = createIdMapFromIdSet(deletes, []) + // @todo, get deletes from the older doc + return new DiffAttributionManager(insertMap, deleteMap, prevDoc, nextDoc) +} diff --git a/src/utils/IdMap.js b/src/utils/IdMap.js index 335657c0..7ec75aec 100644 --- a/src/utils/IdMap.js +++ b/src/utils/IdMap.js @@ -49,7 +49,7 @@ const _hashAttribution = attr => { * @param {V} val * @return {AttributionItem} */ -export const createAttribution = (name, val) => new AttributionItem(name, val) +export const createAttributionItem = (name, val) => new AttributionItem(name, val) /** * @template T diff --git a/src/utils/IdSet.js b/src/utils/IdSet.js index 82fda299..f225e750 100644 --- a/src/utils/IdSet.js +++ b/src/utils/IdSet.js @@ -312,11 +312,11 @@ export const _diffSet = (set, exclude) => { } /** - * Remove all ranges from `exclude` from `ds`. The result is a fresh IdSet containing all ranges from `idSet` that are not + * Remove all ranges from `exclude` from `idSet`. The result is a fresh IdSet containing all ranges from `idSet` that are not * in `exclude`. * * @template {IdSet} Set - * @param {Set} set + * @param {Set} idSet * @param {IdSet | IdMap} exclude * @return {Set} */ diff --git a/tests/IdMap.tests.js b/tests/IdMap.tests.js index cf7ddae7..82cbcad1 100644 --- a/tests/IdMap.tests.js +++ b/tests/IdMap.tests.js @@ -1,6 +1,6 @@ import * as t from 'lib0/testing' import * as idmap from '../src/utils/IdMap.js' -import { compareIdmaps, createIdMap, ID, createRandomIdSet, createRandomIdMap, createAttribution } from './testHelper.js' +import { compareIdmaps, createIdMap, ID, createRandomIdSet, createRandomIdMap, createAttributionItem } from './testHelper.js' import * as YY from '../src/internals.js' /** @@ -10,7 +10,7 @@ import * as YY from '../src/internals.js' const simpleConstructAttrs = ops => { const attrs = createIdMap() ops.forEach(op => { - attrs.add(op[0], op[1], op[2], op[3].map(v => createAttribution('', v))) + attrs.add(op[0], op[1], op[2], op[3].map(v => createAttributionItem('', v))) }) return attrs } diff --git a/tests/testHelper.js b/tests/testHelper.js index da0eef70..14b6b949 100644 --- a/tests/testHelper.js +++ b/tests/testHelper.js @@ -436,7 +436,7 @@ export const createRandomIdMap = (gen, clients, clockRange, attrChoices) => { attrs.push(a) } } - idMap.add(client, clockStart, len, attrs.map(v => Y.createAttribution('', v))) + idMap.add(client, clockStart, len, attrs.map(v => Y.createAttributionItem('', v))) } t.info(`Created IdMap with ${numOfOps} ranges and ${attrChoices.length} different attributes. Encoded size: ${encodeIdMap(idMap).byteLength}`) return idMap diff --git a/tests/y-xml.tests.js b/tests/y-xml.tests.js index 72dd9dc4..c3d0fd33 100644 --- a/tests/y-xml.tests.js +++ b/tests/y-xml.tests.js @@ -284,9 +284,54 @@ export const testElementAttributedContent = _tc => { null, { delete: [] } ).insert([{ nodeName: 'span', children: delta.createArrayDelta(), attributes: {} }]) - .insert([ - delta.createTextDelta().insert('world', null, { insert: [] }) - ], null, { insert: [] }) + .insert([ + delta.createTextDelta().insert('world', null, { insert: [] }) + ], null, { insert: [] }) + const attributedContent = yelement.getContentDeep(attributionManager) + console.log('children', JSON.stringify(attributedContent.children.toJSON().ops, null, 2)) + console.log('cs expec', JSON.stringify(expectedContent.toJSON().ops, null, 2)) + console.log('attributes', attributedContent.attributes) + t.assert(attributedContent.children.equals(expectedContent)) + t.compare(attributedContent.attributes, { key: { prevValue: undefined, value: '42', attribution: { insert: [] } } }) + t.assert(attributedContent.nodeName === 'UNDEFINED') + }) + }) +} + +/** + * @param {t.TestCase} _tc + */ +export const testElementAttributedContentViaDiffer = _tc => { + const ydocV1 = new Y.Doc() + ydocV1.getXmlElement('p').insert(0, [new Y.XmlText('hello'), new Y.XmlElement('span')]) + const ydoc = new Y.Doc() + Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(ydocV1)) + const yelement = ydoc.getXmlElement('p') + const elem1 = yelement.get(0) // new Y.XmlText('hello') + const elem2 = yelement.get(1) // new Y.XmlElement('span') + const elem3 = new Y.XmlText('world') + t.group('insert / delete', () => { + ydoc.transact(() => { + yelement.delete(0, 1) + yelement.insert(1, [elem3]) + yelement.setAttribute('key', '42') + }) + const attributionManager = Y.createAttributionManagerFromDiff(ydocV1, ydoc) + const expectedContent = delta.createArrayDelta().insert([delta.createTextDelta().insert('hello', null, { delete: [] })], null, { delete: [] }).insert([elem2.getContentDeep()]).insert([delta.createTextDelta().insert('world', null, { insert: [] })], null, { insert: [] }) + const attributedContent = yelement.getContentDeep(attributionManager) + console.log('children', attributedContent.children.toJSON().ops) + console.log('attributes', attributedContent.attributes) + t.assert(attributedContent.children.equals(expectedContent)) + t.compare(attributedContent.attributes, { key: { prevValue: undefined, value: '42', attribution: { insert: [] } } }) + t.group('test getContentDeep', () => { + const expectedContent = delta.createArrayDelta().insert( + [delta.createTextDelta().insert('hello', null, { delete: [] })], + null, + { delete: [] } + ).insert([{ nodeName: 'span', children: delta.createArrayDelta(), attributes: {} }]) + .insert([ + delta.createTextDelta().insert('world', null, { insert: [] }) + ], null, { insert: [] }) const attributedContent = yelement.getContentDeep(attributionManager) console.log('children', JSON.stringify(attributedContent.children.toJSON().ops, null, 2)) console.log('cs expec', JSON.stringify(expectedContent.toJSON().ops, null, 2))