diff --git a/services/project-history/app/js/ChunkTranslator.js b/services/project-history/app/js/ChunkTranslator.js index 6d9eb37b5d..43aac34003 100644 --- a/services/project-history/app/js/ChunkTranslator.js +++ b/services/project-history/app/js/ChunkTranslator.js @@ -137,7 +137,7 @@ class UpdateSetBuilder { } for (const op of change.operations) { - this.applyOperation(op, timestamp, authors) + this.applyOperation(op, timestamp, authors, change.origin) } this.currentUpdate.pathnames = Array.from(this.currentUpdate.pathnames) @@ -146,9 +146,9 @@ class UpdateSetBuilder { this.version += 1 } - applyOperation(op, timestamp, authors) { + applyOperation(op, timestamp, authors, origin) { if (UpdateSetBuilder._isTextOperation(op)) { - this.applyTextOperation(op, timestamp, authors) + this.applyTextOperation(op, timestamp, authors, origin) } else if (UpdateSetBuilder._isRenameOperation(op)) { this.applyRenameOperation(op, timestamp, authors) } else if (UpdateSetBuilder._isRemoveFileOperation(op)) { @@ -158,7 +158,7 @@ class UpdateSetBuilder { } } - applyTextOperation(operation, timestamp, authors) { + applyTextOperation(operation, timestamp, authors, origin) { const { pathname } = operation if (pathname === '') { // this shouldn't happen, but we continue to allow the user to see the history @@ -180,7 +180,7 @@ class UpdateSetBuilder { return } - file.applyTextOperation(authors, timestamp, this.version, operation) + file.applyTextOperation(authors, timestamp, this.version, operation, origin) this.currentUpdate.pathnames.add(pathname) } @@ -285,8 +285,8 @@ class File { this.operations = [] } - applyTextOperation(authors, timestamp, version, operation) { - this.operations.push({ authors, timestamp, version, operation }) + applyTextOperation(authors, timestamp, version, operation, origin) { + this.operations.push({ authors, timestamp, version, operation, origin }) } rename(pathname) { @@ -309,13 +309,12 @@ class File { let initialContent const updates = [] - for (let operation of this.operations) { - if (!('textOperation' in operation.operation)) { + for (const operationInfo of this.operations) { + if (!('textOperation' in operationInfo.operation)) { // We only care about text operations continue } - let authors, ops, timestamp, version - ;({ authors, timestamp, version, operation } = operation) + const { authors, timestamp, version, operation } = operationInfo // Set the initialContent to the latest version we have before the diff // begins. 'version' here refers to the document version as we are // applying the updates. So we store the content *before* applying the @@ -327,6 +326,7 @@ class File { ) } + let ops ;({ content, ops } = this._convertTextOperation( content, operation, @@ -335,7 +335,7 @@ class File { // We only need to return the updates between fromVersion and toVersion if (fromVersion <= version && version < toVersion) { - updates.push({ + const update = { meta: { users: authors, start_ts: timestamp.getTime(), @@ -343,7 +343,11 @@ class File { }, v: version, op: ops, - }) + } + if (operationInfo.origin) { + update.meta.origin = operationInfo.origin + } + updates.push(update) } } diff --git a/services/project-history/app/js/DiffGenerator.js b/services/project-history/app/js/DiffGenerator.js index 7f698202d4..57e8d5a94d 100644 --- a/services/project-history/app/js/DiffGenerator.js +++ b/services/project-history/app/js/DiffGenerator.js @@ -1,18 +1,4 @@ -/* eslint-disable - no-proto, - no-unused-vars, -*/ -// TODO: This file was created by bulk-decaffeinate. -// Fix any style issues and re-enable lint. -/* - * decaffeinate suggestions: - * DS101: Remove unnecessary use of Array.from - * DS102: Remove unnecessary code created because of implicit returns - * DS207: Consider shorter variations of null checks - * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md - */ - -import logger from '@overleaf/logger' +import _ from 'lodash' import OError from '@overleaf/o-error' export class ConsistencyError extends OError {} @@ -26,7 +12,7 @@ export const _mocks = {} export function buildDiff(initialContent, updates) { let diff = [{ u: initialContent }] - for (const update of Array.from(updates)) { + for (const update of updates) { diff = applyUpdateToDiff(diff, update) } diff = compressDiff(diff) @@ -35,38 +21,54 @@ export function buildDiff(initialContent, updates) { _mocks.compressDiff = diff => { const newDiff = [] - for (const part of Array.from(diff)) { - const lastPart = newDiff[newDiff.length - 1] - if ( - lastPart != null && - (lastPart.meta != null ? lastPart.meta.user : undefined) != null && - (part.meta != null ? part.meta.user : undefined) != null - ) { - if ( - lastPart.i != null && - part.i != null && - lastPart.meta.user.id === part.meta.user.id - ) { - lastPart.i += part.i - lastPart.meta.start_ts = Math.min( - lastPart.meta.start_ts, - part.meta.start_ts - ) - lastPart.meta.end_ts = Math.max(lastPart.meta.end_ts, part.meta.end_ts) - } else if ( - lastPart.d != null && - part.d != null && - lastPart.meta.user.id === part.meta.user.id - ) { - lastPart.d += part.d - lastPart.meta.start_ts = Math.min( - lastPart.meta.start_ts, - part.meta.start_ts - ) - lastPart.meta.end_ts = Math.max(lastPart.meta.end_ts, part.meta.end_ts) - } else { + for (const part of diff) { + const users = part.meta?.users ?? [] + + if (part.meta?.origin?.kind === 'history-resync') { + // Skip history resync updates. Inserts are converted to unchanged text + // and deletes are skipped, so that they effectively don't appear in the + // diff. + if (part.u != null) { newDiff.push(part) + } else if (part.i != null) { + newDiff.push({ u: part.i }) } + continue + } + + if (newDiff.length === 0) { + // If we haven't seen other parts yet, we have nothing to merge. + newDiff.push(part) + continue + } + + const lastPart = newDiff[newDiff.length - 1] + const lastUsers = lastPart.meta?.users ?? [] + const usersNotInBothParts = _.xor(users, lastUsers) + + if (usersNotInBothParts.length > 0) { + // If the set of users in the last part and this part are not the same, we + // can't merge. + newDiff.push(part) + continue + } + + if (lastPart.i != null && part.i != null) { + // Merge two inserts + lastPart.i += part.i + lastPart.meta.start_ts = Math.min( + lastPart.meta.start_ts, + part.meta.start_ts + ) + lastPart.meta.end_ts = Math.max(lastPart.meta.end_ts, part.meta.end_ts) + } else if (lastPart.d != null && part.d != null) { + // Merge two deletes + lastPart.d += part.d + lastPart.meta.start_ts = Math.min( + lastPart.meta.start_ts, + part.meta.start_ts + ) + lastPart.meta.end_ts = Math.max(lastPart.meta.end_ts, part.meta.end_ts) } else { newDiff.push(part) } @@ -80,7 +82,6 @@ export function compressDiff(...args) { export function applyOpToDiff(diff, op, meta) { let consumedDiff - const position = 0 let remainingDiff = diff.slice() ;({ consumedDiff, remainingDiff } = _consumeToOffset(remainingDiff, op.p)) @@ -97,16 +98,16 @@ export function applyOpToDiff(diff, op, meta) { op, meta )) - newDiff.push(...Array.from(consumedDiff || [])) + newDiff.push(...(consumedDiff || [])) } - newDiff.push(...Array.from(remainingDiff || [])) + newDiff.push(...(remainingDiff || [])) return newDiff } _mocks.applyUpdateToDiff = (diff, update) => { - for (const op of Array.from(update.op)) { + for (const op of update.op) { if (op.broken !== true) { diff = applyOpToDiff(diff, op, update.meta) } diff --git a/services/project-history/app/js/SummarizedUpdatesManager.js b/services/project-history/app/js/SummarizedUpdatesManager.js index b6f409f350..c94074136b 100644 --- a/services/project-history/app/js/SummarizedUpdatesManager.js +++ b/services/project-history/app/js/SummarizedUpdatesManager.js @@ -189,10 +189,10 @@ function _summarizeUpdates(updates, labels, existingSummarizedUpdates, toV) { toV = update.v + 1 } - // Skip empty and history-resync updates (only record their version). - // Empty updates are updates that only contain comment operations. We don't have a UI for + // Skip empty updates (only record their version). Empty updates are + // updates that only contain comment operations. We don't have a UI for // these yet. - if (isUpdateEmpty(update) || isHistoryResyncUpdate(update)) { + if (isUpdateEmpty(update)) { continue } @@ -282,8 +282,11 @@ function _shouldMergeUpdate(update, summarizedUpdate, labels) { const updateHasFileOps = update.project_ops.length > 0 const summarizedUpdateHasTextOps = summarizedUpdate.pathnames.size > 0 const summarizedUpdateHasFileOps = summarizedUpdate.project_ops.length > 0 + const isHistoryResync = + update.meta.origin && + ['history-resync', 'history-migration'].includes(update.meta.origin.kind) if ( - !isHistoryResyncUpdate(update) && + !isHistoryResync && ((updateHasTextOps && summarizedUpdateHasFileOps) || (updateHasFileOps && summarizedUpdateHasTextOps)) ) { @@ -343,10 +346,3 @@ function _mergeUpdate(update, summarizedUpdate) { function isUpdateEmpty(update) { return update.project_ops.length === 0 && update.pathnames.length === 0 } - -function isHistoryResyncUpdate(update) { - return ( - update.meta.origin?.kind === 'history-resync' || - update.meta.origin?.kind === 'history-migration' - ) -} diff --git a/services/project-history/test/unit/js/DiffGenerator/DiffGeneratorTests.js b/services/project-history/test/unit/js/DiffGenerator/DiffGeneratorTests.js index 037207f952..251ddb3947 100644 --- a/services/project-history/test/unit/js/DiffGenerator/DiffGeneratorTests.js +++ b/services/project-history/test/unit/js/DiffGenerator/DiffGeneratorTests.js @@ -1,16 +1,3 @@ -/* eslint-disable - no-return-assign, - no-undef, - no-unused-vars, -*/ -// TODO: This file was created by bulk-decaffeinate. -// Fix any style issues and re-enable lint. -/* - * decaffeinate suggestions: - * DS101: Remove unnecessary use of Array.from - * DS102: Remove unnecessary code created because of implicit returns - * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md - */ import sinon from 'sinon' import { expect } from 'chai' import { strict as esmock } from 'esmock' @@ -23,11 +10,11 @@ describe('DiffGenerator', function () { this.ts = Date.now() this.user_id = 'mock-user-id' this.user_id_2 = 'mock-user-id-2' - return (this.meta = { + this.meta = { start_ts: this.ts, end_ts: this.ts, user_id: this.user_id, - }) + } }) describe('buildDiff', function () { @@ -43,18 +30,15 @@ describe('DiffGenerator', function () { .stub() .returns(this.diff) this.DiffGenerator._mocks.compressDiff = sinon.stub().returns(this.diff) - return (this.result = this.DiffGenerator.buildDiff( - this.content, - this.updates - )) + this.result = this.DiffGenerator.buildDiff(this.content, this.updates) }) it('should return the diff', function () { - return this.result.should.deep.equal(this.diff) + this.result.should.deep.equal(this.diff) }) it('should build the content into an initial diff', function () { - return this.DiffGenerator._mocks.applyUpdateToDiff + this.DiffGenerator._mocks.applyUpdateToDiff .calledWith( [ { @@ -67,106 +51,127 @@ describe('DiffGenerator', function () { }) it('should apply each update', function () { - return Array.from(this.updates).map(update => + this.updates.map(update => this.DiffGenerator._mocks.applyUpdateToDiff .calledWith(sinon.match.any, update) .should.equal(true) ) }) - return it('should compress the diff', function () { - return this.DiffGenerator._mocks.compressDiff + it('should compress the diff', function () { + this.DiffGenerator._mocks.compressDiff .calledWith(this.diff) .should.equal(true) }) }) describe('compressDiff', function () { - describe('with adjacent inserts with the same user_id', function () { - return it('should create one update with combined meta data and min/max timestamps', function () { + describe('with adjacent inserts with the same user id', function () { + it('should create one update with combined meta data and min/max timestamps', function () { const diff = this.DiffGenerator.compressDiff([ { i: 'foo', - meta: { start_ts: 10, end_ts: 20, user: { id: this.user_id } }, + meta: { start_ts: 10, end_ts: 20, users: [this.user_id] }, }, { i: 'bar', - meta: { start_ts: 5, end_ts: 15, user: { id: this.user_id } }, + meta: { start_ts: 5, end_ts: 15, users: [this.user_id] }, }, ]) - return expect(diff).to.deep.equal([ + expect(diff).to.deep.equal([ { i: 'foobar', - meta: { start_ts: 5, end_ts: 20, user: { id: this.user_id } }, + meta: { start_ts: 5, end_ts: 20, users: [this.user_id] }, }, ]) }) }) - describe('with adjacent inserts with different user_ids', function () { - return it('should leave the inserts unchanged', function () { + describe('with adjacent inserts with different user ids', function () { + it('should leave the inserts unchanged', function () { const input = [ { i: 'foo', - meta: { start_ts: 10, end_ts: 20, user: { id: this.user_id } }, + meta: { start_ts: 10, end_ts: 20, users: [this.user_id] }, }, { i: 'bar', - meta: { start_ts: 5, end_ts: 15, user: { id: this.user_id_2 } }, + meta: { start_ts: 5, end_ts: 15, users: [this.user_id_2] }, }, ] const output = this.DiffGenerator.compressDiff(input) - return expect(output).to.deep.equal(input) + expect(output).to.deep.equal(input) }) }) - describe('with adjacent deletes with the same user_id', function () { - return it('should create one update with combined meta data and min/max timestamps', function () { + describe('with adjacent deletes with the same user id', function () { + it('should create one update with combined meta data and min/max timestamps', function () { const diff = this.DiffGenerator.compressDiff([ { d: 'foo', - meta: { start_ts: 10, end_ts: 20, user: { id: this.user_id } }, + meta: { start_ts: 10, end_ts: 20, users: [this.user_id] }, }, { d: 'bar', - meta: { start_ts: 5, end_ts: 15, user: { id: this.user_id } }, + meta: { start_ts: 5, end_ts: 15, users: [this.user_id] }, }, ]) - return expect(diff).to.deep.equal([ + expect(diff).to.deep.equal([ { d: 'foobar', - meta: { start_ts: 5, end_ts: 20, user: { id: this.user_id } }, + meta: { start_ts: 5, end_ts: 20, users: [this.user_id] }, }, ]) }) }) - return describe('with adjacent deletes with different user_ids', function () { - return it('should leave the deletes unchanged', function () { + describe('with adjacent deletes with different user ids', function () { + it('should leave the deletes unchanged', function () { const input = [ { d: 'foo', - meta: { start_ts: 10, end_ts: 20, user: { id: this.user_id } }, + meta: { start_ts: 10, end_ts: 20, users: [this.user_id] }, }, { d: 'bar', - meta: { start_ts: 5, end_ts: 15, user: { id: this.user_id_2 } }, + meta: { start_ts: 5, end_ts: 15, users: [this.user_id_2] }, }, ] const output = this.DiffGenerator.compressDiff(input) - return expect(output).to.deep.equal(input) + expect(output).to.deep.equal(input) + }) + }) + + describe('with history resync updates', function () { + it('should keep only inserts and mark them as unchanged text', function () { + const input = [ + { u: 'untracked text' }, + { + i: 'inserted anonymously', + meta: { origin: { kind: 'history-resync' } }, + }, + { + d: 'deleted anonymously', + meta: { origin: { kind: 'history-resync' } }, + }, + ] + const output = this.DiffGenerator.compressDiff(input) + expect(output).to.deep.equal([ + { u: 'untracked text' }, + { u: 'inserted anonymously' }, + ]) }) }) }) - return describe('applyUpdateToDiff', function () { + describe('applyUpdateToDiff', function () { describe('an insert', function () { it('should insert into the middle of (u)nchanged text', function () { const diff = this.DiffGenerator.applyUpdateToDiff([{ u: 'foobar' }], { op: [{ p: 3, i: 'baz' }], meta: this.meta, }) - return expect(diff).to.deep.equal([ + expect(diff).to.deep.equal([ { u: 'foo' }, { i: 'baz', meta: this.meta }, { u: 'bar' }, @@ -178,7 +183,7 @@ describe('DiffGenerator', function () { op: [{ p: 0, i: 'baz' }], meta: this.meta, }) - return expect(diff).to.deep.equal([ + expect(diff).to.deep.equal([ { i: 'baz', meta: this.meta }, { u: 'foobar' }, ]) @@ -189,7 +194,7 @@ describe('DiffGenerator', function () { op: [{ p: 6, i: 'baz' }], meta: this.meta, }) - return expect(diff).to.deep.equal([ + expect(diff).to.deep.equal([ { u: 'foobar' }, { i: 'baz', meta: this.meta }, ]) @@ -200,19 +205,19 @@ describe('DiffGenerator', function () { [{ i: 'foobar', meta: this.meta }], { op: [{ p: 3, i: 'baz' }], meta: this.meta } ) - return expect(diff).to.deep.equal([ + expect(diff).to.deep.equal([ { i: 'foo', meta: this.meta }, { i: 'baz', meta: this.meta }, { i: 'bar', meta: this.meta }, ]) }) - return it('should not count deletes in the running length total', function () { + it('should not count deletes in the running length total', function () { const diff = this.DiffGenerator.applyUpdateToDiff( [{ d: 'deleted', meta: this.meta }, { u: 'foobar' }], { op: [{ p: 3, i: 'baz' }], meta: this.meta } ) - return expect(diff).to.deep.equal([ + expect(diff).to.deep.equal([ { d: 'deleted', meta: this.meta }, { u: 'foo' }, { i: 'baz', meta: this.meta }, @@ -221,14 +226,14 @@ describe('DiffGenerator', function () { }) }) - return describe('a delete', function () { + describe('a delete', function () { describe('deleting unchanged text', function () { it('should delete from the middle of (u)nchanged text', function () { const diff = this.DiffGenerator.applyUpdateToDiff( [{ u: 'foobazbar' }], { op: [{ p: 3, d: 'baz' }], meta: this.meta } ) - return expect(diff).to.deep.equal([ + expect(diff).to.deep.equal([ { u: 'foo' }, { d: 'baz', meta: this.meta }, { u: 'bar' }, @@ -240,7 +245,7 @@ describe('DiffGenerator', function () { [{ u: 'foobazbar' }], { op: [{ p: 0, d: 'foo' }], meta: this.meta } ) - return expect(diff).to.deep.equal([ + expect(diff).to.deep.equal([ { d: 'foo', meta: this.meta }, { u: 'bazbar' }, ]) @@ -251,18 +256,18 @@ describe('DiffGenerator', function () { [{ u: 'foobazbar' }], { op: [{ p: 6, d: 'bar' }], meta: this.meta } ) - return expect(diff).to.deep.equal([ + expect(diff).to.deep.equal([ { u: 'foobaz' }, { d: 'bar', meta: this.meta }, ]) }) - return it('should delete across multiple (u)changed text parts', function () { + it('should delete across multiple (u)changed text parts', function () { const diff = this.DiffGenerator.applyUpdateToDiff( [{ u: 'foo' }, { u: 'baz' }, { u: 'bar' }], { op: [{ p: 2, d: 'obazb' }], meta: this.meta } ) - return expect(diff).to.deep.equal([ + expect(diff).to.deep.equal([ { u: 'fo' }, { d: 'o', meta: this.meta }, { d: 'baz', meta: this.meta }, @@ -278,7 +283,7 @@ describe('DiffGenerator', function () { [{ i: 'foobazbar', meta: this.meta }], { op: [{ p: 3, d: 'baz' }], meta: this.meta } ) - return expect(diff).to.deep.equal([ + expect(diff).to.deep.equal([ { i: 'foo', meta: this.meta }, { i: 'bar', meta: this.meta }, ]) @@ -289,7 +294,7 @@ describe('DiffGenerator', function () { [{ i: 'foobazbar', meta: this.meta }], { op: [{ p: 0, d: 'foo' }], meta: this.meta } ) - return expect(diff).to.deep.equal([{ i: 'bazbar', meta: this.meta }]) + expect(diff).to.deep.equal([{ i: 'bazbar', meta: this.meta }]) }) it('should delete from the end of (u)nchanged text', function () { @@ -297,15 +302,15 @@ describe('DiffGenerator', function () { [{ i: 'foobazbar', meta: this.meta }], { op: [{ p: 6, d: 'bar' }], meta: this.meta } ) - return expect(diff).to.deep.equal([{ i: 'foobaz', meta: this.meta }]) + expect(diff).to.deep.equal([{ i: 'foobaz', meta: this.meta }]) }) - return it('should delete across multiple (u)changed and (i)nserted text parts', function () { + it('should delete across multiple (u)changed and (i)nserted text parts', function () { const diff = this.DiffGenerator.applyUpdateToDiff( [{ u: 'foo' }, { i: 'baz', meta: this.meta }, { u: 'bar' }], { op: [{ p: 2, d: 'obazb' }], meta: this.meta } ) - return expect(diff).to.deep.equal([ + expect(diff).to.deep.equal([ { u: 'fo' }, { d: 'o', meta: this.meta }, { d: 'b', meta: this.meta }, @@ -315,12 +320,12 @@ describe('DiffGenerator', function () { }) describe('deleting over existing deletes', function () { - return it('should delete across multiple (u)changed and (d)deleted text parts', function () { + it('should delete across multiple (u)changed and (d)deleted text parts', function () { const diff = this.DiffGenerator.applyUpdateToDiff( [{ u: 'foo' }, { d: 'baz', meta: this.meta }, { u: 'bar' }], { op: [{ p: 2, d: 'ob' }], meta: this.meta } ) - return expect(diff).to.deep.equal([ + expect(diff).to.deep.equal([ { u: 'fo' }, { d: 'o', meta: this.meta }, { d: 'baz', meta: this.meta }, @@ -332,7 +337,7 @@ describe('DiffGenerator', function () { describe("deleting when the text doesn't match", function () { it('should throw an error when deleting from the middle of (u)nchanged text', function () { - return expect(() => + expect(() => this.DiffGenerator.applyUpdateToDiff([{ u: 'foobazbar' }], { op: [{ p: 3, d: 'xxx' }], meta: this.meta, @@ -341,7 +346,7 @@ describe('DiffGenerator', function () { }) it('should throw an error when deleting from the start of (u)nchanged text', function () { - return expect(() => + expect(() => this.DiffGenerator.applyUpdateToDiff([{ u: 'foobazbar' }], { op: [{ p: 0, d: 'xxx' }], meta: this.meta, @@ -349,8 +354,8 @@ describe('DiffGenerator', function () { ).to.throw(this.DiffGenerator.ConsistencyError) }) - return it('should throw an error when deleting from the end of (u)nchanged text', function () { - return expect(() => + it('should throw an error when deleting from the end of (u)nchanged text', function () { + expect(() => this.DiffGenerator.applyUpdateToDiff([{ u: 'foobazbar' }], { op: [{ p: 6, d: 'xxx' }], meta: this.meta, @@ -360,12 +365,12 @@ describe('DiffGenerator', function () { }) describe('when the last update in the existing diff is a delete', function () { - return it('should insert the new update before the delete', function () { + it('should insert the new update before the delete', function () { const diff = this.DiffGenerator.applyUpdateToDiff( [{ u: 'foo' }, { d: 'bar', meta: this.meta }], { op: [{ p: 3, i: 'baz' }], meta: this.meta } ) - return expect(diff).to.deep.equal([ + expect(diff).to.deep.equal([ { u: 'foo' }, { i: 'baz', meta: this.meta }, { d: 'bar', meta: this.meta }, @@ -373,13 +378,13 @@ describe('DiffGenerator', function () { }) }) - return describe('when the only update in the existing diff is a delete', function () { - return it('should insert the new update after the delete', function () { + describe('when the only update in the existing diff is a delete', function () { + it('should insert the new update after the delete', function () { const diff = this.DiffGenerator.applyUpdateToDiff( [{ d: 'bar', meta: this.meta }], { op: [{ p: 0, i: 'baz' }], meta: this.meta } ) - return expect(diff).to.deep.equal([ + expect(diff).to.deep.equal([ { d: 'bar', meta: this.meta }, { i: 'baz', meta: this.meta }, ]) diff --git a/services/project-history/test/unit/js/SummarizedUpdatesManager/SummarizedUpdatesManagerTests.js b/services/project-history/test/unit/js/SummarizedUpdatesManager/SummarizedUpdatesManagerTests.js index a735edcc39..3cd08426b0 100644 --- a/services/project-history/test/unit/js/SummarizedUpdatesManager/SummarizedUpdatesManagerTests.js +++ b/services/project-history/test/unit/js/SummarizedUpdatesManager/SummarizedUpdatesManagerTests.js @@ -595,12 +595,12 @@ describe('SummarizedUpdatesManager', function () { makeUpdate({ startTs: 20, v: 3, - origin: { kind: 'origin-a' }, + origin: { kind: 'history-resync' }, }), makeUpdate({ startTs: 30, v: 4, - origin: { kind: 'origin-a' }, + origin: { kind: 'history-resync' }, }), makeUpdate({ startTs: 40, v: 5 }), makeUpdate({ startTs: 50, v: 6 }), @@ -617,7 +617,7 @@ describe('SummarizedUpdatesManager', function () { endTs: 40, fromV: 3, toV: 5, - origin: { kind: 'origin-a' }, + origin: { kind: 'history-resync' }, }), makeSummary({ startTs: 0, endTs: 20, fromV: 1, toV: 3 }), ] @@ -687,7 +687,13 @@ describe('SummarizedUpdatesManager', function () { describe('history resync updates', function () { setupChunks([ [ - makeUpdate({ startTs: 0, v: 1 }), + makeUpdate({ + startTs: 0, + v: 1, + origin: { kind: 'history-resync' }, + projectOps: [{ add: { pathname: 'file1.tex' } }], + pathnames: [], + }), makeUpdate({ startTs: 20, v: 2, @@ -703,13 +709,47 @@ describe('SummarizedUpdatesManager', function () { v: 3, origin: { kind: 'history-resync' }, projectOps: [{ add: { pathname: 'file4.tex' } }], + pathnames: [], }), - makeUpdate({ startTs: 60, v: 4 }), - makeUpdate({ startTs: 80, v: 5 }), + makeUpdate({ + startTs: 60, + v: 4, + origin: { kind: 'history-resync' }, + projectOps: [], + pathnames: ['file1.tex', 'file2.tex', 'file5.tex'], + }), + makeUpdate({ + startTs: 80, + v: 5, + origin: { kind: 'history-resync' }, + projectOps: [], + pathnames: ['file4.tex'], + }), + makeUpdate({ startTs: 100, v: 6, pathnames: ['file1.tex'] }), ], ]) - expectSummaries('should skip history-resync updates', {}, [ - makeSummary({ startTs: 0, endTs: 90, fromV: 1, toV: 6 }), + expectSummaries('should merge creates and edits', {}, [ + makeSummary({ + startTs: 100, + endTs: 110, + fromV: 6, + toV: 7, + pathnames: ['file1.tex'], + }), + makeSummary({ + startTs: 0, + endTs: 90, + fromV: 1, + toV: 6, + origin: { kind: 'history-resync' }, + pathnames: ['file5.tex'], + projectOps: [ + { add: { pathname: 'file4.tex' }, atV: 3 }, + { add: { pathname: 'file2.tex' }, atV: 2 }, + { add: { pathname: 'file3.tex' }, atV: 2 }, + { add: { pathname: 'file1.tex' }, atV: 1 }, + ], + }), ]) }) }) diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 3417948e6b..bda62ab545 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -580,6 +580,7 @@ "history_label_project_current_state": "", "history_label_this_version": "", "history_new_label_name": "", + "history_resync": "", "history_view_a11y_description": "", "history_view_all": "", "history_view_labels": "", diff --git a/services/web/frontend/js/features/history/components/change-list/history-resync-change.tsx b/services/web/frontend/js/features/history/components/change-list/history-resync-change.tsx new file mode 100644 index 0000000000..05c02477b8 --- /dev/null +++ b/services/web/frontend/js/features/history/components/change-list/history-resync-change.tsx @@ -0,0 +1,9 @@ +import { useTranslation } from 'react-i18next' + +function HistoryResyncChange() { + const { t } = useTranslation() + + return