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)
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) {
req.logger.setLevel('warn')
if (shouldSendErrorResponse) {

View file

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

View file

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

View file

@ -618,7 +618,7 @@ function _checkValidElementName(folder, name) {
.concat(folder.folders || [])
for (const element of elements) {
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 {
await UpdateMerger.promises.mergeUpdate(null, projectId, path, req, source)
} catch (error) {
if (error.constructor === Errors.InvalidNameError) {
if (
error instanceof Errors.InvalidNameError ||
error instanceof Errors.DuplicateNameError
) {
return res.sendStatus(422)
} else {
throw error

View file

@ -12,6 +12,7 @@ const { InvalidZipFileError } = require('./ArchiveErrors')
const multer = require('multer')
const { defaultsDeep } = require('lodash')
const { expressify } = require('@overleaf/promise-utils')
const { DuplicateNameError } = require('../Errors/Errors')
const upload = multer(
defaultsDeep(
@ -107,6 +108,11 @@ async function uploadFile(req, res, next) {
success: false,
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') {
return res.status(422).json({
success: false,

View file

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

View file

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

View file

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

View file

@ -12,10 +12,16 @@ 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 {
findFileByNameInFolder,
findFolderByNameInFolder,
} 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'
import {
Conflict,
FileUploadConflicts,
FolderUploadConflicts,
} from '@/features/file-tree/components/file-tree-create/file-tree-upload-conflicts'
import getMeta from '@/utils/meta'
export default function FileTreeUploadDoc() {
@ -26,8 +32,8 @@ export default function FileTreeUploadDoc() {
const [error, setError] = useState<string>()
const [conflicts, setConflicts] = useState<FileTreeEntity[]>([])
const [folderConflicts, setFolderConflicts] = useState<FileTreeEntity[]>([])
const [conflicts, setConflicts] = useState<Conflict[]>([])
const [folderConflicts, setFolderConflicts] = useState<Conflict[]>([])
const [overwrite, setOverwrite] = useState(false)
const maxNumberOfFiles = 180
@ -35,46 +41,79 @@ export default function FileTreeUploadDoc() {
// calculate conflicts
const buildConflicts = (files: Record<string, any>) => {
const conflicts = new Set<FileTreeEntity>()
const conflicts: Conflict[] = []
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)
const duplicateFile = findFileByNameInFolder(
fileTreeData,
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 conflicts = new Set<FileTreeEntity>()
const conflicts: Conflict[] = []
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)
}
const targetFolderId = file.meta.targetFolderId ?? parentFolderId
const duplicateFile = findFileByNameInFolder(
fileTreeData,
targetFolderId,
rootName
)
if (duplicateFile) {
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) => {
@ -214,25 +253,32 @@ export default function FileTreeUploadDoc() {
uppy.upload()
}, [uppy])
// whether to show a message about conflicting files
const showConflicts =
!overwrite && (conflicts.length > 0 || folderConflicts.length > 0)
const showFolderUploadConflicts = !overwrite && folderConflicts.length > 0
const showFileUploadConfilcts =
!overwrite && !showFolderUploadConflicts && conflicts.length > 0
const showDashboard = !showFileUploadConfilcts && !showFolderUploadConflicts
return (
<>
{error && (
<UploadErrorMessage error={error} maxNumberOfFiles={maxNumberOfFiles} />
)}
{showConflicts ? (
<UploadConflicts
{showFolderUploadConflicts && (
<FolderUploadConflicts
cancel={cancel}
conflicts={conflicts}
folderConflicts={folderConflicts}
conflicts={folderConflicts}
handleOverwrite={handleOverwrite}
setError={setError}
/>
) : (
)}
{showFileUploadConfilcts && (
<FileUploadConflicts
cancel={cancel}
conflicts={conflicts}
handleOverwrite={handleOverwrite}
/>
)}
{showDashboard && (
<Dashboard
uppy={uppy}
showProgressDetails

View file

@ -8,21 +8,35 @@ export function isNameUniqueInFolder(
parentFolderId: string,
name: string
): boolean {
return !findByNameInFolder(tree, parentFolderId, name)
return !(
findFileByNameInFolder(tree, parentFolderId, name) ||
findFolderByNameInFolder(tree, parentFolderId, name)
)
}
export function findByNameInFolder(
export function findFileByNameInFolder(
tree: Folder,
parentFolderId: string,
name: string
): Doc | FileRef | Folder | undefined {
): Doc | FileRef | 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)
tree.fileRefs.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_in_this_project": "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_size": "File size",
"file_too_large": "File too large",
@ -1990,6 +1991,7 @@
"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_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_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",

View file

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

View file

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