moveOrCopyAction.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. /**
  2. * @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
  3. *
  4. * @author John Molakvoæ <skjnldsv@protonmail.com>
  5. *
  6. * @license AGPL-3.0-or-later
  7. *
  8. * This program is free software: you can redistribute it and/or modify
  9. * it under the terms of the GNU Affero General Public License as
  10. * published by the Free Software Foundation, either version 3 of the
  11. * License, or (at your option) any later version.
  12. *
  13. * This program is distributed in the hope that it will be useful,
  14. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  15. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  16. * GNU Affero General Public License for more details.
  17. *
  18. * You should have received a copy of the GNU Affero General Public License
  19. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  20. *
  21. */
  22. import '@nextcloud/dialogs/style.css'
  23. import type { Folder, Node, View } from '@nextcloud/files'
  24. import type { IFilePickerButton } from '@nextcloud/dialogs'
  25. // eslint-disable-next-line n/no-extraneous-import
  26. import { AxiosError } from 'axios'
  27. import { basename, join } from 'path'
  28. import { emit } from '@nextcloud/event-bus'
  29. import { generateRemoteUrl } from '@nextcloud/router'
  30. import { getCurrentUser } from '@nextcloud/auth'
  31. import { getFilePickerBuilder, showError } from '@nextcloud/dialogs'
  32. import { Permission, FileAction, FileType, NodeStatus } from '@nextcloud/files'
  33. import { translate as t } from '@nextcloud/l10n'
  34. import axios from '@nextcloud/axios'
  35. import Vue from 'vue'
  36. import CopyIcon from 'vue-material-design-icons/FileMultiple.vue'
  37. import FolderMoveSvg from '@mdi/svg/svg/folder-move.svg?raw'
  38. import MoveIcon from 'vue-material-design-icons/FolderMove.vue'
  39. import { MoveCopyAction, canCopy, canMove, getQueue } from './moveOrCopyActionUtils'
  40. import logger from '../logger'
  41. /**
  42. * Return the action that is possible for the given nodes
  43. * @param {Node[]} nodes The nodes to check against
  44. * @return {MoveCopyAction} The action that is possible for the given nodes
  45. */
  46. const getActionForNodes = (nodes: Node[]): MoveCopyAction => {
  47. if (canMove(nodes)) {
  48. if (canCopy(nodes)) {
  49. return MoveCopyAction.MOVE_OR_COPY
  50. }
  51. return MoveCopyAction.MOVE
  52. }
  53. // Assuming we can copy as the enabled checks for copy permissions
  54. return MoveCopyAction.COPY
  55. }
  56. /**
  57. * Handle the copy/move of a node to a destination
  58. * This can be imported and used by other scripts/components on server
  59. * @param {Node} node The node to copy/move
  60. * @param {Folder} destination The destination to copy/move the node to
  61. * @param {MoveCopyAction} method The method to use for the copy/move
  62. * @param {boolean} overwrite Whether to overwrite the destination if it exists
  63. * @return {Promise<void>} A promise that resolves when the copy/move is done
  64. */
  65. export const handleCopyMoveNodeTo = async (node: Node, destination: Folder, method: MoveCopyAction.COPY | MoveCopyAction.MOVE, overwrite = false) => {
  66. if (!destination) {
  67. return
  68. }
  69. if (destination.type !== FileType.Folder) {
  70. throw new Error(t('files', 'Destination is not a folder'))
  71. }
  72. if (node.dirname === destination.path) {
  73. throw new Error(t('files', 'This file/folder is already in that directory'))
  74. }
  75. if (node.path.startsWith(destination.path)) {
  76. throw new Error(t('files', 'You cannot move a file/folder onto itself or into a subfolder of itself'))
  77. }
  78. const relativePath = join(destination.path, node.basename)
  79. const destinationUrl = generateRemoteUrl(encodePath(`dav/files/${getCurrentUser()?.uid}${relativePath}`))
  80. logger.debug(`${method} ${node.basename} to ${destinationUrl}`)
  81. // Set loading state
  82. Vue.set(node, 'status', NodeStatus.LOADING)
  83. const queue = getQueue()
  84. return await queue.add(async () => {
  85. try {
  86. await axios({
  87. method: method === MoveCopyAction.COPY ? 'COPY' : 'MOVE',
  88. url: node.encodedSource,
  89. headers: {
  90. Destination: encodeURI(destinationUrl),
  91. Overwrite: overwrite ? undefined : 'F',
  92. },
  93. })
  94. // If we're moving, update the node
  95. // if we're copying, we don't need to update the node
  96. // the view will refresh itself
  97. if (method === MoveCopyAction.MOVE) {
  98. // Delete the node as it will be fetched again
  99. // when navigating to the destination folder
  100. emit('files:node:deleted', node)
  101. }
  102. } catch (error) {
  103. if (error instanceof AxiosError) {
  104. if (error?.response?.status === 412) {
  105. throw new Error(t('files', 'A file or folder with that name already exists in this folder'))
  106. } else if (error?.response?.status === 423) {
  107. throw new Error(t('files', 'The files is locked'))
  108. } else if (error?.response?.status === 404) {
  109. throw new Error(t('files', 'The file does not exist anymore'))
  110. } else if (error.message) {
  111. throw new Error(error.message)
  112. }
  113. }
  114. throw new Error()
  115. } finally {
  116. Vue.set(node, 'status', undefined)
  117. }
  118. })
  119. }
  120. /**
  121. * Open a file picker for the given action
  122. * @param {MoveCopyAction} action The action to open the file picker for
  123. * @param {string} dir The directory to start the file picker in
  124. * @param {Node} node The node to move/copy
  125. * @return {Promise<boolean>} A promise that resolves to true if the action was successful
  126. */
  127. const openFilePickerForAction = async (action: MoveCopyAction, dir = '/', node: Node): Promise<boolean> => {
  128. const filePicker = getFilePickerBuilder(t('files', 'Chose destination'))
  129. .allowDirectories(true)
  130. .setFilter((n: Node) => {
  131. // We only want to show folders that we can create nodes in
  132. return (n.permissions & Permission.CREATE) !== 0
  133. // We don't want to show the current node in the file picker
  134. && node.fileid !== n.fileid
  135. })
  136. .setMimeTypeFilter([])
  137. .setMultiSelect(false)
  138. .startAt(dir)
  139. return new Promise((resolve, reject) => {
  140. filePicker.setButtonFactory((nodes: Node[], path: string) => {
  141. const buttons: IFilePickerButton[] = []
  142. const target = basename(path)
  143. if (node.dirname === path) {
  144. // This file/folder is already in that directory
  145. return buttons
  146. }
  147. if (node.path === path) {
  148. // You cannot move a file/folder onto itself
  149. return buttons
  150. }
  151. if (action === MoveCopyAction.COPY || action === MoveCopyAction.MOVE_OR_COPY) {
  152. buttons.push({
  153. label: target ? t('files', 'Copy to {target}', { target }) : t('files', 'Copy'),
  154. type: 'primary',
  155. icon: CopyIcon,
  156. async callback(destination: Node[]) {
  157. try {
  158. await handleCopyMoveNodeTo(node, destination[0], MoveCopyAction.COPY)
  159. resolve(true)
  160. } catch (error) {
  161. reject(error)
  162. }
  163. },
  164. })
  165. }
  166. if (action === MoveCopyAction.MOVE || action === MoveCopyAction.MOVE_OR_COPY) {
  167. buttons.push({
  168. label: target ? t('files', 'Move to {target}', { target }) : t('files', 'Move'),
  169. type: action === MoveCopyAction.MOVE ? 'primary' : 'secondary',
  170. icon: MoveIcon,
  171. async callback(destination: Node[]) {
  172. try {
  173. await handleCopyMoveNodeTo(node, destination[0], MoveCopyAction.MOVE)
  174. resolve(true)
  175. } catch (error) {
  176. reject(error)
  177. }
  178. },
  179. })
  180. }
  181. return buttons
  182. })
  183. const picker = filePicker.build()
  184. picker.pick().catch(() => {
  185. reject(new Error(t('files', 'Cancelled move or copy operation')))
  186. })
  187. })
  188. }
  189. export const action = new FileAction({
  190. id: 'move-copy',
  191. displayName(nodes: Node[]) {
  192. switch (getActionForNodes(nodes)) {
  193. case MoveCopyAction.MOVE:
  194. return t('files', 'Move')
  195. case MoveCopyAction.COPY:
  196. return t('files', 'Copy')
  197. case MoveCopyAction.MOVE_OR_COPY:
  198. return t('files', 'Move or copy')
  199. }
  200. },
  201. iconSvgInline: () => FolderMoveSvg,
  202. enabled(nodes: Node[]) {
  203. // We only support moving/copying files within the user folder
  204. if (!nodes.every(node => node.root?.startsWith('/files/'))) {
  205. return false
  206. }
  207. return nodes.length > 0 && (canMove(nodes) || canCopy(nodes))
  208. },
  209. async exec(node: Node, view: View, dir: string) {
  210. const action = getActionForNodes([node])
  211. try {
  212. await openFilePickerForAction(action, dir, node)
  213. return true
  214. } catch (error) {
  215. if (error instanceof Error && !!error.message) {
  216. showError(error.message)
  217. // Silent action as we handle the toast
  218. return null
  219. }
  220. return false
  221. }
  222. },
  223. order: 15,
  224. })