mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-06-05 07:09:02 +02:00
292230b1db
GitOrigin-RevId: 7aefee892d491b1ec191d07bf1871317b897dec8
1699 lines
44 KiB
JavaScript
1699 lines
44 KiB
JavaScript
import _ from 'lodash'
|
|
import OError from '@overleaf/o-error'
|
|
import logger from '@overleaf/logger'
|
|
import Settings from '@overleaf/settings'
|
|
import Path from 'node:path'
|
|
import fs from 'node:fs'
|
|
import { Doc } from '../../models/Doc.mjs'
|
|
import DocstoreManager from '../Docstore/DocstoreManager.mjs'
|
|
import DocumentUpdaterHandler from '../../Features/DocumentUpdater/DocumentUpdaterHandler.mjs'
|
|
import Errors from '../Errors/Errors.js'
|
|
import FileStoreHandler from '../FileStore/FileStoreHandler.mjs'
|
|
import LockManager from '../../infrastructure/LockManager.mjs'
|
|
import { Project } from '../../models/Project.mjs'
|
|
import ProjectEntityHandler from './ProjectEntityHandler.mjs'
|
|
import ProjectGetter from './ProjectGetter.mjs'
|
|
import ProjectLocator from './ProjectLocator.mjs'
|
|
import ProjectOptionsHandler from './ProjectOptionsHandler.mjs'
|
|
import ProjectUpdateHandler from './ProjectUpdateHandler.mjs'
|
|
import ProjectEntityMongoUpdateHandler from './ProjectEntityMongoUpdateHandler.mjs'
|
|
import SafePath from './SafePath.mjs'
|
|
import TpdsUpdateSender from '../ThirdPartyDataStore/TpdsUpdateSender.mjs'
|
|
import FileWriter from '../../infrastructure/FileWriter.mjs'
|
|
import EditorRealTimeController from '../Editor/EditorRealTimeController.mjs'
|
|
import { callbackifyMultiResult, callbackify } from '@overleaf/promise-utils'
|
|
import { iterablePaths } from './IterablePath.mjs'
|
|
|
|
const LOCK_NAMESPACE = 'sequentialProjectStructureUpdateLock'
|
|
const VALID_ROOT_DOC_EXTENSIONS = Settings.validRootDocExtensions
|
|
const VALID_ROOT_DOC_REGEXP = new RegExp(
|
|
`^\\.(${VALID_ROOT_DOC_EXTENSIONS.join('|')})$`,
|
|
'i'
|
|
)
|
|
|
|
function wrapWithLock(methodWithoutLock, lockManager = LockManager) {
|
|
// This lock is used to make sure that the project structure updates are made
|
|
// sequentially. In particular the updates must be made in mongo and sent to
|
|
// the doc-updater in the same order.
|
|
if (typeof methodWithoutLock === 'function') {
|
|
const methodWithLock = async (projectId, ...rest) => {
|
|
return lockManager.promises.runWithLock(LOCK_NAMESPACE, projectId, () =>
|
|
methodWithoutLock(projectId, ...rest)
|
|
)
|
|
}
|
|
methodWithLock.withoutLock = methodWithoutLock
|
|
return methodWithLock
|
|
} else {
|
|
// handle case with separate setup and locked stages
|
|
const mainTask = methodWithoutLock.withLock
|
|
const methodWithLock = async (projectId, ...rest) => {
|
|
const arg = await methodWithoutLock.beforeLock(projectId, ...rest)
|
|
return lockManager.promises.runWithLock(LOCK_NAMESPACE, projectId, () =>
|
|
mainTask(arg)
|
|
)
|
|
}
|
|
methodWithLock.withoutLock = async (...args) => {
|
|
return await mainTask(await methodWithoutLock.beforeLock(...args))
|
|
}
|
|
methodWithLock.beforeLock = methodWithoutLock.beforeLock
|
|
methodWithLock.mainTask = methodWithoutLock.withLock
|
|
return methodWithLock
|
|
}
|
|
}
|
|
|
|
async function getDocContext(projectId, docId) {
|
|
let project
|
|
try {
|
|
project = await ProjectGetter.promises.getProject(projectId, {
|
|
name: true,
|
|
rootFolder: true,
|
|
})
|
|
} catch (err) {
|
|
throw OError.tag(err, 'error fetching project', {
|
|
projectId,
|
|
})
|
|
}
|
|
|
|
if (!project) {
|
|
throw new Errors.NotFoundError('project not found')
|
|
}
|
|
try {
|
|
const { path, folder } = await ProjectLocator.promises.findElement({
|
|
project,
|
|
element_id: docId,
|
|
type: 'docs',
|
|
})
|
|
return {
|
|
projectName: project.name,
|
|
isDeletedDoc: false,
|
|
path: path.fileSystem,
|
|
folder,
|
|
}
|
|
} catch (err) {
|
|
if (err instanceof Errors.NotFoundError) {
|
|
// (Soft-)Deleted docs are removed from the file-tree (rootFolder).
|
|
// docstore can tell whether it exists and is (soft)-deleted.
|
|
let isDeletedDoc
|
|
try {
|
|
isDeletedDoc = await DocstoreManager.promises.isDocDeleted(
|
|
projectId,
|
|
docId
|
|
)
|
|
if (!isDeletedDoc) {
|
|
// NOTE: This can happen while we delete a doc:
|
|
// 1. web will update the projects entry
|
|
// 2. web triggers flushes to tpds/doc-updater
|
|
// 3. web triggers (soft)-delete in docstore
|
|
// Specifically when an update comes in after 1
|
|
// and before 3 completes.
|
|
logger.debug(
|
|
{ projectId, docId },
|
|
'updating doc that is in process of getting soft-deleted'
|
|
)
|
|
}
|
|
return {
|
|
projectName: project.name,
|
|
isDeletedDoc: true,
|
|
path: null,
|
|
folder: null,
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof Errors.NotFoundError) {
|
|
logger.warn(
|
|
{ projectId, docId },
|
|
'doc not found while updating doc lines'
|
|
)
|
|
throw error
|
|
}
|
|
throw OError.tag(
|
|
error,
|
|
'error checking deletion status with docstore',
|
|
{
|
|
projectId,
|
|
docId,
|
|
}
|
|
)
|
|
}
|
|
} else {
|
|
throw OError.tag(err, 'error finding doc in rootFolder', {
|
|
docId,
|
|
projectId,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
async function updateDocLines(
|
|
projectId,
|
|
docId,
|
|
lines,
|
|
version,
|
|
ranges,
|
|
lastUpdatedAt,
|
|
lastUpdatedBy
|
|
) {
|
|
let ctx
|
|
try {
|
|
ctx = await getDocContext(projectId, docId)
|
|
} catch (error) {
|
|
if (error instanceof Errors.NotFoundError) {
|
|
// Do not allow an update to a doc which has never exist on this project
|
|
logger.warn(
|
|
{ docId, projectId },
|
|
'project or doc not found while updating doc lines'
|
|
)
|
|
}
|
|
|
|
throw error
|
|
}
|
|
const { projectName, isDeletedDoc, path, folder } = ctx
|
|
logger.debug({ projectId, docId }, 'telling docstore manager to update doc')
|
|
let modified, rev
|
|
try {
|
|
;({ modified, rev } = await DocstoreManager.promises.updateDoc(
|
|
projectId,
|
|
docId,
|
|
lines,
|
|
version,
|
|
ranges
|
|
))
|
|
} catch (err) {
|
|
throw OError.tag(err, 'error sending doc to docstore', { docId, projectId })
|
|
}
|
|
// path will only be present if the doc is not deleted
|
|
if (!modified || isDeletedDoc) {
|
|
return { rev }
|
|
}
|
|
// Don't need to block for marking as updated
|
|
ProjectUpdateHandler.promises
|
|
.markAsUpdated(projectId, lastUpdatedAt, lastUpdatedBy)
|
|
.catch(error => {
|
|
logger.error({ error }, 'failed to mark project as updated')
|
|
})
|
|
await TpdsUpdateSender.promises.addDoc({
|
|
projectId,
|
|
path,
|
|
docId,
|
|
projectName,
|
|
rev,
|
|
folderId: folder?._id,
|
|
})
|
|
return { rev, modified }
|
|
}
|
|
|
|
async function setRootDoc(projectId, newRootDocID) {
|
|
logger.debug({ projectId, rootDocId: newRootDocID }, 'setting root doc')
|
|
if (projectId == null || newRootDocID == null) {
|
|
throw new Errors.InvalidError('missing arguments (project or doc)')
|
|
}
|
|
const docPath =
|
|
await ProjectEntityHandler.promises.getDocPathByProjectIdAndDocId(
|
|
projectId,
|
|
newRootDocID
|
|
)
|
|
if (ProjectEntityUpdateHandler.isPathValidForRootDoc(docPath)) {
|
|
await Project.updateOne(
|
|
{ _id: projectId },
|
|
{ rootDoc_id: newRootDocID }
|
|
).exec()
|
|
} else {
|
|
throw new Errors.UnsupportedFileTypeError(
|
|
'invalid file extension for root doc'
|
|
)
|
|
}
|
|
return newRootDocID
|
|
}
|
|
|
|
async function unsetRootDoc(projectId) {
|
|
logger.debug({ projectId }, 'removing root doc')
|
|
await Project.updateOne(
|
|
{ _id: projectId },
|
|
{ $unset: { rootDoc_id: true } }
|
|
).exec()
|
|
}
|
|
|
|
async function addDoc(projectId, folderId, docName, docLines, userId, source) {
|
|
return await ProjectEntityUpdateHandler.promises.addDocWithRanges(
|
|
projectId,
|
|
folderId,
|
|
docName,
|
|
docLines,
|
|
{},
|
|
userId,
|
|
source
|
|
)
|
|
}
|
|
|
|
const addDocWithRanges = wrapWithLock({
|
|
async beforeLock(
|
|
projectId,
|
|
folderId,
|
|
docName,
|
|
docLines,
|
|
ranges,
|
|
userId,
|
|
source
|
|
) {
|
|
if (!SafePath.isCleanFilename(docName)) {
|
|
throw new Errors.InvalidNameError('invalid element name')
|
|
}
|
|
// Put doc in docstore first, so that if it errors, we don't have a doc_id in the project
|
|
// which hasn't been created in docstore.
|
|
const doc = new Doc({ name: docName })
|
|
const { rev } = await DocstoreManager.promises.updateDoc(
|
|
projectId.toString(),
|
|
doc._id.toString(),
|
|
docLines,
|
|
0,
|
|
ranges
|
|
)
|
|
|
|
doc.rev = rev
|
|
return {
|
|
projectId,
|
|
folderId,
|
|
doc,
|
|
docName,
|
|
docLines,
|
|
ranges,
|
|
userId,
|
|
source,
|
|
}
|
|
},
|
|
async withLock({
|
|
projectId,
|
|
folderId,
|
|
doc,
|
|
docName,
|
|
docLines,
|
|
ranges,
|
|
userId,
|
|
source,
|
|
}) {
|
|
const { result, project } =
|
|
await ProjectEntityUpdateHandler._addDocAndSendToTpds(
|
|
projectId,
|
|
folderId,
|
|
doc,
|
|
userId
|
|
)
|
|
const docPath = result?.path?.fileSystem
|
|
const projectHistoryId = project?.overleaf?.history?.id
|
|
const newDocs = [
|
|
{
|
|
doc,
|
|
path: docPath,
|
|
docLines: docLines.join('\n'),
|
|
ranges,
|
|
},
|
|
]
|
|
await DocumentUpdaterHandler.promises.updateProjectStructure(
|
|
projectId,
|
|
projectHistoryId,
|
|
userId,
|
|
{ newDocs, newProject: project },
|
|
source
|
|
)
|
|
return { doc, folderId: folderId || project.rootFolder[0]._id }
|
|
},
|
|
})
|
|
|
|
const addFile = wrapWithLock({
|
|
async beforeLock(
|
|
projectId,
|
|
folderId,
|
|
fileName,
|
|
fsPath,
|
|
linkedFileData,
|
|
userId,
|
|
source
|
|
) {
|
|
if (!SafePath.isCleanFilename(fileName)) {
|
|
throw new Errors.InvalidNameError('invalid element name')
|
|
}
|
|
const { fileRef, createdBlob } =
|
|
await ProjectEntityUpdateHandler._uploadFile(
|
|
projectId,
|
|
folderId,
|
|
fileName,
|
|
fsPath,
|
|
linkedFileData
|
|
)
|
|
|
|
return {
|
|
projectId,
|
|
folderId,
|
|
userId,
|
|
fileRef,
|
|
createdBlob,
|
|
source,
|
|
}
|
|
},
|
|
async withLock({
|
|
projectId,
|
|
folderId,
|
|
userId,
|
|
fileRef,
|
|
createdBlob,
|
|
source,
|
|
}) {
|
|
const { result, project } =
|
|
await ProjectEntityUpdateHandler._addFileAndSendToTpds(
|
|
projectId,
|
|
folderId,
|
|
fileRef,
|
|
userId
|
|
)
|
|
const projectHistoryId = project.overleaf?.history?.id
|
|
const newFiles = [
|
|
{
|
|
createdBlob,
|
|
file: fileRef,
|
|
path: result && result.path && result.path.fileSystem,
|
|
},
|
|
]
|
|
await DocumentUpdaterHandler.promises.updateProjectStructure(
|
|
projectId,
|
|
projectHistoryId,
|
|
userId,
|
|
{ newFiles, newProject: project },
|
|
source
|
|
)
|
|
return { fileRef, folderId, createdBlob }
|
|
},
|
|
})
|
|
|
|
const upsertDoc = wrapWithLock(
|
|
async function (projectId, folderId, docName, docLines, source, userId) {
|
|
if (!SafePath.isCleanFilename(docName)) {
|
|
throw new Errors.InvalidNameError('invalid element name')
|
|
}
|
|
let element, folderPath
|
|
try {
|
|
;({ element, path: folderPath } =
|
|
await ProjectLocator.promises.findElement({
|
|
project_id: projectId,
|
|
element_id: folderId,
|
|
type: 'folder',
|
|
}))
|
|
} catch (error) {
|
|
if (error instanceof Errors.NotFoundError) {
|
|
throw new Error('folder_not_found')
|
|
}
|
|
throw error
|
|
}
|
|
|
|
if (element == null) {
|
|
throw new Error("Couldn't find folder")
|
|
}
|
|
|
|
const existingDoc = element.docs.find(({ name }) => name === docName)
|
|
const existingFile = element.fileRefs.find(({ name }) => name === docName)
|
|
if (existingFile) {
|
|
const doc = new Doc({ name: docName })
|
|
const filePath = `${folderPath.fileSystem}/${existingFile.name}`
|
|
const { rev } = await DocstoreManager.promises.updateDoc(
|
|
projectId.toString(),
|
|
doc._id.toString(),
|
|
docLines,
|
|
0,
|
|
{}
|
|
)
|
|
|
|
doc.rev = rev
|
|
const project =
|
|
await ProjectEntityMongoUpdateHandler.promises.replaceFileWithDoc(
|
|
projectId,
|
|
existingFile._id,
|
|
doc,
|
|
userId
|
|
)
|
|
|
|
await TpdsUpdateSender.promises.addDoc({
|
|
projectId,
|
|
docId: doc._id,
|
|
path: filePath,
|
|
projectName: project.name,
|
|
rev: existingFile.rev + 1,
|
|
folderId,
|
|
})
|
|
|
|
const projectHistoryId =
|
|
project.overleaf &&
|
|
project.overleaf.history &&
|
|
project.overleaf.history.id
|
|
const newDocs = [
|
|
{
|
|
doc,
|
|
path: filePath,
|
|
docLines: docLines.join('\n'),
|
|
},
|
|
]
|
|
const oldFiles = [
|
|
{
|
|
file: existingFile,
|
|
path: filePath,
|
|
},
|
|
]
|
|
await DocumentUpdaterHandler.promises.updateProjectStructure(
|
|
projectId,
|
|
projectHistoryId,
|
|
userId,
|
|
{ oldFiles, newDocs, newProject: project },
|
|
source
|
|
)
|
|
|
|
EditorRealTimeController.emitToRoom(
|
|
projectId,
|
|
'removeEntity',
|
|
existingFile._id,
|
|
'convertFileToDoc'
|
|
)
|
|
return { doc, isNew: true }
|
|
} else if (existingDoc) {
|
|
const result = await DocumentUpdaterHandler.promises.setDocument(
|
|
projectId,
|
|
existingDoc._id,
|
|
userId,
|
|
docLines,
|
|
source
|
|
)
|
|
logger.debug(
|
|
{ projectId, docId: existingDoc._id },
|
|
'notifying users that the document has been updated'
|
|
)
|
|
// there is no need to flush the doc to mongo at this point as docupdater
|
|
// flushes it as part of setDoc.
|
|
//
|
|
// combine rev from response with existing doc metadata
|
|
return {
|
|
doc: { ...existingDoc, ...result },
|
|
isNew: existingDoc == null,
|
|
}
|
|
} else {
|
|
const { doc } =
|
|
await ProjectEntityUpdateHandler.promises.addDocWithRanges.withoutLock(
|
|
projectId,
|
|
folderId,
|
|
docName,
|
|
docLines,
|
|
{},
|
|
userId,
|
|
source
|
|
)
|
|
|
|
return { doc, isNew: existingDoc == null }
|
|
}
|
|
}
|
|
)
|
|
|
|
const appendToDoc = wrapWithLock(
|
|
async (projectId, docId, lines, source, userId) => {
|
|
const { element } = await ProjectLocator.promises.findElement({
|
|
project_id: projectId,
|
|
element_id: docId,
|
|
type: 'doc',
|
|
})
|
|
|
|
return await DocumentUpdaterHandler.promises.appendToDocument(
|
|
projectId,
|
|
element._id,
|
|
userId,
|
|
lines,
|
|
source
|
|
)
|
|
}
|
|
)
|
|
|
|
const upsertFile = wrapWithLock({
|
|
async beforeLock(
|
|
projectId,
|
|
folderId,
|
|
fileName,
|
|
fsPath,
|
|
linkedFileData,
|
|
userId,
|
|
source
|
|
) {
|
|
if (!SafePath.isCleanFilename(fileName)) {
|
|
throw new Errors.InvalidNameError('invalid element name')
|
|
}
|
|
// create a new file
|
|
const fileArgs = {
|
|
name: fileName,
|
|
linkedFileData,
|
|
}
|
|
const { fileRef, createdBlob } =
|
|
await FileStoreHandler.promises.uploadFileFromDisk(
|
|
projectId,
|
|
fileArgs,
|
|
fsPath
|
|
)
|
|
|
|
return {
|
|
projectId,
|
|
folderId,
|
|
fileName,
|
|
fsPath,
|
|
linkedFileData,
|
|
userId,
|
|
fileRef,
|
|
createdBlob,
|
|
source,
|
|
}
|
|
},
|
|
async withLock({
|
|
projectId,
|
|
folderId,
|
|
fileName,
|
|
userId,
|
|
fileRef,
|
|
createdBlob,
|
|
source,
|
|
}) {
|
|
let element
|
|
try {
|
|
;({ element } = await ProjectLocator.promises.findElement({
|
|
project_id: projectId,
|
|
element_id: folderId,
|
|
type: 'folder',
|
|
}))
|
|
} catch (error) {
|
|
if (error instanceof Errors.NotFoundError) {
|
|
throw new Error('folder_not_found')
|
|
}
|
|
throw error
|
|
}
|
|
|
|
if (element == null) {
|
|
throw new Error("Couldn't find folder")
|
|
}
|
|
const existingFile = element.fileRefs.find(({ name }) => name === fileName)
|
|
const existingDoc = element.docs.find(({ name }) => name === fileName)
|
|
|
|
if (existingDoc) {
|
|
let path
|
|
try {
|
|
;({ path } = await ProjectLocator.promises.findElement({
|
|
project_id: projectId,
|
|
element_id: existingDoc._id,
|
|
type: 'doc',
|
|
}))
|
|
} catch (err) {
|
|
throw new Error("couldn't find existing file")
|
|
}
|
|
const project =
|
|
await ProjectEntityMongoUpdateHandler.promises.replaceDocWithFile(
|
|
projectId,
|
|
existingDoc._id,
|
|
fileRef,
|
|
userId
|
|
)
|
|
const projectHistoryId = project.overleaf?.history?.id
|
|
await TpdsUpdateSender.promises.addFile({
|
|
projectId: project._id,
|
|
historyId: projectHistoryId,
|
|
fileId: fileRef._id,
|
|
hash: fileRef.hash,
|
|
path: path.fileSystem,
|
|
rev: fileRef.rev,
|
|
projectName: project.name,
|
|
folderId,
|
|
})
|
|
await DocumentUpdaterHandler.promises.updateProjectStructure(
|
|
projectId,
|
|
projectHistoryId,
|
|
userId,
|
|
{
|
|
oldDocs: [{ doc: existingDoc, path: path.fileSystem }],
|
|
|
|
newFiles: [
|
|
{
|
|
createdBlob,
|
|
file: fileRef,
|
|
path: path.fileSystem,
|
|
},
|
|
],
|
|
newProject: project,
|
|
},
|
|
source
|
|
)
|
|
EditorRealTimeController.emitToRoom(
|
|
projectId,
|
|
'removeEntity',
|
|
existingDoc._id,
|
|
'convertDocToFile'
|
|
)
|
|
return { fileRef, isNew: true, oldFileRef: existingFile }
|
|
} else if (existingFile) {
|
|
await ProjectEntityUpdateHandler._replaceFile(
|
|
projectId,
|
|
existingFile._id,
|
|
userId,
|
|
fileRef,
|
|
folderId,
|
|
source,
|
|
createdBlob
|
|
)
|
|
|
|
return { fileRef, isNew: false, oldFileRef: existingFile }
|
|
} else {
|
|
// this calls directly into the addFile main task (without the beforeLock part)
|
|
await ProjectEntityUpdateHandler.promises.addFile.mainTask({
|
|
projectId,
|
|
folderId,
|
|
userId,
|
|
fileRef,
|
|
createdBlob,
|
|
source,
|
|
})
|
|
|
|
return {
|
|
fileRef,
|
|
isNew: existingFile == null,
|
|
oldFileRef: existingFile,
|
|
}
|
|
}
|
|
},
|
|
})
|
|
|
|
const upsertDocWithPath = wrapWithLock(
|
|
async function (projectId, elementPath, docLines, source, userId) {
|
|
if (!SafePath.isCleanPath(elementPath)) {
|
|
throw new Errors.InvalidNameError('invalid element name')
|
|
}
|
|
const docName = Path.basename(elementPath)
|
|
const folderPath = Path.dirname(elementPath)
|
|
const { newFolders, folder } =
|
|
await ProjectEntityUpdateHandler.promises.mkdirp.withoutLock(
|
|
projectId,
|
|
folderPath,
|
|
userId
|
|
)
|
|
const { isNew, doc } =
|
|
await ProjectEntityUpdateHandler.promises.upsertDoc.withoutLock(
|
|
projectId,
|
|
folder._id,
|
|
docName,
|
|
docLines,
|
|
source,
|
|
userId
|
|
)
|
|
|
|
return { doc, isNew, newFolders, folder }
|
|
}
|
|
)
|
|
|
|
const upsertFileWithPath = wrapWithLock({
|
|
async beforeLock(
|
|
projectId,
|
|
elementPath,
|
|
fsPath,
|
|
linkedFileData,
|
|
userId,
|
|
source
|
|
) {
|
|
if (!SafePath.isCleanPath(elementPath)) {
|
|
throw new Errors.InvalidNameError('invalid element name')
|
|
}
|
|
const fileName = Path.basename(elementPath)
|
|
const folderPath = Path.dirname(elementPath)
|
|
// create a new file
|
|
const fileArgs = {
|
|
name: fileName,
|
|
linkedFileData,
|
|
}
|
|
const { fileRef, createdBlob } =
|
|
await FileStoreHandler.promises.uploadFileFromDisk(
|
|
projectId,
|
|
fileArgs,
|
|
fsPath
|
|
)
|
|
|
|
return {
|
|
projectId,
|
|
folderPath,
|
|
fileName,
|
|
fsPath,
|
|
linkedFileData,
|
|
userId,
|
|
fileRef,
|
|
createdBlob,
|
|
source,
|
|
}
|
|
},
|
|
async withLock({
|
|
projectId,
|
|
folderPath,
|
|
fileName,
|
|
fsPath,
|
|
linkedFileData,
|
|
userId,
|
|
fileRef,
|
|
createdBlob,
|
|
source,
|
|
}) {
|
|
const { newFolders, folder } =
|
|
await ProjectEntityUpdateHandler.promises.mkdirp.withoutLock(
|
|
projectId,
|
|
folderPath,
|
|
userId
|
|
)
|
|
// this calls directly into the upsertFile main task (without the beforeLock part)
|
|
const {
|
|
fileRef: newFileRef,
|
|
isNew,
|
|
oldFileRef,
|
|
} = await ProjectEntityUpdateHandler.promises.upsertFile.mainTask({
|
|
projectId,
|
|
folderId: folder._id,
|
|
fileName,
|
|
fsPath,
|
|
linkedFileData,
|
|
userId,
|
|
fileRef,
|
|
createdBlob,
|
|
source,
|
|
})
|
|
|
|
return {
|
|
fileRef: newFileRef,
|
|
isNew,
|
|
oldFileRef,
|
|
newFolders,
|
|
folder,
|
|
}
|
|
},
|
|
})
|
|
|
|
const deleteEntity = wrapWithLock(
|
|
async function (projectId, entityId, entityType, userId, source, callback) {
|
|
logger.debug({ entityId, entityType, projectId }, 'deleting project entity')
|
|
if (entityType == null) {
|
|
logger.warn({ err: 'No entityType set', projectId, entityId })
|
|
throw new Error('No entityType set')
|
|
}
|
|
entityType = entityType.toLowerCase()
|
|
|
|
// Flush the entire project to avoid leaving partially deleted docs in redis.
|
|
await DocumentUpdaterHandler.promises.flushProjectToMongo(projectId)
|
|
|
|
const { entity, path, projectBeforeDeletion, newProject } =
|
|
await ProjectEntityMongoUpdateHandler.promises.deleteEntity(
|
|
projectId,
|
|
entityId,
|
|
entityType,
|
|
userId
|
|
)
|
|
const subtreeListing = await ProjectEntityUpdateHandler._cleanUpEntity(
|
|
projectBeforeDeletion,
|
|
newProject,
|
|
entity,
|
|
entityType,
|
|
path.fileSystem,
|
|
userId,
|
|
source
|
|
)
|
|
|
|
const subtreeEntityIds = subtreeListing.map(entry =>
|
|
entry.entity._id.toString()
|
|
)
|
|
await TpdsUpdateSender.promises.deleteEntity({
|
|
projectId,
|
|
path: path.fileSystem,
|
|
projectName: projectBeforeDeletion.name,
|
|
entityId,
|
|
entityType,
|
|
subtreeEntityIds,
|
|
})
|
|
|
|
return entityId
|
|
}
|
|
)
|
|
|
|
const deleteEntityWithPath = wrapWithLock(
|
|
async (projectId, path, userId, source) => {
|
|
const { element, type } = await ProjectLocator.promises.findElementByPath({
|
|
project_id: projectId,
|
|
path,
|
|
exactCaseMatch: true,
|
|
})
|
|
if (element == null) {
|
|
throw new Errors.NotFoundError('project not found')
|
|
}
|
|
return await ProjectEntityUpdateHandler.promises.deleteEntity.withoutLock(
|
|
projectId,
|
|
element._id,
|
|
type,
|
|
userId,
|
|
source
|
|
)
|
|
}
|
|
)
|
|
|
|
const mkdirp = wrapWithLock(async function (projectId, path, userId) {
|
|
for (const folder of path.split('/')) {
|
|
if (folder.length > 0 && !SafePath.isCleanFilename(folder)) {
|
|
throw new Errors.InvalidNameError('invalid element name')
|
|
}
|
|
}
|
|
return await ProjectEntityMongoUpdateHandler.promises.mkdirp(
|
|
projectId,
|
|
path,
|
|
userId,
|
|
{ exactCaseMatch: false }
|
|
)
|
|
})
|
|
|
|
const mkdirpWithExactCase = wrapWithLock(
|
|
async function (projectId, path, userId) {
|
|
for (const folder of path.split('/')) {
|
|
if (folder.length > 0 && !SafePath.isCleanFilename(folder)) {
|
|
throw new Errors.InvalidNameError('invalid element name')
|
|
}
|
|
}
|
|
return await ProjectEntityMongoUpdateHandler.promises.mkdirp(
|
|
projectId,
|
|
path,
|
|
userId,
|
|
{ exactCaseMatch: true }
|
|
)
|
|
}
|
|
)
|
|
|
|
const addFolder = wrapWithLock(
|
|
async function (projectId, parentFolderId, folderName, userId) {
|
|
if (!SafePath.isCleanFilename(folderName)) {
|
|
throw new Errors.InvalidNameError('invalid element name')
|
|
}
|
|
return await ProjectEntityMongoUpdateHandler.promises.addFolder(
|
|
projectId,
|
|
parentFolderId,
|
|
folderName,
|
|
userId
|
|
)
|
|
}
|
|
)
|
|
|
|
const moveEntity = wrapWithLock(
|
|
async function (
|
|
projectId,
|
|
entityId,
|
|
destFolderId,
|
|
entityType,
|
|
userId,
|
|
source
|
|
) {
|
|
logger.debug(
|
|
{ entityType, entityId, projectId, destFolderId },
|
|
'moving entity'
|
|
)
|
|
if (entityType == null) {
|
|
logger.warn({ err: 'No entityType set', projectId, entityId })
|
|
throw new Error('No entityType set')
|
|
}
|
|
entityType = entityType.toLowerCase()
|
|
await DocumentUpdaterHandler.promises.flushProjectToMongo(projectId)
|
|
const { project, startPath, endPath, rev, changes } =
|
|
await ProjectEntityMongoUpdateHandler.promises.moveEntity(
|
|
projectId,
|
|
entityId,
|
|
destFolderId,
|
|
entityType,
|
|
userId
|
|
)
|
|
|
|
const projectHistoryId = project.overleaf?.history?.id
|
|
try {
|
|
await TpdsUpdateSender.promises.moveEntity({
|
|
projectId,
|
|
projectName: project.name,
|
|
startPath,
|
|
endPath,
|
|
rev,
|
|
entityId,
|
|
entityType,
|
|
folderId: destFolderId,
|
|
})
|
|
} catch (err) {
|
|
logger.error({ err }, 'error sending tpds update')
|
|
}
|
|
|
|
return await DocumentUpdaterHandler.promises.updateProjectStructure(
|
|
projectId,
|
|
projectHistoryId,
|
|
userId,
|
|
changes,
|
|
source
|
|
)
|
|
}
|
|
)
|
|
|
|
const renameEntity = wrapWithLock(
|
|
async function (projectId, entityId, entityType, newName, userId, source) {
|
|
if (!newName || typeof newName !== 'string') {
|
|
const err = new OError('invalid newName value', {
|
|
value: newName,
|
|
type: typeof newName,
|
|
projectId,
|
|
entityId,
|
|
entityType,
|
|
userId,
|
|
source,
|
|
})
|
|
logger.error({ err }, 'Invalid newName passed to renameEntity')
|
|
throw err
|
|
}
|
|
if (!SafePath.isCleanFilename(newName)) {
|
|
throw new Errors.InvalidNameError('invalid element name')
|
|
}
|
|
logger.debug({ entityId, projectId }, `renaming ${entityType}`)
|
|
if (entityType == null) {
|
|
logger.warn({ err: 'No entityType set', projectId, entityId })
|
|
throw new Error('No entityType set')
|
|
}
|
|
entityType = entityType.toLowerCase()
|
|
await DocumentUpdaterHandler.promises.flushProjectToMongo(projectId)
|
|
const { project, startPath, endPath, rev, changes } =
|
|
await ProjectEntityMongoUpdateHandler.promises.renameEntity(
|
|
projectId,
|
|
entityId,
|
|
entityType,
|
|
newName,
|
|
userId
|
|
)
|
|
|
|
const projectHistoryId = project.overleaf?.history?.id
|
|
try {
|
|
await TpdsUpdateSender.promises.moveEntity({
|
|
projectId,
|
|
projectName: project.name,
|
|
startPath,
|
|
endPath,
|
|
rev,
|
|
entityId,
|
|
entityType,
|
|
folderId: null, // this means the folder has not changed
|
|
})
|
|
} catch (err) {
|
|
logger.error({ err }, 'error sending tpds update')
|
|
}
|
|
return await DocumentUpdaterHandler.promises.updateProjectStructure(
|
|
projectId,
|
|
projectHistoryId,
|
|
userId,
|
|
changes,
|
|
source
|
|
)
|
|
}
|
|
)
|
|
|
|
// This doesn't directly update project structure, but we need to take the lock
|
|
// to prevent anything else being queued before the resync update
|
|
const resyncProjectHistory = wrapWithLock(
|
|
async (projectId, opts) => {
|
|
const project = await ProjectGetter.promises.getProject(projectId, {
|
|
rootFolder: true,
|
|
overleaf: true,
|
|
})
|
|
const projectHistoryId = project.overleaf?.history?.id
|
|
if (projectHistoryId == null) {
|
|
throw new Errors.ProjectHistoryDisabledError(
|
|
`project history not enabled for ${projectId}`
|
|
)
|
|
}
|
|
|
|
const { docs, files, folders } =
|
|
ProjectEntityHandler.getAllEntitiesFromProject(project)
|
|
// _checkFileTree() must be passed the folders before docs and
|
|
// files
|
|
await ProjectEntityUpdateHandler._checkFiletree(
|
|
projectId,
|
|
projectHistoryId,
|
|
[...folders, ...docs, ...files]
|
|
)
|
|
|
|
await DocumentUpdaterHandler.promises.resyncProjectHistory(
|
|
projectId,
|
|
projectHistoryId,
|
|
docs,
|
|
files,
|
|
opts
|
|
)
|
|
if (opts.historyRangesMigration) {
|
|
return await ProjectOptionsHandler.promises.setHistoryRangesSupport(
|
|
projectId,
|
|
opts.historyRangesMigration === 'forwards'
|
|
)
|
|
}
|
|
},
|
|
LockManager.withTimeout(6 * 60) // use an extended lock for the resync operations
|
|
)
|
|
|
|
const convertDocToFile = wrapWithLock({
|
|
async beforeLock(projectId, docId, userId, source) {
|
|
await DocumentUpdaterHandler.promises.flushDocToMongo(projectId, docId)
|
|
const { element: doc, path } = await ProjectLocator.promises.findElement({
|
|
project_id: projectId,
|
|
element_id: docId,
|
|
type: 'doc',
|
|
})
|
|
const docPath = path.fileSystem
|
|
const { lines, rev, ranges } = await DocstoreManager.promises.getDoc(
|
|
projectId,
|
|
docId
|
|
)
|
|
if (!_.isEmpty(ranges)) {
|
|
throw new Errors.DocHasRangesError({})
|
|
}
|
|
await DocumentUpdaterHandler.promises.deleteDoc(projectId, docId, false)
|
|
const fsPath = await FileWriter.promises.writeLinesToDisk(projectId, lines)
|
|
const { fileRef, createdBlob } =
|
|
await FileStoreHandler.promises.uploadFileFromDisk(
|
|
projectId,
|
|
{ name: doc.name, rev: rev + 1 },
|
|
fsPath
|
|
)
|
|
try {
|
|
await fs.promises.unlink(fsPath)
|
|
} catch (err) {
|
|
logger.warn({ err, path: fsPath }, 'failed to clean up temporary file')
|
|
}
|
|
return {
|
|
projectId,
|
|
doc,
|
|
path: docPath,
|
|
fileRef,
|
|
userId,
|
|
source,
|
|
createdBlob,
|
|
}
|
|
},
|
|
async withLock({
|
|
projectId,
|
|
doc,
|
|
path,
|
|
fileRef,
|
|
userId,
|
|
source,
|
|
createdBlob,
|
|
}) {
|
|
const project =
|
|
await ProjectEntityMongoUpdateHandler.promises.replaceDocWithFile(
|
|
projectId,
|
|
doc._id,
|
|
fileRef,
|
|
userId
|
|
)
|
|
const projectHistoryId = project.overleaf?.history?.id
|
|
await DocumentUpdaterHandler.promises.updateProjectStructure(
|
|
projectId,
|
|
projectHistoryId,
|
|
userId,
|
|
{
|
|
oldDocs: [{ doc, path }],
|
|
newFiles: [{ file: fileRef, path, createdBlob }],
|
|
newProject: project,
|
|
},
|
|
source
|
|
)
|
|
const { folder } = await ProjectLocator.promises.findElement({
|
|
project_id: projectId,
|
|
element_id: fileRef._id,
|
|
type: 'file',
|
|
})
|
|
EditorRealTimeController.emitToRoom(
|
|
projectId,
|
|
'removeEntity',
|
|
doc._id,
|
|
'convertDocToFile'
|
|
)
|
|
EditorRealTimeController.emitToRoom(
|
|
projectId,
|
|
'reciveNewFile',
|
|
folder._id,
|
|
fileRef,
|
|
'convertDocToFile',
|
|
null,
|
|
userId
|
|
)
|
|
return fileRef
|
|
},
|
|
})
|
|
|
|
async function setMainBibliographyDoc(projectId, newBibliographyDocId) {
|
|
logger.debug(
|
|
{ projectId, mainBibliographyDocId: newBibliographyDocId },
|
|
'setting main bibliography doc'
|
|
)
|
|
if (projectId == null || newBibliographyDocId == null) {
|
|
throw new Errors.InvalidError('missing arguments (project or doc)')
|
|
}
|
|
const docPath =
|
|
await ProjectEntityHandler.promises.getDocPathByProjectIdAndDocId(
|
|
projectId,
|
|
newBibliographyDocId
|
|
)
|
|
if (ProjectEntityUpdateHandler.isPathValidForMainBibliographyDoc(docPath)) {
|
|
await Project.updateOne(
|
|
{ _id: projectId },
|
|
{ mainBibliographyDoc_id: newBibliographyDocId }
|
|
).exec()
|
|
} else {
|
|
throw new Errors.UnsupportedFileTypeError(
|
|
'invalid file extension for main bibliography doc'
|
|
)
|
|
}
|
|
}
|
|
|
|
const ProjectEntityUpdateHandler = {
|
|
LOCK_NAMESPACE,
|
|
|
|
addDoc: callbackifyMultiResult(addDoc, ['doc', 'folderId']),
|
|
|
|
addDocWithRanges: callbackifyMultiResult(addDocWithRanges, [
|
|
'doc',
|
|
'folderId',
|
|
]),
|
|
|
|
addFile: callbackifyMultiResult(addFile, [
|
|
'fileRef',
|
|
'folderId',
|
|
'createdBlob',
|
|
]),
|
|
|
|
addFolder: callbackifyMultiResult(addFolder, ['folder', 'parentFolderId']),
|
|
|
|
convertDocToFile: callbackify(convertDocToFile),
|
|
|
|
deleteEntity: callbackify(deleteEntity),
|
|
|
|
deleteEntityWithPath: callbackify(deleteEntityWithPath),
|
|
|
|
mkdirp: callbackifyMultiResult(mkdirp, [
|
|
'newFolders',
|
|
'folder',
|
|
'parentFolder',
|
|
]),
|
|
|
|
mkdirpWithExactCase: callbackifyMultiResult(mkdirpWithExactCase, [
|
|
'newFolders',
|
|
'folder',
|
|
'parentFolder',
|
|
]),
|
|
|
|
moveEntity: callbackify(moveEntity),
|
|
|
|
renameEntity: callbackify(renameEntity),
|
|
|
|
resyncProjectHistory: callbackify(resyncProjectHistory),
|
|
|
|
setRootDoc: callbackify(setRootDoc),
|
|
|
|
unsetRootDoc: callbackify(unsetRootDoc),
|
|
|
|
setMainBibliographyDoc: callbackify(setMainBibliographyDoc),
|
|
|
|
updateDocLines: callbackify(updateDocLines),
|
|
|
|
upsertDoc: callbackifyMultiResult(upsertDoc, ['doc', 'isNew']),
|
|
|
|
appendToDoc: callbackify(appendToDoc),
|
|
|
|
upsertDocWithPath: callbackifyMultiResult(upsertDocWithPath, [
|
|
'doc',
|
|
'isNew',
|
|
'newFolders',
|
|
'folder',
|
|
]),
|
|
|
|
upsertFile: callbackifyMultiResult(upsertFile, [
|
|
'fileRef',
|
|
'isNew',
|
|
'oldFileRef',
|
|
]),
|
|
|
|
upsertFileWithPath: callbackifyMultiResult(upsertFileWithPath, [
|
|
'fileRef',
|
|
'isNew',
|
|
'oldFileRef',
|
|
'newFolders',
|
|
'folder',
|
|
]),
|
|
|
|
promises: {
|
|
addDoc,
|
|
addDocWithRanges,
|
|
addFile,
|
|
addFolder,
|
|
convertDocToFile,
|
|
deleteEntity,
|
|
deleteEntityWithPath,
|
|
mkdirp,
|
|
mkdirpWithExactCase,
|
|
moveEntity,
|
|
renameEntity,
|
|
resyncProjectHistory,
|
|
setRootDoc,
|
|
unsetRootDoc,
|
|
updateDocLines,
|
|
upsertDoc,
|
|
upsertDocWithPath,
|
|
upsertFile,
|
|
upsertFileWithPath,
|
|
appendToDocWithPath: appendToDoc,
|
|
setMainBibliographyDoc,
|
|
},
|
|
|
|
async _addDocAndSendToTpds(projectId, folderId, doc, userId) {
|
|
let result, project
|
|
try {
|
|
;({ result, project } =
|
|
await ProjectEntityMongoUpdateHandler.promises.addDoc(
|
|
projectId,
|
|
folderId,
|
|
doc,
|
|
userId
|
|
))
|
|
} catch (err) {
|
|
throw OError.tag(err, 'error adding file with project', {
|
|
projectId,
|
|
folderId,
|
|
doc_name: doc != null ? doc.name : undefined,
|
|
doc_id: doc != null ? doc._id : undefined,
|
|
})
|
|
}
|
|
|
|
await TpdsUpdateSender.promises.addDoc({
|
|
projectId,
|
|
docId: doc != null ? doc._id : undefined,
|
|
path: result?.path?.fileSystem,
|
|
projectName: project.name,
|
|
rev: 0,
|
|
folderId,
|
|
})
|
|
return { result, project }
|
|
},
|
|
|
|
async _uploadFile(projectId, folderId, fileName, fsPath, linkedFileData) {
|
|
if (!SafePath.isCleanFilename(fileName)) {
|
|
throw new Errors.InvalidNameError('invalid element name')
|
|
}
|
|
const fileArgs = {
|
|
name: fileName,
|
|
linkedFileData,
|
|
}
|
|
try {
|
|
return await FileStoreHandler.promises.uploadFileFromDisk(
|
|
projectId,
|
|
fileArgs,
|
|
fsPath
|
|
)
|
|
} catch (err) {
|
|
throw OError.tag(err, 'error uploading image to s3', {
|
|
projectId,
|
|
folderId,
|
|
file_name: fileName,
|
|
})
|
|
}
|
|
},
|
|
|
|
async _addFileAndSendToTpds(projectId, folderId, fileRef, userId) {
|
|
let result, project
|
|
try {
|
|
;({ result, project } =
|
|
await ProjectEntityMongoUpdateHandler.promises.addFile(
|
|
projectId,
|
|
folderId,
|
|
fileRef,
|
|
userId
|
|
))
|
|
} catch (err) {
|
|
throw OError.tag(err, 'error adding file with project', {
|
|
projectId,
|
|
folderId,
|
|
file_name: fileRef.name,
|
|
fileRef,
|
|
})
|
|
}
|
|
|
|
const historyId = project?.overleaf?.history?.id
|
|
if (!historyId) {
|
|
throw new OError('project does not have a history id', { projectId })
|
|
}
|
|
await TpdsUpdateSender.promises.addFile({
|
|
projectId,
|
|
historyId,
|
|
fileId: fileRef._id,
|
|
hash: fileRef.hash,
|
|
path: result?.path?.fileSystem,
|
|
projectName: project.name,
|
|
rev: fileRef.rev,
|
|
folderId,
|
|
})
|
|
return { result, project }
|
|
},
|
|
|
|
async _replaceFile(
|
|
projectId,
|
|
fileId,
|
|
userId,
|
|
newFileRef,
|
|
folderId,
|
|
source,
|
|
createdBlob
|
|
) {
|
|
const {
|
|
oldFileRef,
|
|
project,
|
|
path,
|
|
newProject,
|
|
newFileRef: updatedFileRef,
|
|
} = await ProjectEntityMongoUpdateHandler.promises.replaceFileWithNew(
|
|
projectId,
|
|
fileId,
|
|
newFileRef,
|
|
userId
|
|
)
|
|
|
|
const oldFiles = [
|
|
{
|
|
file: oldFileRef,
|
|
path: path.fileSystem,
|
|
},
|
|
]
|
|
const newFiles = [
|
|
{
|
|
file: updatedFileRef,
|
|
createdBlob,
|
|
path: path.fileSystem,
|
|
},
|
|
]
|
|
const projectHistoryId = project.overleaf?.history?.id
|
|
await TpdsUpdateSender.promises.addFile({
|
|
projectId: project._id,
|
|
historyId: projectHistoryId,
|
|
fileId: updatedFileRef._id,
|
|
hash: updatedFileRef.hash,
|
|
path: path.fileSystem,
|
|
rev: updatedFileRef.rev,
|
|
projectName: project.name,
|
|
folderId,
|
|
})
|
|
|
|
await DocumentUpdaterHandler.promises.updateProjectStructure(
|
|
projectId,
|
|
projectHistoryId,
|
|
userId,
|
|
{ oldFiles, newFiles, newProject },
|
|
source
|
|
)
|
|
|
|
return updatedFileRef
|
|
},
|
|
|
|
async _checkFiletree(projectId, projectHistoryId, entities) {
|
|
const adjustPathsAfterFolderRename = (oldPath, newPath) => {
|
|
oldPath = oldPath + '/'
|
|
newPath = newPath + '/'
|
|
for (const entity of entities) {
|
|
if (entity.path.startsWith(oldPath)) {
|
|
entity.path = newPath + entity.path.slice(oldPath.length)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Data structures for recording pending renames
|
|
const renames = []
|
|
const paths = new Set()
|
|
for (const entity of entities) {
|
|
const originalName = entity.folder
|
|
? entity.folder.name
|
|
: entity.doc
|
|
? entity.doc.name
|
|
: entity.file.name
|
|
|
|
let newPath = entity.path
|
|
let newName = originalName
|
|
|
|
// Clean the filename if necessary
|
|
if (newName === '') {
|
|
newName = 'untitled'
|
|
} else {
|
|
newName = SafePath.clean(newName)
|
|
}
|
|
if (newName !== originalName) {
|
|
newPath = Path.join(
|
|
newPath.slice(0, newPath.length - originalName.length),
|
|
newName
|
|
)
|
|
}
|
|
|
|
// Check if we've seen that path already
|
|
if (paths.has(newPath)) {
|
|
newPath = ProjectEntityUpdateHandler.findNextAvailablePath(
|
|
paths,
|
|
newPath
|
|
)
|
|
newName = newPath.split('/').pop()
|
|
}
|
|
|
|
// If we've changed the filename, schedule a rename
|
|
if (newName !== originalName) {
|
|
renames.push({ entity, newName, newPath })
|
|
if (entity.folder) {
|
|
// Here, we rely on entities being processed in the right order.
|
|
// Parent folders need to be processed before their children. This is
|
|
// the case only because getAllEntitiesFromProject() returns folders
|
|
// in that order and resyncProjectHistory() calls us with the folders
|
|
// first.
|
|
|
|
adjustPathsAfterFolderRename(entity.path, newPath)
|
|
}
|
|
}
|
|
|
|
// Remember that we've seen this path
|
|
paths.add(newPath)
|
|
}
|
|
|
|
if (renames.length === 0) {
|
|
return
|
|
}
|
|
logger.warn(
|
|
{
|
|
projectId,
|
|
renames: renames.map(rename => ({
|
|
oldPath: rename.entity.path,
|
|
newPath: rename.newPath,
|
|
})),
|
|
},
|
|
'found conflicts or bad filenames in filetree'
|
|
)
|
|
|
|
// Avoid conflicts by processing renames in the reverse order. If we have
|
|
// the following starting situation:
|
|
//
|
|
// somefile.tex
|
|
// somefile.tex
|
|
// somefile.tex (1)
|
|
//
|
|
// somefile.tex would be processed first, and then somefile.tex (1),
|
|
// yielding the following renames:
|
|
//
|
|
// somefile.tex -> somefile.tex (1)
|
|
// somefile.tex (1) -> somefile.tex (2)
|
|
//
|
|
// When the first rename was decided, we didn't know that somefile.tex (1)
|
|
// existed, so that created a conflict. By processing renames in the
|
|
// reverse order, we start with the files that had the most extensive
|
|
// information about existing files.
|
|
renames.reverse()
|
|
|
|
for (const rename of renames) {
|
|
// rename the duplicate files
|
|
const entity = rename.entity
|
|
const entityId = entity.folder
|
|
? entity.folder._id
|
|
: entity.doc
|
|
? entity.doc._id
|
|
: entity.file._id
|
|
const entityType = entity.folder ? 'folder' : entity.doc ? 'doc' : 'file'
|
|
const { changes } =
|
|
await ProjectEntityMongoUpdateHandler.promises.renameEntity(
|
|
projectId,
|
|
entityId,
|
|
entityType,
|
|
rename.newName,
|
|
null // unset lastUpdatedBy
|
|
)
|
|
|
|
// update the renamed entity for the resync
|
|
entity.path = rename.newPath
|
|
if (entityType === 'folder') {
|
|
entity.folder.name = rename.newName
|
|
} else if (entityType === 'doc') {
|
|
entity.doc.name = rename.newName
|
|
} else {
|
|
entity.file.name = rename.newName
|
|
}
|
|
await DocumentUpdaterHandler.promises.updateProjectStructure(
|
|
projectId,
|
|
projectHistoryId,
|
|
null,
|
|
changes,
|
|
'automatic-fix'
|
|
)
|
|
}
|
|
},
|
|
|
|
findNextAvailablePath(allPaths, candidatePath) {
|
|
const incrementReplacer = (match, p1) => {
|
|
return ' (' + (parseInt(p1, 10) + 1) + ')'
|
|
}
|
|
// if the filename was invalid we should normalise it here too. Currently
|
|
// this only handles renames in the same folder, so we will be out of luck
|
|
// if it is the folder name which in invalid. We could handle folder
|
|
// renames by returning the folders list from getAllEntitiesFromProject
|
|
do {
|
|
// does the filename look like "foo (1)" if so, increment the number in parentheses
|
|
if (/ \(\d+\)$/.test(candidatePath)) {
|
|
candidatePath = candidatePath.replace(/ \((\d+)\)$/, incrementReplacer)
|
|
} else {
|
|
// otherwise, add a ' (1)' suffix to the name
|
|
candidatePath = candidatePath + ' (1)'
|
|
}
|
|
} while (allPaths.has(candidatePath)) // keep going until the name is unique
|
|
// add the new name to the set
|
|
allPaths.add(candidatePath)
|
|
return candidatePath
|
|
},
|
|
|
|
isPathValidForRootDoc(docPath) {
|
|
const docExtension = Path.extname(docPath)
|
|
return VALID_ROOT_DOC_REGEXP.test(docExtension)
|
|
},
|
|
|
|
isPathValidForMainBibliographyDoc(docPath) {
|
|
const docExtension = Path.extname(docPath).toLowerCase()
|
|
return docExtension === '.bib'
|
|
},
|
|
|
|
async _cleanUpEntity(
|
|
project,
|
|
newProject,
|
|
entity,
|
|
entityType,
|
|
path,
|
|
userId,
|
|
source
|
|
) {
|
|
const subtreeListing = _listSubtree(entity, entityType, path)
|
|
await ProjectEntityUpdateHandler._updateProjectStructureWithDeletedEntity(
|
|
project,
|
|
newProject,
|
|
subtreeListing,
|
|
userId,
|
|
source
|
|
)
|
|
|
|
for (const entry of subtreeListing) {
|
|
if (entry.type === 'doc') {
|
|
await ProjectEntityUpdateHandler._cleanUpDoc(
|
|
project,
|
|
entry.entity,
|
|
entry.path,
|
|
userId
|
|
)
|
|
}
|
|
}
|
|
return subtreeListing
|
|
},
|
|
|
|
async _updateProjectStructureWithDeletedEntity(
|
|
project,
|
|
newProject,
|
|
subtreeListing,
|
|
userId,
|
|
source
|
|
) {
|
|
const changes = { oldDocs: [], oldFiles: [] }
|
|
for (const entry of subtreeListing) {
|
|
if (entry.type === 'doc') {
|
|
changes.oldDocs.push({ doc: entry.entity, path: entry.path })
|
|
} else if (entry.type === 'file') {
|
|
changes.oldFiles.push({ file: entry.entity, path: entry.path })
|
|
}
|
|
}
|
|
|
|
// now send the project structure changes to the docupdater
|
|
changes.newProject = newProject
|
|
const projectId = project._id.toString()
|
|
const projectHistoryId =
|
|
project.overleaf &&
|
|
project.overleaf.history &&
|
|
project.overleaf.history.id
|
|
return await DocumentUpdaterHandler.promises.updateProjectStructure(
|
|
projectId,
|
|
projectHistoryId,
|
|
userId,
|
|
changes,
|
|
source
|
|
)
|
|
},
|
|
|
|
async _cleanUpDoc(project, doc) {
|
|
const projectId = project._id.toString()
|
|
const docId = doc._id.toString()
|
|
if (project.rootDoc_id != null && project.rootDoc_id.toString() === docId) {
|
|
await ProjectEntityUpdateHandler.promises.unsetRootDoc(projectId)
|
|
}
|
|
|
|
const { name } = doc
|
|
const deletedAt = new Date()
|
|
await DocstoreManager.promises.deleteDoc(projectId, docId, name, deletedAt)
|
|
|
|
return await DocumentUpdaterHandler.promises.deleteDoc(projectId, docId)
|
|
},
|
|
}
|
|
|
|
/**
|
|
* List all descendants of an entity along with their type and path. Include
|
|
* the top-level entity as well.
|
|
*/
|
|
function _listSubtree(entity, entityType, entityPath) {
|
|
if (entityType.indexOf('file') !== -1) {
|
|
return [{ type: 'file', entity, path: entityPath }]
|
|
} else if (entityType.indexOf('doc') !== -1) {
|
|
return [{ type: 'doc', entity, path: entityPath }]
|
|
} else if (entityType.indexOf('folder') !== -1) {
|
|
const listing = []
|
|
const _recurseFolder = (folder, folderPath) => {
|
|
listing.push({ type: 'folder', entity: folder, path: folderPath })
|
|
for (const doc of iterablePaths(folder, 'docs')) {
|
|
listing.push({
|
|
type: 'doc',
|
|
entity: doc,
|
|
path: Path.join(folderPath, doc.name),
|
|
})
|
|
}
|
|
for (const file of iterablePaths(folder, 'fileRefs')) {
|
|
listing.push({
|
|
type: 'file',
|
|
entity: file,
|
|
path: Path.join(folderPath, file.name),
|
|
})
|
|
}
|
|
for (const childFolder of iterablePaths(folder, 'folders')) {
|
|
_recurseFolder(childFolder, Path.join(folderPath, childFolder.name))
|
|
}
|
|
}
|
|
_recurseFolder(entity, entityPath)
|
|
return listing
|
|
} else {
|
|
// This shouldn't happen, but if it does, fail silently.
|
|
return []
|
|
}
|
|
}
|
|
|
|
export default ProjectEntityUpdateHandler
|