Files
overleaf-cep/services/web/app/src/Features/Project/ProjectDuplicator.mjs
T
Andrew Rumble f02f6475ac Update paths
GitOrigin-RevId: a9474c8f36e8b287de4dd8fda29bc082001444ac
2025-10-23 08:06:10 +00:00

284 lines
8.7 KiB
JavaScript

import { callbackify } from 'node:util'
import Path from 'node:path'
import logger from '@overleaf/logger'
import OError from '@overleaf/o-error'
import { promiseMapWithLimit } from '@overleaf/promise-utils'
import { Doc } from '../../models/Doc.js'
import { File } from '../../models/File.js'
import DocstoreManager from '../Docstore/DocstoreManager.js'
import DocumentUpdaterHandler from '../DocumentUpdater/DocumentUpdaterHandler.mjs'
import HistoryManager from '../History/HistoryManager.mjs'
import ProjectCreationHandler from './ProjectCreationHandler.mjs'
import ProjectDeleter from './ProjectDeleter.mjs'
import ProjectEntityMongoUpdateHandler from './ProjectEntityMongoUpdateHandler.mjs'
import ProjectEntityUpdateHandler from './ProjectEntityUpdateHandler.mjs'
import ProjectGetter from './ProjectGetter.mjs'
import ProjectLocator from './ProjectLocator.mjs'
import ProjectOptionsHandler from './ProjectOptionsHandler.js'
import SafePath from './SafePath.js'
import TpdsProjectFlusher from '../ThirdPartyDataStore/TpdsProjectFlusher.mjs'
import _ from 'lodash'
import TagsHandler from '../Tags/TagsHandler.js'
import ClsiCacheManager from '../Compile/ClsiCacheManager.mjs'
import Modules from '../../infrastructure/Modules.js'
export default {
duplicate: callbackify(duplicate),
promises: {
duplicate,
},
}
async function duplicate(owner, originalProjectId, newProjectName, tags = []) {
await DocumentUpdaterHandler.promises.flushProjectToMongo(originalProjectId)
const originalProject = await ProjectGetter.promises.getProject(
originalProjectId,
{
compiler: true,
imageName: true,
rootFolder: true,
rootDoc_id: true,
fromV1TemplateId: true,
fromV1TemplateVersionId: true,
overleaf: true,
}
)
const { path: rootDocPath } = await ProjectLocator.promises.findRootDoc({
project_id: originalProjectId,
})
const originalEntries = _getFolderEntries(originalProject.rootFolder[0])
await Modules.promises.hooks.fire('preDuplicateProject', {
owner,
originalProjectId,
newProjectName,
tags,
originalProject,
originalEntries,
})
// Pass template ID as analytics segmentation if duplicating project from a template
const segmentation = _.pick(originalProject, [
'fromV1TemplateId',
'fromV1TemplateVersionId',
])
segmentation.duplicatedFromProject = originalProjectId
// count the number of tags before and after, for analytics
segmentation['original-tags'] =
await TagsHandler.promises.countTagsForProject(
owner._id,
originalProject._id
)
segmentation['updated-tags'] = tags.length
// remove any leading or trailing spaces
newProjectName = newProjectName.trim()
// Now create the new project, cleaning it up on failure if necessary
const newProject = await ProjectCreationHandler.promises.createBlankProject(
owner._id,
newProjectName,
{ segmentation }
)
let prepareClsiCacheInBackground = Promise.resolve()
if (originalProject.imageName === newProject.imageName) {
// Populate the clsi-cache unless the TeXLive release has changed.
prepareClsiCacheInBackground = ClsiCacheManager.prepareClsiCache(
newProject._id,
owner._id,
{ sourceProjectId: originalProjectId }
).catch(err => {
logger.warn(
{ err, originalProjectId, projectId: newProject._id },
'failed to prepare clsi-cache for cloned project'
)
})
}
try {
await ProjectOptionsHandler.promises.setCompiler(
newProject._id,
originalProject.compiler
)
const [docEntries, fileEntries] = await Promise.all([
_copyDocs(originalEntries.docEntries, originalProject, newProject),
_copyFiles(originalEntries.fileEntries, originalProject, newProject),
])
const projectVersion =
await ProjectEntityMongoUpdateHandler.promises.createNewFolderStructure(
newProject._id,
docEntries,
fileEntries
)
// Silently ignore the rootDoc in case it's not valid per the new limits.
if (
rootDocPath &&
ProjectEntityUpdateHandler.isPathValidForRootDoc(rootDocPath.fileSystem)
) {
await _setRootDoc(newProject._id, rootDocPath.fileSystem)
}
await _notifyDocumentUpdater(newProject, owner._id, {
newFiles: fileEntries,
newDocs: docEntries,
newProject: { version: projectVersion },
})
await TpdsProjectFlusher.promises.flushProjectToTpds(newProject._id)
if (tags?.length > 0) {
await TagsHandler.promises.addProjectToTags(
owner._id,
tags.map(tag => tag.id),
newProject._id
)
}
} catch (err) {
// Clean up broken clone on error.
// Make sure we delete the new failed project, not the original one!
await ProjectDeleter.promises.deleteProject(newProject._id)
throw OError.tag(err, 'error cloning project, broken clone deleted', {
originalProjectId,
newProjectName,
newProjectId: newProject._id,
})
}
try {
await prepareClsiCacheInBackground
} catch {}
return newProject
}
function _getFolderEntries(folder, folderPath = '/') {
const docEntries = []
const fileEntries = []
const docs = folder.docs || []
const files = folder.fileRefs || []
const subfolders = folder.folders || []
for (const doc of docs) {
if (doc == null || doc._id == null) {
continue
}
const path = Path.join(folderPath, doc.name)
docEntries.push({ doc, path })
}
for (const file of files) {
if (file == null || file._id == null) {
continue
}
const path = Path.join(folderPath, file.name)
fileEntries.push({ file, path })
}
for (const subfolder of subfolders) {
if (subfolder == null || subfolder._id == null) {
continue
}
const subfolderPath = Path.join(folderPath, subfolder.name)
const subfolderEntries = _getFolderEntries(subfolder, subfolderPath)
for (const docEntry of subfolderEntries.docEntries) {
docEntries.push(docEntry)
}
for (const fileEntry of subfolderEntries.fileEntries) {
fileEntries.push(fileEntry)
}
}
return { docEntries, fileEntries }
}
async function _copyDocs(sourceEntries, sourceProject, targetProject) {
const docLinesById = await _getDocLinesForProject(sourceProject._id)
const targetEntries = []
for (const sourceEntry of sourceEntries) {
const sourceDoc = sourceEntry.doc
const path = sourceEntry.path
const doc = new Doc({ name: sourceDoc.name })
const docLines = docLinesById.get(sourceDoc._id.toString())
await DocstoreManager.promises.updateDoc(
targetProject._id.toString(),
doc._id.toString(),
docLines,
0,
{}
)
targetEntries.push({ doc, path, docLines: docLines.join('\n') })
}
return targetEntries
}
async function _getDocLinesForProject(projectId) {
const docs = await DocstoreManager.promises.getAllDocs(projectId)
const docLinesById = new Map(docs.map(doc => [doc._id, doc.lines]))
return docLinesById
}
async function _copyFiles(sourceEntries, sourceProject, targetProject) {
const sourceHistoryId = sourceProject.overleaf?.history?.id
const targetHistoryId = targetProject.overleaf?.history?.id
if (!sourceHistoryId) {
throw new OError('missing history id', { sourceProject })
}
if (!targetHistoryId) {
throw new OError('missing history id', { targetProject })
}
const targetEntries = await promiseMapWithLimit(
5,
sourceEntries,
async sourceEntry => {
const sourceFile = sourceEntry.file
const path = sourceEntry.path
const file = new File({
name: SafePath.clean(sourceFile.name),
hash: sourceFile.hash,
})
if (sourceFile.linkedFileData != null) {
file.linkedFileData = sourceFile.linkedFileData
file.created = sourceFile.created
}
try {
await HistoryManager.promises.copyBlob(
sourceHistoryId,
targetHistoryId,
file.hash
)
return { createdBlob: true, file, path }
} catch (err) {
throw OError.tag(err, 'unexpected error copying blob', {
sourceProjectId: sourceProject._id,
targetProjectId: targetProject._id,
sourceFile,
sourceHistoryId,
})
}
}
)
return targetEntries
}
async function _setRootDoc(projectId, path) {
const { element: rootDoc } = await ProjectLocator.promises.findElementByPath({
project_id: projectId,
path,
exactCaseMatch: true,
})
await ProjectEntityUpdateHandler.promises.setRootDoc(projectId, rootDoc._id)
}
async function _notifyDocumentUpdater(project, userId, changes) {
const projectHistoryId =
project.overleaf && project.overleaf.history && project.overleaf.history.id
await DocumentUpdaterHandler.promises.updateProjectStructure(
project._id,
projectHistoryId,
userId,
changes,
null
)
}