ProjectDuplicator.js 8.3 KB

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