From f4b176c93d0be35a59b66a6fce8b5ee217948c14 Mon Sep 17 00:00:00 2001 From: Alf Eaton Date: Thu, 15 Feb 2024 09:48:48 +0000 Subject: [PATCH] Preserve folder structure when uploading folders (#16502) GitOrigin-RevId: 791233ce1e68920a4f2d7042ed4c60ca3f4be8fb --- .../Uploads/ProjectUploadController.js | 263 +++++++++--------- .../app/src/infrastructure/ExpressLocals.js | 1 + services/web/cypress/support/ct/window.ts | 2 + .../web/frontend/extracted-translations.json | 5 + .../file-tree-upload-conflicts.tsx | 135 +++++++++ .../modes/file-tree-upload-doc.tsx | 154 +++++----- .../contexts/file-tree-draggable.tsx | 11 +- .../file-tree/util/is-acceptable-file.ts | 9 + .../util/is-name-unique-in-folder.js | 13 - .../util/is-name-unique-in-folder.ts | 28 ++ .../web/frontend/stories/decorators/scope.tsx | 2 + .../stylesheets/app/editor/file-tree.less | 4 + services/web/locales/en.json | 5 + .../Uploads/ProjectUploadControllerTests.js | 55 ++++ services/web/types/exposed-settings.ts | 1 + 15 files changed, 473 insertions(+), 215 deletions(-) create mode 100644 services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-upload-conflicts.tsx create mode 100644 services/web/frontend/js/features/file-tree/util/is-acceptable-file.ts delete mode 100644 services/web/frontend/js/features/file-tree/util/is-name-unique-in-folder.js create mode 100644 services/web/frontend/js/features/file-tree/util/is-name-unique-in-folder.ts diff --git a/services/web/app/src/Features/Uploads/ProjectUploadController.js b/services/web/app/src/Features/Uploads/ProjectUploadController.js index 228123daa1..d0429b24e9 100644 --- a/services/web/app/src/Features/Uploads/ProjectUploadController.js +++ b/services/web/app/src/Features/Uploads/ProjectUploadController.js @@ -1,16 +1,3 @@ -/* eslint-disable - max-len, - no-unused-vars, -*/ -// TODO: This file was created by bulk-decaffeinate. -// Fix any style issues and re-enable lint. -/* - * decaffeinate suggestions: - * DS102: Remove unnecessary code created because of implicit returns - * DS207: Consider shorter variations of null checks - * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md - */ -let ProjectUploadController const logger = require('@overleaf/logger') const metrics = require('@overleaf/metrics') const fs = require('fs') @@ -18,13 +5,16 @@ const Path = require('path') const FileSystemImportManager = require('./FileSystemImportManager') const ProjectUploadManager = require('./ProjectUploadManager') const SessionManager = require('../Authentication/SessionManager') +const EditorController = require('../Editor/EditorController') +const ProjectLocator = require('../Project/ProjectLocator') const Settings = require('@overleaf/settings') const { InvalidZipFileError } = require('./ArchiveErrors') const multer = require('multer') -const _ = require('lodash') +const { defaultsDeep } = require('lodash') +const { expressify } = require('@overleaf/promise-utils') const upload = multer( - _.defaultsDeep( + defaultsDeep( { dest: Settings.path.uploadFolder, limits: { @@ -35,120 +25,141 @@ const upload = multer( ) ) -module.exports = ProjectUploadController = { - uploadProject(req, res, next) { - const timer = new metrics.Timer('project-upload') - const userId = SessionManager.getLoggedInUserId(req.session) - const { path } = req.file - const name = Path.basename(req.body.name, '.zip') - return ProjectUploadManager.createProjectFromZipArchive( - userId, - name, - path, - function (error, project) { - fs.unlink(path, function () {}) - timer.done() - if (error != null) { - logger.error( - { err: error, filePath: path, fileName: name }, - 'error uploading project' - ) - if (error instanceof InvalidZipFileError) { - return res.status(422).json({ - success: false, - error: req.i18n.translate(error.message), - }) - } else { - return res.status(500).json({ - success: false, - error: req.i18n.translate('upload_failed'), - }) - } +function uploadProject(req, res, next) { + const timer = new metrics.Timer('project-upload') + const userId = SessionManager.getLoggedInUserId(req.session) + const { path } = req.file + const name = Path.basename(req.body.name, '.zip') + return ProjectUploadManager.createProjectFromZipArchive( + userId, + name, + path, + function (error, project) { + fs.unlink(path, function () {}) + timer.done() + if (error != null) { + logger.error( + { err: error, filePath: path, fileName: name }, + 'error uploading project' + ) + if (error instanceof InvalidZipFileError) { + return res.status(422).json({ + success: false, + error: req.i18n.translate(error.message), + }) } else { - return res.json({ success: true, project_id: project._id }) - } - } - ) - }, - - uploadFile(req, res, next) { - const timer = new metrics.Timer('file-upload') - const name = req.body.name - const path = req.file != null ? req.file.path : undefined - const projectId = req.params.Project_id - const { folder_id: folderId } = req.query - if (name == null || name.length === 0 || name.length > 150) { - return res.status(422).json({ - success: false, - error: 'invalid_filename', - }) - } - const userId = SessionManager.getLoggedInUserId(req.session) - - return FileSystemImportManager.addEntity( - userId, - projectId, - folderId, - name, - path, - true, - function (error, entity) { - fs.unlink(path, function () {}) - timer.done() - if (error != null) { - if (error.name === 'InvalidNameError') { - return res.status(422).json({ - success: false, - error: 'invalid_filename', - }) - } else if (error.message === 'project_has_too_many_files') { - return res.status(422).json({ - success: false, - error: 'project_has_too_many_files', - }) - } else if (error.message === 'folder_not_found') { - return res.status(422).json({ - success: false, - error: 'folder_not_found', - }) - } else { - logger.error( - { - err: error, - projectId, - filePath: path, - fileName: name, - folderId, - }, - 'error uploading file' - ) - return res.status(422).json({ success: false }) - } - } else { - return res.json({ - success: true, - entity_id: entity != null ? entity._id : undefined, - entity_type: entity != null ? entity.type : undefined, + return res.status(500).json({ + success: false, + error: req.i18n.translate('upload_failed'), }) } + } else { + return res.json({ success: true, project_id: project._id }) } - ) - }, - - multerMiddleware(req, res, next) { - if (upload == null) { - return res - .status(500) - .json({ success: false, error: req.i18n.translate('upload_failed') }) } - return upload.single('qqfile')(req, res, function (err) { - if (err instanceof multer.MulterError && err.code === 'LIMIT_FILE_SIZE') { - return res - .status(422) - .json({ success: false, error: req.i18n.translate('file_too_large') }) - } - - return next(err) - }) - }, + ) +} + +async function uploadFile(req, res, next) { + const timer = new metrics.Timer('file-upload') + const name = req.body.name + const path = req.file?.path + const projectId = req.params.Project_id + let { folder_id: folderId } = req.query + if (name == null || name.length === 0 || name.length > 150) { + return res.status(422).json({ + success: false, + error: 'invalid_filename', + }) + } + + // preserve the directory structure from an uploaded folder + const { relativePath } = req.body + // NOTE: Uppy sends a "null" string for `relativePath` when the file is not nested in a folder + if (relativePath && relativePath !== 'null') { + const { path } = await ProjectLocator.promises.findElement({ + project_id: projectId, + element_id: folderId, + type: 'folder', + }) + const { lastFolder } = await EditorController.promises.mkdirp( + projectId, + Path.dirname(Path.join('/', path.fileSystem, relativePath)) + ) + folderId = lastFolder._id + } + + const userId = SessionManager.getLoggedInUserId(req.session) + + return FileSystemImportManager.addEntity( + userId, + projectId, + folderId, + name, + path, + true, + function (error, entity) { + fs.unlink(path, function () {}) + timer.done() + if (error != null) { + if (error.name === 'InvalidNameError') { + return res.status(422).json({ + success: false, + error: 'invalid_filename', + }) + } else if (error.message === 'project_has_too_many_files') { + return res.status(422).json({ + success: false, + error: 'project_has_too_many_files', + }) + } else if (error.message === 'folder_not_found') { + return res.status(422).json({ + success: false, + error: 'folder_not_found', + }) + } else { + logger.error( + { + err: error, + projectId, + filePath: path, + fileName: name, + folderId, + }, + 'error uploading file' + ) + return res.status(422).json({ success: false }) + } + } else { + return res.json({ + success: true, + entity_id: entity?._id, + entity_type: entity?.type, + }) + } + } + ) +} + +function multerMiddleware(req, res, next) { + if (upload == null) { + return res + .status(500) + .json({ success: false, error: req.i18n.translate('upload_failed') }) + } + return upload.single('qqfile')(req, res, function (err) { + if (err instanceof multer.MulterError && err.code === 'LIMIT_FILE_SIZE') { + return res + .status(422) + .json({ success: false, error: req.i18n.translate('file_too_large') }) + } + + return next(err) + }) +} + +module.exports = { + uploadProject, + uploadFile: expressify(uploadFile), + multerMiddleware, } diff --git a/services/web/app/src/infrastructure/ExpressLocals.js b/services/web/app/src/infrastructure/ExpressLocals.js index cd7ab842c3..dd6a07d25d 100644 --- a/services/web/app/src/infrastructure/ExpressLocals.js +++ b/services/web/app/src/infrastructure/ExpressLocals.js @@ -403,6 +403,7 @@ module.exports = function (webRouter, privateApiRouter, publicApiRouter) { textExtensions: Settings.textExtensions, editableFilenames: Settings.editableFilenames, validRootDocExtensions: Settings.validRootDocExtensions, + fileIgnorePattern: Settings.fileIgnorePattern, sentryAllowedOriginRegex: Settings.sentry.allowedOriginRegex, sentryDsn: Settings.sentry.publicDSN, sentryEnvironment: Settings.sentry.environment, diff --git a/services/web/cypress/support/ct/window.ts b/services/web/cypress/support/ct/window.ts index 6a2529d2c9..7f1ab17b4a 100644 --- a/services/web/cypress/support/ct/window.ts +++ b/services/web/cypress/support/ct/window.ts @@ -2,4 +2,6 @@ window.i18n = { currentLangCode: 'en' } window.ExposedSettings = { appName: 'Overleaf', validRootDocExtensions: ['tex', 'Rtex', 'ltx', 'Rnw'], + fileIgnorePattern: + '**/{{__MACOSX,.git,.texpadtmp,.R}{,/**},.!(latexmkrc),*.{dvi,aux,log,toc,out,pdfsync,synctex,synctex(busy),fdb_latexmk,fls,nlo,ind,glo,gls,glg,bbl,blg,doc,docx,gz,swp}}', } as typeof window.ExposedSettings diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index eb90ad1d52..7da2bd43bb 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -284,6 +284,8 @@ "display_deleted_user": "", "do_this_later": "", "do_you_want_to_change_your_primary_email_address_to": "", + "do_you_want_to_overwrite_it": "", + "do_you_want_to_overwrite_it_plural": "", "do_you_want_to_overwrite_them": "", "document_too_long": "", "document_too_long_detail": "", @@ -851,6 +853,7 @@ "overleaf_labs": "", "overview": "", "overwrite": "", + "overwriting_the_original_folder": "", "owned_by_x": "", "owner": "", "page_current": "", @@ -1281,6 +1284,8 @@ "thanks_for_subscribing_you_help_sl": "", "thanks_settings_updated": "", "the_following_files_already_exist_in_this_project": "", + "the_following_folder_already_exists_in_this_project": "", + "the_following_folder_already_exists_in_this_project_plural": "", "the_target_folder_could_not_be_found": "", "the_width_you_choose_here_is_based_on_the_width_of_the_text_in_your_document": "", "their_projects_will_be_transferred_to_another_user": "", diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-upload-conflicts.tsx b/services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-upload-conflicts.tsx new file mode 100644 index 0000000000..e3ba6fa590 --- /dev/null +++ b/services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-upload-conflicts.tsx @@ -0,0 +1,135 @@ +import { FileTreeEntity } from '../../../../../../types/file-tree-entity' +import { useTranslation } from 'react-i18next' +import { useProjectContext } from '@/shared/context/project-context' +import { useCallback } from 'react' +import { syncDelete } from '@/features/file-tree/util/sync-mutation' +import { Button } from 'react-bootstrap' + +export function UploadConflicts({ + cancel, + conflicts, + folderConflicts, + handleOverwrite, + setError, +}: { + cancel: () => void + conflicts: FileTreeEntity[] + folderConflicts: FileTreeEntity[] + handleOverwrite: () => void + setError: (error: string) => void +}) { + const { t } = useTranslation() + + // ensure that no uploads happen while there are folder conflicts + if (folderConflicts.length > 0) { + return ( + + ) + } + + return ( +
+ {conflicts.length > 0 && ( + <> +

+ {t('the_following_files_already_exist_in_this_project')} +

+ +
    + {conflicts.map((conflict, index) => ( +
  • + {conflict.name} +
  • + ))} +
+ + )} + +

+ {t('do_you_want_to_overwrite_them')} +

+ +

+ +   + +

+
+ ) +} + +function FolderUploadConflicts({ + cancel, + handleOverwrite, + folderConflicts, + setError, +}: { + cancel: () => void + handleOverwrite: () => void + folderConflicts: FileTreeEntity[] + setError: (error: string) => void +}) { + const { t } = useTranslation() + const { _id: projectId } = useProjectContext() + + const deleteAndRetry = useCallback(async () => { + // TODO: confirm deletion? + + try { + await Promise.all( + folderConflicts.map( + entity => syncDelete(projectId, 'folder', entity._id) // TODO: might be a file! + ) + ) + + handleOverwrite() + } catch (error: any) { + setError(error.message) + } + }, [setError, folderConflicts, handleOverwrite, projectId]) + + return ( +
+

+ {t('the_following_folder_already_exists_in_this_project', { + count: folderConflicts.length, + })} +

+ +
    + {folderConflicts.map((entity, index) => ( +
  • + {entity.name} +
  • + ))} +
+ +

+ {t('overwriting_the_original_folder')} +
+ {t('do_you_want_to_overwrite_it', { + count: folderConflicts.length, + })} +

+ +

+ +   + +

+
+ ) +} diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-upload-doc.tsx b/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-upload-doc.tsx index 5fe41f8877..6015a49e09 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-upload-doc.tsx +++ b/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-upload-doc.tsx @@ -1,37 +1,80 @@ import { useTranslation } from 'react-i18next' -import { Button } from 'react-bootstrap' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import Uppy from '@uppy/core' import XHRUpload from '@uppy/xhr-upload' import { Dashboard } from '@uppy/react' import { useFileTreeActionable } from '../../../contexts/file-tree-actionable' import { useProjectContext } from '../../../../../shared/context/project-context' import * as eventTracking from '../../../../../infrastructure/event-tracking' - import '@uppy/core/dist/style.css' import '@uppy/dashboard/dist/style.css' import { refreshProjectMetadata } from '../../../util/api' import ErrorMessage from '../error-message' import { debugConsole } from '@/utils/debugging' +import { isAcceptableFile } from '@/features/file-tree/util/is-acceptable-file' +import { findByNameInFolder } from '@/features/file-tree/util/is-name-unique-in-folder' +import { useFileTreeData } from '@/shared/context/file-tree-data-context' +import { FileTreeEntity } from '../../../../../../../types/file-tree-entity' +import { UploadConflicts } from '@/features/file-tree/components/file-tree-create/file-tree-upload-conflicts' export default function FileTreeUploadDoc() { - const { parentFolderId, cancel, isDuplicate, droppedFiles, setDroppedFiles } = + const { parentFolderId, cancel, droppedFiles, setDroppedFiles } = useFileTreeActionable() + const { fileTreeData } = useFileTreeData() const { _id: projectId } = useProjectContext() const [error, setError] = useState() - const [conflicts, setConflicts] = useState([]) + const [conflicts, setConflicts] = useState([]) + const [folderConflicts, setFolderConflicts] = useState([]) const [overwrite, setOverwrite] = useState(false) const maxNumberOfFiles = 40 const maxFileSize = window.ExposedSettings.maxUploadSize // calculate conflicts - const buildConflicts = (files: Record) => - Object.values(files).filter(file => - isDuplicate(file.meta.targetFolderId ?? parentFolderId, file.meta.name) - ) + const buildConflicts = (files: Record) => { + const conflicts = new Set() + + for (const file of Object.values(files)) { + const { name, relativePath } = file.meta + + if (!relativePath) { + const targetFolderId = file.meta.targetFolderId ?? parentFolderId + const duplicate = findByNameInFolder(fileTreeData, targetFolderId, name) + if (duplicate) { + conflicts.add(duplicate) + } + } + } + + return [...conflicts] + } + + const buildFolderConflicts = (files: Record) => { + const conflicts = new Set() + + for (const file of Object.values(files)) { + const { relativePath } = file.meta + + if (relativePath) { + const [rootName] = relativePath.replace(/^\//, '').split('/') + if (!conflicts.has(rootName)) { + const targetFolderId = file.meta.targetFolderId ?? parentFolderId + const duplicateEntity = findByNameInFolder( + fileTreeData, + targetFolderId, + rootName + ) + if (duplicateEntity) { + conflicts.add(duplicateEntity) + } + } + } + } + + return [...conflicts] + } const buildEndpoint = (projectId: string, targetFolderId: string) => { let endpoint = `/project/${projectId}/upload` @@ -43,6 +86,11 @@ export default function FileTreeUploadDoc() { return endpoint } + const overwriteRef = useRef(overwrite) + useEffect(() => { + overwriteRef.current = overwrite + }, [overwrite]) + // initialise the Uppy object const [uppy] = useState(() => { const endpoint = buildEndpoint(projectId, parentFolderId) @@ -55,24 +103,21 @@ export default function FileTreeUploadDoc() { maxNumberOfFiles, maxFileSize: maxFileSize || null, }, - onBeforeUpload: files => { - let result = true - - setOverwrite(overwrite => { - if (!overwrite) { - setConflicts(() => { - const conflicts = buildConflicts(files) - - result = conflicts.length === 0 - - return conflicts - }) - } - - return overwrite - }) - - return result + onBeforeFileAdded(file) { + if (!isAcceptableFile(file)) { + return false + } + }, + onBeforeUpload(files) { + if (overwriteRef.current) { + return true + } else { + const conflicts = buildConflicts(files) + const folderConflicts = buildFolderConflicts(files) + setConflicts(conflicts) + setFolderConflicts(folderConflicts) + return conflicts.length === 0 && folderConflicts.length === 0 + } }, autoProceed: true, }) @@ -135,6 +180,7 @@ export default function FileTreeUploadDoc() { source: 'Local', isRemote: false, meta: { + relativePath: file.relativePath, targetFolderId: droppedFiles.targetFolderId, }, }) @@ -163,7 +209,8 @@ export default function FileTreeUploadDoc() { }, [uppy]) // whether to show a message about conflicting files - const showConflicts = !overwrite && conflicts.length > 0 + const showConflicts = + !overwrite && (conflicts.length > 0 || folderConflicts.length > 0) return ( <> @@ -175,7 +222,9 @@ export default function FileTreeUploadDoc() { ) : ( @@ -227,45 +279,3 @@ function UploadErrorMessage({ return } } - -function UploadConflicts({ - cancel, - conflicts, - handleOverwrite, -}: { - cancel: () => void - conflicts: any[] - handleOverwrite: () => void -}) { - const { t } = useTranslation() - - return ( -
-

- {t('the_following_files_already_exist_in_this_project')} -

- -
    - {conflicts.map((conflict, index) => ( -
  • - {conflict.meta.name} -
  • - ))} -
- -

- {t('do_you_want_to_overwrite_them')} -

- -

- -   - -

-
- ) -} diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-draggable.tsx b/services/web/frontend/js/features/file-tree/contexts/file-tree-draggable.tsx index df2c72b1c1..2b766dd084 100644 --- a/services/web/frontend/js/features/file-tree/contexts/file-tree-draggable.tsx +++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-draggable.tsx @@ -15,6 +15,7 @@ import { useFileTreeActionable } from './file-tree-actionable' import { useFileTreeData } from '@/shared/context/file-tree-data-context' import { useFileTreeSelectable } from '../contexts/file-tree-selectable' import { useEditorContext } from '@/shared/context/editor-context' +import { isAcceptableFile } from '@/features/file-tree/util/is-acceptable-file' const DRAGGABLE_TYPE = 'ENTITY' export const FileTreeDraggableProvider: FC<{ @@ -120,10 +121,12 @@ export function useDroppable(targetEntityId: string) { } // native file(s) dragged in from outside - getDroppedFiles(item as unknown as DataTransfer).then(files => { - setDroppedFiles({ files, targetFolderId: targetEntityId }) - startUploadingDocOrFile() - }) + getDroppedFiles(item as unknown as DataTransfer) + .then(files => files.filter(isAcceptableFile)) + .then(files => { + setDroppedFiles({ files, targetFolderId: targetEntityId }) + startUploadingDocOrFile() + }) }, collect(monitor) { return { diff --git a/services/web/frontend/js/features/file-tree/util/is-acceptable-file.ts b/services/web/frontend/js/features/file-tree/util/is-acceptable-file.ts new file mode 100644 index 0000000000..417724be64 --- /dev/null +++ b/services/web/frontend/js/features/file-tree/util/is-acceptable-file.ts @@ -0,0 +1,9 @@ +import { Minimatch } from 'minimatch' + +const fileIgnoreMatcher = new Minimatch( + window.ExposedSettings.fileIgnorePattern, + { nocase: true, dot: true } +) + +export const isAcceptableFile = (file: { name: string }) => + !fileIgnoreMatcher.match(file.name) diff --git a/services/web/frontend/js/features/file-tree/util/is-name-unique-in-folder.js b/services/web/frontend/js/features/file-tree/util/is-name-unique-in-folder.js deleted file mode 100644 index d8d44a1520..0000000000 --- a/services/web/frontend/js/features/file-tree/util/is-name-unique-in-folder.js +++ /dev/null @@ -1,13 +0,0 @@ -import { findInTree } from '../util/find-in-tree' - -export function isNameUniqueInFolder(tree, parentFolderId, name) { - if (tree._id !== parentFolderId) { - tree = findInTree(tree, parentFolderId).entity - } - - if (tree.docs.some(entity => entity.name === name)) return false - if (tree.fileRefs.some(entity => entity.name === name)) return false - if (tree.folders.some(entity => entity.name === name)) return false - - return true -} diff --git a/services/web/frontend/js/features/file-tree/util/is-name-unique-in-folder.ts b/services/web/frontend/js/features/file-tree/util/is-name-unique-in-folder.ts new file mode 100644 index 0000000000..a6a03ba804 --- /dev/null +++ b/services/web/frontend/js/features/file-tree/util/is-name-unique-in-folder.ts @@ -0,0 +1,28 @@ +import { findInTree } from '../util/find-in-tree' +import { Folder } from '../../../../../types/folder' +import { Doc } from '../../../../../types/doc' +import { FileRef } from '../../../../../types/file-ref' + +export function isNameUniqueInFolder( + tree: Folder, + parentFolderId: string, + name: string +): boolean { + return !findByNameInFolder(tree, parentFolderId, name) +} + +export function findByNameInFolder( + tree: Folder, + parentFolderId: string, + name: string +): Doc | FileRef | Folder | undefined { + if (tree._id !== parentFolderId) { + tree = findInTree(tree, parentFolderId).entity + } + + return ( + tree.docs.find(entity => entity.name === name) || + tree.fileRefs.find(entity => entity.name === name) || + tree.folders.find(entity => entity.name === name) + ) +} diff --git a/services/web/frontend/stories/decorators/scope.tsx b/services/web/frontend/stories/decorators/scope.tsx index 164af54289..ea431e2e3a 100644 --- a/services/web/frontend/stories/decorators/scope.tsx +++ b/services/web/frontend/stories/decorators/scope.tsx @@ -194,6 +194,8 @@ const initialize = () => { ], editableFilenames: ['latexmkrc', '.latexmkrc', 'makefile', 'gnumakefile'], validRootDocExtensions: ['tex', 'Rtex', 'ltx', 'Rnw'], + fileIgnorePattern: + '**/{{__MACOSX,.git,.texpadtmp,.R}{,/**},.!(latexmkrc),*.{dvi,aux,log,toc,out,pdfsync,synctex,synctex(busy),fdb_latexmk,fls,nlo,ind,glo,gls,glg,bbl,blg,doc,docx,gz,swp}}', projectUploadTimeout: 12000, } diff --git a/services/web/frontend/stylesheets/app/editor/file-tree.less b/services/web/frontend/stylesheets/app/editor/file-tree.less index b40dcf8a4a..43d3d6bab3 100644 --- a/services/web/frontend/stylesheets/app/editor/file-tree.less +++ b/services/web/frontend/stylesheets/app/editor/file-tree.less @@ -534,4 +534,8 @@ font-size: inherit; } } + + .uppy-Dashboard-AddFiles-title { + width: 26em; // sized to create a wrap between the sentences + } } diff --git a/services/web/locales/en.json b/services/web/locales/en.json index bafc5c5d1b..f19b77b766 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -421,6 +421,8 @@ "do_not_link_accounts": "Don’t link accounts", "do_this_later": "I’ll do this later", "do_you_want_to_change_your_primary_email_address_to": "Do you want to change your primary email address to __email__?", + "do_you_want_to_overwrite_it": "Do you want to overwrite it?", + "do_you_want_to_overwrite_it_plural": "Do you want to overwrite them?", "do_you_want_to_overwrite_them": "Do you want to overwrite them?", "document_history": "Document history", "document_too_long": "Document Too Long", @@ -1283,6 +1285,7 @@ "overleaf_labs": "Overleaf Labs", "overview": "Overview", "overwrite": "Overwrite", + "overwriting_the_original_folder": "Overwriting the original folder will delete it and all the files it contains.", "owned_by_x": "owned by __x__", "owner": "Owner", "page_current": "Page __page__, Current Page", @@ -1882,6 +1885,8 @@ "the_easy_online_collab_latex_editor": "The easy to use, online, collaborative LaTeX editor", "the_file_supplied_is_of_an_unsupported_type ": "The link to open this content on Overleaf pointed to the wrong kind of file. Valid file types are .tex documents and .zip files. If this keeps happening for links on a particular site, please report this to them.", "the_following_files_already_exist_in_this_project": "The following files already exist in this project:", + "the_following_folder_already_exists_in_this_project": "The following folder already exists in this project:", + "the_following_folder_already_exists_in_this_project_plural": "The following folders already exist in this project:", "the_project_that_contains_this_file_is_not_shared_with_you": "The project that contains this file is not shared with you", "the_requested_conversion_job_was_not_found": "The link to open this content on Overleaf specified a conversion job that could not be found. It’s possible that the job has expired and needs to be run again. If this keeps happening for links on a particular site, please report this to them.", "the_requested_publisher_was_not_found": "The link to open this content on Overleaf specified a publisher that could not be found. If this keeps happening for links on a particular site, please report this to them.", diff --git a/services/web/test/unit/src/Uploads/ProjectUploadControllerTests.js b/services/web/test/unit/src/Uploads/ProjectUploadControllerTests.js index 935882cce3..f08a6ebb78 100644 --- a/services/web/test/unit/src/Uploads/ProjectUploadControllerTests.js +++ b/services/web/test/unit/src/Uploads/ProjectUploadControllerTests.js @@ -40,6 +40,12 @@ describe('ProjectUploadController', function () { this.SessionManager = { getLoggedInUserId: sinon.stub().returns(this.user_id), } + this.ProjectLocator = { + promises: {}, + } + this.EditorController = { + promises: {}, + } return (this.ProjectUploadController = SandboxedModule.require(modulePath, { requires: { @@ -50,6 +56,8 @@ describe('ProjectUploadController', function () { '@overleaf/metrics': this.metrics, '../Authentication/SessionManager': this.SessionManager, './ArchiveErrors': ArchiveErrors, + '../Project/ProjectLocator': this.ProjectLocator, + '../Editor/EditorController': this.EditorController, fs: (this.fs = {}), }, })) @@ -224,6 +232,53 @@ describe('ProjectUploadController', function () { }) }) + describe('with folder structure', function () { + beforeEach(function (done) { + this.entity = { + _id: '1234', + type: 'file', + } + this.FileSystemImportManager.addEntity = sinon + .stub() + .callsArgWith(6, null, this.entity) + this.ProjectLocator.promises.findElement = sinon.stub().resolves({ + path: { fileSystem: '/test' }, + }) + this.EditorController.promises.mkdirp = sinon.stub().resolves({ + lastFolder: { _id: 'folder-id' }, + }) + this.req.body.relativePath = 'foo/bar/' + this.name + this.res.json = data => { + expect(data.success).to.be.true + done() + } + this.ProjectUploadController.uploadFile(this.req, this.res) + }) + + it('should insert the file', function () { + this.ProjectLocator.promises.findElement.should.be.calledOnceWithExactly( + { + project_id: this.project_id, + element_id: this.folder_id, + type: 'folder', + } + ) + + this.EditorController.promises.mkdirp.should.be.calledWith( + this.project_id, + '/test/foo/bar' + ) + + this.FileSystemImportManager.addEntity.should.be.calledOnceWith( + this.user_id, + this.project_id, + 'folder-id', + this.name, + this.path + ) + }) + }) + describe('when FileSystemImportManager.addEntity returns a generic error', function () { beforeEach(function () { this.FileSystemImportManager.addEntity = sinon diff --git a/services/web/types/exposed-settings.ts b/services/web/types/exposed-settings.ts index 2b74fdb8a5..4bf076b2a8 100644 --- a/services/web/types/exposed-settings.ts +++ b/services/web/types/exposed-settings.ts @@ -42,6 +42,7 @@ export type ExposedSettings = { textExtensions: string[] editableFilenames: string[] validRootDocExtensions: string[] + fileIgnorePattern: string templateLinks?: TemplateLink[] labsEnabled: boolean }