moveOrCopyAction.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  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. import type { FileStat, ResponseDataDetailed } from 'webdav'
  26. import type { MoveCopyResult } from './moveOrCopyActionUtils'
  27. // eslint-disable-next-line n/no-extraneous-import
  28. import { AxiosError } from 'axios'
  29. import { basename, join } from 'path'
  30. import { emit } from '@nextcloud/event-bus'
  31. import { FilePickerClosed, getFilePickerBuilder, showError } from '@nextcloud/dialogs'
  32. import { Permission, FileAction, FileType, NodeStatus, davGetClient, davRootPath, davResultToNode, davGetDefaultPropfind } from '@nextcloud/files'
  33. import { translate as t } from '@nextcloud/l10n'
  34. import { openConflictPicker, hasConflict } from '@nextcloud/upload'
  35. import Vue from 'vue'
  36. import CopyIconSvg from '@mdi/svg/svg/folder-multiple.svg?raw'
  37. import FolderMoveSvg from '@mdi/svg/svg/folder-move.svg?raw'
  38. import { MoveCopyAction, canCopy, canMove, getQueue } from './moveOrCopyActionUtils'
  39. import { getContents } from '../services/Files'
  40. import logger from '../logger'
  41. import { getUniqueName } from '../utils/fileUtils'
  42. /**
  43. * Return the action that is possible for the given nodes
  44. * @param {Node[]} nodes The nodes to check against
  45. * @return {MoveCopyAction} The action that is possible for the given nodes
  46. */
  47. const getActionForNodes = (nodes: Node[]): MoveCopyAction => {
  48. if (canMove(nodes)) {
  49. if (canCopy(nodes)) {
  50. return MoveCopyAction.MOVE_OR_COPY
  51. }
  52. return MoveCopyAction.MOVE
  53. }
  54. // Assuming we can copy as the enabled checks for copy permissions
  55. return MoveCopyAction.COPY
  56. }
  57. /**
  58. * Handle the copy/move of a node to a destination
  59. * This can be imported and used by other scripts/components on server
  60. * @param {Node} node The node to copy/move
  61. * @param {Folder} destination The destination to copy/move the node to
  62. * @param {MoveCopyAction} method The method to use for the copy/move
  63. * @param {boolean} overwrite Whether to overwrite the destination if it exists
  64. * @return {Promise<void>} A promise that resolves when the copy/move is done
  65. */
  66. export const handleCopyMoveNodeTo = async (node: Node, destination: Folder, method: MoveCopyAction.COPY | MoveCopyAction.MOVE, overwrite = false) => {
  67. if (!destination) {
  68. return
  69. }
  70. if (destination.type !== FileType.Folder) {
  71. throw new Error(t('files', 'Destination is not a folder'))
  72. }
  73. // Do not allow to MOVE a node to the same folder it is already located
  74. if (method === MoveCopyAction.MOVE && node.dirname === destination.path) {
  75. throw new Error(t('files', 'This file/folder is already in that directory'))
  76. }
  77. /**
  78. * Example:
  79. * - node: /foo/bar/file.txt -> path = /foo/bar/file.txt, destination: /foo
  80. * Allow move of /foo does not start with /foo/bar/file.txt so allow
  81. * - node: /foo , destination: /foo/bar
  82. * Do not allow as it would copy foo within itself
  83. * - node: /foo/bar.txt, destination: /foo
  84. * Allow copy a file to the same directory
  85. * - node: "/foo/bar", destination: "/foo/bar 1"
  86. * Allow to move or copy but we need to check with trailing / otherwise it would report false positive
  87. */
  88. if (`${destination.path}/`.startsWith(`${node.path}/`)) {
  89. throw new Error(t('files', 'You cannot move a file/folder onto itself or into a subfolder of itself'))
  90. }
  91. // Set loading state
  92. Vue.set(node, 'status', NodeStatus.LOADING)
  93. const queue = getQueue()
  94. return await queue.add(async () => {
  95. const copySuffix = (index: number) => {
  96. if (index === 1) {
  97. return t('files', '(copy)') // TRANSLATORS: Mark a file as a copy of another file
  98. }
  99. return t('files', '(copy %n)', undefined, index) // TRANSLATORS: Meaning it is the n'th copy of a file
  100. }
  101. try {
  102. const client = davGetClient()
  103. const currentPath = join(davRootPath, node.path)
  104. const destinationPath = join(davRootPath, destination.path)
  105. if (method === MoveCopyAction.COPY) {
  106. let target = node.basename
  107. // If we do not allow overwriting then find an unique name
  108. if (!overwrite) {
  109. const otherNodes = await client.getDirectoryContents(destinationPath) as FileStat[]
  110. target = getUniqueName(node.basename, otherNodes.map((n) => n.basename), copySuffix)
  111. }
  112. await client.copyFile(currentPath, join(destinationPath, target))
  113. // If the node is copied into current directory the view needs to be updated
  114. if (node.dirname === destination.path) {
  115. const { data } = await client.stat(
  116. join(destinationPath, target),
  117. {
  118. details: true,
  119. data: davGetDefaultPropfind(),
  120. },
  121. ) as ResponseDataDetailed<FileStat>
  122. emit('files:node:created', davResultToNode(data))
  123. }
  124. } else {
  125. // show conflict file popup if we do not allow overwriting
  126. const otherNodes = await getContents(destination.path)
  127. if (hasConflict([node], otherNodes.contents)) {
  128. try {
  129. // Let the user choose what to do with the conflicting files
  130. const { selected, renamed } = await openConflictPicker(destination.path, [node], otherNodes.contents)
  131. // if the user selected to keep the old file, and did not select the new file
  132. // that means they opted to delete the current node
  133. if (!selected.length && !renamed.length) {
  134. await client.deleteFile(currentPath)
  135. emit('files:node:deleted', node)
  136. return
  137. }
  138. } catch (error) {
  139. // User cancelled
  140. showError(t('files','Move cancelled'))
  141. return
  142. }
  143. }
  144. // getting here means either no conflict, file was renamed to keep both files
  145. // in a conflict, or the selected file was chosen to be kept during the conflict
  146. await client.moveFile(currentPath, join(destinationPath, node.basename))
  147. // Delete the node as it will be fetched again
  148. // when navigating to the destination folder
  149. emit('files:node:deleted', node)
  150. }
  151. } catch (error) {
  152. if (error instanceof AxiosError) {
  153. if (error?.response?.status === 412) {
  154. throw new Error(t('files', 'A file or folder with that name already exists in this folder'))
  155. } else if (error?.response?.status === 423) {
  156. throw new Error(t('files', 'The files are locked'))
  157. } else if (error?.response?.status === 404) {
  158. throw new Error(t('files', 'The file does not exist anymore'))
  159. } else if (error.message) {
  160. throw new Error(error.message)
  161. }
  162. }
  163. logger.debug(error as Error)
  164. throw new Error()
  165. } finally {
  166. Vue.set(node, 'status', undefined)
  167. }
  168. })
  169. }
  170. /**
  171. * Open a file picker for the given action
  172. * @param {MoveCopyAction} action The action to open the file picker for
  173. * @param {string} dir The directory to start the file picker in
  174. * @param {Node[]} nodes The nodes to move/copy
  175. * @return {Promise<MoveCopyResult>} The picked destination
  176. */
  177. const openFilePickerForAction = async (action: MoveCopyAction, dir = '/', nodes: Node[]): Promise<MoveCopyResult> => {
  178. const fileIDs = nodes.map(node => node.fileid).filter(Boolean)
  179. const filePicker = getFilePickerBuilder(t('files', 'Choose destination'))
  180. .allowDirectories(true)
  181. .setFilter((n: Node) => {
  182. // We only want to show folders that we can create nodes in
  183. return (n.permissions & Permission.CREATE) !== 0
  184. // We don't want to show the current nodes in the file picker
  185. && !fileIDs.includes(n.fileid)
  186. })
  187. .setMimeTypeFilter([])
  188. .setMultiSelect(false)
  189. .startAt(dir)
  190. return new Promise((resolve, reject) => {
  191. filePicker.setButtonFactory((_selection, path: string) => {
  192. const buttons: IFilePickerButton[] = []
  193. const target = basename(path)
  194. const dirnames = nodes.map(node => node.dirname)
  195. const paths = nodes.map(node => node.path)
  196. if (action === MoveCopyAction.COPY || action === MoveCopyAction.MOVE_OR_COPY) {
  197. buttons.push({
  198. label: target ? t('files', 'Copy to {target}', { target }, undefined, { escape: false, sanitize: false }) : t('files', 'Copy'),
  199. type: 'primary',
  200. icon: CopyIconSvg,
  201. async callback(destination: Node[]) {
  202. resolve({
  203. destination: destination[0] as Folder,
  204. action: MoveCopyAction.COPY,
  205. } as MoveCopyResult)
  206. },
  207. })
  208. }
  209. // Invalid MOVE targets (but valid copy targets)
  210. if (dirnames.includes(path)) {
  211. // This file/folder is already in that directory
  212. return buttons
  213. }
  214. if (paths.includes(path)) {
  215. // You cannot move a file/folder onto itself
  216. return buttons
  217. }
  218. if (action === MoveCopyAction.MOVE || action === MoveCopyAction.MOVE_OR_COPY) {
  219. buttons.push({
  220. label: target ? t('files', 'Move to {target}', { target }, undefined, { escape: false, sanitize: false }) : t('files', 'Move'),
  221. type: action === MoveCopyAction.MOVE ? 'primary' : 'secondary',
  222. icon: FolderMoveSvg,
  223. async callback(destination: Node[]) {
  224. resolve({
  225. destination: destination[0] as Folder,
  226. action: MoveCopyAction.MOVE,
  227. } as MoveCopyResult)
  228. },
  229. })
  230. }
  231. return buttons
  232. })
  233. const picker = filePicker.build()
  234. picker.pick().catch((error) => {
  235. logger.debug(error as Error)
  236. if (error instanceof FilePickerClosed) {
  237. reject(new Error(t('files', 'Cancelled move or copy operation')))
  238. } else {
  239. reject(new Error(t('files', 'Move or copy operation failed')))
  240. }
  241. })
  242. })
  243. }
  244. export const action = new FileAction({
  245. id: 'move-copy',
  246. displayName(nodes: Node[]) {
  247. switch (getActionForNodes(nodes)) {
  248. case MoveCopyAction.MOVE:
  249. return t('files', 'Move')
  250. case MoveCopyAction.COPY:
  251. return t('files', 'Copy')
  252. case MoveCopyAction.MOVE_OR_COPY:
  253. return t('files', 'Move or copy')
  254. }
  255. },
  256. iconSvgInline: () => FolderMoveSvg,
  257. enabled(nodes: Node[]) {
  258. // We only support moving/copying files within the user folder
  259. if (!nodes.every(node => node.root?.startsWith('/files/'))) {
  260. return false
  261. }
  262. return nodes.length > 0 && (canMove(nodes) || canCopy(nodes))
  263. },
  264. async exec(node: Node, view: View, dir: string) {
  265. const action = getActionForNodes([node])
  266. let result
  267. try {
  268. result = await openFilePickerForAction(action, dir, [node])
  269. } catch (e) {
  270. logger.error(e as Error)
  271. return false
  272. }
  273. try {
  274. await handleCopyMoveNodeTo(node, result.destination, result.action)
  275. return true
  276. } catch (error) {
  277. if (error instanceof Error && !!error.message) {
  278. showError(error.message)
  279. // Silent action as we handle the toast
  280. return null
  281. }
  282. return false
  283. }
  284. },
  285. async execBatch(nodes: Node[], view: View, dir: string) {
  286. const action = getActionForNodes(nodes)
  287. const result = await openFilePickerForAction(action, dir, nodes)
  288. const promises = nodes.map(async node => {
  289. try {
  290. await handleCopyMoveNodeTo(node, result.destination, result.action)
  291. return true
  292. } catch (error) {
  293. logger.error(`Failed to ${result.action} node`, { node, error })
  294. return false
  295. }
  296. })
  297. // We need to keep the selection on error!
  298. // So we do not return null, and for batch action
  299. // we let the front handle the error.
  300. return await Promise.all(promises)
  301. },
  302. order: 15,
  303. })