// @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} */ *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, }