Merge pull request #19400 from overleaf/dp-duplicate-file-folder-name

Improvements to handling of file/folder upload conflicts

GitOrigin-RevId: 526edf30dfbaec7ee1e03ffd156365f09be25e86
This commit is contained in:
David 2024-07-24 14:38:51 +01:00 committed by Copybot
parent c07d2f3fa2
commit d7357b4d62
14 changed files with 203 additions and 94 deletions

View file

@ -65,6 +65,12 @@ async function handleError(error, req, res, next) {
res.status(400) res.status(400)
plainTextResponse(res, error.message) plainTextResponse(res, error.message)
} }
} else if (error instanceof Errors.DuplicateNameError) {
req.logger.setLevel('warn')
if (shouldSendErrorResponse) {
res.status(400)
plainTextResponse(res, error.message)
}
} else if (error instanceof Errors.InvalidNameError) { } else if (error instanceof Errors.InvalidNameError) {
req.logger.setLevel('warn') req.logger.setLevel('warn')
if (shouldSendErrorResponse) { if (shouldSendErrorResponse) {

View file

@ -38,6 +38,8 @@ class ServiceNotConfiguredError extends BackwardCompatibleError {}
class TooManyRequestsError extends BackwardCompatibleError {} class TooManyRequestsError extends BackwardCompatibleError {}
class DuplicateNameError extends OError {}
class InvalidNameError extends BackwardCompatibleError {} class InvalidNameError extends BackwardCompatibleError {}
class UnsupportedFileTypeError extends BackwardCompatibleError {} class UnsupportedFileTypeError extends BackwardCompatibleError {}
@ -270,6 +272,7 @@ module.exports = {
ForbiddenError, ForbiddenError,
ServiceNotConfiguredError, ServiceNotConfiguredError,
TooManyRequestsError, TooManyRequestsError,
DuplicateNameError,
InvalidNameError, InvalidNameError,
UnsupportedFileTypeError, UnsupportedFileTypeError,
FileTooLargeError, FileTooLargeError,

View file

@ -222,8 +222,8 @@ const RestoreManager = {
try { try {
return await addEntityWithName(basename) return await addEntityWithName(basename)
} catch (error) { } catch (error) {
if (error instanceof Errors.InvalidNameError) { if (error instanceof Errors.DuplicateNameError) {
// likely a duplicate name, so try with a prefix // Duplicate name, so try with a prefix
const date = moment(new Date()).format('Do MMM YY H:mm:ss') const date = moment(new Date()).format('Do MMM YY H:mm:ss')
// Move extension to the end so the file type is preserved // Move extension to the end so the file type is preserved
const extension = Path.extname(basename) const extension = Path.extname(basename)

View file

@ -618,7 +618,7 @@ function _checkValidElementName(folder, name) {
.concat(folder.folders || []) .concat(folder.folders || [])
for (const element of elements) { for (const element of elements) {
if (element.name === name) { if (element.name === name) {
throw new Errors.InvalidNameError('file already exists') throw new Errors.DuplicateNameError('file already exists')
} }
} }
} }

View file

@ -146,7 +146,10 @@ async function updateProjectContents(req, res, next) {
try { try {
await UpdateMerger.promises.mergeUpdate(null, projectId, path, req, source) await UpdateMerger.promises.mergeUpdate(null, projectId, path, req, source)
} catch (error) { } catch (error) {
if (error.constructor === Errors.InvalidNameError) { if (
error instanceof Errors.InvalidNameError ||
error instanceof Errors.DuplicateNameError
) {
return res.sendStatus(422) return res.sendStatus(422)
} else { } else {
throw error throw error

View file

@ -12,6 +12,7 @@ const { InvalidZipFileError } = require('./ArchiveErrors')
const multer = require('multer') const multer = require('multer')
const { defaultsDeep } = require('lodash') const { defaultsDeep } = require('lodash')
const { expressify } = require('@overleaf/promise-utils') const { expressify } = require('@overleaf/promise-utils')
const { DuplicateNameError } = require('../Errors/Errors')
const upload = multer( const upload = multer(
defaultsDeep( defaultsDeep(
@ -107,6 +108,11 @@ async function uploadFile(req, res, next) {
success: false, success: false,
error: 'invalid_filename', error: 'invalid_filename',
}) })
} else if (error instanceof DuplicateNameError) {
return res.status(422).json({
success: false,
error: 'duplicate_file_name',
})
} else if (error.message === 'project_has_too_many_files') { } else if (error.message === 'project_has_too_many_files') {
return res.status(422).json({ return res.status(422).json({
success: false, success: false,

View file

@ -428,6 +428,7 @@
"file_name_figure_modal": "", "file_name_figure_modal": "",
"file_name_in_this_project": "", "file_name_in_this_project": "",
"file_name_in_this_project_figure_modal": "", "file_name_in_this_project_figure_modal": "",
"file_or_folder_name_already_exists": "",
"file_outline": "", "file_outline": "",
"file_size": "", "file_size": "",
"files_cannot_include_invalid_characters": "", "files_cannot_include_invalid_characters": "",
@ -1385,6 +1386,7 @@
"thanks_for_subscribing_you_help_sl": "", "thanks_for_subscribing_you_help_sl": "",
"thanks_settings_updated": "", "thanks_settings_updated": "",
"the_following_files_already_exist_in_this_project": "", "the_following_files_already_exist_in_this_project": "",
"the_following_files_and_folders_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_folder_already_exists_in_this_project_plural": "",
"the_original_text_has_changed": "", "the_original_text_has_changed": "",

View file

@ -42,6 +42,13 @@ export default function ErrorMessage({ error }) {
</DangerMessage> </DangerMessage>
) )
case 'duplicate_file_name':
return (
<DangerMessage>
{t('file_or_folder_name_already_exists')}
</DangerMessage>
)
case 'rate-limit-hit': case 'rate-limit-hit':
return ( return (
<DangerMessage> <DangerMessage>

View file

@ -4,90 +4,110 @@ import { useProjectContext } from '@/shared/context/project-context'
import { useCallback } from 'react' import { useCallback } from 'react'
import { syncDelete } from '@/features/file-tree/util/sync-mutation' import { syncDelete } from '@/features/file-tree/util/sync-mutation'
import { Button } from 'react-bootstrap' import { Button } from 'react-bootstrap'
import { TFunction } from 'i18next'
export function UploadConflicts({ export type Conflict = {
entity: FileTreeEntity
type: 'file' | 'folder'
}
const getConflictText = (conflicts: Conflict[], t: TFunction) => {
const hasFolderConflict = conflicts.some(
conflict => conflict.type === 'folder'
)
const hasFileConflict = conflicts.some(conflict => conflict.type === 'file')
if (hasFolderConflict && hasFileConflict) {
return t('the_following_files_and_folders_already_exist_in_this_project')
}
if (hasFolderConflict) {
return t('the_following_folder_already_exists_in_this_project', {
count: conflicts.length,
})
}
return t('the_following_files_already_exist_in_this_project')
}
export function FileUploadConflicts({
cancel, cancel,
conflicts, conflicts,
folderConflicts,
handleOverwrite, handleOverwrite,
setError,
}: { }: {
cancel: () => void cancel: () => void
conflicts: FileTreeEntity[] conflicts: Conflict[]
folderConflicts: FileTreeEntity[]
handleOverwrite: () => void handleOverwrite: () => void
setError: (error: string) => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
// ensure that no uploads happen while there are folder conflicts // Don't allow overwriting folders with files
if (folderConflicts.length > 0) { const hasFolderConflict = conflicts.some(
return ( conflict => conflict.type === 'folder'
<FolderUploadConflicts )
cancel={cancel}
folderConflicts={folderConflicts}
handleOverwrite={handleOverwrite}
setError={setError}
/>
)
}
return ( return (
<div className="small modal-new-file--body-conflict"> <div className="small modal-new-file--body-conflict">
{conflicts.length > 0 && ( {conflicts.length > 0 && (
<> <>
<p className="text-center mb-0"> <p className="text-center mb-0">{getConflictText(conflicts, t)}</p>
{t('the_following_files_already_exist_in_this_project')}
</p>
<ul className="text-center list-unstyled row-spaced-small mt-1"> <ul className="text-center list-unstyled row-spaced-small mt-1">
{conflicts.map((conflict, index) => ( {conflicts.map((conflict, index) => (
<li key={index}> <li key={index}>
<strong>{conflict.name}</strong> <strong>{conflict.entity.name}</strong>
</li> </li>
))} ))}
</ul> </ul>
</> </>
)} )}
<p className="text-center row-spaced-small"> {!hasFolderConflict && (
{t('do_you_want_to_overwrite_them')} <p className="text-center row-spaced-small">
</p> {t('do_you_want_to_overwrite_them')}
</p>
)}
<p className="text-center"> <p className="text-center">
<Button bsStyle={null} className="btn-secondary" onClick={cancel}> <Button bsStyle={null} className="btn-secondary" onClick={cancel}>
{t('cancel')} {t('cancel')}
</Button> </Button>
&nbsp; &nbsp;
<Button bsStyle="danger" onClick={handleOverwrite}> {!hasFolderConflict && (
{t('overwrite')} <Button bsStyle="danger" onClick={handleOverwrite}>
</Button> {t('overwrite')}
</Button>
)}
</p> </p>
</div> </div>
) )
} }
function FolderUploadConflicts({ export function FolderUploadConflicts({
cancel, cancel,
handleOverwrite, handleOverwrite,
folderConflicts, conflicts,
setError, setError,
}: { }: {
cancel: () => void cancel: () => void
handleOverwrite: () => void handleOverwrite: () => void
folderConflicts: FileTreeEntity[] conflicts: Conflict[]
setError: (error: string) => void setError: (error: string) => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { _id: projectId } = useProjectContext() const { _id: projectId } = useProjectContext()
// Don't allow overwriting files with a folder
const hasFileConflict = conflicts.some(conflict => conflict.type === 'file')
const deleteAndRetry = useCallback(async () => { const deleteAndRetry = useCallback(async () => {
// TODO: confirm deletion? // TODO: confirm deletion?
try { try {
await Promise.all( await Promise.all(
folderConflicts.map( conflicts.map(conflict =>
entity => syncDelete(projectId, 'folder', entity._id) // TODO: might be a file! syncDelete(projectId, 'folder', conflict.entity._id)
) )
) )
@ -95,40 +115,40 @@ function FolderUploadConflicts({
} catch (error: any) { } catch (error: any) {
setError(error.message) setError(error.message)
} }
}, [setError, folderConflicts, handleOverwrite, projectId]) }, [setError, conflicts, handleOverwrite, projectId])
return ( return (
<div className="small modal-new-file--body-conflict"> <div className="small modal-new-file--body-conflict">
<p className="text-center mb-0"> <p className="text-center mb-0">{getConflictText(conflicts, t)}</p>
{t('the_following_folder_already_exists_in_this_project', {
count: folderConflicts.length,
})}
</p>
<ul className="text-center list-unstyled row-spaced-small mt-1"> <ul className="text-center list-unstyled row-spaced-small mt-1">
{folderConflicts.map((entity, index) => ( {conflicts.map((conflict, index) => (
<li key={index}> <li key={index}>
<strong>{entity.name}</strong> <strong>{conflict.entity.name}</strong>
</li> </li>
))} ))}
</ul> </ul>
<p className="text-center row-spaced-small"> {!hasFileConflict && (
{t('overwriting_the_original_folder')} <p className="text-center row-spaced-small">
<br /> {t('overwriting_the_original_folder')}
{t('do_you_want_to_overwrite_it', { <br />
count: folderConflicts.length, {t('do_you_want_to_overwrite_it', {
})} count: conflicts.length,
</p> })}
</p>
)}
<p className="text-center"> <p className="text-center">
<Button bsStyle={null} className="btn-secondary" onClick={cancel}> <Button bsStyle={null} className="btn-secondary" onClick={cancel}>
{t('cancel')} {t('cancel')}
</Button> </Button>
&nbsp; &nbsp;
<Button bsStyle="danger" onClick={deleteAndRetry}> {!hasFileConflict && (
{t('overwrite')} <Button bsStyle="danger" onClick={deleteAndRetry}>
</Button> {t('overwrite')}
</Button>
)}
</p> </p>
</div> </div>
) )

View file

@ -12,10 +12,16 @@ import { refreshProjectMetadata } from '../../../util/api'
import ErrorMessage from '../error-message' import ErrorMessage from '../error-message'
import { debugConsole } from '@/utils/debugging' import { debugConsole } from '@/utils/debugging'
import { isAcceptableFile } from '@/features/file-tree/util/is-acceptable-file' import { isAcceptableFile } from '@/features/file-tree/util/is-acceptable-file'
import { findByNameInFolder } from '@/features/file-tree/util/is-name-unique-in-folder' import {
findFileByNameInFolder,
findFolderByNameInFolder,
} from '@/features/file-tree/util/is-name-unique-in-folder'
import { useFileTreeData } from '@/shared/context/file-tree-data-context' import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import { FileTreeEntity } from '../../../../../../../types/file-tree-entity' import {
import { UploadConflicts } from '@/features/file-tree/components/file-tree-create/file-tree-upload-conflicts' Conflict,
FileUploadConflicts,
FolderUploadConflicts,
} from '@/features/file-tree/components/file-tree-create/file-tree-upload-conflicts'
import getMeta from '@/utils/meta' import getMeta from '@/utils/meta'
export default function FileTreeUploadDoc() { export default function FileTreeUploadDoc() {
@ -26,8 +32,8 @@ export default function FileTreeUploadDoc() {
const [error, setError] = useState<string>() const [error, setError] = useState<string>()
const [conflicts, setConflicts] = useState<FileTreeEntity[]>([]) const [conflicts, setConflicts] = useState<Conflict[]>([])
const [folderConflicts, setFolderConflicts] = useState<FileTreeEntity[]>([]) const [folderConflicts, setFolderConflicts] = useState<Conflict[]>([])
const [overwrite, setOverwrite] = useState(false) const [overwrite, setOverwrite] = useState(false)
const maxNumberOfFiles = 180 const maxNumberOfFiles = 180
@ -35,46 +41,79 @@ export default function FileTreeUploadDoc() {
// calculate conflicts // calculate conflicts
const buildConflicts = (files: Record<string, any>) => { const buildConflicts = (files: Record<string, any>) => {
const conflicts = new Set<FileTreeEntity>() const conflicts: Conflict[] = []
for (const file of Object.values(files)) { for (const file of Object.values(files)) {
const { name, relativePath } = file.meta const { name, relativePath } = file.meta
if (!relativePath) { if (!relativePath) {
const targetFolderId = file.meta.targetFolderId ?? parentFolderId const targetFolderId = file.meta.targetFolderId ?? parentFolderId
const duplicate = findByNameInFolder(fileTreeData, targetFolderId, name) const duplicateFile = findFileByNameInFolder(
if (duplicate) { fileTreeData,
conflicts.add(duplicate) targetFolderId,
name
)
if (duplicateFile) {
conflicts.push({
entity: duplicateFile,
type: 'file',
})
}
const duplicateFolder = findFolderByNameInFolder(
fileTreeData,
targetFolderId,
name
)
if (duplicateFolder) {
conflicts.push({
entity: duplicateFolder,
type: 'folder',
})
} }
} }
} }
return [...conflicts] return conflicts
} }
const buildFolderConflicts = (files: Record<string, any>) => { const buildFolderConflicts = (files: Record<string, any>) => {
const conflicts = new Set<FileTreeEntity>() const conflicts: Conflict[] = []
for (const file of Object.values(files)) { for (const file of Object.values(files)) {
const { relativePath } = file.meta const { relativePath } = file.meta
if (relativePath) { if (relativePath) {
const [rootName] = relativePath.replace(/^\//, '').split('/') const [rootName] = relativePath.replace(/^\//, '').split('/')
if (!conflicts.has(rootName)) {
const targetFolderId = file.meta.targetFolderId ?? parentFolderId const targetFolderId = file.meta.targetFolderId ?? parentFolderId
const duplicateEntity = findByNameInFolder( const duplicateFile = findFileByNameInFolder(
fileTreeData, fileTreeData,
targetFolderId, targetFolderId,
rootName rootName
) )
if (duplicateEntity) { if (duplicateFile) {
conflicts.add(duplicateEntity) conflicts.push({
} entity: duplicateFile,
type: 'file',
})
}
const duplicateFolder = findFolderByNameInFolder(
fileTreeData,
targetFolderId,
rootName
)
if (duplicateFolder) {
conflicts.push({
entity: duplicateFolder,
type: 'folder',
})
} }
} }
} }
return [...conflicts] return conflicts
} }
const buildEndpoint = (projectId: string, targetFolderId: string) => { const buildEndpoint = (projectId: string, targetFolderId: string) => {
@ -214,25 +253,32 @@ export default function FileTreeUploadDoc() {
uppy.upload() uppy.upload()
}, [uppy]) }, [uppy])
// whether to show a message about conflicting files const showFolderUploadConflicts = !overwrite && folderConflicts.length > 0
const showConflicts = const showFileUploadConfilcts =
!overwrite && (conflicts.length > 0 || folderConflicts.length > 0) !overwrite && !showFolderUploadConflicts && conflicts.length > 0
const showDashboard = !showFileUploadConfilcts && !showFolderUploadConflicts
return ( return (
<> <>
{error && ( {error && (
<UploadErrorMessage error={error} maxNumberOfFiles={maxNumberOfFiles} /> <UploadErrorMessage error={error} maxNumberOfFiles={maxNumberOfFiles} />
)} )}
{showFolderUploadConflicts && (
{showConflicts ? ( <FolderUploadConflicts
<UploadConflicts
cancel={cancel} cancel={cancel}
conflicts={conflicts} conflicts={folderConflicts}
folderConflicts={folderConflicts}
handleOverwrite={handleOverwrite} handleOverwrite={handleOverwrite}
setError={setError} setError={setError}
/> />
) : ( )}
{showFileUploadConfilcts && (
<FileUploadConflicts
cancel={cancel}
conflicts={conflicts}
handleOverwrite={handleOverwrite}
/>
)}
{showDashboard && (
<Dashboard <Dashboard
uppy={uppy} uppy={uppy}
showProgressDetails showProgressDetails

View file

@ -8,21 +8,35 @@ export function isNameUniqueInFolder(
parentFolderId: string, parentFolderId: string,
name: string name: string
): boolean { ): boolean {
return !findByNameInFolder(tree, parentFolderId, name) return !(
findFileByNameInFolder(tree, parentFolderId, name) ||
findFolderByNameInFolder(tree, parentFolderId, name)
)
} }
export function findByNameInFolder( export function findFileByNameInFolder(
tree: Folder, tree: Folder,
parentFolderId: string, parentFolderId: string,
name: string name: string
): Doc | FileRef | Folder | undefined { ): Doc | FileRef | undefined {
if (tree._id !== parentFolderId) { if (tree._id !== parentFolderId) {
tree = findInTree(tree, parentFolderId).entity tree = findInTree(tree, parentFolderId).entity
} }
return ( return (
tree.docs.find(entity => entity.name === name) || tree.docs.find(entity => entity.name === name) ||
tree.fileRefs.find(entity => entity.name === name) || tree.fileRefs.find(entity => entity.name === name)
tree.folders.find(entity => entity.name === name)
) )
} }
export function findFolderByNameInFolder(
tree: Folder,
parentFolderId: string,
name: string
): Folder | undefined {
if (tree._id !== parentFolderId) {
tree = findInTree(tree, parentFolderId).entity
}
return tree.folders.find(entity => entity.name === name)
}

View file

@ -634,6 +634,7 @@
"file_name_figure_modal": "File name", "file_name_figure_modal": "File name",
"file_name_in_this_project": "File Name In This Project", "file_name_in_this_project": "File Name In This Project",
"file_name_in_this_project_figure_modal": "File name in this project", "file_name_in_this_project_figure_modal": "File name in this project",
"file_or_folder_name_already_exists": "A file or folder with this name already exists",
"file_outline": "File outline", "file_outline": "File outline",
"file_size": "File size", "file_size": "File size",
"file_too_large": "File too large", "file_too_large": "File too large",
@ -1990,6 +1991,7 @@
"thanks_settings_updated": "Thanks, your settings have been updated.", "thanks_settings_updated": "Thanks, your settings have been updated.",
"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_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_files_already_exist_in_this_project": "The following files already exist in this project:",
"the_following_files_and_folders_already_exist_in_this_project": "The following files and folders 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": "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_following_folder_already_exists_in_this_project_plural": "The following folders already exist in this project:",
"the_original_text_has_changed": "The original text has changed, so this suggestion cant be applied", "the_original_text_has_changed": "The original text has changed, so this suggestion cant be applied",

View file

@ -180,9 +180,9 @@ describe('RestoreManager', function () {
}) })
}) })
describe('with an invalid name', function () { describe('with a duplicate name', function () {
beforeEach(async function () { beforeEach(async function () {
this.addEntityWithName.rejects(new Errors.InvalidNameError()) this.addEntityWithName.rejects(new Errors.DuplicateNameError())
this.addEntityWithName this.addEntityWithName
.onSecondCall() .onSecondCall()
.resolves((this.entity = 'mock-entity')) .resolves((this.entity = 'mock-entity'))

View file

@ -856,7 +856,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
'doc', 'doc',
this.folder.name this.folder.name
) )
).to.be.rejectedWith(Errors.InvalidNameError) ).to.be.rejectedWith(Errors.DuplicateNameError)
}) })
}) })
}) })
@ -990,7 +990,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
file, file,
'file' 'file'
) )
).to.be.rejectedWith(Errors.InvalidNameError) ).to.be.rejectedWith(Errors.DuplicateNameError)
}) })
}) })
}) })