mirror of
https://github.com/nextcloud/server.git
synced 2026-06-08 16:26:59 -04:00
Merge pull request #44409 from nextcloud/fix/files-dnd-files
This commit is contained in:
commit
32e86052d5
25 changed files with 942 additions and 242 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -171,6 +171,6 @@ composer.phar
|
|||
core/js/mimetypelist.js
|
||||
|
||||
# Tests - cypress
|
||||
cypress/downloads
|
||||
cypress/snapshots
|
||||
cypress/videos
|
||||
cypress/downloads
|
||||
124
__tests__/FileSystemAPIUtils.ts
Normal file
124
__tests__/FileSystemAPIUtils.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { basename } from 'node:path'
|
||||
import mime from 'mime'
|
||||
|
||||
class FileSystemEntry {
|
||||
|
||||
private _isFile: boolean
|
||||
private _fullPath: string
|
||||
|
||||
constructor(isFile: boolean, fullPath: string) {
|
||||
this._isFile = isFile
|
||||
this._fullPath = fullPath
|
||||
}
|
||||
|
||||
get isFile() {
|
||||
return !!this._isFile
|
||||
}
|
||||
|
||||
get isDirectory() {
|
||||
return !this.isFile
|
||||
}
|
||||
|
||||
get name() {
|
||||
return basename(this._fullPath)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export class FileSystemFileEntry extends FileSystemEntry {
|
||||
|
||||
private _contents: string
|
||||
private _lastModified: number
|
||||
|
||||
constructor(fullPath: string, contents: string, lastModified = Date.now()) {
|
||||
super(true, fullPath)
|
||||
this._contents = contents
|
||||
this._lastModified = lastModified
|
||||
}
|
||||
|
||||
file(success: (file: File) => void) {
|
||||
const lastModified = this._lastModified
|
||||
// Faking the mime by using the file extension
|
||||
const type = mime.getType(this.name) || ''
|
||||
success(new File([this._contents], this.name, { lastModified, type }))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export class FileSystemDirectoryEntry extends FileSystemEntry {
|
||||
|
||||
private _entries: FileSystemEntry[]
|
||||
|
||||
constructor(fullPath: string, entries: FileSystemEntry[]) {
|
||||
super(false, fullPath)
|
||||
this._entries = entries || []
|
||||
}
|
||||
|
||||
createReader() {
|
||||
let read = false
|
||||
return {
|
||||
readEntries: (success: (entries: FileSystemEntry[]) => void) => {
|
||||
if (read) {
|
||||
return success([])
|
||||
}
|
||||
read = true
|
||||
success(this._entries)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* This mocks the File API's File class
|
||||
* It will allow us to test the Filesystem API as well as the
|
||||
* File API in the same test suite.
|
||||
*/
|
||||
export class DataTransferItem {
|
||||
|
||||
private _type: string
|
||||
private _entry: FileSystemEntry
|
||||
|
||||
getAsEntry?: () => FileSystemEntry
|
||||
|
||||
constructor(type = '', entry: FileSystemEntry, isFileSystemAPIAvailable = true) {
|
||||
this._type = type
|
||||
this._entry = entry
|
||||
|
||||
// Only when the Files API is available we are
|
||||
// able to get the entry
|
||||
if (isFileSystemAPIAvailable) {
|
||||
this.getAsEntry = () => this._entry
|
||||
}
|
||||
}
|
||||
|
||||
get kind() {
|
||||
return 'file'
|
||||
}
|
||||
|
||||
get type() {
|
||||
return this._type
|
||||
}
|
||||
|
||||
getAsFile(): File|null {
|
||||
if (this._entry.isFile && this._entry instanceof FileSystemFileEntry) {
|
||||
let file: File | null = null
|
||||
this._entry.file((f) => {
|
||||
file = f
|
||||
})
|
||||
return file
|
||||
}
|
||||
|
||||
// The browser will return an empty File object if the entry is a directory
|
||||
return new File([], this._entry.name, { type: '' })
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const fileSystemEntryToDataTransferItem = (entry: FileSystemEntry, isFileSystemAPIAvailable = true): DataTransferItem => {
|
||||
return new DataTransferItem(
|
||||
entry.isFile ? 'text/plain' : 'httpd/unix-directory',
|
||||
entry,
|
||||
isFileSystemAPIAvailable,
|
||||
)
|
||||
}
|
||||
|
|
@ -34,7 +34,9 @@
|
|||
:force-icon-text="true"
|
||||
:title="titleForSection(index, section)"
|
||||
:aria-description="ariaForSection(section)"
|
||||
@click.native="onClick(section.to)">
|
||||
@click.native="onClick(section.to)"
|
||||
@dragover.native="onDragOver($event, section.dir)"
|
||||
@drop="onDrop($event, section.dir)">
|
||||
<template v-if="index === 0" #icon>
|
||||
<NcIconSvgWrapper :size="20"
|
||||
:svg="viewIcon" />
|
||||
|
|
@ -49,20 +51,25 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { Node } from '@nextcloud/files'
|
||||
import { Permission, type Node } from '@nextcloud/files'
|
||||
|
||||
import { translate as t} from '@nextcloud/l10n'
|
||||
import { basename } from 'path'
|
||||
import homeSvg from '@mdi/svg/svg/home.svg?raw'
|
||||
import { defineComponent } from 'vue'
|
||||
import { translate as t} from '@nextcloud/l10n'
|
||||
import HomeSvg from '@mdi/svg/svg/home.svg?raw'
|
||||
import NcBreadcrumb from '@nextcloud/vue/dist/Components/NcBreadcrumb.js'
|
||||
import NcBreadcrumbs from '@nextcloud/vue/dist/Components/NcBreadcrumbs.js'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
import { onDropInternalFiles, dataTransferToFileTree, onDropExternalFiles } from '../services/DropService'
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { useDragAndDropStore } from '../store/dragging.ts'
|
||||
import { useFilesStore } from '../store/files.ts'
|
||||
import { usePathsStore } from '../store/paths.ts'
|
||||
import { useSelectionStore } from '../store/selection.ts'
|
||||
import { useUploaderStore } from '../store/uploader.ts'
|
||||
import filesListWidthMixin from '../mixins/filesListWidth.ts'
|
||||
import logger from '../logger'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BreadCrumbs',
|
||||
|
|
@ -73,6 +80,10 @@ export default defineComponent({
|
|||
NcIconSvgWrapper,
|
||||
},
|
||||
|
||||
mixins: [
|
||||
filesListWidthMixin,
|
||||
],
|
||||
|
||||
props: {
|
||||
path: {
|
||||
type: String,
|
||||
|
|
@ -80,18 +91,18 @@ export default defineComponent({
|
|||
},
|
||||
},
|
||||
|
||||
mixins: [
|
||||
filesListWidthMixin,
|
||||
],
|
||||
|
||||
setup() {
|
||||
const draggingStore = useDragAndDropStore()
|
||||
const filesStore = useFilesStore()
|
||||
const pathsStore = usePathsStore()
|
||||
const selectionStore = useSelectionStore()
|
||||
const uploaderStore = useUploaderStore()
|
||||
|
||||
return {
|
||||
draggingStore,
|
||||
filesStore,
|
||||
pathsStore,
|
||||
selectionStore,
|
||||
uploaderStore,
|
||||
}
|
||||
},
|
||||
|
|
@ -110,7 +121,7 @@ export default defineComponent({
|
|||
},
|
||||
|
||||
sections() {
|
||||
return this.dirs.map((dir: string) => {
|
||||
return this.dirs.map((dir: string, index: number) => {
|
||||
const fileid = this.getFileIdFromPath(dir)
|
||||
const to = { ...this.$route, params: { fileid }, query: { dir } }
|
||||
return {
|
||||
|
|
@ -118,6 +129,8 @@ export default defineComponent({
|
|||
exact: true,
|
||||
name: this.getDirDisplayName(dir),
|
||||
to,
|
||||
// disable drop on current directory
|
||||
disableDrop: index === this.dirs.length - 1,
|
||||
}
|
||||
})
|
||||
},
|
||||
|
|
@ -128,13 +141,27 @@ export default defineComponent({
|
|||
|
||||
// Hide breadcrumbs if an upload is ongoing
|
||||
shouldShowBreadcrumbs(): boolean {
|
||||
return this.filesListWidth > 400 && !this.isUploadInProgress
|
||||
// If we're uploading files, only show the breadcrumbs
|
||||
// if the files list is greater than 768px wide
|
||||
if (this.isUploadInProgress) {
|
||||
return this.filesListWidth > 768
|
||||
}
|
||||
// If we're not uploading, we have enough space from 400px
|
||||
return this.filesListWidth > 400
|
||||
},
|
||||
|
||||
// used to show the views icon for the first breadcrumb
|
||||
viewIcon(): string {
|
||||
return this.currentView?.icon ?? homeSvg
|
||||
}
|
||||
return this.currentView?.icon ?? HomeSvg
|
||||
},
|
||||
|
||||
selectedFiles() {
|
||||
return this.selectionStore.selected
|
||||
},
|
||||
|
||||
draggingFiles() {
|
||||
return this.draggingStore.dragging
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
|
@ -160,6 +187,77 @@ export default defineComponent({
|
|||
}
|
||||
},
|
||||
|
||||
onDragOver(event: DragEvent, path: string) {
|
||||
// Cannot drop on the current directory
|
||||
if (path === this.dirs[this.dirs.length - 1]) {
|
||||
event.dataTransfer.dropEffect = 'none'
|
||||
return
|
||||
}
|
||||
|
||||
// Handle copy/move drag and drop
|
||||
if (event.ctrlKey) {
|
||||
event.dataTransfer.dropEffect = 'copy'
|
||||
} else {
|
||||
event.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
},
|
||||
|
||||
async onDrop(event: DragEvent, path: string) {
|
||||
// skip if native drop like text drag and drop from files names
|
||||
if (!this.draggingFiles && !event.dataTransfer?.items?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
// Do not stop propagation, so the main content
|
||||
// drop event can be triggered too and clear the
|
||||
// dragover state on the DragAndDropNotice component.
|
||||
event.preventDefault()
|
||||
|
||||
// Caching the selection
|
||||
const selection = this.draggingFiles
|
||||
const items = [...event.dataTransfer?.items || []] as DataTransferItem[]
|
||||
|
||||
// We need to process the dataTransfer ASAP before the
|
||||
// browser clears it. This is why we cache the items too.
|
||||
const fileTree = await dataTransferToFileTree(items)
|
||||
|
||||
// We might not have the target directory fetched yet
|
||||
const contents = await this.currentView?.getContents(path)
|
||||
const folder = contents?.folder
|
||||
if (!folder) {
|
||||
showError(this.t('files', 'Target folder does not exist any more'))
|
||||
return
|
||||
}
|
||||
|
||||
const canDrop = (folder.permissions & Permission.CREATE) !== 0
|
||||
const isCopy = event.ctrlKey
|
||||
|
||||
// If another button is pressed, cancel it. This
|
||||
// allows cancelling the drag with the right click.
|
||||
if (!canDrop || event.button !== 0) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.debug('Dropped', { event, folder, selection, fileTree })
|
||||
|
||||
// Check whether we're uploading files
|
||||
if (fileTree.contents.length > 0) {
|
||||
await onDropExternalFiles(fileTree, folder, contents.contents)
|
||||
return
|
||||
}
|
||||
|
||||
// Else we're moving/copying files
|
||||
const nodes = selection.map(fileid => this.filesStore.getNode(fileid)) as Node[]
|
||||
await onDropInternalFiles(nodes, folder, contents.contents, isCopy)
|
||||
|
||||
// Reset selection after we dropped the files
|
||||
// if the dropped files are within the selection
|
||||
if (selection.some(fileid => this.selectedFiles.includes(fileid))) {
|
||||
logger.debug('Dropped selection, resetting select store...')
|
||||
this.selectionStore.reset()
|
||||
}
|
||||
},
|
||||
|
||||
titleForSection(index, section) {
|
||||
if (section?.to?.query?.dir === this.$route.query.dir) {
|
||||
return t('files', 'Reload current directory')
|
||||
|
|
|
|||
|
|
@ -46,14 +46,14 @@
|
|||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
import { Folder, Permission } from '@nextcloud/files'
|
||||
import { showError, showSuccess } from '@nextcloud/dialogs'
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import { UploadStatus } from '@nextcloud/upload'
|
||||
|
||||
import TrayArrowDownIcon from 'vue-material-design-icons/TrayArrowDown.vue'
|
||||
|
||||
import logger from '../logger.js'
|
||||
import { handleDrop } from '../services/DropService'
|
||||
import { dataTransferToFileTree, onDropExternalFiles } from '../services/DropService'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'DragAndDropNotice',
|
||||
|
|
@ -76,6 +76,10 @@ export default defineComponent({
|
|||
},
|
||||
|
||||
computed: {
|
||||
currentView() {
|
||||
return this.$navigation.active
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if the current folder has create permissions
|
||||
*/
|
||||
|
|
@ -146,8 +150,6 @@ export default defineComponent({
|
|||
},
|
||||
|
||||
async onDrop(event: DragEvent) {
|
||||
logger.debug('Dropped on DragAndDropNotice', { event })
|
||||
|
||||
// cantUploadLabel is null if we can upload
|
||||
if (this.cantUploadLabel) {
|
||||
showError(this.cantUploadLabel)
|
||||
|
|
@ -161,38 +163,53 @@ export default defineComponent({
|
|||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
if (event.dataTransfer && event.dataTransfer.items.length > 0) {
|
||||
// Start upload
|
||||
logger.debug(`Uploading files to ${this.currentFolder.path}`)
|
||||
// Process finished uploads
|
||||
const uploads = await handleDrop(event.dataTransfer)
|
||||
logger.debug('Upload terminated', { uploads })
|
||||
// Caching the selection
|
||||
const items: DataTransferItem[] = [...event.dataTransfer?.items || []]
|
||||
|
||||
if (uploads.some((upload) => upload.status === UploadStatus.FAILED)) {
|
||||
showError(t('files', 'Some files could not be uploaded'))
|
||||
const failedUploads = uploads.filter((upload) => upload.status === UploadStatus.FAILED)
|
||||
logger.debug('Some files could not be uploaded', { failedUploads })
|
||||
} else {
|
||||
showSuccess(t('files', 'Files uploaded successfully'))
|
||||
}
|
||||
// We need to process the dataTransfer ASAP before the
|
||||
// browser clears it. This is why we cache the items too.
|
||||
const fileTree = await dataTransferToFileTree(items)
|
||||
|
||||
// Scroll to last successful upload in current directory if terminated
|
||||
const lastUpload = uploads.findLast((upload) => upload.status !== UploadStatus.FAILED
|
||||
&& !upload.file.webkitRelativePath.includes('/')
|
||||
&& upload.response?.headers?.['oc-fileid'])
|
||||
|
||||
if (lastUpload !== undefined) {
|
||||
this.$router.push({
|
||||
...this.$route,
|
||||
params: {
|
||||
view: this.$route.params?.view ?? 'files',
|
||||
fileid: parseInt(lastUpload.response!.headers['oc-fileid']),
|
||||
},
|
||||
})
|
||||
}
|
||||
// We might not have the target directory fetched yet
|
||||
const contents = await this.currentView?.getContents(this.currentFolder.path)
|
||||
const folder = contents?.folder
|
||||
if (!folder) {
|
||||
showError(this.t('files', 'Target folder does not exist any more'))
|
||||
return
|
||||
}
|
||||
|
||||
// If another button is pressed, cancel it. This
|
||||
// allows cancelling the drag with the right click.
|
||||
if (event.button) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.debug('Dropped', { event, folder, fileTree })
|
||||
|
||||
// Check whether we're uploading files
|
||||
const uploads = await onDropExternalFiles(fileTree, folder, contents.contents)
|
||||
|
||||
// Scroll to last successful upload in current directory if terminated
|
||||
const lastUpload = uploads.findLast((upload) => upload.status !== UploadStatus.FAILED
|
||||
&& !upload.file.webkitRelativePath.includes('/')
|
||||
&& upload.response?.headers?.['oc-fileid']
|
||||
// Only use the last ID if it's in the current folder
|
||||
&& upload.source.replace(folder.source, '').split('/').length === 2)
|
||||
|
||||
if (lastUpload !== undefined) {
|
||||
logger.debug('Scrolling to last upload in current folder', { lastUpload })
|
||||
this.$router.push({
|
||||
...this.$route,
|
||||
params: {
|
||||
view: this.$route.params?.view ?? 'files',
|
||||
fileid: parseInt(lastUpload.response!.headers['oc-fileid']),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
this.dragover = false
|
||||
},
|
||||
|
||||
t,
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -22,21 +22,19 @@
|
|||
|
||||
import type { PropType } from 'vue'
|
||||
|
||||
import { extname, join } from 'path'
|
||||
import { extname } from 'path'
|
||||
import { FileType, Permission, Folder, File as NcFile, NodeStatus, Node, View } from '@nextcloud/files'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { showError, showSuccess } from '@nextcloud/dialogs'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import { Upload, getUploader } from '@nextcloud/upload'
|
||||
import { vOnClickOutside } from '@vueuse/components'
|
||||
import Vue, { defineComponent } from 'vue'
|
||||
|
||||
import { action as sidebarAction } from '../actions/sidebarAction.ts'
|
||||
import { getDragAndDropPreview } from '../utils/dragUtils.ts'
|
||||
import { handleCopyMoveNodeTo } from '../actions/moveOrCopyAction.ts'
|
||||
import { hashCode } from '../utils/hashUtils.ts'
|
||||
import { MoveCopyAction } from '../actions/moveOrCopyActionUtils.ts'
|
||||
import { dataTransferToFileTree, onDropExternalFiles, onDropInternalFiles } from '../services/DropService.ts'
|
||||
import logger from '../logger.js'
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
|
||||
Vue.directive('onClickOutside', vOnClickOutside)
|
||||
|
||||
|
|
@ -309,79 +307,53 @@ export default defineComponent({
|
|||
|
||||
async onDrop(event: DragEvent) {
|
||||
// skip if native drop like text drag and drop from files names
|
||||
if (!this.draggingFiles && !event.dataTransfer?.files?.length) {
|
||||
if (!this.draggingFiles && !event.dataTransfer?.items?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
// If another button is pressed, cancel it
|
||||
// This allows cancelling the drag with the right click
|
||||
if (!this.canDrop || event.button !== 0) {
|
||||
// Caching the selection
|
||||
const selection = this.draggingFiles
|
||||
const items = [...event.dataTransfer?.items || []] as DataTransferItem[]
|
||||
|
||||
// We need to process the dataTransfer ASAP before the
|
||||
// browser clears it. This is why we cache the items too.
|
||||
const fileTree = await dataTransferToFileTree(items)
|
||||
|
||||
// We might not have the target directory fetched yet
|
||||
const contents = await this.currentView?.getContents(this.source.path)
|
||||
const folder = contents?.folder
|
||||
if (!folder) {
|
||||
showError(this.t('files', 'Target folder does not exist any more'))
|
||||
return
|
||||
}
|
||||
|
||||
// If another button is pressed, cancel it. This
|
||||
// allows cancelling the drag with the right click.
|
||||
if (!this.canDrop || event.button) {
|
||||
return
|
||||
}
|
||||
|
||||
const isCopy = event.ctrlKey
|
||||
this.dragover = false
|
||||
|
||||
logger.debug('Dropped', { event, selection: this.draggingFiles })
|
||||
logger.debug('Dropped', { event, folder, selection, fileTree })
|
||||
|
||||
// Check whether we're uploading files
|
||||
if (event.dataTransfer?.files
|
||||
&& event.dataTransfer.files.length > 0) {
|
||||
const uploader = getUploader()
|
||||
|
||||
// Check whether the uploader is in the same folder
|
||||
// This should never happen™
|
||||
if (!uploader.destination.path.startsWith(uploader.destination.path)) {
|
||||
logger.error('The current uploader destination is not the same as the current folder')
|
||||
showError(t('files', 'An error occurred while uploading. Please try again later.'))
|
||||
return
|
||||
}
|
||||
|
||||
logger.debug(`Uploading files to ${this.source.path}`)
|
||||
const queue = [] as Promise<Upload>[]
|
||||
for (const file of event.dataTransfer.files) {
|
||||
// Because the uploader destination is properly set to the current folder
|
||||
// we can just use the basename as the relative path.
|
||||
queue.push(uploader.upload(join(this.source.basename, file.name), file))
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(queue)
|
||||
const errors = results.filter(result => result.status === 'rejected')
|
||||
if (errors.length > 0) {
|
||||
logger.error('Error while uploading files', { errors })
|
||||
showError(t('files', 'Some files could not be uploaded'))
|
||||
return
|
||||
}
|
||||
|
||||
logger.debug('Files uploaded successfully')
|
||||
showSuccess(t('files', 'Files uploaded successfully'))
|
||||
if (fileTree.contents.length > 0) {
|
||||
await onDropExternalFiles(fileTree, folder, contents.contents)
|
||||
return
|
||||
}
|
||||
|
||||
const nodes = this.draggingFiles.map(fileid => this.filesStore.getNode(fileid)) as Node[]
|
||||
nodes.forEach(async (node: Node) => {
|
||||
Vue.set(node, 'status', NodeStatus.LOADING)
|
||||
try {
|
||||
// TODO: resolve potential conflicts prior and force overwrite
|
||||
await handleCopyMoveNodeTo(node, this.source, isCopy ? MoveCopyAction.COPY : MoveCopyAction.MOVE)
|
||||
} catch (error) {
|
||||
logger.error('Error while moving file', { error })
|
||||
if (isCopy) {
|
||||
showError(t('files', 'Could not copy {file}. {message}', { file: node.basename, message: error.message || '' }))
|
||||
} else {
|
||||
showError(t('files', 'Could not move {file}. {message}', { file: node.basename, message: error.message || '' }))
|
||||
}
|
||||
} finally {
|
||||
Vue.set(node, 'status', undefined)
|
||||
}
|
||||
})
|
||||
// Else we're moving/copying files
|
||||
const nodes = selection.map(fileid => this.filesStore.getNode(fileid)) as Node[]
|
||||
await onDropInternalFiles(nodes, folder, contents.contents, isCopy)
|
||||
|
||||
// Reset selection after we dropped the files
|
||||
// if the dropped files are within the selection
|
||||
if (this.draggingFiles.some(fileid => this.selectedFiles.includes(fileid))) {
|
||||
if (selection.some(fileid => this.selectedFiles.includes(fileid))) {
|
||||
logger.debug('Dropped selection, resetting select store...')
|
||||
this.selectionStore.reset()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
* @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de>
|
||||
*
|
||||
* @author Ferdinand Thiessen <opensource@fthiessen.de>
|
||||
* @author John Molakvoæ <skjnldsv@protonmail.com>
|
||||
*
|
||||
* @license AGPL-3.0-or-later
|
||||
*
|
||||
|
|
@ -11,7 +12,7 @@
|
|||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* but WITHOUT ANY WARRANTY without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
|
|
@ -21,123 +22,196 @@
|
|||
*/
|
||||
|
||||
import type { Upload } from '@nextcloud/upload'
|
||||
import type { FileStat, ResponseDataDetailed } from 'webdav'
|
||||
import type { RootDirectory } from './DropServiceUtils'
|
||||
|
||||
import { davGetClient, davGetDefaultPropfind, davResultToNode, davRootPath } from '@nextcloud/files'
|
||||
import { emit } from '@nextcloud/event-bus'
|
||||
import { getUploader } from '@nextcloud/upload'
|
||||
import { Folder, Node, NodeStatus, davRootPath } from '@nextcloud/files'
|
||||
import { getUploader, hasConflict } from '@nextcloud/upload'
|
||||
import { join } from 'path'
|
||||
import { joinPaths } from '@nextcloud/paths'
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { showError, showInfo, showSuccess, showWarning } from '@nextcloud/dialogs'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import Vue from 'vue'
|
||||
|
||||
import { Directory, traverseTree, resolveConflict, createDirectoryIfNotExists } from './DropServiceUtils'
|
||||
import { handleCopyMoveNodeTo } from '../actions/moveOrCopyAction'
|
||||
import { MoveCopyAction } from '../actions/moveOrCopyActionUtils'
|
||||
import logger from '../logger.js'
|
||||
|
||||
export const handleDrop = async (data: DataTransfer): Promise<Upload[]> => {
|
||||
// TODO: Maybe handle `getAsFileSystemHandle()` in the future
|
||||
|
||||
const uploads = [] as Upload[]
|
||||
// we need to cache the entries to prevent Blink engine bug that clears the list (`data.items`) after first access props of one of the entries
|
||||
const entries = [...data.items]
|
||||
/**
|
||||
* This function converts a list of DataTransferItems to a file tree.
|
||||
* It uses the Filesystem API if available, otherwise it falls back to the File API.
|
||||
* The File API will NOT be available if the browser is not in a secure context (e.g. HTTP).
|
||||
* ⚠️ When using this method, you need to use it as fast as possible, as the DataTransferItems
|
||||
* will be cleared after the first access to the props of one of the entries.
|
||||
*
|
||||
* @param items the list of DataTransferItems
|
||||
*/
|
||||
export const dataTransferToFileTree = async (items: DataTransferItem[]): Promise<RootDirectory> => {
|
||||
// Check if the browser supports the Filesystem API
|
||||
// We need to cache the entries to prevent Blink engine bug that clears
|
||||
// the list (`data.items`) after first access props of one of the entries
|
||||
const entries = items
|
||||
.filter((item) => {
|
||||
if (item.kind !== 'file') {
|
||||
logger.debug('Skipping dropped item', { kind: item.kind, type: item.type })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
.map((item) => {
|
||||
}).map((item) => {
|
||||
// MDN recommends to try both, as it might be renamed in the future
|
||||
return (item as unknown as { getAsEntry?: () => FileSystemEntry|undefined})?.getAsEntry?.() ?? item.webkitGetAsEntry() ?? item
|
||||
})
|
||||
return (item as unknown as { getAsEntry?: () => FileSystemEntry|undefined })?.getAsEntry?.()
|
||||
?? item?.webkitGetAsEntry?.()
|
||||
?? item
|
||||
}) as (FileSystemEntry | DataTransferItem)[]
|
||||
|
||||
let warned = false
|
||||
const fileTree = new Directory('root') as RootDirectory
|
||||
|
||||
// Traverse the file tree
|
||||
for (const entry of entries) {
|
||||
// Handle browser issues if Filesystem API is not available. Fallback to File API
|
||||
if (entry instanceof DataTransferItem) {
|
||||
logger.debug('Could not get FilesystemEntry of item, falling back to file')
|
||||
logger.warn('Could not get FilesystemEntry of item, falling back to file')
|
||||
|
||||
const file = entry.getAsFile()
|
||||
if (file === null) {
|
||||
logger.warn('Could not process DataTransferItem', { type: entry.type, kind: entry.kind })
|
||||
showError(t('files', 'One of the dropped files could not be processed'))
|
||||
} else {
|
||||
uploads.push(await handleFileUpload(file))
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
logger.debug('Handle recursive upload', { entry: entry.name })
|
||||
// Use Filesystem API
|
||||
uploads.push(...await handleRecursiveUpload(entry))
|
||||
|
||||
// Warn the user that the browser does not support the Filesystem API
|
||||
// we therefore cannot upload directories recursively.
|
||||
if (file.type === 'httpd/unix-directory' || !file.type) {
|
||||
if (!warned) {
|
||||
logger.warn('Browser does not support Filesystem API. Directories will not be uploaded')
|
||||
showWarning(t('files', 'Your browser does not support the Filesystem API. Directories will not be uploaded'))
|
||||
warned = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
fileTree.contents.push(file)
|
||||
continue
|
||||
}
|
||||
|
||||
// Use Filesystem API
|
||||
try {
|
||||
fileTree.contents.push(await traverseTree(entry))
|
||||
} catch (error) {
|
||||
// Do not throw, as we want to continue with the other files
|
||||
logger.error('Error while traversing file tree', { error })
|
||||
}
|
||||
}
|
||||
return uploads
|
||||
|
||||
return fileTree
|
||||
}
|
||||
|
||||
const handleFileUpload = async (file: File, path: string = '') => {
|
||||
export const onDropExternalFiles = async (root: RootDirectory, destination: Folder, contents: Node[]): Promise<Upload[]> => {
|
||||
const uploader = getUploader()
|
||||
|
||||
try {
|
||||
return await uploader.upload(`${path}${file.name}`, file)
|
||||
} catch (e) {
|
||||
showError(t('files', 'Uploading "{filename}" failed', { filename: file.name }))
|
||||
throw e
|
||||
// Check for conflicts on root elements
|
||||
if (await hasConflict(root.contents, contents)) {
|
||||
root.contents = await resolveConflict(root.contents, destination, contents)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRecursiveUpload = async (entry: FileSystemEntry, path: string = ''): Promise<Upload[]> => {
|
||||
if (entry.isFile) {
|
||||
return [
|
||||
await new Promise<Upload>((resolve, reject) => {
|
||||
(entry as FileSystemFileEntry).file(
|
||||
async (file) => resolve(await handleFileUpload(file, path)),
|
||||
(error) => reject(error),
|
||||
)
|
||||
}),
|
||||
]
|
||||
} else {
|
||||
const directory = entry as FileSystemDirectoryEntry
|
||||
|
||||
// TODO: Implement this on `@nextcloud/upload`
|
||||
const absolutPath = joinPaths(davRootPath, getUploader().destination.path, path, directory.name)
|
||||
|
||||
logger.debug('Handle directory recursively', { name: directory.name, absolutPath })
|
||||
|
||||
const davClient = davGetClient()
|
||||
const dirExists = await davClient.exists(absolutPath)
|
||||
if (!dirExists) {
|
||||
logger.debug('Directory does not exist, creating it', { absolutPath })
|
||||
await davClient.createDirectory(absolutPath, { recursive: true })
|
||||
const stat = await davClient.stat(absolutPath, { details: true, data: davGetDefaultPropfind() }) as ResponseDataDetailed<FileStat>
|
||||
emit('files:node:created', davResultToNode(stat.data))
|
||||
}
|
||||
|
||||
const entries = await readDirectory(directory)
|
||||
// sorted so we upload files first before starting next level
|
||||
const promises = entries.sort((a) => a.isFile ? -1 : 1)
|
||||
.map((file) => handleRecursiveUpload(file, `${path}${directory.name}/`))
|
||||
return (await Promise.all(promises)).flat()
|
||||
if (root.contents.length === 0) {
|
||||
logger.info('No files to upload', { root })
|
||||
showInfo(t('files', 'No files to upload'))
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a directory using Filesystem API
|
||||
* @param directory the directory to read
|
||||
*/
|
||||
function readDirectory(directory: FileSystemDirectoryEntry) {
|
||||
const dirReader = directory.createReader()
|
||||
// Let's process the files
|
||||
logger.debug(`Uploading files to ${destination.path}`, { root, contents: root.contents })
|
||||
const queue = [] as Promise<Upload>[]
|
||||
|
||||
return new Promise<FileSystemEntry[]>((resolve, reject) => {
|
||||
const entries = [] as FileSystemEntry[]
|
||||
const getEntries = () => {
|
||||
dirReader.readEntries((results) => {
|
||||
if (results.length) {
|
||||
entries.push(...results)
|
||||
getEntries()
|
||||
} else {
|
||||
resolve(entries)
|
||||
const uploadDirectoryContents = async (directory: Directory, path: string) => {
|
||||
for (const file of directory.contents) {
|
||||
// This is the relative path to the resource
|
||||
// from the current uploader destination
|
||||
const relativePath = join(path, file.name)
|
||||
|
||||
// If the file is a directory, we need to create it first
|
||||
// then browse its tree and upload its contents.
|
||||
if (file instanceof Directory) {
|
||||
const absolutePath = joinPaths(davRootPath, destination.path, relativePath)
|
||||
try {
|
||||
console.debug('Processing directory', { relativePath })
|
||||
await createDirectoryIfNotExists(absolutePath)
|
||||
await uploadDirectoryContents(file, relativePath)
|
||||
} catch (error) {
|
||||
showError(t('files', 'Unable to create the directory {directory}', { directory: file.name }))
|
||||
logger.error('', { error, absolutePath, directory: file })
|
||||
}
|
||||
}, (error) => {
|
||||
reject(error)
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
getEntries()
|
||||
})
|
||||
// If we've reached a file, we can upload it
|
||||
logger.debug('Uploading file to ' + join(destination.path, relativePath), { file })
|
||||
|
||||
// Overriding the root to avoid changing the current uploader context
|
||||
queue.push(uploader.upload(relativePath, file, destination.source))
|
||||
}
|
||||
}
|
||||
|
||||
// Pause the uploader to prevent it from starting
|
||||
// while we compute the queue
|
||||
uploader.pause()
|
||||
|
||||
// Upload the files. Using '/' as the starting point
|
||||
// as we already adjusted the uploader destination
|
||||
await uploadDirectoryContents(root, '/')
|
||||
uploader.start()
|
||||
|
||||
// Wait for all promises to settle
|
||||
const results = await Promise.allSettled(queue)
|
||||
|
||||
// Check for errors
|
||||
const errors = results.filter(result => result.status === 'rejected')
|
||||
if (errors.length > 0) {
|
||||
logger.error('Error while uploading files', { errors })
|
||||
showError(t('files', 'Some files could not be uploaded'))
|
||||
return []
|
||||
}
|
||||
|
||||
logger.debug('Files uploaded successfully')
|
||||
showSuccess(t('files', 'Files uploaded successfully'))
|
||||
|
||||
return Promise.all(queue)
|
||||
}
|
||||
|
||||
export const onDropInternalFiles = async (nodes: Node[], destination: Folder, contents: Node[], isCopy = false) => {
|
||||
const queue = [] as Promise<void>[]
|
||||
|
||||
// Check for conflicts on root elements
|
||||
if (await hasConflict(nodes, contents)) {
|
||||
nodes = await resolveConflict(nodes, destination, contents)
|
||||
}
|
||||
|
||||
if (nodes.length === 0) {
|
||||
logger.info('No files to process', { nodes })
|
||||
showInfo(t('files', 'No files to process'))
|
||||
return
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
Vue.set(node, 'status', NodeStatus.LOADING)
|
||||
// TODO: resolve potential conflicts prior and force overwrite
|
||||
queue.push(handleCopyMoveNodeTo(node, destination, isCopy ? MoveCopyAction.COPY : MoveCopyAction.MOVE))
|
||||
}
|
||||
|
||||
// Wait for all promises to settle
|
||||
const results = await Promise.allSettled(queue)
|
||||
nodes.forEach(node => Vue.set(node, 'status', undefined))
|
||||
|
||||
// Check for errors
|
||||
const errors = results.filter(result => result.status === 'rejected')
|
||||
if (errors.length > 0) {
|
||||
logger.error('Error while copying or moving files', { errors })
|
||||
showError(isCopy ? t('files', 'Some files could not be copied') : t('files', 'Some files could not be moved'))
|
||||
return
|
||||
}
|
||||
|
||||
logger.debug('Files copy/move successful')
|
||||
showSuccess(isCopy ? t('files', 'Files copied successfully') : t('files', 'Files moved successfully'))
|
||||
}
|
||||
|
|
|
|||
142
apps/files/src/services/DropServiceUtils.spec.ts
Normal file
142
apps/files/src/services/DropServiceUtils.spec.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { describe, it, expect } from '@jest/globals'
|
||||
|
||||
import { FileSystemDirectoryEntry, FileSystemFileEntry, fileSystemEntryToDataTransferItem, DataTransferItem as DataTransferItemMock } from '../../../../__tests__/FileSystemAPIUtils'
|
||||
import { join } from 'node:path'
|
||||
import { Directory, traverseTree } from './DropServiceUtils'
|
||||
import { dataTransferToFileTree } from './DropService'
|
||||
import logger from '../logger'
|
||||
|
||||
const dataTree = {
|
||||
'file0.txt': ['Hello, world!', 1234567890],
|
||||
dir1: {
|
||||
'file1.txt': ['Hello, world!', 4567891230],
|
||||
'file2.txt': ['Hello, world!', 7891234560],
|
||||
},
|
||||
dir2: {
|
||||
'file3.txt': ['Hello, world!', 1234567890],
|
||||
},
|
||||
}
|
||||
|
||||
// This is mocking a file tree using the FileSystem API
|
||||
const buildFileSystemDirectoryEntry = (path: string, tree: any): FileSystemDirectoryEntry => {
|
||||
const entries = Object.entries(tree).map(([name, contents]) => {
|
||||
const fullPath = join(path, name)
|
||||
if (Array.isArray(contents)) {
|
||||
return new FileSystemFileEntry(fullPath, contents[0], contents[1])
|
||||
} else {
|
||||
return buildFileSystemDirectoryEntry(fullPath, contents)
|
||||
}
|
||||
})
|
||||
return new FileSystemDirectoryEntry(path, entries)
|
||||
}
|
||||
|
||||
const buildDataTransferItemArray = (path: string, tree: any, isFileSystemAPIAvailable = true): DataTransferItemMock[] => {
|
||||
return Object.entries(tree).map(([name, contents]) => {
|
||||
const fullPath = join(path, name)
|
||||
if (Array.isArray(contents)) {
|
||||
const entry = new FileSystemFileEntry(fullPath, contents[0], contents[1])
|
||||
return fileSystemEntryToDataTransferItem(entry, isFileSystemAPIAvailable)
|
||||
}
|
||||
|
||||
const entry = buildFileSystemDirectoryEntry(fullPath, contents)
|
||||
return fileSystemEntryToDataTransferItem(entry, isFileSystemAPIAvailable)
|
||||
})
|
||||
}
|
||||
|
||||
describe('Filesystem API traverseTree', () => {
|
||||
it('Should traverse a file tree from root', async () => {
|
||||
// Fake a FileSystemEntry tree
|
||||
const root = buildFileSystemDirectoryEntry('root', dataTree)
|
||||
const tree = await traverseTree(root as unknown as FileSystemEntry) as Directory
|
||||
|
||||
expect(tree.name).toBe('root')
|
||||
expect(tree).toBeInstanceOf(Directory)
|
||||
expect(tree.contents).toHaveLength(3)
|
||||
expect(tree.size).toBe(13 * 4) // 13 bytes from 'Hello, world!'
|
||||
})
|
||||
|
||||
it('Should traverse a file tree from a subdirectory', async () => {
|
||||
// Fake a FileSystemEntry tree
|
||||
const dir2 = buildFileSystemDirectoryEntry('dir2', dataTree.dir2)
|
||||
const tree = await traverseTree(dir2 as unknown as FileSystemEntry) as Directory
|
||||
|
||||
expect(tree.name).toBe('dir2')
|
||||
expect(tree).toBeInstanceOf(Directory)
|
||||
expect(tree.contents).toHaveLength(1)
|
||||
expect(tree.contents[0].name).toBe('file3.txt')
|
||||
expect(tree.size).toBe(13) // 13 bytes from 'Hello, world!'
|
||||
})
|
||||
|
||||
it('Should properly compute the last modified', async () => {
|
||||
// Fake a FileSystemEntry tree
|
||||
const root = buildFileSystemDirectoryEntry('root', dataTree)
|
||||
const rootTree = await traverseTree(root as unknown as FileSystemEntry) as Directory
|
||||
|
||||
expect(rootTree.lastModified).toBe(7891234560)
|
||||
|
||||
// Fake a FileSystemEntry tree
|
||||
const dir2 = buildFileSystemDirectoryEntry('root', dataTree.dir2)
|
||||
const dir2Tree = await traverseTree(dir2 as unknown as FileSystemEntry) as Directory
|
||||
expect(dir2Tree.lastModified).toBe(1234567890)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DropService dataTransferToFileTree', () => {
|
||||
|
||||
beforeAll(() => {
|
||||
// DataTransferItem doesn't exists in jsdom, let's mock
|
||||
// a dumb one so we can check the instanceof
|
||||
// @ts-expect-error jsdom doesn't have DataTransferItem
|
||||
window.DataTransferItem = DataTransferItemMock
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
// @ts-expect-error jsdom doesn't have DataTransferItem
|
||||
delete window.DataTransferItem
|
||||
})
|
||||
|
||||
it('Should return a RootDirectory with Filesystem API', async () => {
|
||||
jest.spyOn(logger, 'error').mockImplementation(() => jest.fn())
|
||||
jest.spyOn(logger, 'warn').mockImplementation(() => jest.fn())
|
||||
|
||||
const dataTransferItems = buildDataTransferItemArray('root', dataTree)
|
||||
const fileTree = await dataTransferToFileTree(dataTransferItems as unknown as DataTransferItem[])
|
||||
|
||||
expect(fileTree.name).toBe('root')
|
||||
expect(fileTree).toBeInstanceOf(Directory)
|
||||
expect(fileTree.contents).toHaveLength(3)
|
||||
|
||||
// The file tree should be recursive when using the Filesystem API
|
||||
expect(fileTree.contents[1]).toBeInstanceOf(Directory)
|
||||
expect((fileTree.contents[1] as Directory).contents).toHaveLength(2)
|
||||
expect(fileTree.contents[2]).toBeInstanceOf(Directory)
|
||||
expect((fileTree.contents[2] as Directory).contents).toHaveLength(1)
|
||||
|
||||
expect(logger.error).not.toBeCalled()
|
||||
expect(logger.warn).not.toBeCalled()
|
||||
})
|
||||
|
||||
it('Should return a RootDirectory with legacy File API ignoring recursive directories', async () => {
|
||||
jest.spyOn(logger, 'error').mockImplementation(() => jest.fn())
|
||||
jest.spyOn(logger, 'warn').mockImplementation(() => jest.fn())
|
||||
|
||||
const dataTransferItems = buildDataTransferItemArray('root', dataTree, false)
|
||||
|
||||
const fileTree = await dataTransferToFileTree(dataTransferItems as unknown as DataTransferItem[])
|
||||
|
||||
expect(fileTree.name).toBe('root')
|
||||
expect(fileTree).toBeInstanceOf(Directory)
|
||||
expect(fileTree.contents).toHaveLength(1)
|
||||
|
||||
// The file tree should be recursive when using the Filesystem API
|
||||
expect(fileTree.contents[0]).not.toBeInstanceOf(Directory)
|
||||
expect((fileTree.contents[0].name)).toBe('file0.txt')
|
||||
|
||||
expect(logger.error).not.toBeCalled()
|
||||
expect(logger.warn).toHaveBeenNthCalledWith(1, 'Could not get FilesystemEntry of item, falling back to file')
|
||||
expect(logger.warn).toHaveBeenNthCalledWith(2, 'Could not get FilesystemEntry of item, falling back to file')
|
||||
expect(logger.warn).toHaveBeenNthCalledWith(3, 'Browser does not support Filesystem API. Directories will not be uploaded')
|
||||
expect(logger.warn).toHaveBeenNthCalledWith(4, 'Could not get FilesystemEntry of item, falling back to file')
|
||||
expect(logger.warn).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
})
|
||||
195
apps/files/src/services/DropServiceUtils.ts
Normal file
195
apps/files/src/services/DropServiceUtils.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
/**
|
||||
* @copyright Copyright (c) 2024 John Molakvoæ <skjnldsv@protonmail.com>
|
||||
*
|
||||
* @author John Molakvoæ <skjnldsv@protonmail.com>
|
||||
*
|
||||
* @license AGPL-3.0-or-later
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
import type { FileStat, ResponseDataDetailed } from 'webdav'
|
||||
|
||||
import { emit } from '@nextcloud/event-bus'
|
||||
import { Folder, Node, davGetClient, davGetDefaultPropfind, davResultToNode } from '@nextcloud/files'
|
||||
import { openConflictPicker } from '@nextcloud/upload'
|
||||
import { showError, showInfo } from '@nextcloud/dialogs'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
|
||||
import logger from '../logger.js'
|
||||
|
||||
/**
|
||||
* This represents a Directory in the file tree
|
||||
* We extend the File class to better handling uploading
|
||||
* and stay as close as possible as the Filesystem API.
|
||||
* This also allow us to hijack the size or lastModified
|
||||
* properties to compute them dynamically.
|
||||
*/
|
||||
export class Directory extends File {
|
||||
|
||||
/* eslint-disable no-use-before-define */
|
||||
_contents: (Directory|File)[]
|
||||
|
||||
constructor(name, contents: (Directory|File)[] = []) {
|
||||
super([], name, { type: 'httpd/unix-directory' })
|
||||
this._contents = contents
|
||||
}
|
||||
|
||||
set contents(contents: (Directory|File)[]) {
|
||||
this._contents = contents
|
||||
}
|
||||
|
||||
get contents(): (Directory|File)[] {
|
||||
return this._contents
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this._computeDirectorySize(this)
|
||||
}
|
||||
|
||||
get lastModified() {
|
||||
if (this._contents.length === 0) {
|
||||
return Date.now()
|
||||
}
|
||||
return this._computeDirectoryMtime(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last modification time of a file tree
|
||||
* This is not perfect, but will get us a pretty good approximation
|
||||
* @param directory the directory to traverse
|
||||
*/
|
||||
_computeDirectoryMtime(directory: Directory): number {
|
||||
return directory.contents.reduce((acc, file) => {
|
||||
return file.lastModified > acc
|
||||
// If the file is a directory, the lastModified will
|
||||
// also return the results of its _computeDirectoryMtime method
|
||||
// Fancy recursion, huh?
|
||||
? file.lastModified
|
||||
: acc
|
||||
}, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the size of a file tree
|
||||
* @param directory the directory to traverse
|
||||
*/
|
||||
_computeDirectorySize(directory: Directory): number {
|
||||
return directory.contents.reduce((acc: number, entry: Directory|File) => {
|
||||
// If the file is a directory, the size will
|
||||
// also return the results of its _computeDirectorySize method
|
||||
// Fancy recursion, huh?
|
||||
return acc + entry.size
|
||||
}, 0)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export type RootDirectory = Directory & {
|
||||
name: 'root'
|
||||
}
|
||||
|
||||
/**
|
||||
* Traverse a file tree using the Filesystem API
|
||||
* @param entry the entry to traverse
|
||||
*/
|
||||
export const traverseTree = async (entry: FileSystemEntry): Promise<Directory|File> => {
|
||||
// Handle file
|
||||
if (entry.isFile) {
|
||||
return new Promise<File>((resolve, reject) => {
|
||||
(entry as FileSystemFileEntry).file(resolve, reject)
|
||||
})
|
||||
}
|
||||
|
||||
// Handle directory
|
||||
logger.debug('Handling recursive file tree', { entry: entry.name })
|
||||
const directory = entry as FileSystemDirectoryEntry
|
||||
const entries = await readDirectory(directory)
|
||||
const contents = (await Promise.all(entries.map(traverseTree))).flat()
|
||||
return new Directory(directory.name, contents)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a directory using Filesystem API
|
||||
* @param directory the directory to read
|
||||
*/
|
||||
const readDirectory = (directory: FileSystemDirectoryEntry): Promise<FileSystemEntry[]> => {
|
||||
const dirReader = directory.createReader()
|
||||
|
||||
return new Promise<FileSystemEntry[]>((resolve, reject) => {
|
||||
const entries = [] as FileSystemEntry[]
|
||||
const getEntries = () => {
|
||||
dirReader.readEntries((results) => {
|
||||
if (results.length) {
|
||||
entries.push(...results)
|
||||
getEntries()
|
||||
} else {
|
||||
resolve(entries)
|
||||
}
|
||||
}, (error) => {
|
||||
reject(error)
|
||||
})
|
||||
}
|
||||
|
||||
getEntries()
|
||||
})
|
||||
}
|
||||
|
||||
export const createDirectoryIfNotExists = async (absolutePath: string) => {
|
||||
const davClient = davGetClient()
|
||||
const dirExists = await davClient.exists(absolutePath)
|
||||
if (!dirExists) {
|
||||
logger.debug('Directory does not exist, creating it', { absolutePath })
|
||||
await davClient.createDirectory(absolutePath, { recursive: true })
|
||||
const stat = await davClient.stat(absolutePath, { details: true, data: davGetDefaultPropfind() }) as ResponseDataDetailed<FileStat>
|
||||
emit('files:node:created', davResultToNode(stat.data))
|
||||
}
|
||||
}
|
||||
|
||||
export const resolveConflict = async <T extends ((Directory|File)|Node)>(files: Array<T>, destination: Folder, contents: Node[]): Promise<T[]> => {
|
||||
try {
|
||||
// List all conflicting files
|
||||
const conflicts = files.filter((file: File|Node) => {
|
||||
return contents.find((node: Node) => node.basename === (file instanceof File ? file.name : file.basename))
|
||||
}).filter(Boolean) as (File|Node)[]
|
||||
|
||||
// List of incoming files that are NOT in conflict
|
||||
const uploads = files.filter((file: File|Node) => {
|
||||
return !conflicts.includes(file)
|
||||
})
|
||||
|
||||
// Let the user choose what to do with the conflicting files
|
||||
const { selected, renamed } = await openConflictPicker(destination.path, conflicts, contents)
|
||||
|
||||
logger.debug('Conflict resolution', { uploads, selected, renamed })
|
||||
|
||||
// If the user selected nothing, we cancel the upload
|
||||
if (selected.length === 0 && renamed.length === 0) {
|
||||
// User skipped
|
||||
showInfo(t('files', 'Conflicts resolution skipped'))
|
||||
logger.info('User skipped the conflict resolution')
|
||||
return []
|
||||
}
|
||||
|
||||
// Update the list of files to upload
|
||||
return [...uploads, ...selected, ...renamed] as (typeof files)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
// User cancelled
|
||||
showError(t('files', 'Upload cancelled'))
|
||||
logger.error('User cancelled the upload')
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ export default defineConfig({
|
|||
experimentalInteractiveRunEvents: true,
|
||||
|
||||
// faster video processing
|
||||
video: !process.env.CI,
|
||||
videoCompression: false,
|
||||
|
||||
// Prevent elements to be scrolled under a top bar during actions (click, clear, type, etc). Default is 'top'.
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ describe('files: Drag and Drop', { testIsolation: true }, () => {
|
|||
|
||||
cy.intercept('PUT', /\/remote.php\/dav\/files\//).as('uploadFile')
|
||||
|
||||
// Make sure the drop notice is not visible
|
||||
cy.get('[data-cy-files-drag-drop-area]').should('not.be.visible')
|
||||
|
||||
// Trigger the drop notice
|
||||
cy.get('main.app-content').trigger('dragover', { dataTransfer })
|
||||
cy.get('[data-cy-files-drag-drop-area]').should('be.visible')
|
||||
|
|
@ -27,6 +29,11 @@ describe('files: Drag and Drop', { testIsolation: true }, () => {
|
|||
|
||||
cy.wait('@uploadFile')
|
||||
|
||||
// Make sure the upload is finished
|
||||
cy.get('[data-cy-files-drag-drop-area]').should('not.be.visible')
|
||||
cy.get('[data-cy-upload-picker] progress').should('not.be.visible')
|
||||
cy.get('@uploadFile.all').should('have.length', 1)
|
||||
|
||||
getRowForFile('single-file.txt').should('be.visible')
|
||||
getRowForFile('single-file.txt').find('[data-cy-files-list-row-size]').should('contain', '6 KB')
|
||||
})
|
||||
|
|
@ -38,6 +45,9 @@ describe('files: Drag and Drop', { testIsolation: true }, () => {
|
|||
|
||||
cy.intercept('PUT', /\/remote.php\/dav\/files\//).as('uploadFile')
|
||||
|
||||
// Make sure the drop notice is not visible
|
||||
cy.get('[data-cy-files-drag-drop-area]').should('not.be.visible')
|
||||
|
||||
// Trigger the drop notice
|
||||
cy.get('main.app-content').trigger('dragover', { dataTransfer })
|
||||
cy.get('[data-cy-files-drag-drop-area]').should('be.visible')
|
||||
|
|
@ -56,7 +66,71 @@ describe('files: Drag and Drop', { testIsolation: true }, () => {
|
|||
|
||||
cy.wait('@uploadFile')
|
||||
|
||||
// Make sure the upload is finished
|
||||
cy.get('[data-cy-files-drag-drop-area]').should('not.be.visible')
|
||||
cy.get('[data-cy-upload-picker] progress').should('not.be.visible')
|
||||
cy.get('@uploadFile.all').should('have.length', 2)
|
||||
|
||||
getRowForFile('first.txt').should('be.visible')
|
||||
getRowForFile('second.txt').should('be.visible')
|
||||
})
|
||||
|
||||
it('will ignore legacy Folders', () => {
|
||||
cy.window().then((win) => {
|
||||
// Remove the Filesystem API to force the legacy File API
|
||||
// See how cypress mocks the Filesystem API in https://github.com/cypress-io/cypress/blob/74109094a92df3bef073dda15f17194f31850d7d/packages/driver/src/cy/commands/actions/selectFile.ts#L24-L37
|
||||
Object.defineProperty(win.DataTransferItem.prototype, 'getAsEntry', { get: undefined })
|
||||
Object.defineProperty(win.DataTransferItem.prototype, 'webkitGetAsEntry', { get: undefined })
|
||||
})
|
||||
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(new File([], 'first.txt'))
|
||||
dataTransfer.items.add(new File([], 'second.txt'))
|
||||
|
||||
// Legacy File API (not FileSystem API), will treat Folders as Files
|
||||
// with empty type and empty content
|
||||
dataTransfer.items.add(new File([], 'Foo', { type: 'httpd/unix-directory' }))
|
||||
dataTransfer.items.add(new File([], 'Bar'))
|
||||
|
||||
cy.intercept('PUT', /\/remote.php\/dav\/files\//).as('uploadFile')
|
||||
|
||||
// Make sure the drop notice is not visible
|
||||
cy.get('[data-cy-files-drag-drop-area]').should('not.be.visible')
|
||||
|
||||
// Trigger the drop notice
|
||||
cy.get('main.app-content').trigger('dragover', { dataTransfer })
|
||||
cy.get('[data-cy-files-drag-drop-area]').should('be.visible')
|
||||
|
||||
// Upload drop a file
|
||||
cy.get('[data-cy-files-drag-drop-area]').selectFile([
|
||||
{
|
||||
fileName: 'first.txt',
|
||||
contents: ['Hello'],
|
||||
},
|
||||
{
|
||||
fileName: 'second.txt',
|
||||
contents: ['World'],
|
||||
},
|
||||
{
|
||||
fileName: 'Foo',
|
||||
contents: {},
|
||||
},
|
||||
{
|
||||
fileName: 'Bar',
|
||||
contents: { mimeType: 'httpd/unix-directory' },
|
||||
},
|
||||
], { action: 'drag-drop' })
|
||||
|
||||
cy.wait('@uploadFile')
|
||||
|
||||
// Make sure the upload is finished
|
||||
cy.get('[data-cy-files-drag-drop-area]').should('not.be.visible')
|
||||
cy.get('[data-cy-upload-picker] progress').should('not.be.visible')
|
||||
cy.get('@uploadFile.all').should('have.length', 2)
|
||||
|
||||
getRowForFile('first.txt').should('be.visible')
|
||||
getRowForFile('second.txt').should('be.visible')
|
||||
getRowForFile('Foo').should('not.exist')
|
||||
getRowForFile('Bar').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
2
dist/3841-3841.js
vendored
Normal file
2
dist/3841-3841.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/3841-3841.js.map
vendored
Normal file
1
dist/3841-3841.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
3
dist/5225-5225.js
vendored
3
dist/5225-5225.js
vendored
File diff suppressed because one or more lines are too long
9
dist/5225-5225.js.LICENSE.txt
vendored
9
dist/5225-5225.js.LICENSE.txt
vendored
|
|
@ -1,9 +0,0 @@
|
|||
//! authors : Tim Wood, Iskren Chernev, Moment.js contributors
|
||||
|
||||
//! license : MIT
|
||||
|
||||
//! moment.js
|
||||
|
||||
//! momentjs.com
|
||||
|
||||
//! version : 2.29.4
|
||||
1
dist/5225-5225.js.map
vendored
1
dist/5225-5225.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/core-common.js
vendored
4
dist/core-common.js
vendored
File diff suppressed because one or more lines are too long
2
dist/core-common.js.map
vendored
2
dist/core-common.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/files-init.js
vendored
4
dist/files-init.js
vendored
File diff suppressed because one or more lines are too long
2
dist/files-init.js.map
vendored
2
dist/files-init.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/files-main.js
vendored
4
dist/files-main.js
vendored
File diff suppressed because one or more lines are too long
3
dist/files-main.js.LICENSE.txt
vendored
3
dist/files-main.js.LICENSE.txt
vendored
|
|
@ -146,6 +146,7 @@
|
|||
* @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de>
|
||||
*
|
||||
* @author Ferdinand Thiessen <opensource@fthiessen.de>
|
||||
* @author John Molakvoæ <skjnldsv@protonmail.com>
|
||||
*
|
||||
* @license AGPL-3.0-or-later
|
||||
*
|
||||
|
|
@ -155,7 +156,7 @@
|
|||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* but WITHOUT ANY WARRANTY without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
|
|
|
|||
2
dist/files-main.js.map
vendored
2
dist/files-main.js.map
vendored
File diff suppressed because one or more lines are too long
|
|
@ -26,18 +26,25 @@ const ignorePatterns = [
|
|||
'@buttercup/fetch',
|
||||
'@juliushaertl',
|
||||
'@mdi/svg',
|
||||
'@nextcloud/upload',
|
||||
'@nextcloud/vue',
|
||||
'ansi-regex',
|
||||
'camelcase',
|
||||
'char-regex',
|
||||
'hot-patcher',
|
||||
'is-svg',
|
||||
'mime',
|
||||
'p-cancelable',
|
||||
'p-limit',
|
||||
'p-queue',
|
||||
'p-timeout',
|
||||
'splitpanes',
|
||||
'string-length',
|
||||
'strip-ansi',
|
||||
'tributejs',
|
||||
'vue-material-design-icons',
|
||||
'webdav',
|
||||
'yocto-queue',
|
||||
]
|
||||
|
||||
const config: Config = {
|
||||
|
|
|
|||
64
package-lock.json
generated
64
package-lock.json
generated
|
|
@ -29,7 +29,7 @@
|
|||
"@nextcloud/paths": "^2.1.0",
|
||||
"@nextcloud/router": "^3.0.0",
|
||||
"@nextcloud/sharing": "^0.1.0",
|
||||
"@nextcloud/upload": "^1.0.5",
|
||||
"@nextcloud/upload": "^1.1.0",
|
||||
"@nextcloud/vue": "^8.11.1",
|
||||
"@skjnldsv/sanitize-svg": "^1.0.2",
|
||||
"@vueuse/components": "^10.7.2",
|
||||
|
|
@ -142,6 +142,7 @@
|
|||
"karma-jasmine-sinon": "^1.0.4",
|
||||
"karma-spec-reporter": "^0.0.36",
|
||||
"karma-viewport": "^1.0.9",
|
||||
"mime": "^4.0.1",
|
||||
"puppeteer": "^22.5.0",
|
||||
"raw-loader": "^4.0.2",
|
||||
"regextras": "^0.8.0",
|
||||
|
|
@ -4528,19 +4529,20 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@nextcloud/upload": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@nextcloud/upload/-/upload-1.0.5.tgz",
|
||||
"integrity": "sha512-QMojKvnBnxmxiKaFTpFIugaGsVQtjCvOrLdKzpa5IoNhouupI0vrE77aEXZuoOrhUHga9unN1YSA2hY0n8WrOQ==",
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@nextcloud/upload/-/upload-1.1.0.tgz",
|
||||
"integrity": "sha512-SRBNKrPWZNMLwCkIiDfSvcDlbGisaliAbUDW0p7D0s4nA1zAG8Xfew87NQxmxNeqVeAM7IP8O83jd5MSPjKYDw==",
|
||||
"dependencies": {
|
||||
"@nextcloud/auth": "^2.2.1",
|
||||
"@nextcloud/axios": "^2.4.0",
|
||||
"@nextcloud/dialogs": "^5.0.0-beta.6",
|
||||
"@nextcloud/files": "^3.0.0",
|
||||
"@nextcloud/dialogs": "^5.2.0",
|
||||
"@nextcloud/files": "^3.1.1",
|
||||
"@nextcloud/l10n": "^2.2.0",
|
||||
"@nextcloud/logger": "^2.7.0",
|
||||
"@nextcloud/moment": "^1.3.1",
|
||||
"@nextcloud/paths": "^2.1.0",
|
||||
"@nextcloud/router": "^2.2.0",
|
||||
"axios": "^1.6.2",
|
||||
"@nextcloud/router": "^3.0.0",
|
||||
"axios": "^1.6.8",
|
||||
"buffer": "^6.0.3",
|
||||
"crypto-browserify": "^3.12.0",
|
||||
"p-cancelable": "^4.0.1",
|
||||
|
|
@ -4557,19 +4559,6 @@
|
|||
"vue": "^2.7.16"
|
||||
}
|
||||
},
|
||||
"node_modules/@nextcloud/upload/node_modules/@nextcloud/router": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@nextcloud/router/-/router-2.2.0.tgz",
|
||||
"integrity": "sha512-M4AVGnB5tt3MYO5RpH/R2jq7z/nW05AmRhk4Lh68krVwRIYGo8pgNikKrPGogHd2Q3UgzF5Py1drHz3uuV99bQ==",
|
||||
"dependencies": {
|
||||
"@nextcloud/typings": "^1.7.0",
|
||||
"core-js": "^3.6.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.0.0",
|
||||
"npm": "^9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nextcloud/upload/node_modules/eventemitter3": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
|
||||
|
|
@ -7554,11 +7543,11 @@
|
|||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.6.7",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.6.7.tgz",
|
||||
"integrity": "sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==",
|
||||
"version": "1.6.8",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz",
|
||||
"integrity": "sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.4",
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.0",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
|
|
@ -18470,6 +18459,18 @@
|
|||
"wrap-ansi": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/karma/node_modules/mime": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
|
||||
"integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/karma/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
|
|
@ -20127,15 +20128,18 @@
|
|||
"integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA=="
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
|
||||
"integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-4.0.1.tgz",
|
||||
"integrity": "sha512-5lZ5tyrIfliMXzFtkYyekWbtRXObT9OWa8IwQ5uxTBDHucNNwniRqo0yInflj+iYi5CBa6qxadGzGarDfuEOxA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa"
|
||||
],
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
"mime": "bin/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@
|
|||
"@nextcloud/paths": "^2.1.0",
|
||||
"@nextcloud/router": "^3.0.0",
|
||||
"@nextcloud/sharing": "^0.1.0",
|
||||
"@nextcloud/upload": "^1.0.5",
|
||||
"@nextcloud/upload": "^1.1.0",
|
||||
"@nextcloud/vue": "^8.11.1",
|
||||
"@skjnldsv/sanitize-svg": "^1.0.2",
|
||||
"@vueuse/components": "^10.7.2",
|
||||
|
|
@ -169,6 +169,7 @@
|
|||
"karma-jasmine-sinon": "^1.0.4",
|
||||
"karma-spec-reporter": "^0.0.36",
|
||||
"karma-viewport": "^1.0.9",
|
||||
"mime": "^4.0.1",
|
||||
"puppeteer": "^22.5.0",
|
||||
"raw-loader": "^4.0.2",
|
||||
"regextras": "^0.8.0",
|
||||
|
|
|
|||
Loading…
Reference in a new issue