mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-07 20:31:06 -05:00
Send operations to project-history when accepting tracked changes (#17599)
* added getHistoryOpForAcceptedChange in RangesManager * rename adjustHistoryUpdatesMetadata to be treated as public * handle retain op in UpdateTranslator and updateCompressor * send op to project-history in acceptChanges * use promises.queueOps * use ranges in getHistoryOpForAcceptedChange * using rangesWithChangeRemoved * acceptChanges acceptance test * using change.op.hpos * Revert "using change.op.hpos" This reverts commit f53333b5099c840ab8fb8bb08df198ad6cfa2d84. * use getHistoryOpForAcceptedChanges * fix historyDocLength * Revert "rename adjustHistoryUpdatesMetadata to be treated as public" This reverts commit 2ba9443fd040a5c953828584285887c00dc40ea6. * fix typescript issues * sort changes before creating history updates * fix tests * sinon spy RangesManager.getHistoryUpdatesForAcceptedChanges * added unit tests * sort deletes before inserts * use getDocLength function * fix docLength calculation * fix typo * allow all retains * fix lint error * refactor RangesTests * fix ts error * fix history_doc_length calculation in RangesManager * remove retain tracking check from UpdateCompressor * use makeRanges() properly in tests * refactor acceptance tests GitOrigin-RevId: ab12ec53c5f52c20d44827c6037335e048f2edb0
This commit is contained in:
parent
1116f9ea9a
commit
c4437c69bc
13 changed files with 853 additions and 21 deletions
|
@ -237,10 +237,14 @@ const DocumentManager = {
|
|||
changeIds = []
|
||||
}
|
||||
|
||||
const { lines, version, ranges } = await DocumentManager.getDoc(
|
||||
projectId,
|
||||
docId
|
||||
)
|
||||
const {
|
||||
lines,
|
||||
version,
|
||||
ranges,
|
||||
pathname,
|
||||
projectHistoryId,
|
||||
historyRangesSupport,
|
||||
} = await DocumentManager.getDoc(projectId, docId)
|
||||
if (lines == null || version == null) {
|
||||
throw new Errors.NotFoundError(`document not found: ${docId}`)
|
||||
}
|
||||
|
@ -256,6 +260,26 @@ const DocumentManager = {
|
|||
newRanges,
|
||||
{}
|
||||
)
|
||||
|
||||
if (historyRangesSupport) {
|
||||
const historyUpdates = RangesManager.getHistoryUpdatesForAcceptedChanges({
|
||||
docId,
|
||||
acceptedChangeIds: changeIds,
|
||||
changes: ranges.changes || [],
|
||||
lines,
|
||||
pathname,
|
||||
projectHistoryId,
|
||||
})
|
||||
|
||||
if (historyUpdates.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
await ProjectHistoryRedisManager.promises.queueOps(
|
||||
projectId,
|
||||
...historyUpdates.map(op => JSON.stringify(op))
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
async updateCommentState(projectId, docId, commentId, userId, resolved) {
|
||||
|
|
|
@ -5,7 +5,7 @@ const logger = require('@overleaf/logger')
|
|||
const OError = require('@overleaf/o-error')
|
||||
const Metrics = require('./Metrics')
|
||||
const _ = require('lodash')
|
||||
const { isInsert, isDelete, isComment } = require('./Utils')
|
||||
const { isInsert, isDelete, isComment, getDocLength } = require('./Utils')
|
||||
|
||||
/**
|
||||
* @typedef {import('./types').Comment} Comment
|
||||
|
@ -15,6 +15,7 @@ const { isInsert, isDelete, isComment } = require('./Utils')
|
|||
* @typedef {import('./types').HistoryDeleteOp} HistoryDeleteOp
|
||||
* @typedef {import('./types').HistoryDeleteTrackedChange} HistoryDeleteTrackedChange
|
||||
* @typedef {import('./types').HistoryInsertOp} HistoryInsertOp
|
||||
* @typedef {import('./types').HistoryRetainOp} HistoryRetainOp
|
||||
* @typedef {import('./types').HistoryOp} HistoryOp
|
||||
* @typedef {import('./types').HistoryUpdate} HistoryUpdate
|
||||
* @typedef {import('./types').InsertOp} InsertOp
|
||||
|
@ -138,6 +139,118 @@ const RangesManager = {
|
|||
return newRanges
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} args
|
||||
* @param {string} args.docId
|
||||
* @param {string[]} args.acceptedChangeIds
|
||||
* @param {TrackedChange[]} args.changes
|
||||
* @param {string} args.pathname
|
||||
* @param {string} args.projectHistoryId
|
||||
* @param {string[]} args.lines
|
||||
*/
|
||||
getHistoryUpdatesForAcceptedChanges({
|
||||
docId,
|
||||
acceptedChangeIds,
|
||||
changes,
|
||||
pathname,
|
||||
projectHistoryId,
|
||||
lines,
|
||||
}) {
|
||||
/** @type {(change: TrackedChange) => boolean} */
|
||||
const isAccepted = change => acceptedChangeIds.includes(change.id)
|
||||
|
||||
const historyOps = []
|
||||
|
||||
// Keep ops in order of offset, with deletes before inserts
|
||||
const sortedChanges = changes.slice().sort(function (c1, c2) {
|
||||
const result = c1.op.p - c2.op.p
|
||||
if (result !== 0) {
|
||||
return result
|
||||
} else if (isInsert(c1.op) && isDelete(c2.op)) {
|
||||
return 1
|
||||
} else if (isDelete(c1.op) && isInsert(c2.op)) {
|
||||
return -1
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
})
|
||||
|
||||
const docLength = getDocLength(lines)
|
||||
let historyDocLength = docLength
|
||||
for (const change of sortedChanges) {
|
||||
if (isDelete(change.op)) {
|
||||
historyDocLength += change.op.d.length
|
||||
}
|
||||
}
|
||||
|
||||
let unacceptedDeletes = 0
|
||||
for (const change of sortedChanges) {
|
||||
/** @type {HistoryOp | undefined} */
|
||||
let op
|
||||
|
||||
if (isDelete(change.op)) {
|
||||
if (isAccepted(change)) {
|
||||
op = {
|
||||
p: change.op.p,
|
||||
d: change.op.d,
|
||||
}
|
||||
if (unacceptedDeletes > 0) {
|
||||
op.hpos = op.p + unacceptedDeletes
|
||||
}
|
||||
} else {
|
||||
unacceptedDeletes += change.op.d.length
|
||||
}
|
||||
} else if (isInsert(change.op)) {
|
||||
if (isAccepted(change)) {
|
||||
op = {
|
||||
p: change.op.p,
|
||||
r: change.op.i,
|
||||
tracking: {
|
||||
type: 'none',
|
||||
userId: change.metadata.user_id,
|
||||
ts: change.metadata.ts,
|
||||
},
|
||||
}
|
||||
if (unacceptedDeletes > 0) {
|
||||
op.hpos = op.p + unacceptedDeletes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!op) {
|
||||
continue
|
||||
}
|
||||
|
||||
/** @type {HistoryUpdate} */
|
||||
const historyOp = {
|
||||
doc: docId,
|
||||
op: [op],
|
||||
meta: {
|
||||
...change.metadata,
|
||||
doc_length: docLength,
|
||||
pathname,
|
||||
},
|
||||
}
|
||||
|
||||
if (projectHistoryId) {
|
||||
historyOp.projectHistoryId = projectHistoryId
|
||||
}
|
||||
|
||||
if (historyOp.meta && historyDocLength !== docLength) {
|
||||
historyOp.meta.history_doc_length = historyDocLength
|
||||
}
|
||||
|
||||
historyOps.push(historyOp)
|
||||
|
||||
if (isDelete(change.op) && isAccepted(change)) {
|
||||
historyDocLength -= change.op.d.length
|
||||
}
|
||||
}
|
||||
|
||||
return historyOps
|
||||
},
|
||||
|
||||
_getRanges(rangesTracker) {
|
||||
// Return the minimal data structure needed, since most documents won't have any
|
||||
// changes or comments
|
||||
|
|
|
@ -7,7 +7,6 @@ const ProjectHistoryRedisManager = require('./ProjectHistoryRedisManager')
|
|||
const RealTimeRedisManager = require('./RealTimeRedisManager')
|
||||
const ShareJsUpdateManager = require('./ShareJsUpdateManager')
|
||||
const HistoryManager = require('./HistoryManager')
|
||||
const _ = require('lodash')
|
||||
const logger = require('@overleaf/logger')
|
||||
const Metrics = require('./Metrics')
|
||||
const Errors = require('./Errors')
|
||||
|
@ -15,7 +14,7 @@ const DocumentManager = require('./DocumentManager')
|
|||
const RangesManager = require('./RangesManager')
|
||||
const SnapshotManager = require('./SnapshotManager')
|
||||
const Profiler = require('./Profiler')
|
||||
const { isInsert, isDelete } = require('./Utils')
|
||||
const { isInsert, isDelete, getDocLength } = require('./Utils')
|
||||
|
||||
/**
|
||||
* @typedef {import("./types").DeleteOp} DeleteOp
|
||||
|
@ -308,11 +307,7 @@ const UpdateManager = {
|
|||
ranges,
|
||||
historyRangesSupport
|
||||
) {
|
||||
let docLength = _.reduce(lines, (chars, line) => chars + line.length, 0)
|
||||
// Add newline characters. Lines are joined by newlines, but the last line
|
||||
// doesn't include a newline. We must make a special case for an empty list
|
||||
// so that it doesn't report a doc length of -1.
|
||||
docLength += Math.max(lines.length - 1, 0)
|
||||
let docLength = getDocLength(lines)
|
||||
let historyDocLength = docLength
|
||||
for (const change of ranges.changes ?? []) {
|
||||
if ('d' in change.op) {
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
// @ts-check
|
||||
const _ = require('lodash')
|
||||
|
||||
/**
|
||||
* @typedef {import('./types').CommentOp} CommentOp
|
||||
|
@ -38,6 +39,22 @@ function isComment(op) {
|
|||
return 'c' in op && op.c != null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the length of a document from its lines
|
||||
*
|
||||
* @param {string[]} lines
|
||||
* @returns {number}
|
||||
*/
|
||||
function getDocLength(lines) {
|
||||
let docLength = _.reduce(lines, (chars, line) => chars + line.length, 0)
|
||||
// Add newline characters. Lines are joined by newlines, but the last line
|
||||
// doesn't include a newline. We must make a special case for an empty list
|
||||
// so that it doesn't report a doc length of -1.
|
||||
docLength += Math.max(lines.length - 1, 0)
|
||||
|
||||
return docLength
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds given tracked deletes to the given content.
|
||||
*
|
||||
|
@ -66,4 +83,10 @@ function addTrackedDeletesToContent(content, trackedChanges) {
|
|||
return result
|
||||
}
|
||||
|
||||
module.exports = { isInsert, isDelete, isComment, addTrackedDeletesToContent }
|
||||
module.exports = {
|
||||
isInsert,
|
||||
isDelete,
|
||||
isComment,
|
||||
addTrackedDeletesToContent,
|
||||
getDocLength,
|
||||
}
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
import { TrackingPropsRawData } from 'overleaf-editor-core/types/lib/types'
|
||||
|
||||
export type Update = {
|
||||
doc: string
|
||||
op: Op[]
|
||||
v: number
|
||||
meta?: {
|
||||
|
@ -8,7 +11,7 @@ export type Update = {
|
|||
projectHistoryId?: string
|
||||
}
|
||||
|
||||
export type Op = InsertOp | DeleteOp | CommentOp
|
||||
export type Op = InsertOp | DeleteOp | CommentOp | RetainOp
|
||||
|
||||
export type InsertOp = {
|
||||
i: string
|
||||
|
@ -16,6 +19,11 @@ export type InsertOp = {
|
|||
u?: boolean
|
||||
}
|
||||
|
||||
export type RetainOp = {
|
||||
r: string
|
||||
p: number
|
||||
}
|
||||
|
||||
export type DeleteOp = {
|
||||
d: string
|
||||
p: number
|
||||
|
@ -52,7 +60,7 @@ export type TrackedChange = {
|
|||
}
|
||||
}
|
||||
|
||||
export type HistoryOp = HistoryInsertOp | HistoryDeleteOp | HistoryCommentOp
|
||||
export type HistoryOp = HistoryInsertOp | HistoryDeleteOp | HistoryCommentOp | HistoryRetainOp
|
||||
|
||||
export type HistoryInsertOp = InsertOp & {
|
||||
commentIds?: string[]
|
||||
|
@ -60,6 +68,11 @@ export type HistoryInsertOp = InsertOp & {
|
|||
trackedDeleteRejection?: boolean
|
||||
}
|
||||
|
||||
export type HistoryRetainOp = RetainOp & {
|
||||
hpos?: number
|
||||
tracking?: TrackingPropsRawData
|
||||
}
|
||||
|
||||
export type HistoryDeleteOp = DeleteOp & {
|
||||
hpos?: number
|
||||
trackedChanges?: HistoryDeleteTrackedChange[]
|
||||
|
@ -78,7 +91,8 @@ export type HistoryCommentOp = CommentOp & {
|
|||
|
||||
export type HistoryUpdate = {
|
||||
op: HistoryOp[]
|
||||
v: number
|
||||
doc: string
|
||||
v?: number
|
||||
meta?: {
|
||||
pathname?: string
|
||||
doc_length?: number
|
||||
|
|
|
@ -6,6 +6,9 @@ const { db, ObjectId } = require('../../../app/js/mongodb')
|
|||
const MockWebApi = require('./helpers/MockWebApi')
|
||||
const DocUpdaterClient = require('./helpers/DocUpdaterClient')
|
||||
const DocUpdaterApp = require('./helpers/DocUpdaterApp')
|
||||
const RangesManager = require('../../../app/js/RangesManager')
|
||||
|
||||
const sandbox = sinon.createSandbox()
|
||||
|
||||
describe('Ranges', function () {
|
||||
before(function (done) {
|
||||
|
@ -309,7 +312,7 @@ describe('Ranges', function () {
|
|||
|
||||
describe('accepting a change', function () {
|
||||
beforeEach(function (done) {
|
||||
sinon.spy(MockWebApi, 'setDocument')
|
||||
sandbox.spy(MockWebApi, 'setDocument')
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
this.user_id = DocUpdaterClient.randomId()
|
||||
this.id_seed = '587357bd35e64f6157'
|
||||
|
@ -361,7 +364,7 @@ describe('Ranges', function () {
|
|||
})
|
||||
})
|
||||
afterEach(function () {
|
||||
MockWebApi.setDocument.restore()
|
||||
sandbox.restore()
|
||||
})
|
||||
|
||||
it('should remove the change after accepting', function (done) {
|
||||
|
@ -424,6 +427,219 @@ describe('Ranges', function () {
|
|||
})
|
||||
})
|
||||
|
||||
describe('accepting multiple changes', function () {
|
||||
beforeEach(function (done) {
|
||||
this.getHistoryUpdatesSpy = sandbox.spy(
|
||||
RangesManager,
|
||||
'getHistoryUpdatesForAcceptedChanges'
|
||||
)
|
||||
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
this.user_id = DocUpdaterClient.randomId()
|
||||
this.doc = {
|
||||
id: DocUpdaterClient.randomId(),
|
||||
lines: ['aaa', 'bbb', 'ccc', 'ddd', 'eee'],
|
||||
}
|
||||
|
||||
DocUpdaterClient.enableHistoryRangesSupport(this.doc.id, (error, res) => {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
MockWebApi.insertDoc(this.project_id, this.doc.id, {
|
||||
lines: this.doc.lines,
|
||||
version: 0,
|
||||
})
|
||||
|
||||
DocUpdaterClient.preloadDoc(this.project_id, this.doc.id, error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
|
||||
this.id_seed_1 = 'tc_1'
|
||||
this.id_seed_2 = 'tc_2'
|
||||
this.id_seed_3 = 'tc_3'
|
||||
|
||||
this.updates = [
|
||||
{
|
||||
doc: this.doc.id,
|
||||
op: [{ d: 'bbb', p: 4 }],
|
||||
v: 0,
|
||||
meta: {
|
||||
user_id: this.user_id,
|
||||
tc: this.id_seed_1,
|
||||
},
|
||||
},
|
||||
{
|
||||
doc: this.doc.id,
|
||||
op: [{ d: 'ccc', p: 5 }],
|
||||
v: 1,
|
||||
meta: {
|
||||
user_id: this.user_id,
|
||||
tc: this.id_seed_2,
|
||||
},
|
||||
},
|
||||
{
|
||||
doc: this.doc.id,
|
||||
op: [{ d: 'ddd', p: 6 }],
|
||||
v: 2,
|
||||
meta: {
|
||||
user_id: this.user_id,
|
||||
tc: this.id_seed_3,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
DocUpdaterClient.sendUpdates(
|
||||
this.project_id,
|
||||
this.doc.id,
|
||||
this.updates,
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
setTimeout(() => {
|
||||
DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc.id,
|
||||
(error, res, data) => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
const { ranges } = data
|
||||
const changeOps = ranges.changes
|
||||
.map(change => change.op)
|
||||
.flat()
|
||||
changeOps.should.deep.equal([
|
||||
{ d: 'bbb', p: 4 },
|
||||
{ d: 'ccc', p: 5 },
|
||||
{ d: 'ddd', p: 6 },
|
||||
])
|
||||
done()
|
||||
}
|
||||
)
|
||||
}, 200)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
afterEach(function () {
|
||||
sandbox.restore()
|
||||
})
|
||||
|
||||
it('accepting changes in order', function (done) {
|
||||
DocUpdaterClient.acceptChanges(
|
||||
this.project_id,
|
||||
this.doc.id,
|
||||
[
|
||||
this.id_seed_1 + '000001',
|
||||
this.id_seed_2 + '000001',
|
||||
this.id_seed_3 + '000001',
|
||||
],
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
|
||||
const historyUpdates = this.getHistoryUpdatesSpy.returnValues[0]
|
||||
expect(historyUpdates[0]).to.deep.equal({
|
||||
doc: this.doc.id,
|
||||
meta: {
|
||||
pathname: '/a/b/c.tex',
|
||||
doc_length: 10,
|
||||
history_doc_length: 19,
|
||||
ts: historyUpdates[0].meta.ts,
|
||||
user_id: this.user_id,
|
||||
},
|
||||
op: [{ p: 4, d: 'bbb' }],
|
||||
})
|
||||
|
||||
expect(historyUpdates[1]).to.deep.equal({
|
||||
doc: this.doc.id,
|
||||
meta: {
|
||||
pathname: '/a/b/c.tex',
|
||||
doc_length: 10,
|
||||
history_doc_length: 16,
|
||||
ts: historyUpdates[1].meta.ts,
|
||||
user_id: this.user_id,
|
||||
},
|
||||
op: [{ p: 5, d: 'ccc' }],
|
||||
})
|
||||
|
||||
expect(historyUpdates[2]).to.deep.equal({
|
||||
doc: this.doc.id,
|
||||
meta: {
|
||||
pathname: '/a/b/c.tex',
|
||||
doc_length: 10,
|
||||
history_doc_length: 13,
|
||||
ts: historyUpdates[2].meta.ts,
|
||||
user_id: this.user_id,
|
||||
},
|
||||
op: [{ p: 6, d: 'ddd' }],
|
||||
})
|
||||
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('accepting changes in reverse order', function (done) {
|
||||
DocUpdaterClient.acceptChanges(
|
||||
this.project_id,
|
||||
this.doc.id,
|
||||
[
|
||||
this.id_seed_3 + '000001',
|
||||
this.id_seed_2 + '000001',
|
||||
this.id_seed_1 + '000001',
|
||||
],
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
|
||||
const historyUpdates = this.getHistoryUpdatesSpy.returnValues[0]
|
||||
expect(historyUpdates[0]).to.deep.equal({
|
||||
doc: this.doc.id,
|
||||
meta: {
|
||||
pathname: '/a/b/c.tex',
|
||||
doc_length: 10,
|
||||
history_doc_length: 19,
|
||||
ts: historyUpdates[0].meta.ts,
|
||||
user_id: this.user_id,
|
||||
},
|
||||
op: [{ p: 4, d: 'bbb' }],
|
||||
})
|
||||
|
||||
expect(historyUpdates[1]).to.deep.equal({
|
||||
doc: this.doc.id,
|
||||
meta: {
|
||||
pathname: '/a/b/c.tex',
|
||||
doc_length: 10,
|
||||
history_doc_length: 16,
|
||||
ts: historyUpdates[1].meta.ts,
|
||||
user_id: this.user_id,
|
||||
},
|
||||
op: [{ p: 5, d: 'ccc' }],
|
||||
})
|
||||
|
||||
expect(historyUpdates[2]).to.deep.equal({
|
||||
doc: this.doc.id,
|
||||
meta: {
|
||||
pathname: '/a/b/c.tex',
|
||||
doc_length: 10,
|
||||
history_doc_length: 13,
|
||||
ts: historyUpdates[2].meta.ts,
|
||||
user_id: this.user_id,
|
||||
},
|
||||
op: [{ p: 6, d: 'ddd' }],
|
||||
})
|
||||
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleting a comment range', function () {
|
||||
before(function (done) {
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
|
|
|
@ -119,6 +119,10 @@ module.exports = DocUpdaterClient = {
|
|||
)
|
||||
},
|
||||
|
||||
enableHistoryRangesSupport(docId, cb) {
|
||||
rclient.sadd(keys.historyRangesSupport(), docId, cb)
|
||||
},
|
||||
|
||||
preloadDoc(projectId, docId, callback) {
|
||||
DocUpdaterClient.getDoc(projectId, docId, callback)
|
||||
},
|
||||
|
@ -193,6 +197,16 @@ module.exports = DocUpdaterClient = {
|
|||
)
|
||||
},
|
||||
|
||||
acceptChanges(projectId, docId, changeIds, callback) {
|
||||
request.post(
|
||||
{
|
||||
url: `http://localhost:3003/project/${projectId}/doc/${docId}/change/accept`,
|
||||
json: { change_ids: changeIds },
|
||||
},
|
||||
callback
|
||||
)
|
||||
},
|
||||
|
||||
removeComment(projectId, docId, comment, callback) {
|
||||
request.del(
|
||||
`http://localhost:3003/project/${projectId}/doc/${docId}/comment/${comment}`,
|
||||
|
|
|
@ -637,6 +637,265 @@ describe('RangesManager', function () {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getHistoryUpdatesForAcceptedChanges', function () {
|
||||
beforeEach(function () {
|
||||
this.RangesManager = SandboxedModule.require(MODULE_PATH, {
|
||||
requires: {
|
||||
'@overleaf/ranges-tracker': (this.RangesTracker =
|
||||
SandboxedModule.require('@overleaf/ranges-tracker')),
|
||||
'@overleaf/metrics': {},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should create history updates for accepted track inserts', function () {
|
||||
// 'one two three four five' <-- text before changes
|
||||
const ranges = {
|
||||
comments: [],
|
||||
changes: makeRanges([
|
||||
{ i: 'lorem', p: 0 },
|
||||
{ i: 'ipsum', p: 15 },
|
||||
]),
|
||||
}
|
||||
const lines = ['loremone two thipsumree four five']
|
||||
|
||||
const result = this.RangesManager.getHistoryUpdatesForAcceptedChanges({
|
||||
docId: this.doc_id,
|
||||
acceptedChangeIds: ranges.changes.map(change => change.id),
|
||||
changes: ranges.changes,
|
||||
pathname: '',
|
||||
projectHistoryId: '',
|
||||
lines,
|
||||
})
|
||||
|
||||
expect(result).to.deep.equal([
|
||||
{
|
||||
doc: this.doc_id,
|
||||
meta: {
|
||||
user_id: TEST_USER_ID,
|
||||
doc_length: 33,
|
||||
pathname: '',
|
||||
ts: ranges.changes[0].metadata.ts,
|
||||
},
|
||||
op: [
|
||||
{
|
||||
r: 'lorem',
|
||||
p: 0,
|
||||
tracking: {
|
||||
type: 'none',
|
||||
userId: this.user_id,
|
||||
ts: ranges.changes[0].metadata.ts,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
doc: this.doc_id,
|
||||
meta: {
|
||||
user_id: TEST_USER_ID,
|
||||
doc_length: 33,
|
||||
pathname: '',
|
||||
ts: ranges.changes[1].metadata.ts,
|
||||
},
|
||||
op: [
|
||||
{
|
||||
r: 'ipsum',
|
||||
p: 15,
|
||||
tracking: {
|
||||
type: 'none',
|
||||
userId: this.user_id,
|
||||
ts: ranges.changes[1].metadata.ts,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should create history updates for accepted track deletes', function () {
|
||||
// 'one two three four five' <-- text before changes
|
||||
const ranges = {
|
||||
comments: [],
|
||||
changes: makeRanges([
|
||||
{ d: 'two', p: 4 },
|
||||
{ d: 'three', p: 5 },
|
||||
]),
|
||||
}
|
||||
const lines = ['one four five']
|
||||
|
||||
const result = this.RangesManager.getHistoryUpdatesForAcceptedChanges({
|
||||
docId: this.doc_id,
|
||||
acceptedChangeIds: ranges.changes.map(change => change.id),
|
||||
changes: ranges.changes,
|
||||
pathname: '',
|
||||
projectHistoryId: '',
|
||||
lines,
|
||||
})
|
||||
|
||||
expect(result).to.deep.equal([
|
||||
{
|
||||
doc: this.doc_id,
|
||||
meta: {
|
||||
user_id: TEST_USER_ID,
|
||||
doc_length: 15,
|
||||
history_doc_length: 23,
|
||||
pathname: '',
|
||||
ts: ranges.changes[0].metadata.ts,
|
||||
},
|
||||
op: [
|
||||
{
|
||||
d: 'two',
|
||||
p: 4,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
doc: this.doc_id,
|
||||
meta: {
|
||||
user_id: TEST_USER_ID,
|
||||
doc_length: 15,
|
||||
history_doc_length: 20,
|
||||
pathname: '',
|
||||
ts: ranges.changes[1].metadata.ts,
|
||||
},
|
||||
op: [
|
||||
{
|
||||
d: 'three',
|
||||
p: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should create history updates with unaccepted deletes', function () {
|
||||
// 'one two three four five' <-- text before changes
|
||||
const ranges = {
|
||||
comments: [],
|
||||
changes: makeRanges([
|
||||
{ d: 'two', p: 4 },
|
||||
{ d: 'three', p: 5 },
|
||||
]),
|
||||
}
|
||||
const lines = ['one four five']
|
||||
|
||||
const result = this.RangesManager.getHistoryUpdatesForAcceptedChanges({
|
||||
docId: this.doc_id,
|
||||
acceptedChangeIds: [ranges.changes[1].id],
|
||||
changes: ranges.changes,
|
||||
pathname: '',
|
||||
projectHistoryId: '',
|
||||
lines,
|
||||
})
|
||||
|
||||
expect(result).to.deep.equal([
|
||||
{
|
||||
doc: this.doc_id,
|
||||
meta: {
|
||||
user_id: TEST_USER_ID,
|
||||
doc_length: 15,
|
||||
history_doc_length: 23,
|
||||
pathname: '',
|
||||
ts: ranges.changes[1].metadata.ts,
|
||||
},
|
||||
op: [
|
||||
{
|
||||
d: 'three',
|
||||
p: 5,
|
||||
hpos: 8,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should create history updates with mixed track changes', function () {
|
||||
// 'one two three four five' <-- text before changes
|
||||
const ranges = {
|
||||
comments: [],
|
||||
changes: makeRanges([
|
||||
{ d: 'two', p: 4 },
|
||||
{ d: 'three', p: 5 },
|
||||
{ i: 'xxx ', p: 6 },
|
||||
{ d: 'five', p: 15 },
|
||||
]),
|
||||
}
|
||||
const lines = ['one xxx four ']
|
||||
|
||||
const result = this.RangesManager.getHistoryUpdatesForAcceptedChanges({
|
||||
docId: this.doc_id,
|
||||
acceptedChangeIds: [
|
||||
ranges.changes[0].id,
|
||||
// ranges.changes[1].id - second delete is not accepted
|
||||
ranges.changes[2].id,
|
||||
ranges.changes[3].id,
|
||||
],
|
||||
changes: ranges.changes,
|
||||
pathname: '',
|
||||
projectHistoryId: '',
|
||||
lines,
|
||||
})
|
||||
|
||||
expect(result).to.deep.equal([
|
||||
{
|
||||
doc: this.doc_id,
|
||||
meta: {
|
||||
user_id: TEST_USER_ID,
|
||||
doc_length: 15,
|
||||
history_doc_length: 27,
|
||||
pathname: '',
|
||||
ts: ranges.changes[0].metadata.ts,
|
||||
},
|
||||
op: [
|
||||
{
|
||||
d: 'two',
|
||||
p: 4,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
doc: this.doc_id,
|
||||
meta: {
|
||||
user_id: TEST_USER_ID,
|
||||
doc_length: 15,
|
||||
history_doc_length: 24,
|
||||
pathname: '',
|
||||
ts: ranges.changes[1].metadata.ts,
|
||||
},
|
||||
op: [
|
||||
{
|
||||
r: 'xxx ',
|
||||
p: 6,
|
||||
hpos: 11,
|
||||
tracking: {
|
||||
type: 'none',
|
||||
userId: this.user_id,
|
||||
ts: ranges.changes[1].metadata.ts,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
doc: this.doc_id,
|
||||
meta: {
|
||||
user_id: TEST_USER_ID,
|
||||
doc_length: 15,
|
||||
history_doc_length: 24,
|
||||
pathname: '',
|
||||
ts: ranges.changes[2].metadata.ts,
|
||||
},
|
||||
op: [
|
||||
{
|
||||
d: 'five',
|
||||
p: 15,
|
||||
hpos: 20,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function makeRanges(ops) {
|
||||
|
@ -646,7 +905,7 @@ function makeRanges(ops) {
|
|||
changes.push({
|
||||
id: id.toString(),
|
||||
op,
|
||||
metadata: { user_id: TEST_USER_ID },
|
||||
metadata: { user_id: TEST_USER_ID, ts: new Date() },
|
||||
})
|
||||
id += 1
|
||||
}
|
||||
|
|
|
@ -71,6 +71,8 @@ function adjustLengthByOp(length, op, opts = {}) {
|
|||
} else {
|
||||
return length - op.d.length
|
||||
}
|
||||
} else if ('r' in op && op.r != null) {
|
||||
return length
|
||||
} else if ('c' in op && op.c != null) {
|
||||
return length
|
||||
} else {
|
||||
|
|
|
@ -12,6 +12,7 @@ import * as OperationsCompressor from './OperationsCompressor.js'
|
|||
* @typedef {import('./types').DeleteOp} DeleteCommentUpdate
|
||||
* @typedef {import('./types').DeleteOp} DeleteOp
|
||||
* @typedef {import('./types').InsertOp} InsertOp
|
||||
* @typedef {import('./types').RetainOp} RetainOp
|
||||
* @typedef {import('./types').Op} Op
|
||||
* @typedef {import('./types').RawScanOp} RawScanOp
|
||||
* @typedef {import('./types').RenameUpdate} RenameUpdate
|
||||
|
@ -292,7 +293,7 @@ class OperationsBuilder {
|
|||
return
|
||||
}
|
||||
|
||||
if (!isInsert(op) && !isDelete(op)) {
|
||||
if (!isInsert(op) && !isDelete(op) && !isRetain(op)) {
|
||||
throw new Errors.UnexpectedOpTypeError('unexpected op type', { op })
|
||||
}
|
||||
|
||||
|
@ -330,6 +331,14 @@ class OperationsBuilder {
|
|||
}
|
||||
}
|
||||
|
||||
if (isRetain(op)) {
|
||||
if (op.tracking) {
|
||||
this.retain(op.r.length, { tracking: op.tracking })
|
||||
} else {
|
||||
this.retain(op.r.length)
|
||||
}
|
||||
}
|
||||
|
||||
if (isDelete(op)) {
|
||||
const changes = op.trackedChanges ?? []
|
||||
|
||||
|
@ -465,6 +474,14 @@ function isInsert(op) {
|
|||
return 'i' in op && op.i != null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Op} op
|
||||
* @returns {op is RetainOp}
|
||||
*/
|
||||
function isRetain(op) {
|
||||
return 'r' in op && op.r != null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Op} op
|
||||
* @returns {op is DeleteOp}
|
||||
|
|
|
@ -62,7 +62,14 @@ export type RenameUpdate = ProjectUpdateBase & {
|
|||
new_pathname: string
|
||||
}
|
||||
|
||||
export type Op = InsertOp | DeleteOp | CommentOp
|
||||
export type Op = RetainOp | InsertOp | DeleteOp | CommentOp
|
||||
|
||||
export type RetainOp = {
|
||||
r: string
|
||||
p: number
|
||||
hpos?: number
|
||||
tracking?: TrackingProps
|
||||
}
|
||||
|
||||
export type InsertOp = {
|
||||
i: string
|
||||
|
|
|
@ -97,6 +97,42 @@ describe('UpdateCompressor', function () {
|
|||
])
|
||||
})
|
||||
|
||||
it('should not ignore retain ops with tracking data', function () {
|
||||
expect(
|
||||
this.UpdateCompressor.convertToSingleOpUpdates([
|
||||
{
|
||||
op: [
|
||||
(this.op1 = { p: 0, i: 'Foo' }),
|
||||
(this.op2 = {
|
||||
p: 9,
|
||||
r: 'baz',
|
||||
tracking: { type: 'none', ts: this.ts1, userId: this.user_id },
|
||||
}),
|
||||
(this.op3 = { p: 6, i: 'bar' }),
|
||||
],
|
||||
meta: { ts: this.ts1, user_id: this.user_id, doc_length: 10 },
|
||||
v: 42,
|
||||
},
|
||||
])
|
||||
).to.deep.equal([
|
||||
{
|
||||
op: this.op1,
|
||||
meta: { ts: this.ts1, user_id: this.user_id, doc_length: 10 },
|
||||
v: 42,
|
||||
},
|
||||
{
|
||||
op: this.op2,
|
||||
meta: { ts: this.ts1, user_id: this.user_id, doc_length: 13 },
|
||||
v: 42,
|
||||
},
|
||||
{
|
||||
op: this.op3,
|
||||
meta: { ts: this.ts1, user_id: this.user_id, doc_length: 13 },
|
||||
v: 42,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should update doc_length when splitting after an insert', function () {
|
||||
expect(
|
||||
this.UpdateCompressor.convertToSingleOpUpdates([
|
||||
|
|
|
@ -568,6 +568,118 @@ describe('UpdateTranslator', function () {
|
|||
])
|
||||
})
|
||||
|
||||
it('should translate retains without tracking data', function () {
|
||||
const updates = [
|
||||
{
|
||||
update: {
|
||||
doc: this.doc_id,
|
||||
op: [
|
||||
{
|
||||
p: 3,
|
||||
r: 'lo',
|
||||
},
|
||||
],
|
||||
v: this.version,
|
||||
meta: {
|
||||
user_id: this.user_id,
|
||||
ts: new Date(this.timestamp).getTime(),
|
||||
pathname: '/main.tex',
|
||||
doc_length: 20,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const changes = this.UpdateTranslator.convertToChanges(
|
||||
this.project_id,
|
||||
updates
|
||||
).map(change => change.toRaw())
|
||||
|
||||
expect(changes).to.deep.equal([
|
||||
{
|
||||
authors: [],
|
||||
operations: [
|
||||
{
|
||||
pathname: 'main.tex',
|
||||
textOperation: [20],
|
||||
},
|
||||
],
|
||||
v2Authors: [this.user_id],
|
||||
timestamp: this.timestamp,
|
||||
v2DocVersions: {
|
||||
[this.doc_id]: {
|
||||
pathname: 'main.tex',
|
||||
v: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('can translate retains with tracking data', function () {
|
||||
const updates = [
|
||||
{
|
||||
update: {
|
||||
doc: this.doc_id,
|
||||
op: [
|
||||
{
|
||||
p: 3,
|
||||
r: 'lo',
|
||||
tracking: {
|
||||
type: 'none',
|
||||
ts: this.timestamp,
|
||||
userId: this.user_id,
|
||||
},
|
||||
},
|
||||
],
|
||||
v: this.version,
|
||||
meta: {
|
||||
user_id: this.user_id,
|
||||
ts: new Date(this.timestamp).getTime(),
|
||||
pathname: '/main.tex',
|
||||
doc_length: 20,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const changes = this.UpdateTranslator.convertToChanges(
|
||||
this.project_id,
|
||||
updates
|
||||
).map(change => change.toRaw())
|
||||
|
||||
expect(changes).to.deep.equal([
|
||||
{
|
||||
authors: [],
|
||||
operations: [
|
||||
{
|
||||
pathname: 'main.tex',
|
||||
textOperation: [
|
||||
3,
|
||||
{
|
||||
r: 2,
|
||||
tracking: {
|
||||
type: 'none',
|
||||
ts: this.timestamp,
|
||||
userId: this.user_id,
|
||||
},
|
||||
},
|
||||
15,
|
||||
],
|
||||
},
|
||||
],
|
||||
v2Authors: [this.user_id],
|
||||
timestamp: this.timestamp,
|
||||
v2DocVersions: {
|
||||
'59bfd450e3028c4d40a1e9ab': {
|
||||
pathname: 'main.tex',
|
||||
v: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('can translate insertions at the start and end (with zero retained)', function () {
|
||||
const updates = [
|
||||
{
|
||||
|
|
Loading…
Reference in a new issue