mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 13:13:45 +01:00
Merge pull request #34714 from overleaf/mj-tracked-change-merges
[overleaf-editor-core+ranges-tracker] Pick lower timestamp when merging tracking info GitOrigin-RevId: 5b1c8d7c719003df4506014c381f2a525d0a7ff4
This commit is contained in:
committed by
Copybot
parent
7ee385fe45
commit
ef05288f81
@@ -56,8 +56,7 @@ class TrackedChange {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
this.tracking.type === other.tracking.type &&
|
this.tracking.canMergeWith(other.tracking) &&
|
||||||
this.tracking.userId === other.tracking.userId &&
|
|
||||||
this.range.touches(other.range) &&
|
this.range.touches(other.range) &&
|
||||||
this.range.canMerge(other.range)
|
this.range.canMerge(other.range)
|
||||||
)
|
)
|
||||||
@@ -75,13 +74,7 @@ class TrackedChange {
|
|||||||
}
|
}
|
||||||
return new TrackedChange(
|
return new TrackedChange(
|
||||||
this.range.merge(other.range),
|
this.range.merge(other.range),
|
||||||
new TrackingProps(
|
this.tracking.mergeWith(other.tracking)
|
||||||
this.tracking.type,
|
|
||||||
this.tracking.userId,
|
|
||||||
this.tracking.ts.getTime() > other.tracking.ts.getTime()
|
|
||||||
? this.tracking.ts
|
|
||||||
: other.tracking.ts
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -405,6 +405,49 @@ describe('TextOperation', function () {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it(
|
||||||
|
'compose associativity (randomised)',
|
||||||
|
random.test(numTrials, () => {
|
||||||
|
const str = random.string(20)
|
||||||
|
const comments = random.comments(6)
|
||||||
|
|
||||||
|
const a = randomOperation(str, comments.ids)
|
||||||
|
const afterA = new StringFileData(str, comments.comments)
|
||||||
|
a.apply(afterA)
|
||||||
|
|
||||||
|
const b = randomOperation(afterA.getContent(), comments.ids)
|
||||||
|
const afterB = new StringFileData(
|
||||||
|
afterA.getContent(),
|
||||||
|
comments.comments
|
||||||
|
)
|
||||||
|
b.apply(afterB)
|
||||||
|
|
||||||
|
const c = randomOperation(afterB.getContent(), comments.ids)
|
||||||
|
|
||||||
|
const ab = a.compose(b)
|
||||||
|
const ab_c = ab.compose(c)
|
||||||
|
|
||||||
|
const bc = b.compose(c)
|
||||||
|
const a_bc = a.compose(bc)
|
||||||
|
|
||||||
|
const ab_c_file = new StringFileData(str, comments.comments)
|
||||||
|
ab_c.apply(ab_c_file)
|
||||||
|
|
||||||
|
const a_bc_file = new StringFileData(str, comments.comments)
|
||||||
|
a_bc.apply(a_bc_file)
|
||||||
|
|
||||||
|
const fuzzingError = fuzzingErrorMessage({
|
||||||
|
str,
|
||||||
|
comments,
|
||||||
|
a: a.toJSON(),
|
||||||
|
b: b.toJSON(),
|
||||||
|
c: c.toJSON(),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(ab_c_file.toRaw()).to.deep.equal(a_bc_file.toRaw(), fuzzingError)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
it('composes two operations with comments', function () {
|
it('composes two operations with comments', function () {
|
||||||
expect(
|
expect(
|
||||||
compose(
|
compose(
|
||||||
@@ -625,11 +668,54 @@ describe('TextOperation', function () {
|
|||||||
a: a.toJSON(),
|
a: a.toJSON(),
|
||||||
b: b.toJSON(),
|
b: b.toJSON(),
|
||||||
})
|
})
|
||||||
expect(abPrime.equals(baPrime)).to.be.equal(true, fuzzingError)
|
// The composition of ab' and ba' is not guaranteed to be equal, but
|
||||||
|
// should converge to the same file contents + ranges.
|
||||||
expect(abFile.toRaw()).to.deep.equal(baFile.toRaw(), fuzzingError)
|
expect(abFile.toRaw()).to.deep.equal(baFile.toRaw(), fuzzingError)
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it('chooses lower tracked change timestamp', function () {
|
||||||
|
const ts1 = '2024-01-01T01:00:00.000Z'
|
||||||
|
const ts2 = '2024-01-01T02:00:00.000Z'
|
||||||
|
const str = 'abcde'
|
||||||
|
const comments = []
|
||||||
|
|
||||||
|
const a = new TextOperation()
|
||||||
|
.retain(2, {
|
||||||
|
tracking: TrackingProps.fromRaw({
|
||||||
|
ts: ts1,
|
||||||
|
type: 'insert',
|
||||||
|
userId: 'user1',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.retain(1)
|
||||||
|
.retain(2, {
|
||||||
|
tracking: TrackingProps.fromRaw({
|
||||||
|
ts: ts2,
|
||||||
|
type: 'insert',
|
||||||
|
userId: 'user1',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const b = new TextOperation().retain(1).remove(3).retain(1)
|
||||||
|
|
||||||
|
const [aPrime, bPrime] = TextOperation.transform(a, b)
|
||||||
|
const aComposeBPrime = a.compose(bPrime)
|
||||||
|
const bComposeAPrime = b.compose(aPrime)
|
||||||
|
|
||||||
|
const aBPFile = new StringFileData(str, comments)
|
||||||
|
aComposeBPrime.apply(aBPFile)
|
||||||
|
|
||||||
|
const bAPFile = new StringFileData(str, comments)
|
||||||
|
bComposeAPrime.apply(bAPFile)
|
||||||
|
|
||||||
|
expect(aBPFile.toRaw()).to.deep.equal(bAPFile.toRaw())
|
||||||
|
expect(aBPFile.trackedChanges.length).to.equal(1)
|
||||||
|
expect(
|
||||||
|
aBPFile.trackedChanges.asSorted()[0].tracking.ts.toISOString()
|
||||||
|
).to.equal(ts1)
|
||||||
|
})
|
||||||
|
|
||||||
it('adds a tracked change from operation 1', function () {
|
it('adds a tracked change from operation 1', function () {
|
||||||
expect(
|
expect(
|
||||||
transform(
|
transform(
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const { expect } = require('chai')
|
|||||||
describe('TrackedChangeList', function () {
|
describe('TrackedChangeList', function () {
|
||||||
describe('applyInsert', function () {
|
describe('applyInsert', function () {
|
||||||
describe('with same author', function () {
|
describe('with same author', function () {
|
||||||
it('should merge consecutive tracked changes and use the latest timestamp', function () {
|
it('should merge consecutive tracked changes and use the earliest timestamp', function () {
|
||||||
const trackedChanges = TrackedChangeList.fromRaw([
|
const trackedChanges = TrackedChangeList.fromRaw([
|
||||||
{
|
{
|
||||||
range: { pos: 0, length: 3 },
|
range: { pos: 0, length: 3 },
|
||||||
@@ -33,7 +33,7 @@ describe('TrackedChangeList', function () {
|
|||||||
tracking: {
|
tracking: {
|
||||||
type: 'insert',
|
type: 'insert',
|
||||||
userId: 'user1',
|
userId: 'user1',
|
||||||
ts: '2024-01-01T00:00:00.000Z',
|
ts: '2023-01-01T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
@@ -699,7 +699,7 @@ describe('TrackedChangeList', function () {
|
|||||||
tracking: {
|
tracking: {
|
||||||
type: 'insert',
|
type: 'insert',
|
||||||
userId: 'user1',
|
userId: 'user1',
|
||||||
ts: '2024-01-01T00:00:00.000Z',
|
ts: '2023-01-01T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -420,7 +420,7 @@ class RangesTracker {
|
|||||||
offset = opStart - changeStart
|
offset = opStart - changeStart
|
||||||
change.op.i =
|
change.op.i =
|
||||||
change.op.i.slice(0, offset) + op.i + change.op.i.slice(offset)
|
change.op.i.slice(0, offset) + op.i + change.op.i.slice(offset)
|
||||||
change.metadata.ts = metadata.ts
|
change.metadata.ts = pickTimestamp(change.metadata, metadata)
|
||||||
alreadyMerged = true
|
alreadyMerged = true
|
||||||
movedChanges.push(change)
|
movedChanges.push(change)
|
||||||
} else if (opStart <= changeStart) {
|
} else if (opStart <= changeStart) {
|
||||||
@@ -728,6 +728,10 @@ class RangesTracker {
|
|||||||
) {
|
) {
|
||||||
removeChanges.push(change)
|
removeChanges.push(change)
|
||||||
previousChange.op.i += change.op.i
|
previousChange.op.i += change.op.i
|
||||||
|
previousChange.metadata.ts = pickTimestamp(
|
||||||
|
previousChange.metadata,
|
||||||
|
change.metadata
|
||||||
|
)
|
||||||
movedChanges.push(previousChange)
|
movedChanges.push(previousChange)
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (
|
||||||
@@ -790,4 +794,15 @@ class RangesTracker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pickTimestamp(oldMetadata, newMetadata) {
|
||||||
|
// Make sure null values don't get treated as 1970-01-01 dates in the
|
||||||
|
// comparison below.
|
||||||
|
if (oldMetadata.ts == null) return newMetadata.ts
|
||||||
|
if (newMetadata.ts == null) return oldMetadata.ts
|
||||||
|
|
||||||
|
return new Date(oldMetadata.ts) < new Date(newMetadata.ts)
|
||||||
|
? oldMetadata.ts
|
||||||
|
: newMetadata.ts
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = RangesTracker
|
module.exports = RangesTracker
|
||||||
|
|||||||
@@ -222,4 +222,114 @@ describe('RangesTracker', function () {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('merging tracked inserts from the same user', function () {
|
||||||
|
beforeEach(function () {
|
||||||
|
this.comments = []
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the earlier timestamp when the existing insert is older', function () {
|
||||||
|
this.rangesTracker = new RangesTracker(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'id1',
|
||||||
|
op: { p: 10, i: 'foo' },
|
||||||
|
metadata: { user_id: 'user-1', ts: '2024-01-01T00:00:00.000Z' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
this.comments
|
||||||
|
)
|
||||||
|
this.rangesTracker.track_changes = true
|
||||||
|
this.rangesTracker.applyOp(
|
||||||
|
{ p: 13, i: 'bar' },
|
||||||
|
{ user_id: 'user-1', ts: new Date('2024-06-01T00:00:00.000Z') }
|
||||||
|
)
|
||||||
|
expect(this.rangesTracker.changes).to.have.length(1)
|
||||||
|
const [change] = this.rangesTracker.changes
|
||||||
|
expect(change.op).to.deep.equal({ p: 10, i: 'foobar' })
|
||||||
|
expect(change.metadata.user_id).to.equal('user-1')
|
||||||
|
expect(new Date(change.metadata.ts).toISOString()).to.equal(
|
||||||
|
'2024-01-01T00:00:00.000Z'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the earlier timestamp when the incoming insert is older', function () {
|
||||||
|
this.rangesTracker = new RangesTracker(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'id1',
|
||||||
|
op: { p: 10, i: 'foo' },
|
||||||
|
metadata: { user_id: 'user-1', ts: '2024-06-01T00:00:00.000Z' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
this.comments
|
||||||
|
)
|
||||||
|
this.rangesTracker.track_changes = true
|
||||||
|
this.rangesTracker.applyOp(
|
||||||
|
{ p: 13, i: 'bar' },
|
||||||
|
{ user_id: 'user-1', ts: new Date('2024-01-01T00:00:00.000Z') }
|
||||||
|
)
|
||||||
|
expect(this.rangesTracker.changes).to.have.length(1)
|
||||||
|
const [change] = this.rangesTracker.changes
|
||||||
|
expect(change.op).to.deep.equal({ p: 10, i: 'foobar' })
|
||||||
|
expect(change.metadata.user_id).to.equal('user-1')
|
||||||
|
expect(new Date(change.metadata.ts).toISOString()).to.equal(
|
||||||
|
'2024-01-01T00:00:00.000Z'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the defined timestamp when the existing insert has a null timestamp', function () {
|
||||||
|
this.rangesTracker = new RangesTracker(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'id1',
|
||||||
|
op: { p: 10, i: 'foo' },
|
||||||
|
metadata: { user_id: 'user-1', ts: null },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
this.comments
|
||||||
|
)
|
||||||
|
this.rangesTracker.track_changes = true
|
||||||
|
this.rangesTracker.applyOp(
|
||||||
|
{ p: 13, i: 'bar' },
|
||||||
|
{ user_id: 'user-1', ts: new Date('2024-01-01T00:00:00.000Z') }
|
||||||
|
)
|
||||||
|
expect(this.rangesTracker.changes).to.have.length(1)
|
||||||
|
const [change] = this.rangesTracker.changes
|
||||||
|
expect(new Date(change.metadata.ts).toISOString()).to.equal(
|
||||||
|
'2024-01-01T00:00:00.000Z'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the earlier timestamp when re-merging inserts after a deletion', function () {
|
||||||
|
this.rangesTracker = new RangesTracker(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'id1',
|
||||||
|
op: { p: 10, i: 'aaa' },
|
||||||
|
metadata: { user_id: 'user-1', ts: '2024-03-01T00:00:00.000Z' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'id2',
|
||||||
|
op: { p: 13, i: 'bb' },
|
||||||
|
metadata: { user_id: 'user-2', ts: '2024-02-01T00:00:00.000Z' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'id3',
|
||||||
|
op: { p: 15, i: 'ccc' },
|
||||||
|
metadata: { user_id: 'user-1', ts: '2024-01-01T00:00:00.000Z' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
this.comments
|
||||||
|
)
|
||||||
|
this.rangesTracker.applyOp({ p: 13, d: 'bb' })
|
||||||
|
expect(this.rangesTracker.changes).to.have.length(1)
|
||||||
|
const [change] = this.rangesTracker.changes
|
||||||
|
expect(change.op).to.deep.equal({ p: 10, i: 'aaaccc' })
|
||||||
|
expect(change.metadata.user_id).to.equal('user-1')
|
||||||
|
expect(new Date(change.metadata.ts).toISOString()).to.equal(
|
||||||
|
'2024-01-01T00:00:00.000Z'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,13 @@ import sinon from 'sinon'
|
|||||||
import { expect } from 'chai'
|
import { expect } from 'chai'
|
||||||
import mongodb from 'mongodb-legacy'
|
import mongodb from 'mongodb-legacy'
|
||||||
import tk from 'timekeeper'
|
import tk from 'timekeeper'
|
||||||
import { File, Comment, TrackedChange, Range } from 'overleaf-editor-core'
|
import {
|
||||||
|
File,
|
||||||
|
Comment,
|
||||||
|
TrackedChange,
|
||||||
|
Range,
|
||||||
|
TrackingProps,
|
||||||
|
} from 'overleaf-editor-core'
|
||||||
import { UnprocessableError } from 'overleaf-editor-core/lib/errors.js'
|
import { UnprocessableError } from 'overleaf-editor-core/lib/errors.js'
|
||||||
import { strict as esmock } from 'esmock'
|
import { strict as esmock } from 'esmock'
|
||||||
import { FileContentEmptyError } from '../../../../app/js/Errors.js'
|
import { FileContentEmptyError } from '../../../../app/js/Errors.js'
|
||||||
@@ -2013,32 +2019,44 @@ describe('SyncManager', function () {
|
|||||||
describe('syncing tracked changes', function () {
|
describe('syncing tracked changes', function () {
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
this.loadedSnapshotDoc.getTrackedChanges().add(
|
this.loadedSnapshotDoc.getTrackedChanges().add(
|
||||||
new TrackedChange(new Range(4, 6), {
|
new TrackedChange(
|
||||||
type: 'delete',
|
new Range(4, 6),
|
||||||
userId: USER_ID,
|
TrackingProps.fromRaw({
|
||||||
ts: new Date(TIMESTAMP),
|
type: 'delete',
|
||||||
})
|
userId: USER_ID,
|
||||||
|
ts: new Date(TIMESTAMP).toISOString(),
|
||||||
|
})
|
||||||
|
)
|
||||||
)
|
)
|
||||||
this.loadedSnapshotDoc.getTrackedChanges().add(
|
this.loadedSnapshotDoc.getTrackedChanges().add(
|
||||||
new TrackedChange(new Range(10, 6), {
|
new TrackedChange(
|
||||||
type: 'insert',
|
new Range(10, 6),
|
||||||
userId: USER_ID,
|
TrackingProps.fromRaw({
|
||||||
ts: new Date(TIMESTAMP),
|
type: 'insert',
|
||||||
})
|
userId: USER_ID,
|
||||||
|
ts: new Date(TIMESTAMP).toISOString(),
|
||||||
|
})
|
||||||
|
)
|
||||||
)
|
)
|
||||||
this.loadedSnapshotDoc.getTrackedChanges().add(
|
this.loadedSnapshotDoc.getTrackedChanges().add(
|
||||||
new TrackedChange(new Range(20, 6), {
|
new TrackedChange(
|
||||||
type: 'delete',
|
new Range(20, 6),
|
||||||
userId: USER_ID,
|
TrackingProps.fromRaw({
|
||||||
ts: new Date(TIMESTAMP),
|
type: 'delete',
|
||||||
})
|
userId: USER_ID,
|
||||||
|
ts: new Date(TIMESTAMP).toISOString(),
|
||||||
|
})
|
||||||
|
)
|
||||||
)
|
)
|
||||||
this.loadedSnapshotDoc.getTrackedChanges().add(
|
this.loadedSnapshotDoc.getTrackedChanges().add(
|
||||||
new TrackedChange(new Range(40, 3), {
|
new TrackedChange(
|
||||||
type: 'insert',
|
new Range(40, 3),
|
||||||
userId: USER_ID,
|
TrackingProps.fromRaw({
|
||||||
ts: new Date(TIMESTAMP),
|
type: 'insert',
|
||||||
})
|
userId: USER_ID,
|
||||||
|
ts: new Date(TIMESTAMP).toISOString(),
|
||||||
|
})
|
||||||
|
)
|
||||||
)
|
)
|
||||||
this.changes = [
|
this.changes = [
|
||||||
makeTrackedChange('td1', { p: 4, d: 'quick ' }),
|
makeTrackedChange('td1', { p: 4, d: 'quick ' }),
|
||||||
|
|||||||
Reference in New Issue
Block a user