Merge pull request #44409 from nextcloud/fix/files-dnd-files

pull/40407/head
John Molakvoæ 2 months ago committed by GitHub
commit 32e86052d5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

2
.gitignore vendored

@ -171,6 +171,6 @@ composer.phar
core/js/mimetypelist.js
# Tests - cypress
cypress/downloads
cypress/snapshots
cypress/videos
cypress/downloads

@ -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 })
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'))
}
// 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']),
},
})
}
// Caching the selection
const items: DataTransferItem[] = [...event.dataTransfer?.items || []]
// 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.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))
if (root.contents.length === 0) {
logger.info('No files to upload', { root })
showInfo(t('files', 'No files to upload'))
return []
}
// Let's process the files
logger.debug(`Uploading files to ${destination.path}`, { root, contents: root.contents })
const queue = [] as Promise<Upload>[]
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 })
}
continue
}
// 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()
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()
// 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)
}
/**
* Read a directory using Filesystem API
* @param directory the directory to read
*/
function readDirectory(directory: FileSystemDirectoryEntry) {
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)
})
}
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
}
getEntries()
})
logger.debug('Files copy/move successful')
showSuccess(isCopy ? t('files', 'Files copied successfully') : t('files', 'Files moved successfully'))
}

@ -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)
})
})

@ -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,58 @@ 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')
// Upload drop a file
cy.get('[data-cy-files-drag-drop-area]').selectFile([
{
fileName: 'first.txt',
contents: ['Hello'],
},
{
fileName: 'second.txt',
contents: ['World'],
},
], { 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')
})
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')
@ -52,11 +111,26 @@ describe('files: Drag and Drop', { testIsolation: true }, () => {
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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

3
dist/5225-5225.js vendored

File diff suppressed because one or more lines are too long

@ -1,9 +0,0 @@
//! authors : Tim Wood, Iskren Chernev, Moment.js contributors
//! license : MIT
//! moment.js
//! momentjs.com
//! version : 2.29.4

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -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.
*

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

@ -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…
Cancel
Save