ProjectDuplicator.mjs 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. import { callbackify } from 'node:util'
  2. import Path from 'node:path'
  3. import logger from '@overleaf/logger'
  4. import OError from '@overleaf/o-error'
  5. import { promiseMapWithLimit } from '@overleaf/promise-utils'
  6. import { Doc } from '../../models/Doc.js'
  7. import { File } from '../../models/File.js'
  8. import DocstoreManager from '../Docstore/DocstoreManager.mjs'
  9. import DocumentUpdaterHandler from '../DocumentUpdater/DocumentUpdaterHandler.mjs'
  10. import HistoryManager from '../History/HistoryManager.mjs'
  11. import ProjectCreationHandler from './ProjectCreationHandler.mjs'
  12. import ProjectDeleter from './ProjectDeleter.mjs'
  13. import ProjectEntityMongoUpdateHandler from './ProjectEntityMongoUpdateHandler.mjs'
  14. import ProjectEntityUpdateHandler from './ProjectEntityUpdateHandler.mjs'
  15. import ProjectGetter from './ProjectGetter.mjs'
  16. import ProjectLocator from './ProjectLocator.mjs'
  17. import ProjectOptionsHandler from './ProjectOptionsHandler.mjs'
  18. import SafePath from './SafePath.mjs'
  19. import TpdsProjectFlusher from '../ThirdPartyDataStore/TpdsProjectFlusher.mjs'
  20. import _ from 'lodash'
  21. import TagsHandler from '../Tags/TagsHandler.mjs'
  22. import ClsiCacheManager from '../Compile/ClsiCacheManager.mjs'
  23. import Modules from '../../infrastructure/Modules.js'
  24. export default {
  25. duplicate: callbackify(duplicate),
  26. promises: {
  27. duplicate,
  28. },
  29. }
  30. async function duplicate(owner, originalProjectId, newProjectName, tags = []) {
  31. await DocumentUpdaterHandler.promises.flushProjectToMongo(originalProjectId)
  32. const originalProject = await ProjectGetter.promises.getProject(
  33. originalProjectId,
  34. {
  35. compiler: true,
  36. imageName: true,
  37. rootFolder: true,
  38. rootDoc_id: true,
  39. fromV1TemplateId: true,
  40. fromV1TemplateVersionId: true,
  41. overleaf: true,
  42. }
  43. )
  44. const { path: rootDocPath } = await ProjectLocator.promises.findRootDoc({
  45. project_id: originalProjectId,
  46. })
  47. const originalEntries = _getFolderEntries(originalProject.rootFolder[0])
  48. await Modules.promises.hooks.fire('preDuplicateProject', {
  49. owner,
  50. originalProjectId,
  51. newProjectName,
  52. tags,
  53. originalProject,
  54. originalEntries,
  55. })
  56. // Pass template ID as analytics segmentation if duplicating project from a template
  57. const segmentation = _.pick(originalProject, [
  58. 'fromV1TemplateId',
  59. 'fromV1TemplateVersionId',
  60. ])
  61. segmentation.duplicatedFromProject = originalProjectId
  62. // count the number of tags before and after, for analytics
  63. segmentation['original-tags'] =
  64. await TagsHandler.promises.countTagsForProject(
  65. owner._id,
  66. originalProject._id
  67. )
  68. segmentation['updated-tags'] = tags.length
  69. // remove any leading or trailing spaces
  70. newProjectName = newProjectName.trim()
  71. // Now create the new project, cleaning it up on failure if necessary
  72. const newProject = await ProjectCreationHandler.promises.createBlankProject(
  73. owner._id,
  74. newProjectName,
  75. { segmentation }
  76. )
  77. let prepareClsiCacheInBackground = Promise.resolve()
  78. if (originalProject.imageName === newProject.imageName) {
  79. // Populate the clsi-cache unless the TeXLive release has changed.
  80. prepareClsiCacheInBackground = ClsiCacheManager.prepareClsiCache(
  81. newProject._id,
  82. owner._id,
  83. { sourceProjectId: originalProjectId }
  84. ).catch(err => {
  85. logger.warn(
  86. { err, originalProjectId, projectId: newProject._id },
  87. 'failed to prepare clsi-cache for cloned project'
  88. )
  89. })
  90. }
  91. try {
  92. await ProjectOptionsHandler.promises.setCompiler(
  93. newProject._id,
  94. originalProject.compiler
  95. )
  96. const [docEntries, fileEntries] = await Promise.all([
  97. _copyDocs(originalEntries.docEntries, originalProject, newProject),
  98. _copyFiles(originalEntries.fileEntries, originalProject, newProject),
  99. ])
  100. const projectVersion =
  101. await ProjectEntityMongoUpdateHandler.promises.createNewFolderStructure(
  102. newProject._id,
  103. docEntries,
  104. fileEntries
  105. )
  106. // Silently ignore the rootDoc in case it's not valid per the new limits.
  107. if (
  108. rootDocPath &&
  109. ProjectEntityUpdateHandler.isPathValidForRootDoc(rootDocPath.fileSystem)
  110. ) {
  111. await _setRootDoc(newProject._id, rootDocPath.fileSystem)
  112. }
  113. await _notifyDocumentUpdater(newProject, owner._id, {
  114. newFiles: fileEntries,
  115. newDocs: docEntries,
  116. newProject: { version: projectVersion },
  117. })
  118. await TpdsProjectFlusher.promises.flushProjectToTpds(newProject._id)
  119. if (tags?.length > 0) {
  120. await TagsHandler.promises.addProjectToTags(
  121. owner._id,
  122. tags.map(tag => tag.id),
  123. newProject._id
  124. )
  125. }
  126. } catch (err) {
  127. // Clean up broken clone on error.
  128. // Make sure we delete the new failed project, not the original one!
  129. await ProjectDeleter.promises.deleteProject(newProject._id)
  130. throw OError.tag(err, 'error cloning project, broken clone deleted', {
  131. originalProjectId,
  132. newProjectName,
  133. newProjectId: newProject._id,
  134. })
  135. }
  136. try {
  137. await prepareClsiCacheInBackground
  138. } catch {}
  139. return newProject
  140. }
  141. function _getFolderEntries(folder, folderPath = '/') {
  142. const docEntries = []
  143. const fileEntries = []
  144. const docs = folder.docs || []
  145. const files = folder.fileRefs || []
  146. const subfolders = folder.folders || []
  147. for (const doc of docs) {
  148. if (doc == null || doc._id == null) {
  149. continue
  150. }
  151. const path = Path.join(folderPath, doc.name)
  152. docEntries.push({ doc, path })
  153. }
  154. for (const file of files) {
  155. if (file == null || file._id == null) {
  156. continue
  157. }
  158. const path = Path.join(folderPath, file.name)
  159. fileEntries.push({ file, path })
  160. }
  161. for (const subfolder of subfolders) {
  162. if (subfolder == null || subfolder._id == null) {
  163. continue
  164. }
  165. const subfolderPath = Path.join(folderPath, subfolder.name)
  166. const subfolderEntries = _getFolderEntries(subfolder, subfolderPath)
  167. for (const docEntry of subfolderEntries.docEntries) {
  168. docEntries.push(docEntry)
  169. }
  170. for (const fileEntry of subfolderEntries.fileEntries) {
  171. fileEntries.push(fileEntry)
  172. }
  173. }
  174. return { docEntries, fileEntries }
  175. }
  176. async function _copyDocs(sourceEntries, sourceProject, targetProject) {
  177. const docLinesById = await _getDocLinesForProject(sourceProject._id)
  178. const targetEntries = []
  179. for (const sourceEntry of sourceEntries) {
  180. const sourceDoc = sourceEntry.doc
  181. const path = sourceEntry.path
  182. const doc = new Doc({ name: sourceDoc.name })
  183. const docLines = docLinesById.get(sourceDoc._id.toString())
  184. await DocstoreManager.promises.updateDoc(
  185. targetProject._id.toString(),
  186. doc._id.toString(),
  187. docLines,
  188. 0,
  189. {}
  190. )
  191. targetEntries.push({ doc, path, docLines: docLines.join('\n') })
  192. }
  193. return targetEntries
  194. }
  195. async function _getDocLinesForProject(projectId) {
  196. const docs = await DocstoreManager.promises.getAllDocs(projectId)
  197. const docLinesById = new Map(docs.map(doc => [doc._id, doc.lines]))
  198. return docLinesById
  199. }
  200. async function _copyFiles(sourceEntries, sourceProject, targetProject) {
  201. const sourceHistoryId = sourceProject.overleaf?.history?.id
  202. const targetHistoryId = targetProject.overleaf?.history?.id
  203. if (!sourceHistoryId) {
  204. throw new OError('missing history id', { sourceProject })
  205. }
  206. if (!targetHistoryId) {
  207. throw new OError('missing history id', { targetProject })
  208. }
  209. const targetEntries = await promiseMapWithLimit(
  210. 5,
  211. sourceEntries,
  212. async sourceEntry => {
  213. const sourceFile = sourceEntry.file
  214. const path = sourceEntry.path
  215. const file = new File({
  216. name: SafePath.clean(sourceFile.name),
  217. hash: sourceFile.hash,
  218. })
  219. if (sourceFile.linkedFileData != null) {
  220. file.linkedFileData = sourceFile.linkedFileData
  221. file.created = sourceFile.created
  222. }
  223. try {
  224. await HistoryManager.promises.copyBlob(
  225. sourceHistoryId,
  226. targetHistoryId,
  227. file.hash
  228. )
  229. return { createdBlob: true, file, path }
  230. } catch (err) {
  231. throw OError.tag(err, 'unexpected error copying blob', {
  232. sourceProjectId: sourceProject._id,
  233. targetProjectId: targetProject._id,
  234. sourceFile,
  235. sourceHistoryId,
  236. })
  237. }
  238. }
  239. )
  240. return targetEntries
  241. }
  242. async function _setRootDoc(projectId, path) {
  243. const { element: rootDoc } = await ProjectLocator.promises.findElementByPath({
  244. project_id: projectId,
  245. path,
  246. exactCaseMatch: true,
  247. })
  248. await ProjectEntityUpdateHandler.promises.setRootDoc(projectId, rootDoc._id)
  249. }
  250. async function _notifyDocumentUpdater(project, userId, changes) {
  251. const projectHistoryId =
  252. project.overleaf && project.overleaf.history && project.overleaf.history.id
  253. await DocumentUpdaterHandler.promises.updateProjectStructure(
  254. project._id,
  255. projectHistoryId,
  256. userId,
  257. changes,
  258. null
  259. )
  260. }