mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
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:
parent
c07d2f3fa2
commit
d7357b4d62
14 changed files with 203 additions and 94 deletions
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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": "",
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
<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>
|
||||
|
||||
<Button bsStyle="danger" onClick={deleteAndRetry}>
|
||||
{t('overwrite')}
|
||||
</Button>
|
||||
{!hasFileConflict && (
|
||||
<Button bsStyle="danger" onClick={deleteAndRetry}>
|
||||
{t('overwrite')}
|
||||
</Button>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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 can’t be applied",
|
||||
|
|
|
@ -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'))
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
Loading…
Reference in a new issue