Skip to content

Commit eaad30c

Browse files
committed
enh(files): Allow to copy files into same directory
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
1 parent fd73d3a commit eaad30c

2 files changed

Lines changed: 118 additions & 35 deletions

File tree

apps/files/src/actions/moveOrCopyAction.ts

Lines changed: 79 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -22,18 +22,16 @@
2222
import '@nextcloud/dialogs/style.css'
2323
import type { Folder, Node, View } from '@nextcloud/files'
2424
import type { IFilePickerButton } from '@nextcloud/dialogs'
25+
import type { FileStat, ResponseDataDetailed } from 'webdav'
2526
import type { MoveCopyResult } from './moveOrCopyActionUtils'
2627

2728
// eslint-disable-next-line n/no-extraneous-import
2829
import { AxiosError } from 'axios'
2930
import { basename, join } from 'path'
3031
import { emit } from '@nextcloud/event-bus'
31-
import { generateRemoteUrl } from '@nextcloud/router'
32-
import { getCurrentUser } from '@nextcloud/auth'
3332
import { getFilePickerBuilder, showError } from '@nextcloud/dialogs'
34-
import { Permission, FileAction, FileType, NodeStatus } from '@nextcloud/files'
33+
import { Permission, FileAction, FileType, NodeStatus, davGetClient, davRootPath, davResultToNode, davGetDefaultPropfind } from '@nextcloud/files'
3534
import { translate as t } from '@nextcloud/l10n'
36-
import axios from '@nextcloud/axios'
3735
import Vue from 'vue'
3836

