diff --git a/libraries/overleaf-editor-core/index.js b/libraries/overleaf-editor-core/index.js index e5f363441b..028eefabf9 100644 --- a/libraries/overleaf-editor-core/index.js +++ b/libraries/overleaf-editor-core/index.js @@ -29,6 +29,15 @@ const safePathname = require('./lib/safe_pathname') const Snapshot = require('./lib/snapshot') const util = require('./lib/util') const V2DocVersions = require('./lib/v2_doc_versions') +const { + InsertOp, + RemoveOp, + RetainOp, + ScanOp, +} = require('./lib/operation/scan_op') +const TrackedChange = require('./lib/file_data/tracked_change') +const TrackedChangeList = require('./lib/file_data/tracked_change_list') +const Range = require('./lib/range') exports.AddCommentOperation = AddCommentOperation exports.Author = Author @@ -61,3 +70,10 @@ exports.safePathname = safePathname exports.Snapshot = Snapshot exports.util = util exports.V2DocVersions = V2DocVersions +exports.ScanOp = ScanOp +exports.InsertOp = InsertOp +exports.RetainOp = RetainOp +exports.RemoveOp = RemoveOp +exports.TrackedChangeList = TrackedChangeList +exports.TrackedChange = TrackedChange +exports.Range = Range diff --git a/services/project-history/app/js/ChunkTranslator.js b/services/project-history/app/js/ChunkTranslator.js index 68f28d0da0..b5707fa3a0 100644 --- a/services/project-history/app/js/ChunkTranslator.js +++ b/services/project-history/app/js/ChunkTranslator.js @@ -4,6 +4,19 @@ import OError from '@overleaf/o-error' import * as HistoryStoreManager from './HistoryStoreManager.js' import * as WebApiManager from './WebApiManager.js' import * as Errors from './Errors.js' +import { + TextOperation, + InsertOp, + RemoveOp, + RetainOp, + Range, + TrackedChangeList, +} from 'overleaf-editor-core' + +/** + * @typedef {import('overleaf-editor-core/lib/types').RawEditOperation} RawEditOperation + * @typedef {import('overleaf-editor-core/lib/types').TrackedChangeRawData} TrackedChangeRawData + */ export function convertToSummarizedUpdates(chunk, callback) { const version = chunk.chunk.startVersion @@ -242,6 +255,28 @@ class UpdateSetBuilder { } } +/** + * @param {string} content + * @param {TrackedChangeList} trackedChanges + * @returns {string} + */ +function removeTrackedDeletesFromString(content, trackedChanges) { + let result = '' + let cursor = 0 + const trackedDeletes = trackedChanges.trackedChanges.filter( + tc => tc.tracking.type === 'delete' + ) + for (const trackedChange of trackedDeletes) { + if (cursor < trackedChange.range.start) { + result += content.slice(cursor, trackedChange.range.start) + } + // skip the tracked change itself + cursor = trackedChange.range.end + } + result += content.slice(cursor) + return result +} + class File { constructor(pathname, snapshot, initialVersion) { this.pathname = pathname @@ -263,49 +298,75 @@ class File { // Binary file return callback(null, { binary: true }) } - HistoryStoreManager.getProjectBlob( - historyId, - this.snapshot.hash, - (error, content) => { - if (error != null) { - return callback(OError.tag(error)) - } - let initialContent = content - const updates = [] - for (let operation of this.operations) { - let authors, ops, timestamp, version - ;({ authors, timestamp, version, operation } = operation) - ;({ content, ops } = this._convertTextOperation(content, operation)) - - // Keep updating our initialContent, until we're actually in the version - // we want to diff, at which point initialContent is the content just before - // the diff updates we will return - if (version < fromVersion) { - initialContent = content - } - - // We only need to return the updates between fromVersion and toVersion - if (fromVersion <= version && version < toVersion) { - updates.push({ - meta: { - users: authors, - start_ts: timestamp.getTime(), - end_ts: timestamp.getTime(), - }, - v: version, - op: ops, - }) - } - } - - callback(null, { initialContent, updates }) + this._loadContentAndRanges(historyId, (error, content, ranges) => { + if (error != null) { + return callback(OError.tag(error)) } - ) + const trackedChanges = TrackedChangeList.fromRaw( + ranges?.trackedChanges || [] + ) + /** @type {string | undefined} */ + let initialContent + const updates = [] + + for (let operation of this.operations) { + if (!('textOperation' in operation.operation)) { + // We only care about text operations + continue + } + let authors, ops, timestamp, version + ;({ authors, timestamp, version, operation } = operation) + // Set the initialContent to the latest version we have before the diff + // begins. 'version' here refers to the document version as we are + // applying the updates. So we store the content *before* applying the + // updates. + if (version >= fromVersion && initialContent === undefined) { + initialContent = removeTrackedDeletesFromString( + content, + trackedChanges + ) + } + + ;({ content, ops } = this._convertTextOperation( + content, + operation, + trackedChanges + )) + + // We only need to return the updates between fromVersion and toVersion + if (fromVersion <= version && version < toVersion) { + updates.push({ + meta: { + users: authors, + start_ts: timestamp.getTime(), + end_ts: timestamp.getTime(), + }, + v: version, + op: ops, + }) + } + } + + if (initialContent === undefined) { + initialContent = removeTrackedDeletesFromString(content, trackedChanges) + } + callback(null, { initialContent, updates }) + }) } - _convertTextOperation(content, operation) { - const textUpdateBuilder = new TextUpdateBuilder(content) - for (const op of operation.textOperation || []) { + /** + * + * @param {string} initialContent + * @param {RawEditOperation} operation + * @param {TrackedChangeList} trackedChanges + */ + _convertTextOperation(initialContent, operation, trackedChanges) { + const textOp = TextOperation.fromJSON(operation) + const textUpdateBuilder = new TextUpdateBuilder( + initialContent, + trackedChanges + ) + for (const op of textOp.ops) { textUpdateBuilder.applyOp(op) } textUpdateBuilder.finish() @@ -314,76 +375,204 @@ class File { ops: textUpdateBuilder.changes, } } + + _loadContentAndRanges(historyId, callback) { + HistoryStoreManager.getProjectBlob( + historyId, + this.snapshot.hash, + (err, content) => { + if (err) { + return callback(err) + } + if (this.snapshot.rangesHash) { + HistoryStoreManager.getProjectBlob( + historyId, + this.snapshot.rangesHash, + (err, ranges) => { + if (err) { + return callback(err) + } + return callback(null, content, JSON.parse(ranges)) + } + ) + } else { + return callback(null, content, undefined) + } + } + ) + } } class TextUpdateBuilder { - constructor(source) { + /** + * + * @param {string} source + * @param {TrackedChangeList} ranges + */ + constructor(source, ranges) { + this.trackedChanges = ranges this.source = source this.sourceCursor = 0 this.result = '' + /** @type {({i: string, p: number} | {d: string, p: number})[]} */ this.changes = [] } applyOp(op) { - if (TextUpdateBuilder._isRetainOperation(op)) { + if (op instanceof RetainOp) { + const length = this.result.length this.applyRetain(op) + this.trackedChanges.applyRetain(length, op.length, { + tracking: op.tracking, + }) } - if (TextUpdateBuilder._isInsertOperation(op)) { + if (op instanceof InsertOp) { + const length = this.result.length this.applyInsert(op) + this.trackedChanges.applyInsert(length, op.insertion, { + tracking: op.tracking, + }) } - if (TextUpdateBuilder._isDeleteOperation(op)) { - this.applyDelete(-op) + if (op instanceof RemoveOp) { + const length = this.result.length + this.applyDelete(op) + this.trackedChanges.applyDelete(length, op.length) } } - applyRetain(offset) { - this.result += this.source.slice( - this.sourceCursor, - this.sourceCursor + offset - ) - this.sourceCursor += offset + /** + * + * @param {RetainOp} retain + */ + applyRetain(retain) { + const rangeOfRetention = new Range(this.sourceCursor, retain.length) + let cursor = this.sourceCursor + if (retain.tracking) { + // We are modifying existing tracked deletes. We need to treat removal + // (type insert/none) of a tracked delete as an insertion. Similarly, any + // range we introduce as a tracked deletion must be reported as a deletion. + const trackedDeletes = this.trackedChanges.trackedChanges.filter( + tc => + tc.tracking.type === 'delete' && tc.range.overlaps(rangeOfRetention) + ) + for (const trackedDelete of trackedDeletes) { + if (cursor < trackedDelete.range.start) { + if (retain.tracking.type === 'delete') { + this.changes.push({ + d: this.source.slice(cursor, trackedDelete.range.start), + p: this.result.length, + }) + } + this.result += this.source.slice(cursor, trackedDelete.range.start) + cursor = trackedDelete.range.start + } + const endOfInsertion = Math.min( + trackedDelete.range.end, + rangeOfRetention.end + ) + const text = this.source.slice(cursor, endOfInsertion) + if ( + retain.tracking.type === 'none' || + retain.tracking.type === 'insert' + ) { + this.changes.push({ + i: text, + p: this.result.length, + }) + } + this.result += text + cursor = endOfInsertion + if (cursor >= rangeOfRetention.end) { + break + } + } + } + if (cursor < rangeOfRetention.end) { + // The last region is not a tracked delete. But we should still handle + // a new tracked delete as a deletion. + const text = this.source.slice(cursor, rangeOfRetention.end) + if (retain.tracking?.type === 'delete') { + this.changes.push({ + d: text, + p: this.result.length, + }) + } + this.result += text + } + this.sourceCursor += retain.length } - applyInsert(content) { - this.changes.push({ - i: content, - p: this.result.length, - }) - this.result += content + /** + * + * @param {InsertOp} insert + */ + applyInsert(insert) { + if (insert.tracking?.type !== 'delete') { + // Skip tracked deletions + this.changes.push({ + i: insert.insertion, + p: this.result.length, + }) + } + this.result += insert.insertion // The source cursor doesn't advance } - applyDelete(offset) { - const deletedContent = this.source.slice( - this.sourceCursor, - this.sourceCursor + offset - ) - - this.changes.push({ - d: deletedContent, - p: this.result.length, - }) - - this.sourceCursor += offset + /** + * + * @param {RemoveOp} deletion + */ + applyDelete(deletion) { + const rangeOfDeletion = new Range(this.sourceCursor, deletion.length) + const trackedDeletes = this.trackedChanges.trackedChanges + .filter( + tc => + tc.tracking.type === 'delete' && tc.range.overlaps(rangeOfDeletion) + ) + .sort((a, b) => a.range.start - b.range.start) + let cursor = this.sourceCursor + for (const trackedDelete of trackedDeletes) { + if (cursor < trackedDelete.range.start) { + this.changes.push({ + d: this.source.slice(cursor, trackedDelete.range.start), + p: this.result.length, + }) + } + // skip the tracked delete itself + cursor = Math.min(trackedDelete.range.end, rangeOfDeletion.end) + if (cursor >= rangeOfDeletion.end) { + break + } + } + if (cursor < rangeOfDeletion.end) { + this.changes.push({ + d: this.source.slice(cursor, rangeOfDeletion.end), + p: this.result.length, + }) + } + this.sourceCursor = rangeOfDeletion.end } finish() { if (this.sourceCursor < this.source.length) { this.result += this.source.slice(this.sourceCursor) } - } - - static _isRetainOperation(op) { - return typeof op === 'number' && op > 0 - } - - static _isInsertOperation(op) { - return typeof op === 'string' - } - - static _isDeleteOperation(op) { - return typeof op === 'number' && op < 0 + for (const op of this.changes) { + if ('p' in op && typeof op.p === 'number') { + // Maybe we have to move the position of the deletion to account for + // tracked changes that we're hiding in the UI. + op.p -= this.trackedChanges.trackedChanges + .filter(tc => tc.tracking.type === 'delete' && tc.range.start < op.p) + .map(tc => { + if (tc.range.end < op.p) { + return tc.range.length + } + return op.p - tc.range.start + }) + .reduce((a, b) => a + b, 0) + } + } } } diff --git a/services/project-history/test/unit/js/ChunkTranslator/ChunkTranslatorTests.js b/services/project-history/test/unit/js/ChunkTranslator/ChunkTranslatorTests.js index 0d258413ee..d1303666e3 100644 --- a/services/project-history/test/unit/js/ChunkTranslator/ChunkTranslatorTests.js +++ b/services/project-history/test/unit/js/ChunkTranslator/ChunkTranslatorTests.js @@ -275,9 +275,13 @@ describe('ChunkTranslator', function () { op: [ { i: '111 ', p: 0 }, { d: 'aa ', p: 4 }, - { i: '2222 ', p: 10 }, - { d: 'c', p: 15 }, - { i: 'd', p: 15 }, + // NOTE: The construction of TextOperation can merge an + // insertion across a deletion operation, which is why this is + // ever so slightly different from the textOperation defined + // in the chunk. Both diffs represent the same change in + // content. + { i: '2222 d', p: 10 }, + { d: 'c', p: 16 }, ], meta: { users: [this.author1.id], @@ -811,9 +815,7 @@ describe('ChunkTranslator', function () { }, changes: [ { - operations: [ - { pathname: 'main.tex', textOperation: [0, 'foo'] }, - ], + operations: [{ pathname: 'main.tex', textOperation: ['foo'] }], timestamp: this.date.toISOString(), authors: [this.author1.id], }, @@ -1124,9 +1126,7 @@ describe('ChunkTranslator', function () { }, changes: [ { - operations: [ - { pathname: 'main.tex', textOperation: [0, 'foo'] }, - ], + operations: [{ pathname: 'main.tex', textOperation: ['foo'] }], timestamp: this.date.toISOString(), authors: [this.author1.id], }, @@ -1136,9 +1136,7 @@ describe('ChunkTranslator', function () { authors: [this.author1.id], }, { - operations: [ - { pathname: 'other.tex', textOperation: [0, 'foo'] }, - ], + operations: [{ pathname: 'other.tex', textOperation: ['foo'] }], timestamp: this.date.toISOString(), authors: [this.author1.id], }, @@ -1731,4 +1729,554 @@ describe('ChunkTranslator', function () { }) }) }) + + describe('with tracked changes in a file', function () { + describe('convertToDiffUpdates', function () { + beforeEach(function () { + this.rangesHash = 'some_ranges_hash' + this.fileContents = 'Hello planet world, this is a test' + this.ranges = JSON.stringify({ + trackedChanges: [ + { + range: { pos: 6, length: 7 }, + tracking: { + type: 'delete', + userId: this.author1.id, + ts: '2024-01-01T00:00:00.000Z', + }, + }, + ], + }) + this.HistoryStoreManager.getProjectBlob + .withArgs(this.historyId, this.rangesHash) + .yields(null, this.ranges) + this.HistoryStoreManager.getProjectBlob + .withArgs(this.historyId, this.fileHash) + .yields(null, this.fileContents) + }) + + it('should filter out the tracked deletes that were present in the chunk', function (done) { + this.chunk = { + chunk: { + startVersion: 0, + history: { + snapshot: { + files: { + 'main.tex': { + hash: this.fileHash, + rangesHash: this.rangesHash, + stringLength: 42, + }, + }, + }, + changes: [ + { + operations: [ + { + pathname: 'main.tex', + textOperation: [ + // [...] is a tracked delete + 28, // Hello [planet ]world, this is |a test + -1, // Hello [planet ]world, this is | test + 'the', // Hello [planet ]world, this is the| test + 5, // Hello [planet ]world, this is the test| + ], + }, + ], + timestamp: this.date.toISOString(), + authors: [this.author1.id], + }, + ], + }, + }, + authors: [this.author1.id], + } + + this.ChunkTranslator.convertToDiffUpdates( + this.projectId, + this.chunk, + 'main.tex', + 0, + 1, + (error, diff) => { + expect(error).to.be.null + expect(diff.initialContent).to.equal('Hello world, this is a test') + expect(diff.updates).to.deep.equal([ + { + op: [ + { i: 'the', p: 21 }, + { d: 'a', p: 24 }, + ], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 0, + }, + ]) + done() + } + ) + }) + + it('should filter out tracked deletes across multiple changes', function (done) { + this.chunk = { + chunk: { + startVersion: 0, + history: { + snapshot: { + files: { + 'main.tex': { + hash: this.fileHash, + rangesHash: this.rangesHash, + stringLength: 42, + }, + }, + }, + changes: [ + { + operations: [ + { + pathname: 'main.tex', + textOperation: [ + // [...] is a tracked delete + 28, // Hello [planet ]world, this is |a test + -1, // Hello [planet ]world, this is | test + 'the', // Hello [planet ]world, this is the| test + 5, // Hello [planet ]world, this is the test| + ], + }, + { + pathname: 'main.tex', + textOperation: [ + // [...] is a tracked delete + 22, // Hello [planet ]world, th|is is the test + -2, // Hello [planet ]world, th| is the test + 'at', // Hello [planet ]world, that| is the test + 12, // Hello [planet ]world, that is the test| + ], + }, + ], + timestamp: this.date.toISOString(), + authors: [this.author1.id], + }, + ], + }, + }, + authors: [this.author1.id], + } + + this.ChunkTranslator.convertToDiffUpdates( + this.projectId, + this.chunk, + 'main.tex', + 0, + 1, + (error, diff) => { + expect(error).to.be.null + expect(diff.initialContent).to.equal('Hello world, this is a test') + expect(diff.updates).to.deep.equal([ + { + op: [ + { i: 'the', p: 21 }, + { d: 'a', p: 24 }, + ], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 0, + }, + { + op: [ + { i: 'at', p: 15 }, + { d: 'is', p: 17 }, + ], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 0, + }, + ]) + done() + } + ) + }) + + it('should handle tracked delete in the operation', function (done) { + this.chunk = { + chunk: { + startVersion: 0, + history: { + snapshot: { + files: { + 'main.tex': { + hash: this.fileHash, + rangesHash: this.rangesHash, + stringLength: 42, + }, + }, + }, + changes: [ + { + operations: [ + { + pathname: 'main.tex', + textOperation: [ + // [...] is a tracked delete + 5, // Hello| [planet ]world, this is a test + { + r: 1, + tracking: { + type: 'delete', + userId: this.author1.id, + ts: '2024-01-01T00:00:00.000Z', + }, + }, // Hello[ ]|[planet ]world, this is test + 7, // Hello[ ][planet ]|world, this is the test + { + r: 5, + tracking: { + type: 'delete', + userId: this.author1.id, + ts: '2024-01-01T00:00:00.000Z', + }, + }, // Hello[ ][planet ][world]|, this is the test + 18, // Hello[ ][planet ][world], this is the test| + ], + }, + ], + timestamp: this.date.toISOString(), + authors: [this.author1.id], + }, + ], + }, + }, + authors: [this.author1.id], + } + + this.ChunkTranslator.convertToDiffUpdates( + this.projectId, + this.chunk, + 'main.tex', + 0, + 1, + (error, diff) => { + expect(error).to.be.null + expect(diff.initialContent).to.equal('Hello world, this is a test') + expect(diff.updates).to.deep.equal([ + { + op: [ + { d: ' ', p: 5 }, + { d: 'world', p: 5 }, + ], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 0, + }, + ]) + done() + } + ) + }) + + it('should filter out tracked deletes in insert operations', function (done) { + this.chunk = { + chunk: { + startVersion: 0, + history: { + snapshot: { + files: { + 'main.tex': { + hash: this.fileHash, + rangesHash: this.rangesHash, + stringLength: 42, + }, + }, + }, + changes: [ + { + operations: [ + { + pathname: 'main.tex', + textOperation: [ + // [...] is a tracked delete + 13, // Hello [planet ]|world, this is a test + { + i: 'pluto', + tracking: { + type: 'delete', + userId: this.author1.id, + ts: '2024-01-01T00:00:00.000Z', + }, + }, // Hello [planet pluto]|world, this is a test + 21, // Hello [planet pluto]world, this is a test| + ], + }, + ], + timestamp: this.date.toISOString(), + authors: [this.author1.id], + }, + ], + }, + }, + authors: [this.author1.id], + } + + this.ChunkTranslator.convertToDiffUpdates( + this.projectId, + this.chunk, + 'main.tex', + 0, + 1, + (error, diff) => { + expect(error).to.be.null + expect(diff.initialContent).to.equal('Hello world, this is a test') + expect(diff.updates).to.deep.equal([ + { + op: [], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 0, + }, + ]) + done() + } + ) + }) + + it('should filter out tracked deletes in delete operations', function (done) { + this.chunk = { + chunk: { + startVersion: 0, + history: { + snapshot: { + files: { + 'main.tex': { + hash: this.fileHash, + rangesHash: this.rangesHash, + stringLength: 42, + }, + }, + }, + changes: [ + { + operations: [ + { + pathname: 'main.tex', + textOperation: [ + // [...] is a tracked delete + 6, // Hello |[planet ]world, this is a test + -3, // Hello [|net ]world, this is a test + 6, // Hello [net ]wo|rld, this is a test + -3, // Hello [net ]wo|, this is a test + 16, // Hello [net ]wo, this is a test| + ], + }, + ], + timestamp: this.date.toISOString(), + authors: [this.author1.id], + }, + ], + }, + }, + authors: [this.author1.id], + } + + this.ChunkTranslator.convertToDiffUpdates( + this.projectId, + this.chunk, + 'main.tex', + 0, + 1, + (error, diff) => { + expect(error).to.be.null + expect(diff.initialContent).to.equal('Hello world, this is a test') + expect(diff.updates).to.deep.equal([ + { + op: [{ d: 'rld', p: 8 }], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 0, + }, + ]) + done() + } + ) + }) + + it('should filter out tracked deletes in retain operations', function (done) { + this.chunk = { + chunk: { + startVersion: 0, + history: { + snapshot: { + files: { + 'main.tex': { + hash: this.fileHash, + rangesHash: this.rangesHash, + stringLength: 42, + }, + }, + }, + changes: [ + { + operations: [ + { + pathname: 'main.tex', + textOperation: [ + // [...] is a tracked delete + 4, // Hell|o [planet ]world, this is a test + { + r: 4, + tracking: { + type: 'none', + userId: this.author1.id, + ts: '2024-01-01T00:00:00.000Z', + }, + }, // Hello pl|[anet ]world, this is a test + { + r: 3, + tracking: { + type: 'insert', + userId: this.author1.id, + ts: '2024-01-01T00:00:00.000Z', + }, + }, // Hello plane|[t ]world, this is a test + 23, // Hello plane[t ]world, this is a test| + ], + }, + ], + timestamp: this.date.toISOString(), + authors: [this.author1.id], + }, + ], + }, + }, + authors: [this.author1.id], + } + + this.ChunkTranslator.convertToDiffUpdates( + this.projectId, + this.chunk, + 'main.tex', + 0, + 1, + (error, diff) => { + expect(error).to.be.null + expect(diff.initialContent).to.equal('Hello world, this is a test') + expect(diff.updates).to.deep.equal([ + { + op: [ + { + i: 'pl', + p: 6, + }, + { + i: 'ane', + p: 8, + }, + ], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 0, + }, + ]) + done() + } + ) + }) + + it('should report tracked deletion (retains) as deletions', function (done) { + this.chunk = { + chunk: { + startVersion: 0, + history: { + snapshot: { + files: { + 'main.tex': { + hash: this.fileHash, + rangesHash: this.rangesHash, + stringLength: 42, + }, + }, + }, + changes: [ + { + operations: [ + { + pathname: 'main.tex', + textOperation: [ + // [...] is a tracked delete + { + r: 34, + tracking: { + type: 'delete', + userId: this.author1.id, + ts: '2024-01-01T00:00:00.000Z', + }, + }, // [Hello planet world, this is a test]| + ], + }, + ], + timestamp: this.date.toISOString(), + authors: [this.author1.id], + }, + ], + }, + }, + authors: [this.author1.id], + } + + this.ChunkTranslator.convertToDiffUpdates( + this.projectId, + this.chunk, + 'main.tex', + 0, + 1, + (error, diff) => { + expect(error).to.be.null + expect(diff.initialContent).to.equal('Hello world, this is a test') + expect(diff.updates).to.deep.equal([ + { + op: [ + { + d: 'Hello ', + p: 0, + }, + { + d: 'world, this is a test', + p: 0, + }, + ], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 0, + }, + ]) + done() + } + ) + }) + }) + }) })