mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Merge pull request #18355 from overleaf/em-resync-tracked-changes
Handle tracked changes during resyncs GitOrigin-RevId: 1d5b16a4cb17226da184a5430ebbcfc79ad9c7ce
This commit is contained in:
parent
a0334898db
commit
6d216d4738
5 changed files with 545 additions and 83 deletions
|
@ -19,12 +19,19 @@ import * as ErrorRecorder from './ErrorRecorder.js'
|
||||||
import * as RedisManager from './RedisManager.js'
|
import * as RedisManager from './RedisManager.js'
|
||||||
import * as HistoryStoreManager from './HistoryStoreManager.js'
|
import * as HistoryStoreManager from './HistoryStoreManager.js'
|
||||||
import * as HashManager from './HashManager.js'
|
import * as HashManager from './HashManager.js'
|
||||||
|
import { isInsert } from './Utils.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {import('overleaf-editor-core').Comment} HistoryComment
|
* @typedef {import('overleaf-editor-core').Comment} HistoryComment
|
||||||
|
* @typedef {import('overleaf-editor-core').TrackedChange} HistoryTrackedChange
|
||||||
* @typedef {import('./types').Comment} Comment
|
* @typedef {import('./types').Comment} Comment
|
||||||
* @typedef {import('./types').Entity} Entity
|
* @typedef {import('./types').Entity} Entity
|
||||||
* @typedef {import('./types').ResyncDocContentUpdate} ResyncDocContentUpdate
|
* @typedef {import('./types').ResyncDocContentUpdate} ResyncDocContentUpdate
|
||||||
|
* @typedef {import('./types').RetainOp} RetainOp
|
||||||
|
* @typedef {import('./types').TrackedChange} TrackedChange
|
||||||
|
* @typedef {import('./types').TrackedChangeTransition} TrackedChangeTransition
|
||||||
|
* @typedef {import('./types').TrackingDirective} TrackingDirective
|
||||||
|
* @typedef {import('./types').TrackingType} TrackingType
|
||||||
* @typedef {import('./types').Update} Update
|
* @typedef {import('./types').Update} Update
|
||||||
*/
|
*/
|
||||||
const MAX_RESYNC_HISTORY_RECORDS = 100 // keep this many records of previous resyncs
|
const MAX_RESYNC_HISTORY_RECORDS = 100 // keep this many records of previous resyncs
|
||||||
|
@ -639,12 +646,18 @@ class SyncUpdateExpander {
|
||||||
}
|
}
|
||||||
|
|
||||||
const persistedComments = file.getComments().toArray()
|
const persistedComments = file.getComments().toArray()
|
||||||
await this.queueUpdateForOutOfSyncComments(
|
await this.queueUpdatesForOutOfSyncComments(
|
||||||
update,
|
update,
|
||||||
pathname,
|
pathname,
|
||||||
persistedContent,
|
|
||||||
persistedComments
|
persistedComments
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const persistedChanges = file.getTrackedChanges().asSorted()
|
||||||
|
await this.queueUpdatesForOutOfSyncTrackedChanges(
|
||||||
|
update,
|
||||||
|
pathname,
|
||||||
|
persistedChanges
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -694,19 +707,14 @@ class SyncUpdateExpander {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queue update for out of sync comments
|
* Queue updates for out of sync comments
|
||||||
*
|
*
|
||||||
* @param {ResyncDocContentUpdate} update
|
* @param {ResyncDocContentUpdate} update
|
||||||
* @param {string} pathname
|
* @param {string} pathname
|
||||||
* @param {string} persistedContent
|
|
||||||
* @param {HistoryComment[]} persistedComments
|
* @param {HistoryComment[]} persistedComments
|
||||||
*/
|
*/
|
||||||
async queueUpdateForOutOfSyncComments(
|
async queueUpdatesForOutOfSyncComments(update, pathname, persistedComments) {
|
||||||
update,
|
const expectedContent = update.resyncDocContent.content
|
||||||
pathname,
|
|
||||||
persistedContent,
|
|
||||||
persistedComments
|
|
||||||
) {
|
|
||||||
const expectedComments = update.resyncDocContent.ranges?.comments ?? []
|
const expectedComments = update.resyncDocContent.ranges?.comments ?? []
|
||||||
const resolvedComments = new Set(
|
const resolvedComments = new Set(
|
||||||
update.resyncDocContent.resolvedComments ?? []
|
update.resyncDocContent.resolvedComments ?? []
|
||||||
|
@ -760,12 +768,129 @@ class SyncUpdateExpander {
|
||||||
origin: this.origin,
|
origin: this.origin,
|
||||||
ts: update.meta.ts,
|
ts: update.meta.ts,
|
||||||
pathname,
|
pathname,
|
||||||
doc_length: persistedContent.length,
|
doc_length: expectedContent.length,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue updates for out of sync tracked changes
|
||||||
|
*
|
||||||
|
* @param {ResyncDocContentUpdate} update
|
||||||
|
* @param {string} pathname
|
||||||
|
* @param {readonly HistoryTrackedChange[]} persistedChanges
|
||||||
|
*/
|
||||||
|
async queueUpdatesForOutOfSyncTrackedChanges(
|
||||||
|
update,
|
||||||
|
pathname,
|
||||||
|
persistedChanges
|
||||||
|
) {
|
||||||
|
const expectedChanges = update.resyncDocContent.ranges?.changes ?? []
|
||||||
|
const expectedContent = update.resyncDocContent.content
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A cursor on the expected content
|
||||||
|
*/
|
||||||
|
let cursor = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The persisted tracking at cursor
|
||||||
|
*
|
||||||
|
* @type {TrackingDirective}
|
||||||
|
*/
|
||||||
|
let persistedTracking = { type: 'none' }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The expected tracking at cursor
|
||||||
|
*
|
||||||
|
* @type {TrackingDirective}
|
||||||
|
*/
|
||||||
|
let expectedTracking = { type: 'none' }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The retain ops for the update
|
||||||
|
*
|
||||||
|
* @type {RetainOp[]}
|
||||||
|
*/
|
||||||
|
const ops = []
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The retain op being built
|
||||||
|
*
|
||||||
|
* @type {RetainOp | null}
|
||||||
|
*/
|
||||||
|
let currentOp = null
|
||||||
|
|
||||||
|
for (const transition of getTrackedChangesTransitions(
|
||||||
|
persistedChanges,
|
||||||
|
expectedChanges,
|
||||||
|
expectedContent.length
|
||||||
|
)) {
|
||||||
|
if (transition.pos > cursor) {
|
||||||
|
// The next transition will move the cursor. Decide what to do with the interval.
|
||||||
|
if (trackingDirectivesEqual(expectedTracking, persistedTracking)) {
|
||||||
|
// Expected tracking and persisted tracking are in sync. Emit the
|
||||||
|
// current op and skip this interval.
|
||||||
|
if (currentOp != null) {
|
||||||
|
ops.push(currentOp)
|
||||||
|
currentOp = null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Expected tracking and persisted tracking are different.
|
||||||
|
const retainedText = expectedContent.slice(cursor, transition.pos)
|
||||||
|
if (
|
||||||
|
currentOp?.tracking != null &&
|
||||||
|
trackingDirectivesEqual(expectedTracking, currentOp.tracking)
|
||||||
|
) {
|
||||||
|
// The current op has the right tracking. Extend it.
|
||||||
|
currentOp.r += retainedText
|
||||||
|
} else {
|
||||||
|
// The current op doesn't have the right tracking. Emit the current
|
||||||
|
// op and start a new one.
|
||||||
|
if (currentOp != null) {
|
||||||
|
ops.push(currentOp)
|
||||||
|
}
|
||||||
|
currentOp = {
|
||||||
|
r: retainedText,
|
||||||
|
p: cursor,
|
||||||
|
tracking: expectedTracking,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance cursor
|
||||||
|
cursor = transition.pos
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the expected and persisted tracking
|
||||||
|
if (transition.stage === 'persisted') {
|
||||||
|
persistedTracking = transition.tracking
|
||||||
|
} else {
|
||||||
|
expectedTracking = transition.tracking
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit the last op
|
||||||
|
if (currentOp != null) {
|
||||||
|
ops.push(currentOp)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ops.length > 0) {
|
||||||
|
this.expandedUpdates.push({
|
||||||
|
doc: update.doc,
|
||||||
|
op: ops,
|
||||||
|
meta: {
|
||||||
|
resync: true,
|
||||||
|
origin: this.origin,
|
||||||
|
ts: update.meta.ts,
|
||||||
|
pathname,
|
||||||
|
doc_length: expectedContent.length,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -788,6 +913,116 @@ function commentRangesAreInSync(persistedComment, expectedComment) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterates through expected tracked changes and persisted tracked changes and
|
||||||
|
* returns all transitions, sorted by position.
|
||||||
|
*
|
||||||
|
* @param {readonly HistoryTrackedChange[]} persistedChanges
|
||||||
|
* @param {TrackedChange[]} expectedChanges
|
||||||
|
* @param {number} docLength
|
||||||
|
*/
|
||||||
|
function getTrackedChangesTransitions(
|
||||||
|
persistedChanges,
|
||||||
|
expectedChanges,
|
||||||
|
docLength
|
||||||
|
) {
|
||||||
|
/** @type {TrackedChangeTransition[]} */
|
||||||
|
const transitions = []
|
||||||
|
|
||||||
|
for (const change of persistedChanges) {
|
||||||
|
transitions.push({
|
||||||
|
stage: 'persisted',
|
||||||
|
pos: change.range.start,
|
||||||
|
tracking: {
|
||||||
|
type: change.tracking.type,
|
||||||
|
userId: change.tracking.userId,
|
||||||
|
ts: change.tracking.ts.toISOString(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
transitions.push({
|
||||||
|
stage: 'persisted',
|
||||||
|
pos: change.range.end,
|
||||||
|
tracking: { type: 'none' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const change of expectedChanges) {
|
||||||
|
const op = change.op
|
||||||
|
const pos = op.hpos ?? op.p
|
||||||
|
if (isInsert(op)) {
|
||||||
|
transitions.push({
|
||||||
|
stage: 'expected',
|
||||||
|
pos,
|
||||||
|
tracking: {
|
||||||
|
type: 'insert',
|
||||||
|
userId: change.metadata.user_id,
|
||||||
|
ts: change.metadata.ts,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
transitions.push({
|
||||||
|
stage: 'expected',
|
||||||
|
pos: pos + op.i.length,
|
||||||
|
tracking: { type: 'none' },
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
transitions.push({
|
||||||
|
stage: 'expected',
|
||||||
|
pos,
|
||||||
|
tracking: {
|
||||||
|
type: 'delete',
|
||||||
|
userId: change.metadata.user_id,
|
||||||
|
ts: change.metadata.ts,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
transitions.push({
|
||||||
|
stage: 'expected',
|
||||||
|
pos: pos + op.d.length,
|
||||||
|
tracking: { type: 'none' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
transitions.push({
|
||||||
|
stage: 'expected',
|
||||||
|
pos: docLength,
|
||||||
|
tracking: { type: 'none' },
|
||||||
|
})
|
||||||
|
|
||||||
|
transitions.sort((a, b) => {
|
||||||
|
if (a.pos < b.pos) {
|
||||||
|
return -1
|
||||||
|
} else if (a.pos > b.pos) {
|
||||||
|
return 1
|
||||||
|
} else if (a.tracking.type === 'none' && b.tracking.type !== 'none') {
|
||||||
|
// none type comes before other types so that it can be overridden at the
|
||||||
|
// same position
|
||||||
|
return -1
|
||||||
|
} else if (a.tracking.type !== 'none' && b.tracking.type === 'none') {
|
||||||
|
// none type comes before other types so that it can be overridden at the
|
||||||
|
// same position
|
||||||
|
return 1
|
||||||
|
} else {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return transitions
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if both tracking directives are equal
|
||||||
|
*
|
||||||
|
* @param {TrackingDirective} a
|
||||||
|
* @param {TrackingDirective} b
|
||||||
|
*/
|
||||||
|
function trackingDirectivesEqual(a, b) {
|
||||||
|
if (a.type === 'none') {
|
||||||
|
return b.type === 'none'
|
||||||
|
} else {
|
||||||
|
return a.type === b.type && a.userId === b.userId && a.ts === b.ts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// EXPORTS
|
// EXPORTS
|
||||||
|
|
||||||
const startResyncCb = callbackify(startResync)
|
const startResyncCb = callbackify(startResync)
|
||||||
|
|
|
@ -4,19 +4,17 @@ import _ from 'lodash'
|
||||||
import Core from 'overleaf-editor-core'
|
import Core from 'overleaf-editor-core'
|
||||||
import * as Errors from './Errors.js'
|
import * as Errors from './Errors.js'
|
||||||
import * as OperationsCompressor from './OperationsCompressor.js'
|
import * as OperationsCompressor from './OperationsCompressor.js'
|
||||||
|
import { isInsert, isRetain, isDelete, isComment } from './Utils.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {import('./types').AddDocUpdate} AddDocUpdate
|
* @typedef {import('./types').AddDocUpdate} AddDocUpdate
|
||||||
* @typedef {import('./types').AddFileUpdate} AddFileUpdate
|
* @typedef {import('./types').AddFileUpdate} AddFileUpdate
|
||||||
* @typedef {import('./types').CommentOp} CommentOp
|
|
||||||
* @typedef {import('./types').DeleteCommentUpdate} DeleteCommentUpdate
|
* @typedef {import('./types').DeleteCommentUpdate} DeleteCommentUpdate
|
||||||
* @typedef {import('./types').DeleteOp} DeleteOp
|
|
||||||
* @typedef {import('./types').InsertOp} InsertOp
|
|
||||||
* @typedef {import('./types').RetainOp} RetainOp
|
|
||||||
* @typedef {import('./types').Op} Op
|
* @typedef {import('./types').Op} Op
|
||||||
* @typedef {import('./types').RawScanOp} RawScanOp
|
* @typedef {import('./types').RawScanOp} RawScanOp
|
||||||
* @typedef {import('./types').RenameUpdate} RenameUpdate
|
* @typedef {import('./types').RenameUpdate} RenameUpdate
|
||||||
* @typedef {import('./types').TextUpdate} TextUpdate
|
* @typedef {import('./types').TextUpdate} TextUpdate
|
||||||
|
* @typedef {import('./types').TrackingDirective} TrackingDirective
|
||||||
* @typedef {import('./types').TrackingProps} TrackingProps
|
* @typedef {import('./types').TrackingProps} TrackingProps
|
||||||
* @typedef {import('./types').SetCommentStateUpdate} SetCommentStateUpdate
|
* @typedef {import('./types').SetCommentStateUpdate} SetCommentStateUpdate
|
||||||
* @typedef {import('./types').Update} Update
|
* @typedef {import('./types').Update} Update
|
||||||
|
@ -405,7 +403,7 @@ class OperationsBuilder {
|
||||||
/**
|
/**
|
||||||
* @param {number} length
|
* @param {number} length
|
||||||
* @param {object} opts
|
* @param {object} opts
|
||||||
* @param {TrackingProps} [opts.tracking]
|
* @param {TrackingDirective} [opts.tracking]
|
||||||
*/
|
*/
|
||||||
retain(length, opts = {}) {
|
retain(length, opts = {}) {
|
||||||
if (opts.tracking) {
|
if (opts.tracking) {
|
||||||
|
@ -461,35 +459,3 @@ class OperationsBuilder {
|
||||||
return this.operations
|
return this.operations
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {Op} op
|
|
||||||
* @returns {op is InsertOp}
|
|
||||||
*/
|
|
||||||
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}
|
|
||||||
*/
|
|
||||||
function isDelete(op) {
|
|
||||||
return 'd' in op && op.d != null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {Op} op
|
|
||||||
* @returns {op is CommentOp}
|
|
||||||
*/
|
|
||||||
function isComment(op) {
|
|
||||||
return 'c' in op && op.c != null && 't' in op && op.t != null
|
|
||||||
}
|
|
||||||
|
|
41
services/project-history/app/js/Utils.js
Normal file
41
services/project-history/app/js/Utils.js
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import('./types').CommentOp} CommentOp
|
||||||
|
* @typedef {import('./types').DeleteOp} DeleteOp
|
||||||
|
* @typedef {import('./types').InsertOp} InsertOp
|
||||||
|
* @typedef {import('./types').Op} Op
|
||||||
|
* @typedef {import('./types').RetainOp} RetainOp
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Op} op
|
||||||
|
* @returns {op is InsertOp}
|
||||||
|
*/
|
||||||
|
export function isInsert(op) {
|
||||||
|
return 'i' in op && op.i != null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Op} op
|
||||||
|
* @returns {op is RetainOp}
|
||||||
|
*/
|
||||||
|
export function isRetain(op) {
|
||||||
|
return 'r' in op && op.r != null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Op} op
|
||||||
|
* @returns {op is DeleteOp}
|
||||||
|
*/
|
||||||
|
export function isDelete(op) {
|
||||||
|
return 'd' in op && op.d != null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Op} op
|
||||||
|
* @returns {op is CommentOp}
|
||||||
|
*/
|
||||||
|
export function isComment(op) {
|
||||||
|
return 'c' in op && op.c != null && 't' in op && op.t != null
|
||||||
|
}
|
|
@ -97,7 +97,7 @@ export type RetainOp = {
|
||||||
r: string
|
r: string
|
||||||
p: number
|
p: number
|
||||||
hpos?: number
|
hpos?: number
|
||||||
tracking?: TrackingProps
|
tracking?: TrackingDirective
|
||||||
}
|
}
|
||||||
|
|
||||||
export type InsertOp = {
|
export type InsertOp = {
|
||||||
|
@ -140,20 +140,22 @@ export type RawOrigin = {
|
||||||
kind: string
|
kind: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TrackingProps =
|
export type TrackingProps = {
|
||||||
| { type: 'none' }
|
type: 'insert' | 'delete'
|
||||||
| {
|
userId: string
|
||||||
type: 'insert' | 'delete'
|
ts: string
|
||||||
userId: string
|
}
|
||||||
ts: string
|
|
||||||
}
|
export type TrackingDirective = TrackingProps | { type: 'none' }
|
||||||
|
|
||||||
|
export type TrackingType = 'insert' | 'delete' | 'none'
|
||||||
|
|
||||||
export type RawScanOp =
|
export type RawScanOp =
|
||||||
| number
|
| number
|
||||||
| string
|
| string
|
||||||
| { r: number; tracking?: TrackingProps }
|
| { r: number; tracking?: TrackingDirective }
|
||||||
| { i: string; tracking?: TrackingProps; commentIds?: string[] }
|
| { i: string; tracking?: TrackingProps; commentIds?: string[] }
|
||||||
| { d: number; tracking?: TrackingProps }
|
| { d: number }
|
||||||
|
|
||||||
export type TrackedChangeSnapshot = {
|
export type TrackedChangeSnapshot = {
|
||||||
op: {
|
op: {
|
||||||
|
@ -207,9 +209,15 @@ export type Comment = {
|
||||||
|
|
||||||
export type TrackedChange = {
|
export type TrackedChange = {
|
||||||
id: string
|
id: string
|
||||||
op: Op
|
op: InsertOp | DeleteOp
|
||||||
metadata: {
|
metadata: {
|
||||||
user_id: string
|
user_id: string
|
||||||
ts: string
|
ts: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TrackedChangeTransition = {
|
||||||
|
pos: number
|
||||||
|
tracking: TrackingDirective
|
||||||
|
stage: 'persisted' | 'expected'
|
||||||
|
}
|
||||||
|
|
|
@ -2,20 +2,20 @@ 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 { Comment, Range } from 'overleaf-editor-core'
|
import { Comment, TrackedChange, Range } from 'overleaf-editor-core'
|
||||||
import { strict as esmock } from 'esmock'
|
import { strict as esmock } from 'esmock'
|
||||||
const { ObjectId } = mongodb
|
const { ObjectId } = mongodb
|
||||||
|
|
||||||
const MODULE_PATH = '../../../../app/js/SyncManager.js'
|
const MODULE_PATH = '../../../../app/js/SyncManager.js'
|
||||||
|
const TIMESTAMP = new Date().toISOString()
|
||||||
const timestamp = new Date()
|
const USER_ID = 'user-id'
|
||||||
|
|
||||||
function resyncProjectStructureUpdate(docs, files) {
|
function resyncProjectStructureUpdate(docs, files) {
|
||||||
return {
|
return {
|
||||||
resyncProjectStructure: { docs, files },
|
resyncProjectStructure: { docs, files },
|
||||||
|
|
||||||
meta: {
|
meta: {
|
||||||
ts: timestamp,
|
ts: TIMESTAMP,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -37,7 +37,7 @@ function docContentSyncUpdate(
|
||||||
},
|
},
|
||||||
|
|
||||||
meta: {
|
meta: {
|
||||||
ts: timestamp,
|
ts: TIMESTAMP,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -46,12 +46,21 @@ function makeComment(commentId, pos, text) {
|
||||||
return {
|
return {
|
||||||
id: commentId,
|
id: commentId,
|
||||||
op: { p: pos, c: text, t: commentId },
|
op: { p: pos, c: text, t: commentId },
|
||||||
meta: {
|
metadata: {
|
||||||
ts: timestamp,
|
user_id: USER_ID,
|
||||||
|
ts: TIMESTAMP,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeTrackedChange(id, op) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
op,
|
||||||
|
metadata: { user_id: USER_ID, ts: TIMESTAMP },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe('SyncManager', function () {
|
describe('SyncManager', function () {
|
||||||
beforeEach(async function () {
|
beforeEach(async function () {
|
||||||
this.now = new Date()
|
this.now = new Date()
|
||||||
|
@ -476,7 +485,7 @@ describe('SyncManager', function () {
|
||||||
doc: 'doc-id',
|
doc: 'doc-id',
|
||||||
path: 'main.tex',
|
path: 'main.tex',
|
||||||
}
|
}
|
||||||
this.persistedDocContent = 'the quick brown fox jumps over the lazy fox'
|
this.persistedDocContent = 'the quick brown fox jumps over the lazy dog'
|
||||||
this.persistedFile = {
|
this.persistedFile = {
|
||||||
file: 'file-id',
|
file: 'file-id',
|
||||||
path: '1.png',
|
path: '1.png',
|
||||||
|
@ -488,6 +497,9 @@ describe('SyncManager', function () {
|
||||||
getComments: sinon
|
getComments: sinon
|
||||||
.stub()
|
.stub()
|
||||||
.returns({ toArray: sinon.stub().returns([]) }),
|
.returns({ toArray: sinon.stub().returns([]) }),
|
||||||
|
getTrackedChanges: sinon
|
||||||
|
.stub()
|
||||||
|
.returns({ asSorted: sinon.stub().returns([]) }),
|
||||||
getHash: sinon.stub().returns(null),
|
getHash: sinon.stub().returns(null),
|
||||||
}
|
}
|
||||||
this.fileMap = {
|
this.fileMap = {
|
||||||
|
@ -566,7 +578,7 @@ describe('SyncManager', function () {
|
||||||
new_pathname: '',
|
new_pathname: '',
|
||||||
meta: {
|
meta: {
|
||||||
resync: true,
|
resync: true,
|
||||||
ts: timestamp,
|
ts: TIMESTAMP,
|
||||||
origin: { kind: 'history-resync' },
|
origin: { kind: 'history-resync' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -590,7 +602,7 @@ describe('SyncManager', function () {
|
||||||
new_pathname: '',
|
new_pathname: '',
|
||||||
meta: {
|
meta: {
|
||||||
resync: true,
|
resync: true,
|
||||||
ts: timestamp,
|
ts: TIMESTAMP,
|
||||||
origin: { kind: 'history-resync' },
|
origin: { kind: 'history-resync' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -625,7 +637,7 @@ describe('SyncManager', function () {
|
||||||
url: newFile.url,
|
url: newFile.url,
|
||||||
meta: {
|
meta: {
|
||||||
resync: true,
|
resync: true,
|
||||||
ts: timestamp,
|
ts: TIMESTAMP,
|
||||||
origin: { kind: 'history-resync' },
|
origin: { kind: 'history-resync' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -659,7 +671,7 @@ describe('SyncManager', function () {
|
||||||
docLines: '',
|
docLines: '',
|
||||||
meta: {
|
meta: {
|
||||||
resync: true,
|
resync: true,
|
||||||
ts: timestamp,
|
ts: TIMESTAMP,
|
||||||
origin: { kind: 'history-resync' },
|
origin: { kind: 'history-resync' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -694,7 +706,7 @@ describe('SyncManager', function () {
|
||||||
new_pathname: '',
|
new_pathname: '',
|
||||||
meta: {
|
meta: {
|
||||||
resync: true,
|
resync: true,
|
||||||
ts: timestamp,
|
ts: TIMESTAMP,
|
||||||
origin: { kind: 'history-resync' },
|
origin: { kind: 'history-resync' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -704,7 +716,7 @@ describe('SyncManager', function () {
|
||||||
url: fileWichWasADoc.url,
|
url: fileWichWasADoc.url,
|
||||||
meta: {
|
meta: {
|
||||||
resync: true,
|
resync: true,
|
||||||
ts: timestamp,
|
ts: TIMESTAMP,
|
||||||
origin: { kind: 'history-resync' },
|
origin: { kind: 'history-resync' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -791,7 +803,7 @@ describe('SyncManager', function () {
|
||||||
new_pathname: '',
|
new_pathname: '',
|
||||||
meta: {
|
meta: {
|
||||||
resync: true,
|
resync: true,
|
||||||
ts: timestamp,
|
ts: TIMESTAMP,
|
||||||
origin: { kind: 'history-resync' },
|
origin: { kind: 'history-resync' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -801,7 +813,7 @@ describe('SyncManager', function () {
|
||||||
url: persistedFileWithNewContent.url,
|
url: persistedFileWithNewContent.url,
|
||||||
meta: {
|
meta: {
|
||||||
resync: true,
|
resync: true,
|
||||||
ts: timestamp,
|
ts: TIMESTAMP,
|
||||||
origin: { kind: 'history-resync' },
|
origin: { kind: 'history-resync' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -916,7 +928,7 @@ describe('SyncManager', function () {
|
||||||
pathname: this.persistedDoc.path,
|
pathname: this.persistedDoc.path,
|
||||||
doc_length: this.persistedDocContent.length,
|
doc_length: this.persistedDocContent.length,
|
||||||
resync: true,
|
resync: true,
|
||||||
ts: timestamp,
|
ts: TIMESTAMP,
|
||||||
origin: { kind: 'history-resync' },
|
origin: { kind: 'history-resync' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -953,7 +965,7 @@ describe('SyncManager', function () {
|
||||||
docLines: '',
|
docLines: '',
|
||||||
meta: {
|
meta: {
|
||||||
resync: true,
|
resync: true,
|
||||||
ts: timestamp,
|
ts: TIMESTAMP,
|
||||||
origin: { kind: 'history-resync' },
|
origin: { kind: 'history-resync' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -964,7 +976,7 @@ describe('SyncManager', function () {
|
||||||
pathname: newDoc.path,
|
pathname: newDoc.path,
|
||||||
doc_length: 0,
|
doc_length: 0,
|
||||||
resync: true,
|
resync: true,
|
||||||
ts: timestamp,
|
ts: TIMESTAMP,
|
||||||
origin: { kind: 'history-resync' },
|
origin: { kind: 'history-resync' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -1023,7 +1035,7 @@ describe('SyncManager', function () {
|
||||||
pathname: this.persistedDoc.path,
|
pathname: this.persistedDoc.path,
|
||||||
doc_length: this.persistedDocContent.length,
|
doc_length: this.persistedDocContent.length,
|
||||||
resync: true,
|
resync: true,
|
||||||
ts: timestamp,
|
ts: TIMESTAMP,
|
||||||
origin: { kind: 'history-resync' },
|
origin: { kind: 'history-resync' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -1060,7 +1072,7 @@ describe('SyncManager', function () {
|
||||||
pathname: this.persistedDoc.path,
|
pathname: this.persistedDoc.path,
|
||||||
doc_length: 'stored content'.length,
|
doc_length: 'stored content'.length,
|
||||||
resync: true,
|
resync: true,
|
||||||
ts: timestamp,
|
ts: TIMESTAMP,
|
||||||
origin: { kind: 'history-resync' },
|
origin: { kind: 'history-resync' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -1143,7 +1155,7 @@ describe('SyncManager', function () {
|
||||||
},
|
},
|
||||||
pathname: this.persistedDoc.path,
|
pathname: this.persistedDoc.path,
|
||||||
resync: true,
|
resync: true,
|
||||||
ts: timestamp,
|
ts: TIMESTAMP,
|
||||||
doc_length: this.persistedDocContent.length,
|
doc_length: this.persistedDocContent.length,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -1178,7 +1190,7 @@ describe('SyncManager', function () {
|
||||||
kind: 'history-resync',
|
kind: 'history-resync',
|
||||||
},
|
},
|
||||||
resync: true,
|
resync: true,
|
||||||
ts: timestamp,
|
ts: TIMESTAMP,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
@ -1218,7 +1230,7 @@ describe('SyncManager', function () {
|
||||||
kind: 'history-resync',
|
kind: 'history-resync',
|
||||||
},
|
},
|
||||||
resync: true,
|
resync: true,
|
||||||
ts: timestamp,
|
ts: TIMESTAMP,
|
||||||
pathname: this.persistedDoc.path,
|
pathname: this.persistedDoc.path,
|
||||||
doc_length: this.persistedDocContent.length,
|
doc_length: this.persistedDocContent.length,
|
||||||
},
|
},
|
||||||
|
@ -1259,5 +1271,205 @@ describe('SyncManager', function () {
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('syncing tracked changes', function () {
|
||||||
|
beforeEach(function () {
|
||||||
|
this.loadedSnapshotDoc.getTrackedChanges.returns({
|
||||||
|
asSorted: sinon.stub().returns([
|
||||||
|
new TrackedChange(new Range(4, 6), {
|
||||||
|
type: 'delete',
|
||||||
|
userId: USER_ID,
|
||||||
|
ts: new Date(TIMESTAMP),
|
||||||
|
}),
|
||||||
|
new TrackedChange(new Range(10, 6), {
|
||||||
|
type: 'insert',
|
||||||
|
userId: USER_ID,
|
||||||
|
ts: new Date(TIMESTAMP),
|
||||||
|
}),
|
||||||
|
new TrackedChange(new Range(20, 6), {
|
||||||
|
type: 'delete',
|
||||||
|
userId: USER_ID,
|
||||||
|
ts: new Date(TIMESTAMP),
|
||||||
|
}),
|
||||||
|
new TrackedChange(new Range(40, 3), {
|
||||||
|
type: 'insert',
|
||||||
|
userId: USER_ID,
|
||||||
|
ts: new Date(TIMESTAMP),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
this.changes = [
|
||||||
|
makeTrackedChange('td1', { p: 4, d: 'quick ' }),
|
||||||
|
makeTrackedChange('ti1', { p: 4, hpos: 10, i: 'brown ' }),
|
||||||
|
makeTrackedChange('td2', { p: 14, hpos: 20, d: 'jumps ' }),
|
||||||
|
makeTrackedChange('ti2', { p: 28, hpos: 40, i: 'dog' }),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does nothing if tracked changes have not changed', async function () {
|
||||||
|
const updates = [
|
||||||
|
docContentSyncUpdate(this.persistedDoc, this.persistedDocContent, {
|
||||||
|
changes: this.changes,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
const expandedUpdates =
|
||||||
|
await this.SyncManager.promises.expandSyncUpdates(
|
||||||
|
this.projectId,
|
||||||
|
this.historyId,
|
||||||
|
updates,
|
||||||
|
this.extendLock
|
||||||
|
)
|
||||||
|
expect(expandedUpdates).to.deep.equal([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds new tracked changes', async function () {
|
||||||
|
this.changes.splice(
|
||||||
|
3,
|
||||||
|
0,
|
||||||
|
makeTrackedChange('td3', { p: 29, hpos: 35, d: 'lazy ' })
|
||||||
|
)
|
||||||
|
const updates = [
|
||||||
|
docContentSyncUpdate(this.persistedDoc, this.persistedDocContent, {
|
||||||
|
changes: this.changes,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
const expandedUpdates =
|
||||||
|
await this.SyncManager.promises.expandSyncUpdates(
|
||||||
|
this.projectId,
|
||||||
|
this.historyId,
|
||||||
|
updates,
|
||||||
|
this.extendLock
|
||||||
|
)
|
||||||
|
expect(expandedUpdates).to.deep.equal([
|
||||||
|
{
|
||||||
|
doc: this.persistedDoc.doc,
|
||||||
|
op: [
|
||||||
|
{
|
||||||
|
p: 35,
|
||||||
|
r: 'lazy ',
|
||||||
|
tracking: {
|
||||||
|
type: 'delete',
|
||||||
|
userId: USER_ID,
|
||||||
|
ts: TIMESTAMP,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
meta: {
|
||||||
|
origin: {
|
||||||
|
kind: 'history-resync',
|
||||||
|
},
|
||||||
|
pathname: this.persistedDoc.path,
|
||||||
|
resync: true,
|
||||||
|
ts: TIMESTAMP,
|
||||||
|
doc_length: this.persistedDocContent.length,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes extra tracked changes', async function () {
|
||||||
|
this.changes.splice(0, 1)
|
||||||
|
const updates = [
|
||||||
|
docContentSyncUpdate(this.persistedDoc, this.persistedDocContent, {
|
||||||
|
changes: this.changes,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
const expandedUpdates =
|
||||||
|
await this.SyncManager.promises.expandSyncUpdates(
|
||||||
|
this.projectId,
|
||||||
|
this.historyId,
|
||||||
|
updates,
|
||||||
|
this.extendLock
|
||||||
|
)
|
||||||
|
expect(expandedUpdates).to.deep.equal([
|
||||||
|
{
|
||||||
|
doc: this.persistedDoc.doc,
|
||||||
|
op: [
|
||||||
|
{
|
||||||
|
p: 4,
|
||||||
|
r: 'quick ',
|
||||||
|
tracking: { type: 'none' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
meta: {
|
||||||
|
origin: {
|
||||||
|
kind: 'history-resync',
|
||||||
|
},
|
||||||
|
pathname: this.persistedDoc.path,
|
||||||
|
resync: true,
|
||||||
|
ts: TIMESTAMP,
|
||||||
|
doc_length: this.persistedDocContent.length,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles overlapping ranges', async function () {
|
||||||
|
this.changes = [
|
||||||
|
makeTrackedChange('ti1', { p: 0, i: 'the quic' }),
|
||||||
|
makeTrackedChange('td1', { p: 8, d: 'k br' }),
|
||||||
|
makeTrackedChange('ti2', { p: 14, hpos: 23, i: 'ps over' }),
|
||||||
|
]
|
||||||
|
const updates = [
|
||||||
|
docContentSyncUpdate(this.persistedDoc, this.persistedDocContent, {
|
||||||
|
changes: this.changes,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
const expandedUpdates =
|
||||||
|
await this.SyncManager.promises.expandSyncUpdates(
|
||||||
|
this.projectId,
|
||||||
|
this.historyId,
|
||||||
|
updates,
|
||||||
|
this.extendLock
|
||||||
|
)
|
||||||
|
|
||||||
|
// Before: the [quick ][brown ] fox [jumps ]over the lazy dog
|
||||||
|
// After: [the quic][k br]own fox jum[ps over] the lazy dog
|
||||||
|
expect(expandedUpdates).to.deep.equal([
|
||||||
|
{
|
||||||
|
doc: this.persistedDoc.doc,
|
||||||
|
op: [
|
||||||
|
{
|
||||||
|
p: 0,
|
||||||
|
r: 'the quic',
|
||||||
|
tracking: { type: 'insert', userId: USER_ID, ts: TIMESTAMP },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
p: 10,
|
||||||
|
r: 'br',
|
||||||
|
tracking: { type: 'delete', userId: USER_ID, ts: TIMESTAMP },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
p: 12,
|
||||||
|
r: 'own ',
|
||||||
|
tracking: { type: 'none' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
p: 20,
|
||||||
|
r: 'jum',
|
||||||
|
tracking: { type: 'none' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
p: 23,
|
||||||
|
r: 'ps over',
|
||||||
|
tracking: { type: 'insert', userId: USER_ID, ts: TIMESTAMP },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
p: 40,
|
||||||
|
r: 'dog',
|
||||||
|
tracking: { type: 'none' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
meta: {
|
||||||
|
origin: { kind: 'history-resync' },
|
||||||
|
pathname: this.persistedDoc.path,
|
||||||
|
resync: true,
|
||||||
|
ts: TIMESTAMP,
|
||||||
|
doc_length: this.persistedDocContent.length,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
Loading…
Reference in a new issue