mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Preserve folder structure when uploading folders (#16502)
GitOrigin-RevId: 791233ce1e68920a4f2d7042ed4c60ca3f4be8fb
This commit is contained in:
parent
3f29aa2195
commit
f4b176c93d
15 changed files with 473 additions and 215 deletions
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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": "",
|
||||
|
|
|
@ -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 (
|
||||
<FolderUploadConflicts
|
||||
cancel={cancel}
|
||||
folderConflicts={folderConflicts}
|
||||
handleOverwrite={handleOverwrite}
|
||||
setError={setError}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<ul className="text-center list-unstyled row-spaced-small mt-1">
|
||||
{conflicts.map((conflict, index) => (
|
||||
<li key={index}>
|
||||
<strong>{conflict.name}</strong>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
<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>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<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>
|
||||
|
||||
<ul className="text-center list-unstyled row-spaced-small mt-1">
|
||||
{folderConflicts.map((entity, index) => (
|
||||
<li key={index}>
|
||||
<strong>{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>
|
||||
|
||||
<p className="text-center">
|
||||
<Button bsStyle={null} className="btn-secondary" onClick={cancel}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
|
||||
<Button bsStyle="danger" onClick={deleteAndRetry}>
|
||||
{t('overwrite')}
|
||||
</Button>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -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<string>()
|
||||
|
||||
const [conflicts, setConflicts] = useState<any[]>([])
|
||||
const [conflicts, setConflicts] = useState<FileTreeEntity[]>([])
|
||||
const [folderConflicts, setFolderConflicts] = useState<FileTreeEntity[]>([])
|
||||
const [overwrite, setOverwrite] = useState(false)
|
||||
|
||||
const maxNumberOfFiles = 40
|
||||
const maxFileSize = window.ExposedSettings.maxUploadSize
|
||||
|
||||
// calculate conflicts
|
||||
const buildConflicts = (files: Record<string, any>) =>
|
||||
Object.values(files).filter(file =>
|
||||
isDuplicate(file.meta.targetFolderId ?? parentFolderId, file.meta.name)
|
||||
)
|
||||
const buildConflicts = (files: Record<string, any>) => {
|
||||
const conflicts = new Set<FileTreeEntity>()
|
||||
|
||||
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<string, any>) => {
|
||||
const conflicts = new Set<FileTreeEntity>()
|
||||
|
||||
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() {
|
|||
<UploadConflicts
|
||||
cancel={cancel}
|
||||
conflicts={conflicts}
|
||||
folderConflicts={folderConflicts}
|
||||
handleOverwrite={handleOverwrite}
|
||||
setError={setError}
|
||||
/>
|
||||
) : (
|
||||
<Dashboard
|
||||
|
@ -186,16 +235,19 @@ export default function FileTreeUploadDoc() {
|
|||
width="100%"
|
||||
showLinkToFileUploadResult={false}
|
||||
proudlyDisplayPoweredByUppy={false}
|
||||
// allow files or folders to be selected
|
||||
fileManagerSelectionType="both"
|
||||
locale={{
|
||||
strings: {
|
||||
// Text to show on the droppable area.
|
||||
// `%{browse}` is replaced with a link that opens the system file selection dialog.
|
||||
// TODO: 'drag_here' or 'drop_files_here_to_upload'?
|
||||
// dropHereOr: `${t('drag_here')} ${t('or')} %{browse}`,
|
||||
dropPasteFiles: `Drag here, paste an image or file, or %{browseFiles}`,
|
||||
dropPasteBoth: `Drop or paste your files, folder, or images here. %{browseFiles} or %{browseFolders} from your computer.`,
|
||||
// Used as the label for the link that opens the system file selection dialog.
|
||||
// browseFiles: t('select_from_your_computer')
|
||||
browseFiles: 'select from your computer',
|
||||
browseFiles: 'Select files',
|
||||
browseFolders: 'select a folder',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
@ -227,45 +279,3 @@ function UploadErrorMessage({
|
|||
return <ErrorMessage error={error} />
|
||||
}
|
||||
}
|
||||
|
||||
function UploadConflicts({
|
||||
cancel,
|
||||
conflicts,
|
||||
handleOverwrite,
|
||||
}: {
|
||||
cancel: () => void
|
||||
conflicts: any[]
|
||||
handleOverwrite: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="small modal-new-file--body-conflict">
|
||||
<p className="text-center mb-0">
|
||||
{t('the_following_files_already_exist_in_this_project')}
|
||||
</p>
|
||||
|
||||
<ul className="text-center list-unstyled row-spaced-small mt-1">
|
||||
{conflicts.map((conflict, index) => (
|
||||
<li key={index}>
|
||||
<strong>{conflict.meta.name}</strong>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<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>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
@ -534,4 +534,8 @@
|
|||
font-size: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.uppy-Dashboard-AddFiles-title {
|
||||
width: 26em; // sized to create a wrap between the sentences
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 <b>__email__</b>?",
|
||||
"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.",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -42,6 +42,7 @@ export type ExposedSettings = {
|
|||
textExtensions: string[]
|
||||
editableFilenames: string[]
|
||||
validRootDocExtensions: string[]
|
||||
fileIgnorePattern: string
|
||||
templateLinks?: TemplateLink[]
|
||||
labsEnabled: boolean
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue