You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

DropService.ts 7.9KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. /**
  2. * @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de>
  3. *
  4. * @author Ferdinand Thiessen <opensource@fthiessen.de>
  5. * @author John Molakvoæ <skjnldsv@protonmail.com>
  6. *
  7. * @license AGPL-3.0-or-later
  8. *
  9. * This program is free software: you can redistribute it and/or modify
  10. * it under the terms of the GNU Affero General Public License as
  11. * published by the Free Software Foundation, either version 3 of the
  12. * License, or (at your option) any later version.
  13. *
  14. * This program is distributed in the hope that it will be useful,
  15. * but WITHOUT ANY WARRANTY without even the implied warranty of
  16. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  17. * GNU Affero General Public License for more details.
  18. *
  19. * You should have received a copy of the GNU Affero General Public License
  20. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  21. *
  22. */
  23. import type { Upload } from '@nextcloud/upload'
  24. import type { RootDirectory } from './DropServiceUtils'
  25. import { Folder, Node, NodeStatus, davRootPath } from '@nextcloud/files'
  26. import { getUploader, hasConflict } from '@nextcloud/upload'
  27. import { join } from 'path'
  28. import { joinPaths } from '@nextcloud/paths'
  29. import { showError, showInfo, showSuccess, showWarning } from '@nextcloud/dialogs'
  30. import { translate as t } from '@nextcloud/l10n'
  31. import Vue from 'vue'
  32. import { Directory, traverseTree, resolveConflict, createDirectoryIfNotExists } from './DropServiceUtils'
  33. import { handleCopyMoveNodeTo } from '../actions/moveOrCopyAction'
  34. import { MoveCopyAction } from '../actions/moveOrCopyActionUtils'
  35. import logger from '../logger.js'
  36. /**
  37. * This function converts a list of DataTransferItems to a file tree.
  38. * It uses the Filesystem API if available, otherwise it falls back to the File API.
  39. * The File API will NOT be available if the browser is not in a secure context (e.g. HTTP).
  40. * ⚠️ When using this method, you need to use it as fast as possible, as the DataTransferItems
  41. * will be cleared after the first access to the props of one of the entries.
  42. *
  43. * @param items the list of DataTransferItems
  44. */
  45. export const dataTransferToFileTree = async (items: DataTransferItem[]): Promise<RootDirectory> => {
  46. // Check if the browser supports the Filesystem API
  47. // We need to cache the entries to prevent Blink engine bug that clears
  48. // the list (`data.items`) after first access props of one of the entries
  49. const entries = items
  50. .filter((item) => {
  51. if (item.kind !== 'file') {
  52. logger.debug('Skipping dropped item', { kind: item.kind, type: item.type })
  53. return false
  54. }
  55. return true
  56. }).map((item) => {
  57. // MDN recommends to try both, as it might be renamed in the future
  58. return (item as unknown as { getAsEntry?: () => FileSystemEntry|undefined })?.getAsEntry?.()
  59. ?? item?.webkitGetAsEntry?.()
  60. ?? item
  61. }) as (FileSystemEntry | DataTransferItem)[]
  62. let warned = false
  63. const fileTree = new Directory('root') as RootDirectory
  64. // Traverse the file tree
  65. for (const entry of entries) {
  66. // Handle browser issues if Filesystem API is not available. Fallback to File API
  67. if (entry instanceof DataTransferItem) {
  68. logger.warn('Could not get FilesystemEntry of item, falling back to file')
  69. const file = entry.getAsFile()
  70. if (file === null) {
  71. logger.warn('Could not process DataTransferItem', { type: entry.type, kind: entry.kind })
  72. showError(t('files', 'One of the dropped files could not be processed'))
  73. continue
  74. }
  75. // Warn the user that the browser does not support the Filesystem API
  76. // we therefore cannot upload directories recursively.
  77. if (file.type === 'httpd/unix-directory' || !file.type) {
  78. if (!warned) {
  79. logger.warn('Browser does not support Filesystem API. Directories will not be uploaded')
  80. showWarning(t('files', 'Your browser does not support the Filesystem API. Directories will not be uploaded'))
  81. warned = true
  82. }
  83. continue
  84. }
  85. fileTree.contents.push(file)
  86. continue
  87. }
  88. // Use Filesystem API
  89. try {
  90. fileTree.contents.push(await traverseTree(entry))
  91. } catch (error) {
  92. // Do not throw, as we want to continue with the other files
  93. logger.error('Error while traversing file tree', { error })
  94. }
  95. }
  96. return fileTree
  97. }
  98. export const onDropExternalFiles = async (root: RootDirectory, destination: Folder, contents: Node[]): Promise<Upload[]> => {
  99. const uploader = getUploader()
  100. // Check for conflicts on root elements
  101. if (await hasConflict(root.contents, contents)) {
  102. root.contents = await resolveConflict(root.contents, destination, contents)
  103. }
  104. if (root.contents.length === 0) {
  105. logger.info('No files to upload', { root })
  106. showInfo(t('files', 'No files to upload'))
  107. return []
  108. }
  109. // Let's process the files
  110. logger.debug(`Uploading files to ${destination.path}`, { root, contents: root.contents })
  111. const queue = [] as Promise<Upload>[]
  112. const uploadDirectoryContents = async (directory: Directory, path: string) => {
  113. for (const file of directory.contents) {
  114. // This is the relative path to the resource
  115. // from the current uploader destination
  116. const relativePath = join(path, file.name)
  117. // If the file is a directory, we need to create it first
  118. // then browse its tree and upload its contents.
  119. if (file instanceof Directory) {
  120. const absolutePath = joinPaths(davRootPath, destination.path, relativePath)
  121. try {
  122. console.debug('Processing directory', { relativePath })
  123. await createDirectoryIfNotExists(absolutePath)
  124. await uploadDirectoryContents(file, relativePath)
  125. } catch (error) {
  126. showError(t('files', 'Unable to create the directory {directory}', { directory: file.name }))
  127. logger.error('', { error, absolutePath, directory: file })
  128. }
  129. continue
  130. }
  131. // If we've reached a file, we can upload it
  132. logger.debug('Uploading file to ' + join(destination.path, relativePath), { file })
  133. // Overriding the root to avoid changing the current uploader context
  134. queue.push(uploader.upload(relativePath, file, destination.source))
  135. }
  136. }
  137. // Pause the uploader to prevent it from starting
  138. // while we compute the queue
  139. uploader.pause()
  140. // Upload the files. Using '/' as the starting point
  141. // as we already adjusted the uploader destination
  142. await uploadDirectoryContents(root, '/')
  143. uploader.start()
  144. // Wait for all promises to settle
  145. const results = await Promise.allSettled(queue)
  146. // Check for errors
  147. const errors = results.filter(result => result.status === 'rejected')
  148. if (errors.length > 0) {
  149. logger.error('Error while uploading files', { errors })
  150. showError(t('files', 'Some files could not be uploaded'))
  151. return []
  152. }
  153. logger.debug('Files uploaded successfully')
  154. showSuccess(t('files', 'Files uploaded successfully'))
  155. return Promise.all(queue)
  156. }
  157. export const onDropInternalFiles = async (nodes: Node[], destination: Folder, contents: Node[], isCopy = false) => {
  158. const queue = [] as Promise<void>[]
  159. // Check for conflicts on root elements
  160. if (await hasConflict(nodes, contents)) {
  161. nodes = await resolveConflict(nodes, destination, contents)
  162. }
  163. if (nodes.length === 0) {
  164. logger.info('No files to process', { nodes })
  165. showInfo(t('files', 'No files to process'))
  166. return
  167. }
  168. for (const node of nodes) {
  169. Vue.set(node, 'status', NodeStatus.LOADING)
  170. // TODO: resolve potential conflicts prior and force overwrite
  171. queue.push(handleCopyMoveNodeTo(node, destination, isCopy ? MoveCopyAction.COPY : MoveCopyAction.MOVE))
  172. }
  173. // Wait for all promises to settle
  174. const results = await Promise.allSettled(queue)
  175. nodes.forEach(node => Vue.set(node, 'status', undefined))
  176. // Check for errors
  177. const errors = results.filter(result => result.status === 'rejected')
  178. if (errors.length > 0) {
  179. logger.error('Error while copying or moving files', { errors })
  180. showError(isCopy ? t('files', 'Some files could not be copied') : t('files', 'Some files could not be moved'))
  181. return
  182. }
  183. logger.debug('Files copy/move successful')
  184. showSuccess(isCopy ? t('files', 'Files copied successfully') : t('files', 'Files moved successfully'))
  185. }