3937
import CopyIconSvg from '@mdi/svg/svg/folder-multiple.svg?raw'
@@ -69,6 +67,30 @@ const getActionForNodes = (nodes: Node[]): MoveCopyAction => {
6967
* @return {Promise<void>} A promise that resolves when the copy/move is done
7068
*/
7169
export const handleCopyMoveNodeTo = async (node: Node, destination: Folder, method: MoveCopyAction.COPY | MoveCopyAction.MOVE, overwrite = false) => {
70+
/**
71+
* Create an unique name for a node
72+
* @param node Node that is copied
73+
* @param otherNodes Other nodes in the target directory to check for unique name
74+
* @return Either the node basename, if unique, or the name with a `(copy N)` suffix that is unique
75+
*/
76+
const makeUniqueName = (node: Node, otherNodes: Node[]|FileStat[]) => {
77+
const basename = node.basename.slice(0, node.basename.lastIndexOf('.'))
78+
let index = 0
79+
80+
const currentName = () => {
81+
switch (index) {
82+
case 0: return node.basename
83+
case 1: return `${basename} (copy)${node.extension ?? ''}`
84+
default: return `${basename} ${t('files', '(copy %n)', undefined, index)}${node.extension ?? ''}` // TRANSLATORS: Meaning it is the n'th copy of a file
85+
}
86+
}
87+
88+
while (otherNodes.some((other: Node|FileStat) => currentName() === other.basename)) {
89+
index += 1
90+
}
91+
return currentName()
92+
}
93+
7294
if (!destination) {
7395
return
7496
}
@@ -77,42 +99,55 @@ export const handleCopyMoveNodeTo = async (node: Node, destination: Folder, meth
7799
throw new Error(t('files', 'Destination is not a folder'))
78100
}
79101

80-
if (node.dirname === destination.path) {
102+
// Do not allow to MOVE a node to the same folder it is already located
103+
if (method === MoveCopyAction.MOVE && node.dirname === destination.path) {
81104
throw new Error(t('files', 'This file/folder is already in that directory'))
82105
}
83106

84107
/**
85108
* Example:
86-
* node: /foo/bar/file.txt -> path = /foo/bar
87-
* destination: /foo
88-
* Allow move of /foo does not start with /foo/bar so allow
109+
* - node: /foo/bar/file.txt -> path = /foo/bar/file.txt, destination: /foo
110+
* Allow move of /foo does not start with /foo/bar/file.txt so allow
111+
* - node: /foo , destination: /foo/bar
112+
* Do not allow as it would copy foo within itself
113+
* - node: /foo/bar.txt, destination: /foo
114+
* Allow copy a file to the same directory
89115
*/
90116
if (destination.path.startsWith(node.path)) {
91117
throw new Error(t('files', 'You cannot move a file/folder onto itself or into a subfolder of itself'))
92118
}
93119

94-
const relativePath = join(destination.path, node.basename)
95-
const destinationUrl = generateRemoteUrl(`dav/files/${getCurrentUser()?.uid}${relativePath}`)
96-
97120
// Set loading state
98121
Vue.set(node, 'status', NodeStatus.LOADING)
99122

100123
const queue = getQueue()
101124
return await queue.add(async () => {
102125
try {
103-
await axios({
104-
method: method === MoveCopyAction.COPY ? 'COPY' : 'MOVE',
105-
url: node.encodedSource,
106-
headers: {
107-
Destination: encodeURI(destinationUrl),
108-
Overwrite: overwrite ? undefined : 'F',
109-
},
110-
})
126+
const client = davGetClient()
127+
const currentPath = join(davRootPath, node.path)
128+
const destinationPath = join(davRootPath, destination.path)
111129

112-
// If we're moving, update the node
113-
// if we're copying, we don't need to update the node
114-
// the view will refresh itself
115-
if (method === MoveCopyAction.MOVE) {
130+
if (method === MoveCopyAction.COPY) {
131+
let target = node.basename
132+
// If we do not allow overwriting then find an unique name
133+
if (!overwrite) {
134+
const otherNodes = await client.getDirectoryContents(destinationPath) as FileStat[]
135+
target = makeUniqueName(node, otherNodes)
136+
}
137+
await client.copyFile(currentPath, join(destinationPath, target))
138+
// If the node is copied into current directory the view needs to be updated
139+
if (node.dirname === destination.path) {
140+
const { data } = await client.stat(
141+
join(destinationPath, target),
142+
{
143+
details: true,
144+
data: davGetDefaultPropfind(),
145+
},
146+
) as ResponseDataDetailed<FileStat>
147+
emit('files:node:created', davResultToNode(data))
148+
}
149+
} else {
150+
await client.moveFile(currentPath, join(destinationPath, node.basename))
116151
// Delete the node as it will be fetched again
117152
// when navigating to the destination folder
118153
emit('files:node:deleted', node)
@@ -129,6 +164,7 @@ export const handleCopyMoveNodeTo = async (node: Node, destination: Folder, meth
129164
throw new Error(error.message)
130165
}
131166
}
167+
logger.debug(error as Error)
132168
throw new Error()
133169
} finally {
134170
Vue.set(node, 'status', undefined)
@@ -165,16 +201,6 @@ const openFilePickerForAction = async (action: MoveCopyAction, dir = '/', nodes:
165201
const dirnames = nodes.map(node => node.dirname)
166202
const paths = nodes.map(node => node.path)
167203

168-
if (dirnames.includes(path)) {
169-
// This file/folder is already in that directory
170-
return buttons
171-
}
172-
173-
if (paths.includes(path)) {
174-
// You cannot move a file/folder onto itself
175-
return buttons
176-
}
177-
178204
if (action === MoveCopyAction.COPY || action === MoveCopyAction.MOVE_OR_COPY) {
179205
buttons.push({
180206
label: target ? t('files', 'Copy to {target}', { target }) : t('files', 'Copy'),
@@ -189,6 +215,17 @@ const openFilePickerForAction = async (action: MoveCopyAction, dir = '/', nodes:
189215
})
190216
}
191217

218+
// Invalid MOVE targets (but valid copy targets)
219+
if (dirnames.includes(path)) {
220+
// This file/folder is already in that directory
221+
return buttons
222+
}
223+
224+
if (paths.includes(path)) {
225+
// You cannot move a file/folder onto itself
226+
return buttons
227+
}
228+
192229
if (action === MoveCopyAction.MOVE || action === MoveCopyAction.MOVE_OR_COPY) {
193230
buttons.push({
194231
label: target ? t('files', 'Move to {target}', { target }) : t('files', 'Move'),
@@ -207,7 +244,8 @@ const openFilePickerForAction = async (action: MoveCopyAction, dir = '/', nodes:
207244
})
208245

209246
const picker = filePicker.build()
210-
picker.pick().catch(() => {
247+
picker.pick().catch((error) => {
248+
logger.debug(error as Error)
211249
reject(new Error(t('files', 'Cancelled move or copy operation')))
212250
})
213251
})
@@ -236,7 +274,13 @@ export const action = new FileAction({
236274

237275
async exec(node: Node, view: View, dir: string) {
238276
const action = getActionForNodes([node])
239-
const result = await openFilePickerForAction(action, dir, [node])
277+
let result
278+
try {
279+
result = await openFilePickerForAction(action, dir, [node])
280+
} catch (e) {
281+
logger.error(e as Error)
282+
return false
283+
}
240284
try {
241285
await handleCopyMoveNodeTo(node, result.destination, result.action)
242286
return true

cypress/e2e/files/files_copy-move.cy.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,4 +131,43 @@ describe('Files: Move or copy files', { testIsolation: true }, () => {
131131
getRowForFile('new-folder').should('be.visible')
132132
getRowForFile('original.txt').should('be.visible')
133133
})
134+
135+
it('Can copy a file to same folder', () => {
136+
cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt')
137+
cy.login(currentUser)
138+
cy.visit('/apps/files')
139+
140+
// intercept the copy so we can wait for it
141+
cy.intercept('COPY', /\/remote.php\/dav\/files\//).as('copyFile')
142+
143+
getRowForFile('original.txt').should('be.visible')
144+
triggerActionForFile('original.txt', 'move-copy')
145+
146+
// click copy
147+
cy.get('.file-picker').contains('button', 'Copy').should('be.visible').click()
148+
149+
cy.wait('@copyFile')
150+
getRowForFile('original.txt').should('be.visible')
151+
getRowForFile('original (copy).txt').should('be.visible')
152+
})
153+
154+
it('Can copy a file multiple times to same folder', () => {
155+
cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt')
156+
cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original (copy).txt')
157+
cy.login(currentUser)
158+
cy.visit('/apps/files')
159+
160+
// intercept the copy so we can wait for it
161+
cy.intercept('COPY', /\/remote.php\/dav\/files\//).as('copyFile')
162+
163+
getRowForFile('original.txt').should('be.visible')
164+
triggerActionForFile('original.txt', 'move-copy')
165+
166+
// click copy
167+
cy.get('.file-picker').contains('button', 'Copy').should('be.visible').click()
168+
169+
cy.wait('@copyFile')
170+
getRowForFile('original.txt').should('be.visible')
171+
getRowForFile('original (copy 2).txt').should('be.visible')
172+
})
134173
})

0 commit comments

Comments
 (0)