Files
overleaf-cep/services/document-updater/app/js/HistoryConversions.js
T
2026-09-17 11:39:15 +00:00

279 lines
7.3 KiB
JavaScript

// @ts-check
const _ = require('lodash')
const RangesTracker = require('@overleaf/ranges-tracker')
const {
File,
StringFileData,
getDocUpdaterCompatibleRanges,
} = require('overleaf-editor-core')
const { addTrackedDeletesToContent, isDelete } = require('./Utils')
/**
* @import { Comment, HistoryComment, HistoryRanges, HistoryTrackedChange } from './types'
* @import { Ranges, TrackedChange } from './types'
* @import { CommentRawData, StringFileRawData, TrackedChangeRawData } from 'overleaf-editor-core/lib/types'
*/
/**
* Convert editor ranges to history ranges
*
* @param {Ranges} ranges
* @return {HistoryRanges}
*/
function toHistoryRanges(ranges) {
const changes = ranges.changes ?? []
const comments = (ranges.comments ?? []).slice()
// Changes are assumed to be sorted, but not comments
comments.sort((a, b) => a.op.p - b.op.p)
/**
* This will allow us to go through comments at a different pace as we loop
* through tracked changes
*/
const commentsIterator = new CommentsIterator(comments)
/**
* Current offset between editor pos and history pos
*/
let offset = 0
/**
* History comments that might overlap with the tracked change considered
*
* @type {HistoryComment[]}
*/
let pendingComments = []
/**
* The final history comments generated
*
* @type {HistoryComment[]}
*/
const historyComments = []
/**
* The final history tracked changes generated
*
* @type {HistoryTrackedChange[]}
*/
const historyChanges = []
for (const change of changes) {
historyChanges.push(toHistoryChange(change, offset))
// After this point, we're only interested in tracked deletes
if (!isDelete(change.op)) {
continue
}
// Fill pendingComments with new comments that start before this tracked
// delete and might overlap
for (const comment of commentsIterator.nextComments(change.op.p)) {
pendingComments.push(toHistoryComment(comment, offset))
}
// Save comments that are fully before this tracked delete
const newPendingComments = []
for (const historyComment of pendingComments) {
const commentEnd = historyComment.op.p + historyComment.op.c.length
if (commentEnd <= change.op.p) {
historyComments.push(historyComment)
} else {
newPendingComments.push(historyComment)
}
}
pendingComments = newPendingComments
// The rest of pending comments overlap with this tracked change. Adjust
// their history length.
for (const historyComment of pendingComments) {
historyComment.op.hlen =
(historyComment.op.hlen ?? historyComment.op.c.length) +
change.op.d.length
}
// Adjust the offset
offset += change.op.d.length
}
// Save the last pending comments
for (const historyComment of pendingComments) {
historyComments.push(historyComment)
}
// Save any comments that came after the last tracked change
for (const comment of commentsIterator.nextComments()) {
historyComments.push(toHistoryComment(comment, offset))
}
const historyRanges = {}
if (historyComments.length > 0) {
historyRanges.comments = historyComments
}
if (historyChanges.length > 0) {
historyRanges.changes = historyChanges
}
return historyRanges
}
class CommentsIterator {
/**
* Build a CommentsIterator
*
* @param {Comment[]} comments
*/
constructor(comments) {
this.comments = comments
this.currentIndex = 0
}
/**
* Generator that returns the next comments to consider
*
* @param {number} beforePos - only return comments that start before this position
* @return {Iterable<Comment>}
*/
*nextComments(beforePos = Infinity) {
while (this.currentIndex < this.comments.length) {
const comment = this.comments[this.currentIndex]
if (comment.op.p < beforePos) {
yield comment
this.currentIndex += 1
} else {
return
}
}
}
}
/**
* Convert an editor tracked change into a history tracked change
*
* @param {TrackedChange} change
* @param {number} offset - how much the history change is ahead of the
* editor change
* @return {HistoryTrackedChange}
*/
function toHistoryChange(change, offset) {
/** @type {HistoryTrackedChange} */
const historyChange = _.cloneDeep(change)
if (offset > 0) {
historyChange.op.hpos = change.op.p + offset
}
return historyChange
}
/**
* Convert an editor comment into a history comment
*
* @param {Comment} comment
* @param {number} offset - how much the history comment is ahead of the
* editor comment
* @return {HistoryComment}
*/
function toHistoryComment(comment, offset) {
/** @type {HistoryComment} */
const historyComment = _.cloneDeep(comment)
if (offset > 0) {
historyComment.op.hpos = comment.op.p + offset
}
return historyComment
}
/**
* Convert a history-OT doc into editor lines and ranges
*
* Tracked deletes are removed from the content and the ranges use editor
* positions (as maintained by the RangesTracker).
*
* @param {StringFileRawData} raw
* @return {{lines: string[], ranges: Ranges}}
*/
function fromHistoryOT(raw) {
const fileData = StringFileData.fromRaw(raw)
const { changes, comments } = getDocUpdaterCompatibleRanges(
new File(fileData)
)
/** @type {Ranges} */
const ranges = {}
if (changes.length > 0) {
ranges.changes = changes.map(change => ({
...change,
id: RangesTracker.generateId(),
}))
}
if (comments.length > 0) {
ranges.comments = comments.map(comment => ({
...comment,
id: comment.op.t,
}))
}
return { lines: fileData.getLines(), ranges }
}
/**
* Convert editor lines and ranges into a history-OT doc
*
* Tracked deletes are added back to the content and the ranges use history
* positions.
*
* @param {string[]} lines
* @param {Ranges} ranges
* @param {string[]} resolvedCommentIds
* @return {StringFileRawData}
*/
function toHistoryOT(lines, ranges, resolvedCommentIds) {
const historyRanges = toHistoryRanges(ranges)
/** @type {StringFileRawData} */
const raw = {
content: addTrackedDeletesToContent(lines.join('\n'), ranges.changes ?? []),
}
const comments = (historyRanges.comments ?? []).map(comment => {
const length = comment.op.hlen ?? comment.op.c.length
/** @type {CommentRawData} */
const rawComment = {
id: comment.op.t,
// Translate zero length comments into detached comments
ranges:
length > 0 ? [{ pos: comment.op.hpos ?? comment.op.p, length }] : [],
}
if (resolvedCommentIds.includes(comment.op.t)) {
rawComment.resolved = true
}
return rawComment
})
if (comments.length > 0) {
raw.comments = comments
}
const trackedChanges = (historyRanges.changes ?? []).map(change => {
/** @type {TrackedChangeRawData} */
const trackedChange = {
range: {
pos: change.op.hpos ?? change.op.p,
length: isDelete(change.op) ? change.op.d.length : change.op.i.length,
},
tracking: {
type: isDelete(change.op) ? 'delete' : 'insert',
userId: change.metadata.user_id,
ts: new Date(change.metadata.ts).toISOString(),
},
}
return trackedChange
})
if (trackedChanges.length > 0) {
raw.trackedChanges = trackedChanges
}
return raw
}
module.exports = {
toHistoryRanges,
toHistoryOT,
fromHistoryOT,
}