Merge pull request #13327 from overleaf/msm-web-track-changes-cleanup

[web] Cleanup track-changes from web

GitOrigin-RevId: 8cef709ec5e91e4ffe8cd06826038ed84f36ef67
This commit is contained in:
Miguel Serrano
2023-07-17 10:28:12 +00:00
committed by Copybot
parent 0b864be76f
commit 7e6613a1a0
38 changed files with 44 additions and 4955 deletions
@@ -1,394 +0,0 @@
const { ObjectId } = require('mongodb')
const {
db,
READ_PREFERENCE_SECONDARY,
} = require('../../../../app/src/infrastructure/mongodb')
const Settings = require('@overleaf/settings')
const ProjectHistoryHandler = require('../../../../app/src/Features/Project/ProjectHistoryHandler')
const HistoryManager = require('../../../../app/src/Features/History/HistoryManager')
const ProjectHistoryController = require('./ProjectHistoryController')
const ProjectEntityHandler = require('../../../../app/src/Features/Project/ProjectEntityHandler')
const ProjectEntityUpdateHandler = require('../../../../app/src/Features/Project/ProjectEntityUpdateHandler')
const DocumentUpdaterHandler = require('../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler')
// Timestamp of when 'Enable history for SL in background' release
const ID_WHEN_FULL_PROJECT_HISTORY_ENABLED =
Settings.apis.project_history?.idWhenFullProjectHistoryEnabled // was '5a8d8a370000000000000000'
const DATETIME_WHEN_FULL_PROJECT_HISTORY_ENABLED =
ID_WHEN_FULL_PROJECT_HISTORY_ENABLED
? new ObjectId(ID_WHEN_FULL_PROJECT_HISTORY_ENABLED).getTimestamp()
: null
async function countProjects(query = {}) {
const count = await db.projects.countDocuments(query)
return count
}
async function countDocHistory(query = {}) {
const count = await db.docHistory.countDocuments(query)
return count
}
async function findProjects(query = {}, projection = {}) {
const projects = await db.projects.find(query).project(projection).toArray()
return projects
}
async function determineProjectHistoryType(project) {
if (project.overleaf && project.overleaf.history) {
if (project.overleaf.history.upgradeFailed) {
return 'UpgradeFailed'
}
if (project.overleaf.history.conversionFailed) {
return 'ConversionFailed'
}
}
if (
project.overleaf &&
project.overleaf.history &&
project.overleaf.history.id
) {
if (project.overleaf.history.display) {
// v2: full project history, do nothing
return 'V2'
} else {
if (projectCreatedAfterFullProjectHistoryEnabled(project)) {
// IF project initialised after full project history enabled for all projects
// THEN project history should contain all information we need, without intervention
return 'V1WithoutConversion'
} else {
// ELSE SL history may predate full project history
// THEN delete full project history and convert their SL history to full project history
// --
// TODO: how to verify this, can get rough start date of SL history, but not full project history
const preserveHistory = await shouldPreserveHistory(project)
const anyDocHistory = await anyDocHistoryExists(project)
const anyDocHistoryIndex = await anyDocHistoryIndexExists(project)
if (preserveHistory) {
if (anyDocHistory || anyDocHistoryIndex) {
// if SL history exists that we need to preserve, then we must convert
return 'V1WithConversion'
} else {
// otherwise just upgrade without conversion
return 'V1WithoutConversion'
}
} else {
// if preserveHistory false, then max 7 days of SL history
// but v1 already record to both histories, so safe to upgrade
return 'V1WithoutConversion'
}
}
}
} else {
const preserveHistory = await shouldPreserveHistory(project)
const anyDocHistory = await anyDocHistoryExists(project)
const anyDocHistoryIndex = await anyDocHistoryIndexExists(project)
if (anyDocHistory || anyDocHistoryIndex) {
// IF there is SL history ->
if (preserveHistory) {
// that needs to be preserved:
// THEN initialise full project history and convert SL history to full project history
return 'NoneWithConversion'
} else {
return 'NoneWithTemporaryHistory'
}
} else {
// ELSE there is not any SL history ->
// THEN initialise full project history and sync with current content
return 'NoneWithoutConversion'
}
}
}
async function upgradeProject(project, options) {
const historyType = await determineProjectHistoryType(project)
if (historyType === 'V2') {
return { historyType, upgraded: true }
}
const upgradeFn = getUpgradeFunctionForType(historyType)
if (!upgradeFn) {
return { error: 'unsupported history type' }
}
if (options.forceClean) {
try {
const projectId = project._id
// delete any existing history stored in the mongo backend
await HistoryManager.promises.deleteProject(projectId, projectId)
// unset overleaf.history.id to prevent the migration script from failing on checks
await db.projects.updateOne(
{ _id: projectId },
{ $unset: { 'overleaf.history.id': '' } }
)
} catch (err) {
// failed to delete existing history, but we can try to continue
}
}
const result = await upgradeFn(project, options)
result.historyType = historyType
return result
}
// Do upgrades/conversion:
function getUpgradeFunctionForType(historyType) {
return UpgradeFunctionMapping[historyType]
}
const UpgradeFunctionMapping = {
NoneWithoutConversion: doUpgradeForNoneWithoutConversion,
UpgradeFailed: doUpgradeForNoneWithoutConversion,
ConversionFailed: doUpgradeForNoneWithConversion,
V1WithoutConversion: doUpgradeForV1WithoutConversion,
V1WithConversion: doUpgradeForV1WithConversion,
NoneWithConversion: doUpgradeForNoneWithConversion,
NoneWithTemporaryHistory: doUpgradeForNoneWithConversion,
}
async function doUpgradeForV1WithoutConversion(project) {
await db.projects.updateOne(
{ _id: project._id },
{
$set: {
'overleaf.history.display': true,
'overleaf.history.upgradedAt': new Date(),
'overleaf.history.upgradeReason': `v1-without-sl-history`,
},
}
)
return { upgraded: true }
}
async function doUpgradeForV1WithConversion(project) {
const result = {}
const projectId = project._id
// migrateProjectHistory expects project id as a string
const projectIdString = project._id.toString()
try {
// We treat these essentially as None projects, the V1 history is irrelevant,
// so we will delete it, and do a conversion as if we're a None project
await ProjectHistoryController.deleteProjectHistory(projectIdString)
await ProjectHistoryController.migrateProjectHistory(projectIdString)
} catch (err) {
// if migrateProjectHistory fails, it cleans up by deleting
// the history and unsetting the history id
// therefore a failed project will still look like a 'None with conversion' project
result.error = err
await db.projects.updateOne(
{ _id: projectId },
{
$set: {
'overleaf.history.conversionFailed': true,
},
}
)
return result
}
await db.projects.updateOne(
{ _id: projectId },
{
$set: {
'overleaf.history.upgradeReason': `v1-with-conversion`,
},
$unset: {
'overleaf.history.upgradeFailed': true,
'overleaf.history.conversionFailed': true,
},
}
)
result.upgraded = true
return result
}
async function doUpgradeForNoneWithoutConversion(project) {
const result = {}
const projectId = project._id
try {
// Logic originally from ProjectHistoryHandler.ensureHistoryExistsForProject
// However sends a force resync project to project history instead
// of a resync request to doc-updater
let historyId = await ProjectHistoryHandler.promises.getHistoryId(projectId)
if (historyId == null) {
historyId = await HistoryManager.promises.initializeProject(projectId)
if (historyId != null) {
await ProjectHistoryHandler.promises.setHistoryId(projectId, historyId)
}
}
// tell document updater to clear the docs, they will be reloaded with any new history id
await DocumentUpdaterHandler.promises.flushProjectToMongoAndDelete(
projectId
)
// now resync the project
await HistoryManager.promises.resyncProject(projectId, {
force: true,
origin: { kind: 'history-migration' },
})
await HistoryManager.promises.flushProject(projectId)
} catch (err) {
result.error = err
await db.projects.updateOne(
{ _id: project._id },
{
$set: {
'overleaf.history.upgradeFailed': true,
},
}
)
return result
}
await db.projects.updateOne(
{ _id: project._id },
{
$set: {
'overleaf.history.display': true,
'overleaf.history.upgradedAt': new Date(),
'overleaf.history.upgradeReason': `none-without-conversion`,
},
}
)
result.upgraded = true
return result
}
async function doUpgradeForNoneWithConversion(project, options = {}) {
const result = {}
const projectId = project._id
// migrateProjectHistory expects project id as a string
const projectIdString = project._id.toString()
try {
if (options.convertLargeDocsToFile) {
result.convertedDocCount = await convertLargeDocsToFile(
projectId,
options.userId
)
}
await ProjectHistoryController.migrateProjectHistory(
projectIdString,
options.migrationOptions
)
} catch (err) {
// if migrateProjectHistory fails, it cleans up by deleting
// the history and unsetting the history id
// therefore a failed project will still look like a 'None with conversion' project
result.error = err
// We set a failed flag so future runs of the script don't automatically retry
await db.projects.updateOne(
{ _id: projectId },
{
$set: {
'overleaf.history.conversionFailed': true,
},
}
)
return result
}
await db.projects.updateOne(
{ _id: projectId },
{
$set: {
'overleaf.history.upgradeReason':
`none-with-conversion` + options.reason ? `/${options.reason}` : ``,
},
$unset: {
'overleaf.history.upgradeFailed': true,
'overleaf.history.conversionFailed': true,
},
}
)
result.upgraded = true
return result
}
// Util
function projectCreatedAfterFullProjectHistoryEnabled(project) {
if (DATETIME_WHEN_FULL_PROJECT_HISTORY_ENABLED == null) {
return false
} else {
return (
project._id.getTimestamp() >= DATETIME_WHEN_FULL_PROJECT_HISTORY_ENABLED
)
}
}
async function shouldPreserveHistory(project) {
return await db.projectHistoryMetaData.findOne(
{
$and: [
{ project_id: { $eq: project._id } },
{ preserveHistory: { $eq: true } },
],
},
{ readPreference: READ_PREFERENCE_SECONDARY }
)
}
async function anyDocHistoryExists(project) {
return await db.docHistory.findOne(
{ project_id: { $eq: project._id } },
{
projection: { _id: 1 },
readPreference: READ_PREFERENCE_SECONDARY,
}
)
}
async function anyDocHistoryIndexExists(project) {
return await db.docHistoryIndex.findOne(
{ project_id: { $eq: project._id } },
{
projection: { _id: 1 },
readPreference: READ_PREFERENCE_SECONDARY,
}
)
}
async function convertLargeDocsToFile(projectId, userId) {
const docs = await ProjectEntityHandler.promises.getAllDocs(projectId)
let convertedDocCount = 0
for (const doc of Object.values(docs)) {
const sizeBound = JSON.stringify(doc.lines)
if (docIsTooLarge(sizeBound, doc.lines, Settings.max_doc_length)) {
await ProjectEntityUpdateHandler.promises.convertDocToFile(
projectId,
doc._id,
userId,
null
)
convertedDocCount++
}
}
return convertedDocCount
}
// check whether the total size of the document in characters exceeds the
// maxDocLength.
//
// Copied from document-updater:
// https://github.com/overleaf/internal/blob/74adfbebda5f3c2c37d9937f0db5c4106ecde492/services/document-updater/app/js/Limits.js#L18
function docIsTooLarge(estimatedSize, lines, maxDocLength) {
if (estimatedSize <= maxDocLength) {
return false // definitely under the limit, no need to calculate the total size
}
// calculate the total size, bailing out early if the size limit is reached
let size = 0
for (const line of lines) {
size += line.length + 1 // include the newline
if (size > maxDocLength) return true
}
// since we didn't hit the limit in the loop, the document is within the allowed length
return false
}
module.exports = {
countProjects,
countDocHistory,
findProjects,
determineProjectHistoryType,
getUpgradeFunctionForType,
upgradeProject,
convertLargeDocsToFile,
anyDocHistoryExists,
anyDocHistoryIndexExists,
doUpgradeForNoneWithConversion,
}
File diff suppressed because it is too large Load Diff
@@ -1 +0,0 @@
module.exports = {}
@@ -1,346 +0,0 @@
const sinon = require('sinon')
const nock = require('nock')
const { expect } = require('chai')
const fs = require('fs')
const path = require('path')
const SandboxedModule = require('sandboxed-module')
const { ObjectId } = require('mongodb')
const unzipper = require('unzipper')
const modulePath = '../../../app/src/ProjectHistoryController'
describe('ProjectHistoryController', function () {
const projectId = ObjectId('611bd20c5d76a3c1bd0c7c13')
const deletedFileId = ObjectId('60f6e92c6c14d84fb7a71ae1')
const historyId = 123
let clock
const now = new Date(Date.UTC(2021, 1, 1, 0, 0)).getTime()
before(async function () {
clock = sinon.useFakeTimers({
now,
shouldAdvanceTime: true,
})
})
after(function () {
// clock.runAll()
clock.restore()
})
beforeEach(function () {
this.db = {
users: {
countDocuments: sinon.stub().yields(),
},
}
this.project = {
_id: ObjectId('611bd20c5d76a3c1bd0c7c13'),
name: 'My Test Project',
rootDoc_id: ObjectId('611bd20c5d76a3c1bd0c7c15'),
rootFolder: [
{
_id: ObjectId('611bd20c5d76a3c1bd0c7c12'),
name: 'rootFolder',
folders: [
{
_id: ObjectId('611bd242e64281c13303d6b5'),
name: 'a folder',
folders: [
{
_id: ObjectId('611bd247e64281c13303d6b7'),
name: 'a subfolder',
folders: [],
fileRefs: [],
docs: [
{
_id: ObjectId('611bd24ee64281c13303d6b9'),
name: 'a renamed file in a subfolder.tex',
},
],
},
],
fileRefs: [],
docs: [],
},
{
_id: ObjectId('611bd34ee64281c13303d6be'),
name: 'images',
folders: [],
fileRefs: [
{
_id: ObjectId('611bd2bce64281c13303d6bb'),
name: 'overleaf-white.svg',
linkedFileData: {
provider: 'url',
url: 'https://cdn.overleaf.com/img/ol-brand/overleaf-white.svg',
},
created: '2021-08-17T15:16:12.753Z',
},
],
docs: [],
},
],
fileRefs: [
{
_id: ObjectId('611bd20c5d76a3c1bd0c7c19'),
name: 'universe.jpg',
linkedFileData: null,
created: '2021-08-17T15:13:16.400Z',
},
],
docs: [
{
_id: ObjectId('611bd20c5d76a3c1bd0c7c15'),
name: 'main.tex',
},
{
_id: ObjectId('611bd20c5d76a3c1bd0c7c17'),
name: 'references.bib',
},
],
},
],
compiler: 'pdflatex',
description: '',
deletedDocs: [],
members: [],
invites: [],
owner: {
_id: ObjectId('611572e24bff88527f61dccd'),
first_name: 'Test',
last_name: 'User',
email: 'test@example.com',
privileges: 'owner',
signUpDate: '2021-08-12T19:13:38.462Z',
},
features: {},
}
this.multi = {
del: sinon.stub(),
rpush: sinon.stub(),
exec: sinon.stub().yields(null, 1),
}
const { docs, folders } = this.project.rootFolder[0]
const allDocs = [...docs]
const processFolders = folders => {
for (const folder of folders) {
for (const doc of folder.docs) {
allDocs.push(doc)
}
if (folder.folders) {
processFolders(folder.folders)
}
}
}
processFolders(folders)
allDocs.forEach(doc => {
doc.lines = [`this is the contents of ${doc.name}`]
})
// handle Doc.find().lean().cursor()
this.findDocs = sinon.stub().returns({
lean: sinon.stub().returns({
cursor: sinon.stub().returns(allDocs),
}),
})
// handle await Doc.findOne().lean() - single result, no cursor required
this.findOneDoc = sinon.stub().callsFake(id => {
const result = allDocs.find(doc => {
return doc._id.toString() === id.toString()
})
return { lean: sinon.stub().resolves(result) }
})
this.deletedFiles = [
{
_id: deletedFileId,
name: 'testing.tex',
deletedAt: new Date(),
},
]
// handle DeletedFile.find().lean().cursor()
this.findDeletedFiles = sinon.stub().returns({
lean: sinon
.stub()
.returns({ cursor: sinon.stub().returns(this.deletedFiles) }),
})
this.ProjectGetter = {
promises: {
getProject: sinon.stub().resolves(this.project),
},
}
this.FileStoreHandler = {
_buildUrl: (projectId, fileId) =>
`http://filestore.test/${projectId}/${fileId}`,
}
this.ProjectHistoryHandler = {
promises: {
setHistoryId: sinon.stub(),
upgradeHistory: sinon.stub(),
},
}
this.ProjectEntityUpdateHandler = {
promises: {
resyncProjectHistory: sinon.stub(),
},
}
this.DocumentUpdaterHandler = {
promises: {
flushProjectToMongoAndDelete: sinon.stub(),
},
}
this.HistoryManager = {
promises: {
resyncProject: sinon.stub(),
flushProject: sinon.stub(),
initializeProject: sinon.stub().resolves(historyId),
},
}
this.settings = {
redis: {
project_history_migration: {
key_schema: {
projectHistoryOps({ projectId }) {
return `ProjectHistory:Ops:{${projectId}}` // NOTE: the extra braces are intentional
},
},
},
},
apis: {
documentupdater: {
url: 'http://document-updater',
},
trackchanges: {
url: 'http://track-changes',
},
project_history: {
url: 'http://project-history',
},
},
path: {
projectHistories: 'data/projectHistories',
},
}
this.ProjectHistoryController = SandboxedModule.require(modulePath, {
requires: {
'../../../../app/src/Features/Project/ProjectGetter':
this.ProjectGetter,
'../../../../app/src/Features/FileStore/FileStoreHandler':
this.FileStoreHandler,
'../../../../app/src/Features/Project/ProjectHistoryHandler':
this.ProjectHistoryHandler,
'../../../../app/src/Features/Project/ProjectUpdateHandler':
this.ProjectUpdateHandler,
'../../../../app/src/Features/Project/ProjectEntityUpdateHandler':
this.ProjectEntityUpdateHandler,
'../../../../app/src/Features/History/HistoryManager':
this.HistoryManager,
'../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler':
this.DocumentUpdaterHandler,
'../../../../app/src/models/Doc': {
Doc: {
find: this.findDocs,
findOne: this.findOneDoc,
},
},
'../../../../app/src/models/DeletedFile': {
DeletedFile: {
find: this.findDeletedFiles,
},
},
'../../../../app/src/infrastructure/mongodb': {
db: this.db,
},
'../../../../app/src/infrastructure/Mongoose': {
Schema: {
ObjectId: sinon.stub(),
Types: {
Mixed: sinon.stub(),
},
},
},
'../../../../app/src/infrastructure/RedisWrapper': {
client: () => ({
multi: () => this.multi,
llen: sinon.stub().resolves(0),
}),
},
unzipper: {
Open: {
file: () =>
unzipper.Open.file(
path.join(__dirname, 'data/track-changes-project.zip')
),
},
},
'@overleaf/settings': this.settings,
},
})
})
afterEach(function () {
nock.cleanAll()
})
it('migrates a project history', async function () {
const readStream = fs.createReadStream(
path.join(__dirname, 'data/track-changes-project.zip')
)
nock(this.settings.apis.trackchanges.url)
.get(`/project/${projectId}/zip`)
.reply(200, readStream)
nock(this.settings.apis.project_history.url)
.post(`/project`)
.reply(200, { project: { id: historyId } })
await this.ProjectHistoryController.migrateProjectHistory(
projectId.toString(),
5
)
expect(this.multi.exec).to.have.been.calledOnce
expect(this.ProjectHistoryHandler.promises.setHistoryId).to.have.been
.calledOnce
// expect(this.ProjectEntityUpdateHandler.promises.resyncProjectHistory).to
// .have.been.calledOnce
expect(this.HistoryManager.promises.flushProject).to.have.been.calledTwice
expect(this.multi.rpush).to.have.callCount(12)
const args = this.multi.rpush.args
const snapshotPath = path.join(
__dirname,
'data/migrate-project-history.snapshot.json'
)
// const snapshot = JSON.stringify(args, null, 2)
// await fs.promises.writeFile(snapshotPath, snapshot)
const json = await fs.promises.readFile(snapshotPath, 'utf-8')
const expected = JSON.parse(json)
expect(args).to.deep.equal(expected)
})
})
@@ -1,50 +0,0 @@
[
[
"ProjectHistory:Ops:{611bd20c5d76a3c1bd0c7c13}",
"{\"file\":\"60f6e92c6c14d84fb7a71ae1\",\"pathname\":\"/_deleted/60f6e92c6c14d84fb7a71ae1/testing.tex\",\"meta\":{\"user_id\":null,\"ts\":\"2021-07-20T15:18:04.000Z\",\"origin\":{\"kind\":\"history-migration\"}},\"projectHistoryId\":123,\"url\":\"http://filestore.test/611bd20c5d76a3c1bd0c7c13/60f6e92c6c14d84fb7a71ae1\"}"
],
[
"ProjectHistory:Ops:{611bd20c5d76a3c1bd0c7c13}",
"{\"file\":\"60f6e92c6c14d84fb7a71ae1\",\"pathname\":\"/_deleted/60f6e92c6c14d84fb7a71ae1/testing.tex\",\"new_pathname\":\"\",\"meta\":{\"user_id\":null,\"ts\":\"2021-02-01T00:00:00.000Z\",\"origin\":{\"kind\":\"history-migration\"}},\"projectHistoryId\":123}"
],
[
"ProjectHistory:Ops:{611bd20c5d76a3c1bd0c7c13}",
"{\"doc\":\"611bd20c5d76a3c1bd0c7c15\",\"pathname\":\"/main.tex\",\"meta\":{\"user_id\":null,\"ts\":\"2021-08-17T15:13:16.000Z\",\"origin\":{\"kind\":\"history-migration\"}},\"projectHistoryId\":123,\"docLines\":\"\\\\documentclass{article}\\n\\\\usepackage[utf8]{inputenc}\\n\\n\\\\title{My Test Project}\\n\\\\author{alf.eaton+dev }\\n\\\\date{7 2021}\\n\\n\\\\usepackage{natbib}\\n\\\\usepackage{graphicx}\\n\\n\\\\begin{document}\\n\\n\\\\maketitle\\n\\n\\\\section{Introduction}\\nThere is a theory which states that if ever anyone discovers exactly what the Universe is for and why it is here, it will instantly disappear and be replaced by something even more bizarre and inexplicable.\\nThere is another theory which states that this has already happened.\\n\\n\\\\begin{figure}[h!]\\n\\\\centering\\n\\\\includegraphics[scale=1.7]{universe}\\n\\\\caption{The Universe}\\n\\\\label{fig:universe}\\n\\\\end{figure}\\n\\n\\\\section{Conclusion}\\n``I always thought something was fundamentally wrong with the universe'' \\\\citep{adams1995hitchhiker}\\n\\n\\\\bibliographystyle{plain}\\n\\\\bibliography{references}\\n\\\\end{document}\\n\"}"
],
[
"ProjectHistory:Ops:{611bd20c5d76a3c1bd0c7c13}",
"{\"doc\":\"611bd20c5d76a3c1bd0c7c17\",\"pathname\":\"/references.bib\",\"meta\":{\"user_id\":null,\"ts\":\"2021-08-17T15:13:16.000Z\",\"origin\":{\"kind\":\"history-migration\"}},\"projectHistoryId\":123,\"docLines\":\"this is the contents of references.bib\"}"
],
[
"ProjectHistory:Ops:{611bd20c5d76a3c1bd0c7c13}",
"{\"file\":\"611bd20c5d76a3c1bd0c7c19\",\"pathname\":\"/universe.jpg\",\"meta\":{\"user_id\":null,\"ts\":\"2021-08-17T15:13:16.000Z\",\"origin\":{\"kind\":\"history-migration\"}},\"projectHistoryId\":123,\"url\":\"http://filestore.test/611bd20c5d76a3c1bd0c7c13/611bd20c5d76a3c1bd0c7c19\"}"
],
[
"ProjectHistory:Ops:{611bd20c5d76a3c1bd0c7c13}",
"{\"doc\":\"611bd20c5d76a3c1bd0c7c15\",\"op\":[{\"p\":487,\"i\":\"\\n\\nAdding some text here.\"}],\"v\":1,\"lastV\":0,\"meta\":{\"user_id\":\"611572e24bff88527f61dccd\",\"ts\":1629213228148,\"pathname\":\"/main.tex\",\"doc_length\":805,\"origin\":{\"kind\":\"history-migration\"}},\"projectHistoryId\":123}"
],
[
"ProjectHistory:Ops:{611bd20c5d76a3c1bd0c7c13}",
"{\"doc\":\"611bd20c5d76a3c1bd0c7c15\",\"op\":[{\"p\":678,\"d\":\" something\"}],\"v\":2,\"lastV\":1,\"meta\":{\"user_id\":\"611572e24bff88527f61dccd\",\"ts\":1629213235181,\"pathname\":\"/main.tex\",\"doc_length\":829,\"origin\":{\"kind\":\"history-migration\"}},\"projectHistoryId\":123}"
],
[
"ProjectHistory:Ops:{611bd20c5d76a3c1bd0c7c13}",
"{\"doc\":\"611bd20c5d76a3c1bd0c7c15\",\"op\":[{\"d\":\" \",\"p\":722},{\"i\":\"\\n\",\"p\":722}],\"v\":3,\"lastV\":2,\"meta\":{\"user_id\":\"611572e24bff88527f61dccd\",\"ts\":1629213239472,\"pathname\":\"/main.tex\",\"doc_length\":819,\"origin\":{\"kind\":\"history-migration\"}},\"projectHistoryId\":123}"
],
[
"ProjectHistory:Ops:{611bd20c5d76a3c1bd0c7c13}",
"{\"doc\":\"611bd20c5d76a3c1bd0c7c15\",\"op\":[{\"p\":750,\"i\":\"\\n\\nAdding some text after deleting some text.\"}],\"v\":7,\"lastV\":6,\"meta\":{\"user_id\":\"611572e24bff88527f61dccd\",\"ts\":1629213241498,\"pathname\":\"/main.tex\",\"doc_length\":819,\"origin\":{\"kind\":\"history-migration\"}},\"projectHistoryId\":123}"
],
[
"ProjectHistory:Ops:{611bd20c5d76a3c1bd0c7c13}",
"{\"doc\":\"611bd24ee64281c13303d6b9\",\"pathname\":\"/a folder/a subfolder/a renamed file in a subfolder.tex\",\"meta\":{\"user_id\":null,\"ts\":\"2021-08-17T15:14:22.000Z\",\"origin\":{\"kind\":\"history-migration\"}},\"projectHistoryId\":123,\"docLines\":\"\"}"
],
[
"ProjectHistory:Ops:{611bd20c5d76a3c1bd0c7c13}",
"{\"doc\":\"611bd24ee64281c13303d6b9\",\"op\":[{\"p\":0,\"i\":\"Adding some content to the file in the subfolder.\"}],\"v\":2,\"lastV\":1,\"meta\":{\"user_id\":\"611572e24bff88527f61dccd\",\"ts\":1629213266076,\"pathname\":\"/a folder/a subfolder/a renamed file in a subfolder.tex\",\"doc_length\":0,\"origin\":{\"kind\":\"history-migration\"}},\"projectHistoryId\":123}"
],
[
"ProjectHistory:Ops:{611bd20c5d76a3c1bd0c7c13}",
"{\"file\":\"611bd2bce64281c13303d6bb\",\"pathname\":\"/images/overleaf-white.svg\",\"meta\":{\"user_id\":null,\"ts\":\"2021-08-17T15:16:12.000Z\",\"origin\":{\"kind\":\"history-migration\"}},\"projectHistoryId\":123,\"url\":\"http://filestore.test/611bd20c5d76a3c1bd0c7c13/611bd2bce64281c13303d6bb\"}"
]
]