mirror of
https://github.com/nextcloud/server.git
synced 2026-06-09 08:44:07 -04:00
Merge pull request #58129 from nextcloud/backport/57993/stable33
[stable33] fix(files): improve handling of copy-move action
This commit is contained in:
commit
8058da35d4
30 changed files with 273 additions and 309 deletions
|
|
@ -11,18 +11,214 @@ import type { MoveCopyResult } from './moveOrCopyActionUtils.ts'
|
|||
import FolderMoveSvg from '@mdi/svg/svg/folder-move-outline.svg?raw'
|
||||
import CopyIconSvg from '@mdi/svg/svg/folder-multiple-outline.svg?raw'
|
||||
import { isAxiosError } from '@nextcloud/axios'
|
||||
import { FilePickerClosed, getFilePickerBuilder, showError, showInfo, TOAST_PERMANENT_TIMEOUT } from '@nextcloud/dialogs'
|
||||
import { FilePickerClosed, getFilePickerBuilder, openConflictPicker, showError, showLoading } from '@nextcloud/dialogs'
|
||||
import { emit } from '@nextcloud/event-bus'
|
||||
import { FileAction, FileType, getUniqueName, NodeStatus, Permission } from '@nextcloud/files'
|
||||
import { defaultRootPath, getClient, getDefaultPropfind, resultToNode } from '@nextcloud/files/dav'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { hasConflict, openConflictPicker } from '@nextcloud/upload'
|
||||
import { getConflicts } from '@nextcloud/upload'
|
||||
import { basename, join } from 'path'
|
||||
import Vue from 'vue'
|
||||
import logger from '../logger.ts'
|
||||
import { getContents } from '../services/Files.ts'
|
||||
import { canCopy, canMove, getQueue, MoveCopyAction } from './moveOrCopyActionUtils.ts'
|
||||
|
||||
/**
|
||||
* Exception to hint the user about something.
|
||||
* The message is intended to be shown to the user.
|
||||
*/
|
||||
export class HintException extends Error {}
|
||||
|
||||
export const ACTION_COPY_MOVE = 'move-copy'
|
||||
|
||||
export const action = new FileAction({
|
||||
id: ACTION_COPY_MOVE,
|
||||
order: 15,
|
||||
displayName({ nodes }) {
|
||||
switch (getActionForNodes(nodes)) {
|
||||
case MoveCopyAction.MOVE:
|
||||
return t('files', 'Move')
|
||||
case MoveCopyAction.COPY:
|
||||
return t('files', 'Copy')
|
||||
case MoveCopyAction.MOVE_OR_COPY:
|
||||
return t('files', 'Move or copy')
|
||||
}
|
||||
},
|
||||
iconSvgInline: () => FolderMoveSvg,
|
||||
enabled({ nodes, view }): boolean {
|
||||
// We can not copy or move in single file shares
|
||||
if (view.id === 'public-file-share') {
|
||||
return false
|
||||
}
|
||||
// We only support moving/copying files within the user folder
|
||||
if (!nodes.every((node) => node.root?.startsWith('/files/'))) {
|
||||
return false
|
||||
}
|
||||
return nodes.length > 0 && (canMove(nodes) || canCopy(nodes))
|
||||
},
|
||||
|
||||
async exec(context) {
|
||||
return this.execBatch!(context)[0]
|
||||
},
|
||||
|
||||
async execBatch({ nodes, folder }) {
|
||||
const action = getActionForNodes(nodes)
|
||||
const target = await openFilePickerForAction(action, folder.path, nodes)
|
||||
// Handle cancellation silently
|
||||
if (target === false) {
|
||||
return nodes.map(() => null)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await Array.fromAsync(handleCopyMoveNodesTo(nodes, target.destination, target.action))
|
||||
return result.map(() => true)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to ${target.action} node`, { nodes, error })
|
||||
if (error instanceof HintException && !!error.message) {
|
||||
showError(error.message)
|
||||
// Silent action as we handle the toast
|
||||
return nodes.map(() => null)
|
||||
}
|
||||
// We need to keep the selection on error!
|
||||
// So we do not return null, and for batch action
|
||||
return nodes.map(() => false)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Handle the copy/move of a node to a destination
|
||||
* This can be imported and used by other scripts/components on server
|
||||
*
|
||||
* @param nodes The nodes to copy/move
|
||||
* @param destination The destination to copy/move the nodes to
|
||||
* @param method The method to use for the copy/move
|
||||
* @param overwrite Whether to overwrite the destination if it exists
|
||||
* @yields {AsyncGenerator<void, void, never>} A promise that resolves when the copy/move is done
|
||||
*/
|
||||
export async function* handleCopyMoveNodesTo(nodes: INode[], destination: IFolder, method: MoveCopyAction.COPY | MoveCopyAction.MOVE, overwrite = false): AsyncGenerator<void, void, never> {
|
||||
if (!destination) {
|
||||
return
|
||||
}
|
||||
|
||||
if (destination.type !== FileType.Folder) {
|
||||
throw new Error(t('files', 'Destination is not a folder'))
|
||||
}
|
||||
|
||||
// Do not allow to MOVE a node to the same folder it is already located
|
||||
if (method === MoveCopyAction.MOVE && nodes.some((node) => node.dirname === destination.path)) {
|
||||
throw new Error(t('files', 'This file/folder is already in that directory'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Example:
|
||||
* - node: /foo/bar/file.txt -> path = /foo/bar/file.txt, destination: /foo
|
||||
* Allow move of /foo does not start with /foo/bar/file.txt so allow
|
||||
* - node: /foo , destination: /foo/bar
|
||||
* Do not allow as it would copy foo within itself
|
||||
* - node: /foo/bar.txt, destination: /foo
|
||||
* Allow copy a file to the same directory
|
||||
* - node: "/foo/bar", destination: "/foo/bar 1"
|
||||
* Allow to move or copy but we need to check with trailing / otherwise it would report false positive
|
||||
*/
|
||||
if (nodes.some((node) => `${destination.path}/`.startsWith(`${node.path}/`))) {
|
||||
throw new Error(t('files', 'You cannot move a file/folder onto itself or into a subfolder of itself'))
|
||||
}
|
||||
|
||||
const nameMapping = new Map<string, string>()
|
||||
// Check for conflicts if we do not want to overwrite
|
||||
if (!overwrite) {
|
||||
const otherNodes = (await getContents(destination.path)).contents
|
||||
const conflicts = getConflicts(nodes, otherNodes) as unknown as INode[]
|
||||
const nodesToRename: INode[] = []
|
||||
if (conflicts.length > 0) {
|
||||
if (method === MoveCopyAction.MOVE) {
|
||||
// Let the user choose what to do with the conflicting files
|
||||
const content = otherNodes.filter((n) => conflicts.some((c) => c.basename === n.basename))
|
||||
const result = await openConflictPicker(destination.path, conflicts, content)
|
||||
if (!result) {
|
||||
// User cancelled
|
||||
return
|
||||
}
|
||||
|
||||
nodes = nodes.filter((n) => !result.skipped.includes(n as never))
|
||||
nodesToRename.push(...(result.renamed as unknown as INode[]))
|
||||
} else {
|
||||
// for COPY we always rename conflicting files
|
||||
nodesToRename.push(...conflicts)
|
||||
}
|
||||
|
||||
const usedNames = [...otherNodes, ...nodes.filter((n) => !conflicts.includes(n))].map((n) => n.basename)
|
||||
for (const node of nodesToRename) {
|
||||
const newName = getUniqueName(node.basename, usedNames, { ignoreFileExtension: node.type === FileType.Folder })
|
||||
nameMapping.set(node.source, newName)
|
||||
usedNames.push(newName) // add the new name to avoid duplicates for following re-namimgs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const actionFinished = createLoadingNotification(method, nodes.map((node) => node.basename), destination.path)
|
||||
const queue = getQueue()
|
||||
try {
|
||||
for (const node of nodes) {
|
||||
// Set loading state
|
||||
Vue.set(node, 'status', NodeStatus.LOADING)
|
||||
yield queue.add(async () => {
|
||||
try {
|
||||
const client = getClient()
|
||||
|
||||
const currentPath = join(defaultRootPath, node.path)
|
||||
const destinationPath = join(defaultRootPath, destination.path, nameMapping.get(node.source) ?? node.basename)
|
||||
|
||||
if (method === MoveCopyAction.COPY) {
|
||||
await client.copyFile(currentPath, destinationPath)
|
||||
// If the node is copied into current directory the view needs to be updated
|
||||
if (node.dirname === destination.path) {
|
||||
const { data } = await client.stat(
|
||||
destinationPath,
|
||||
{
|
||||
details: true,
|
||||
data: getDefaultPropfind(),
|
||||
},
|
||||
) as ResponseDataDetailed<FileStat>
|
||||
emit('files:node:created', resultToNode(data))
|
||||
}
|
||||
} else {
|
||||
await client.moveFile(currentPath, destinationPath)
|
||||
// Delete the node as it will be fetched again
|
||||
// when navigating to the destination folder
|
||||
emit('files:node:deleted', node)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`Error while trying to ${method === MoveCopyAction.COPY ? 'copy' : 'move'} node`, { node, error })
|
||||
if (isAxiosError(error)) {
|
||||
if (error.response?.status === 412) {
|
||||
throw new HintException(t('files', 'A file or folder with that name already exists in this folder'))
|
||||
} else if (error.response?.status === 423) {
|
||||
throw new HintException(t('files', 'The files are locked'))
|
||||
} else if (error.response?.status === 404) {
|
||||
throw new HintException(t('files', 'The file does not exist anymore'))
|
||||
} else if ('response' in error && error.response) {
|
||||
const parser = new DOMParser()
|
||||
const text = await (error as WebDAVClientError).response!.text()
|
||||
const message = parser.parseFromString(text ?? '', 'text/xml')
|
||||
.querySelector('message')?.textContent
|
||||
if (message) {
|
||||
throw new HintException(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
Vue.set(node, 'status', undefined)
|
||||
}
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
actionFinished()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the action that is possible for the given nodes
|
||||
*
|
||||
|
|
@ -45,168 +241,25 @@ function getActionForNodes(nodes: INode[]): MoveCopyAction {
|
|||
* Create a loading notification toast
|
||||
*
|
||||
* @param mode The move or copy mode
|
||||
* @param source Name of the node that is copied / moved
|
||||
* @param sources Names of the nodes that are copied / moved
|
||||
* @param destination Destination path
|
||||
* @return Function to hide the notification
|
||||
*/
|
||||
function createLoadingNotification(mode: MoveCopyAction, source: string, destination: string): () => void {
|
||||
const text = mode === MoveCopyAction.MOVE ? t('files', 'Moving "{source}" to "{destination}" …', { source, destination }) : t('files', 'Copying "{source}" to "{destination}" …', { source, destination })
|
||||
function createLoadingNotification(mode: MoveCopyAction, sources: string[], destination: string): () => void {
|
||||
const text = mode === MoveCopyAction.MOVE
|
||||
? (sources.length === 1
|
||||
? t('files', 'Moving "{source}" to "{destination}" …', { source: sources[0], destination })
|
||||
: t('files', 'Moving {count} files to "{destination}" …', { count: sources.length, destination })
|
||||
)
|
||||
: (sources.length === 1
|
||||
? t('files', 'Copying "{source}" to "{destination}" …', { source: sources[0], destination })
|
||||
: t('files', 'Copying {count} files to "{destination}" …', { count: sources.length, destination })
|
||||
)
|
||||
|
||||
let toast: ReturnType<typeof showInfo> | undefined
|
||||
toast = showInfo(
|
||||
`<span class="icon icon-loading-small toast-loading-icon"></span> ${text}`,
|
||||
{
|
||||
isHTML: true,
|
||||
timeout: TOAST_PERMANENT_TIMEOUT,
|
||||
onRemove() {
|
||||
toast?.hideToast()
|
||||
toast = undefined
|
||||
},
|
||||
},
|
||||
)
|
||||
const toast = showLoading(text)
|
||||
return () => toast && toast.hideToast()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the copy/move of a node to a destination
|
||||
* This can be imported and used by other scripts/components on server
|
||||
*
|
||||
* @param node The node to copy/move
|
||||
* @param destination The destination to copy/move the node to
|
||||
* @param method The method to use for the copy/move
|
||||
* @param overwrite Whether to overwrite the destination if it exists
|
||||
* @return A promise that resolves when the copy/move is done
|
||||
*/
|
||||
export async function handleCopyMoveNodeTo(node: INode, destination: IFolder, method: MoveCopyAction.COPY | MoveCopyAction.MOVE, overwrite = false) {
|
||||
if (!destination) {
|
||||
return
|
||||
}
|
||||
|
||||
if (destination.type !== FileType.Folder) {
|
||||
throw new Error(t('files', 'Destination is not a folder'))
|
||||
}
|
||||
|
||||
// Do not allow to MOVE a node to the same folder it is already located
|
||||
if (method === MoveCopyAction.MOVE && node.dirname === destination.path) {
|
||||
throw new Error(t('files', 'This file/folder is already in that directory'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Example:
|
||||
* - node: /foo/bar/file.txt -> path = /foo/bar/file.txt, destination: /foo
|
||||
* Allow move of /foo does not start with /foo/bar/file.txt so allow
|
||||
* - node: /foo , destination: /foo/bar
|
||||
* Do not allow as it would copy foo within itself
|
||||
* - node: /foo/bar.txt, destination: /foo
|
||||
* Allow copy a file to the same directory
|
||||
* - node: "/foo/bar", destination: "/foo/bar 1"
|
||||
* Allow to move or copy but we need to check with trailing / otherwise it would report false positive
|
||||
*/
|
||||
if (`${destination.path}/`.startsWith(`${node.path}/`)) {
|
||||
throw new Error(t('files', 'You cannot move a file/folder onto itself or into a subfolder of itself'))
|
||||
}
|
||||
|
||||
// Set loading state
|
||||
Vue.set(node, 'status', NodeStatus.LOADING)
|
||||
const actionFinished = createLoadingNotification(method, node.basename, destination.path)
|
||||
|
||||
const queue = getQueue()
|
||||
return await queue.add(async () => {
|
||||
const copySuffix = (index: number) => {
|
||||
if (index === 1) {
|
||||
return t('files', '(copy)') // TRANSLATORS: Mark a file as a copy of another file
|
||||
}
|
||||
return t('files', '(copy %n)', undefined, index) // TRANSLATORS: Meaning it is the n'th copy of a file
|
||||
}
|
||||
|
||||
try {
|
||||
const client = getClient()
|
||||
const currentPath = join(defaultRootPath, node.path)
|
||||
const destinationPath = join(defaultRootPath, destination.path)
|
||||
|
||||
if (method === MoveCopyAction.COPY) {
|
||||
let target = node.basename
|
||||
// If we do not allow overwriting then find an unique name
|
||||
if (!overwrite) {
|
||||
const otherNodes = await client.getDirectoryContents(destinationPath) as FileStat[]
|
||||
target = getUniqueName(
|
||||
node.basename,
|
||||
otherNodes.map((n) => n.basename),
|
||||
{
|
||||
suffix: copySuffix,
|
||||
ignoreFileExtension: node.type === FileType.Folder,
|
||||
},
|
||||
)
|
||||
}
|
||||
await client.copyFile(currentPath, join(destinationPath, target))
|
||||
// If the node is copied into current directory the view needs to be updated
|
||||
if (node.dirname === destination.path) {
|
||||
const { data } = await client.stat(
|
||||
join(destinationPath, target),
|
||||
{
|
||||
details: true,
|
||||
data: getDefaultPropfind(),
|
||||
},
|
||||
) as ResponseDataDetailed<FileStat>
|
||||
emit('files:node:created', resultToNode(data))
|
||||
}
|
||||
} else {
|
||||
// show conflict file popup if we do not allow overwriting
|
||||
if (!overwrite) {
|
||||
const otherNodes = await getContents(destination.path)
|
||||
if (hasConflict([node], otherNodes.contents)) {
|
||||
try {
|
||||
// Let the user choose what to do with the conflicting files
|
||||
const { selected, renamed } = await openConflictPicker(destination.path, [node], otherNodes.contents)
|
||||
// two empty arrays: either only old files or conflict skipped -> no action required
|
||||
if (!selected.length && !renamed.length) {
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// User cancelled
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// getting here means either no conflict, file was renamed to keep both files
|
||||
// in a conflict, or the selected file was chosen to be kept during the conflict
|
||||
try {
|
||||
await client.moveFile(currentPath, join(destinationPath, node.basename))
|
||||
} catch (error) {
|
||||
const parser = new DOMParser()
|
||||
const text = await (error as WebDAVClientError).response?.text()
|
||||
const message = parser.parseFromString(text ?? '', 'text/xml')
|
||||
.querySelector('message')?.textContent
|
||||
if (message) {
|
||||
showError(message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
// Delete the node as it will be fetched again
|
||||
// when navigating to the destination folder
|
||||
emit('files:node:deleted', node)
|
||||
}
|
||||
} catch (error) {
|
||||
if (isAxiosError(error)) {
|
||||
if (error.response?.status === 412) {
|
||||
throw new Error(t('files', 'A file or folder with that name already exists in this folder'))
|
||||
} else if (error.response?.status === 423) {
|
||||
throw new Error(t('files', 'The files are locked'))
|
||||
} else if (error.response?.status === 404) {
|
||||
throw new Error(t('files', 'The file does not exist anymore'))
|
||||
} else if (error.message) {
|
||||
throw new Error(error.message)
|
||||
}
|
||||
}
|
||||
logger.debug(error as Error)
|
||||
throw new Error()
|
||||
} finally {
|
||||
Vue.set(node, 'status', '')
|
||||
actionFinished()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a file picker for the given action
|
||||
*
|
||||
|
|
@ -302,82 +355,3 @@ async function openFilePickerForAction(
|
|||
|
||||
return promise
|
||||
}
|
||||
|
||||
export const ACTION_COPY_MOVE = 'move-copy'
|
||||
export const action = new FileAction({
|
||||
id: ACTION_COPY_MOVE,
|
||||
displayName({ nodes }) {
|
||||
switch (getActionForNodes(nodes)) {
|
||||
case MoveCopyAction.MOVE:
|
||||
return t('files', 'Move')
|
||||
case MoveCopyAction.COPY:
|
||||
return t('files', 'Copy')
|
||||
case MoveCopyAction.MOVE_OR_COPY:
|
||||
return t('files', 'Move or copy')
|
||||
}
|
||||
},
|
||||
iconSvgInline: () => FolderMoveSvg,
|
||||
enabled({ nodes, view }): boolean {
|
||||
// We can not copy or move in single file shares
|
||||
if (view.id === 'public-file-share') {
|
||||
return false
|
||||
}
|
||||
// We only support moving/copying files within the user folder
|
||||
if (!nodes.every((node) => node.root?.startsWith('/files/'))) {
|
||||
return false
|
||||
}
|
||||
return nodes.length > 0 && (canMove(nodes) || canCopy(nodes))
|
||||
},
|
||||
|
||||
async exec({ nodes, folder }) {
|
||||
const action = getActionForNodes([nodes[0]])
|
||||
let result
|
||||
try {
|
||||
result = await openFilePickerForAction(action, folder.path, [nodes[0]])
|
||||
} catch (e) {
|
||||
logger.error(e as Error)
|
||||
return false
|
||||
}
|
||||
if (result === false) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
await handleCopyMoveNodeTo(nodes[0], result.destination, result.action)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error instanceof Error && !!error.message) {
|
||||
showError(error.message)
|
||||
// Silent action as we handle the toast
|
||||
return null
|
||||
}
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
async execBatch({ nodes, folder }) {
|
||||
const action = getActionForNodes(nodes)
|
||||
const result = await openFilePickerForAction(action, folder.path, nodes)
|
||||
// Handle cancellation silently
|
||||
if (result === false) {
|
||||
return nodes.map(() => null)
|
||||
}
|
||||
|
||||
const promises = nodes.map(async (node) => {
|
||||
try {
|
||||
await handleCopyMoveNodeTo(node, result.destination, result.action)
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error(`Failed to ${result.action} node`, { node, error })
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
// We need to keep the selection on error!
|
||||
// So we do not return null, and for batch action
|
||||
// we let the front handle the error.
|
||||
return await Promise.all(promises)
|
||||
},
|
||||
|
||||
order: 15,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8,12 +8,10 @@ import type { Upload } from '@nextcloud/upload'
|
|||
import type { RootDirectory } from './DropServiceUtils.ts'
|
||||
|
||||
import { showError, showInfo, showSuccess, showWarning } from '@nextcloud/dialogs'
|
||||
import { NodeStatus } from '@nextcloud/files'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { join } from '@nextcloud/paths'
|
||||
import { getUploader, hasConflict } from '@nextcloud/upload'
|
||||
import Vue from 'vue'
|
||||
import { handleCopyMoveNodeTo } from '../actions/moveOrCopyAction.ts'
|
||||
import { handleCopyMoveNodesTo, HintException } from '../actions/moveOrCopyAction.ts'
|
||||
import { MoveCopyAction } from '../actions/moveOrCopyActionUtils.ts'
|
||||
import logger from '../logger.ts'
|
||||
import { createDirectoryIfNotExists, Directory, resolveConflict, traverseTree } from './DropServiceUtils.ts'
|
||||
|
|
@ -178,8 +176,6 @@ export async function onDropExternalFiles(root: RootDirectory, destination: IFol
|
|||
* @param isCopy - Whether the operation is a copy
|
||||
*/
|
||||
export async function onDropInternalFiles(nodes: INode[], destination: IFolder, contents: INode[], isCopy = false) {
|
||||
const queue = [] as Promise<void>[]
|
||||
|
||||
// Check for conflicts on root elements
|
||||
if (await hasConflict(nodes, contents)) {
|
||||
nodes = await resolveConflict(nodes, destination, contents)
|
||||
|
|
@ -191,23 +187,17 @@ export async function onDropInternalFiles(nodes: INode[], destination: IFolder,
|
|||
return
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
Vue.set(node, 'status', NodeStatus.LOADING)
|
||||
queue.push(handleCopyMoveNodeTo(node, destination, isCopy ? MoveCopyAction.COPY : MoveCopyAction.MOVE, true))
|
||||
try {
|
||||
const promises = Array.fromAsync(handleCopyMoveNodesTo(nodes, destination, isCopy ? MoveCopyAction.COPY : MoveCopyAction.MOVE))
|
||||
await promises
|
||||
logger.debug('Files copy/move successful')
|
||||
showSuccess(isCopy ? t('files', 'Files copied successfully') : t('files', 'Files moved successfully'))
|
||||
} catch (error) {
|
||||
logger.error('Error while processing dropped files', { error })
|
||||
if (error instanceof HintException) {
|
||||
showError(error.message)
|
||||
} else {
|
||||
showError(isCopy ? t('files', 'Some files could not be copied') : t('files', 'Some files could not be moved'))
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
logger.debug('Files copy/move successful')
|
||||
showSuccess(isCopy ? t('files', 'Files copied successfully') : t('files', 'Files moved successfully'))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,23 +107,23 @@ describe('Files: Move or copy files', { testIsolation: true }, () => {
|
|||
copyFile('original.txt', '.')
|
||||
|
||||
getRowForFile('original.txt').should('be.visible')
|
||||
getRowForFile('original (copy).txt').should('be.visible')
|
||||
getRowForFile('original (1).txt').should('be.visible')
|
||||
})
|
||||
|
||||
it('Can copy a file multiple times to same folder', () => {
|
||||
cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt')
|
||||
cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original (copy).txt')
|
||||
cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original (1).txt')
|
||||
cy.login(currentUser)
|
||||
cy.visit('/apps/files')
|
||||
|
||||
copyFile('original.txt', '.')
|
||||
|
||||
getRowForFile('original.txt').should('be.visible')
|
||||
getRowForFile('original (copy 2).txt').should('be.visible')
|
||||
getRowForFile('original (2).txt').should('be.visible')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that a copied folder with a dot will be renamed correctly ('foo.bar' -> 'foo.bar (copy)')
|
||||
* Test that a copied folder with a dot will be renamed correctly ('foo.bar' -> 'foo.bar (1)')
|
||||
* Test for: https://github.com/nextcloud/server/issues/43843
|
||||
*/
|
||||
it('Can copy a folder to same folder', () => {
|
||||
|
|
@ -134,7 +134,7 @@ describe('Files: Move or copy files', { testIsolation: true }, () => {
|
|||
copyFile('foo.bar', '.')
|
||||
|
||||
getRowForFile('foo.bar').should('be.visible')
|
||||
getRowForFile('foo.bar (copy)').should('be.visible')
|
||||
getRowForFile('foo.bar (1)').should('be.visible')
|
||||
})
|
||||
|
||||
/** Test for https://github.com/nextcloud/server/issues/43329 */
|
||||
|
|
|
|||
|
|
@ -56,8 +56,8 @@ describe('Files: Live photos', { testIsolation: true }, () => {
|
|||
|
||||
getRowForFile(`${randomFileName}.jpg`).should('have.length', 1)
|
||||
getRowForFile(`${randomFileName}.mov`).should('have.length', 1)
|
||||
getRowForFile(`${randomFileName} (copy).jpg`).should('have.length', 1)
|
||||
getRowForFile(`${randomFileName} (copy).mov`).should('have.length', 1)
|
||||
getRowForFile(`${randomFileName} (1).jpg`).should('have.length', 1)
|
||||
getRowForFile(`${randomFileName} (1).mov`).should('have.length', 1)
|
||||
})
|
||||
|
||||
it('Copies both files when copying the .mov', () => {
|
||||
|
|
@ -65,15 +65,15 @@ describe('Files: Live photos', { testIsolation: true }, () => {
|
|||
reloadCurrentFolder()
|
||||
|
||||
getRowForFile(`${randomFileName}.mov`).should('have.length', 1)
|
||||
getRowForFile(`${randomFileName} (copy).jpg`).should('have.length', 1)
|
||||
getRowForFile(`${randomFileName} (copy).mov`).should('have.length', 1)
|
||||
getRowForFile(`${randomFileName} (1).jpg`).should('have.length', 1)
|
||||
getRowForFile(`${randomFileName} (1).mov`).should('have.length', 1)
|
||||
})
|
||||
|
||||
it('Keeps live photo link when copying folder', () => {
|
||||
createFolder('folder')
|
||||
moveFile(`${randomFileName}.jpg`, 'folder')
|
||||
copyFile('folder', '.')
|
||||
navigateToFolder('folder (copy)')
|
||||
navigateToFolder('folder (1)')
|
||||
|
||||
getRowForFile(`${randomFileName}.jpg`).should('have.length', 1)
|
||||
getRowForFile(`${randomFileName}.mov`).should('have.length', 1)
|
||||
|
|
@ -95,7 +95,7 @@ describe('Files: Live photos', { testIsolation: true }, () => {
|
|||
cy.get('[data-cy-files-list-row-fileid]').should('have.length', 1)
|
||||
getRowForFile(`${randomFileName}.mov`).should('have.length', 1)
|
||||
getRowForFile(`${randomFileName}.jpg`).should('have.length', 0)
|
||||
getRowForFile(`${randomFileName} (copy).jpg`).should('have.length', 0)
|
||||
getRowForFile(`${randomFileName} (1).jpg`).should('have.length', 0)
|
||||
})
|
||||
|
||||
it('Moves files when moving the .jpg', () => {
|
||||
|
|
|
|||
4
dist/core-common.js
vendored
4
dist/core-common.js
vendored
File diff suppressed because one or more lines are too long
2
dist/core-common.js.map
vendored
2
dist/core-common.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/files-init.js
vendored
4
dist/files-init.js
vendored
File diff suppressed because one or more lines are too long
2
dist/files-init.js.map
vendored
2
dist/files-init.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/files-main.js
vendored
4
dist/files-main.js
vendored
File diff suppressed because one or more lines are too long
2
dist/files-main.js.map
vendored
2
dist/files-main.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/files-reference-files.js
vendored
4
dist/files-reference-files.js
vendored
File diff suppressed because one or more lines are too long
2
dist/files-reference-files.js.map
vendored
2
dist/files-reference-files.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/files-settings-personal.js
vendored
4
dist/files-settings-personal.js
vendored
File diff suppressed because one or more lines are too long
2
dist/files-settings-personal.js.map
vendored
2
dist/files-settings-personal.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/files_sharing-files_sharing_tab.js
vendored
4
dist/files_sharing-files_sharing_tab.js
vendored
File diff suppressed because one or more lines are too long
2
dist/files_sharing-files_sharing_tab.js.map
vendored
2
dist/files_sharing-files_sharing_tab.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/files_sharing-personal-settings.js
vendored
4
dist/files_sharing-personal-settings.js
vendored
File diff suppressed because one or more lines are too long
2
dist/files_sharing-personal-settings.js.map
vendored
2
dist/files_sharing-personal-settings.js.map
vendored
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
File diff suppressed because one or more lines are too long
|
|
@ -1,2 +1,2 @@
|
|||
(()=>{"use strict";var e,r,t,o={18342(e,r,t){var o=t(85168),a=t(81222),n=t(53334);window.addEventListener("DOMContentLoaded",function(){const{updateLink:e,updateVersion:r}=(0,a.C)("updatenotification","updateState"),t=(0,n.t)("core","{version} is available. Get more information on how to update.",{version:r});(0,o.cf)(t,{onClick:()=>window.open(e,"_blank")})})}},a={};function n(e){var r=a[e];if(void 0!==r)return r.exports;var t=a[e]={id:e,loaded:!1,exports:{}};return o[e].call(t.exports,t,t.exports,n),t.loaded=!0,t.exports}n.m=o,e=[],n.O=(r,t,o,a)=>{if(!t){var i=1/0;for(u=0;u<e.length;u++){for(var[t,o,a]=e[u],l=!0,d=0;d<t.length;d++)(!1&a||i>=a)&&Object.keys(n.O).every(e=>n.O[e](t[d]))?t.splice(d--,1):(l=!1,a<i&&(i=a));if(l){e.splice(u--,1);var c=o();void 0!==c&&(r=c)}}return r}a=a||0;for(var u=e.length;u>0&&e[u-1][2]>a;u--)e[u]=e[u-1];e[u]=[t,o,a]},n.n=e=>{var r=e&&e.__esModule?()=>e.default:()=>e;return n.d(r,{a:r}),r},n.d=(e,r)=>{for(var t in r)n.o(r,t)&&!n.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:r[t]})},n.f={},n.e=e=>Promise.all(Object.keys(n.f).reduce((r,t)=>(n.f[t](e,r),r),[])),n.u=e=>e+"-"+e+".js?v="+{2710:"0319a4b5398392025378",4471:"9b3c8620f038b7593241",7004:"da5a822695a273d4d2eb",7394:"00400962a1291d103feb",7859:"449c6cbdb74832a2f6c7",8127:"8825f856581210b8af66",8453:"d4f76450bf0c442af291"}[e],n.o=(e,r)=>Object.prototype.hasOwnProperty.call(e,r),r={},t="nextcloud-ui-legacy:",n.l=(e,o,a,i)=>{if(r[e])r[e].push(o);else{var l,d;if(void 0!==a)for(var c=document.getElementsByTagName("script"),u=0;u<c.length;u++){var s=c[u];if(s.getAttribute("src")==e||s.getAttribute("data-webpack")==t+a){l=s;break}}l||(d=!0,(l=document.createElement("script")).charset="utf-8",n.nc&&l.setAttribute("nonce",n.nc),l.setAttribute("data-webpack",t+a),l.src=e),r[e]=[o];var p=(t,o)=>{l.onerror=l.onload=null,clearTimeout(f);var a=r[e];if(delete r[e],l.parentNode&&l.parentNode.removeChild(l),a&&a.forEach(e=>e(o)),t)return t(o)},f=setTimeout(p.bind(null,void 0,{type:"timeout",target:l}),12e4);l.onerror=p.bind(null,l.onerror),l.onload=p.bind(null,l.onload),d&&document.head.appendChild(l)}},n.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.nmd=e=>(e.paths=[],e.children||(e.children=[]),e),n.j=5169,(()=>{var e;globalThis.importScripts&&(e=globalThis.location+"");var r=globalThis.document;if(!e&&r&&(r.currentScript&&"SCRIPT"===r.currentScript.tagName.toUpperCase()&&(e=r.currentScript.src),!e)){var t=r.getElementsByTagName("script");if(t.length)for(var o=t.length-1;o>-1&&(!e||!/^http(s?):/.test(e));)e=t[o--].src}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\?.*$/,"").replace(/\/[^\/]+$/,"/"),n.p=e})(),(()=>{n.b="undefined"!=typeof document&&document.baseURI||self.location.href;var e={5169:0};n.f.j=(r,t)=>{var o=n.o(e,r)?e[r]:void 0;if(0!==o)if(o)t.push(o[2]);else{var a=new Promise((t,a)=>o=e[r]=[t,a]);t.push(o[2]=a);var i=n.p+n.u(r),l=new Error;n.l(i,t=>{if(n.o(e,r)&&(0!==(o=e[r])&&(e[r]=void 0),o)){var a=t&&("load"===t.type?"missing":t.type),i=t&&t.target&&t.target.src;l.message="Loading chunk "+r+" failed.\n("+a+": "+i+")",l.name="ChunkLoadError",l.type=a,l.request=i,o[1](l)}},"chunk-"+r,r)}},n.O.j=r=>0===e[r];var r=(r,t)=>{var o,a,[i,l,d]=t,c=0;if(i.some(r=>0!==e[r])){for(o in l)n.o(l,o)&&(n.m[o]=l[o]);if(d)var u=d(n)}for(r&&r(t);c<i.length;c++)a=i[c],n.o(e,a)&&e[a]&&e[a][0](),e[a]=0;return n.O(u)},t=globalThis.webpackChunknextcloud_ui_legacy=globalThis.webpackChunknextcloud_ui_legacy||[];t.forEach(r.bind(null,0)),t.push=r.bind(null,t.push.bind(t))})(),n.nc=void 0;var i=n.O(void 0,[4208],()=>n(18342));i=n.O(i)})();
|
||||
//# sourceMappingURL=updatenotification-update-notification-legacy.js.map?v=8e0489e469c7ba13432d
|
||||
(()=>{"use strict";var e,r,t,o={18342(e,r,t){var o=t(85168),a=t(81222),n=t(53334);window.addEventListener("DOMContentLoaded",function(){const{updateLink:e,updateVersion:r}=(0,a.C)("updatenotification","updateState"),t=(0,n.t)("core","{version} is available. Get more information on how to update.",{version:r});(0,o.cf)(t,{onClick:()=>window.open(e,"_blank")})})}},a={};function n(e){var r=a[e];if(void 0!==r)return r.exports;var t=a[e]={id:e,loaded:!1,exports:{}};return o[e].call(t.exports,t,t.exports,n),t.loaded=!0,t.exports}n.m=o,e=[],n.O=(r,t,o,a)=>{if(!t){var i=1/0;for(u=0;u<e.length;u++){for(var[t,o,a]=e[u],l=!0,c=0;c<t.length;c++)(!1&a||i>=a)&&Object.keys(n.O).every(e=>n.O[e](t[c]))?t.splice(c--,1):(l=!1,a<i&&(i=a));if(l){e.splice(u--,1);var d=o();void 0!==d&&(r=d)}}return r}a=a||0;for(var u=e.length;u>0&&e[u-1][2]>a;u--)e[u]=e[u-1];e[u]=[t,o,a]},n.n=e=>{var r=e&&e.__esModule?()=>e.default:()=>e;return n.d(r,{a:r}),r},n.d=(e,r)=>{for(var t in r)n.o(r,t)&&!n.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:r[t]})},n.f={},n.e=e=>Promise.all(Object.keys(n.f).reduce((r,t)=>(n.f[t](e,r),r),[])),n.u=e=>e+"-"+e+".js?v="+{2710:"0319a4b5398392025378",4471:"9b3c8620f038b7593241",7004:"da5a822695a273d4d2eb",7394:"00400962a1291d103feb",7859:"449c6cbdb74832a2f6c7",8127:"8825f856581210b8af66",8453:"d4f76450bf0c442af291"}[e],n.o=(e,r)=>Object.prototype.hasOwnProperty.call(e,r),r={},t="nextcloud-ui-legacy:",n.l=(e,o,a,i)=>{if(r[e])r[e].push(o);else{var l,c;if(void 0!==a)for(var d=document.getElementsByTagName("script"),u=0;u<d.length;u++){var s=d[u];if(s.getAttribute("src")==e||s.getAttribute("data-webpack")==t+a){l=s;break}}l||(c=!0,(l=document.createElement("script")).charset="utf-8",n.nc&&l.setAttribute("nonce",n.nc),l.setAttribute("data-webpack",t+a),l.src=e),r[e]=[o];var p=(t,o)=>{l.onerror=l.onload=null,clearTimeout(f);var a=r[e];if(delete r[e],l.parentNode&&l.parentNode.removeChild(l),a&&a.forEach(e=>e(o)),t)return t(o)},f=setTimeout(p.bind(null,void 0,{type:"timeout",target:l}),12e4);l.onerror=p.bind(null,l.onerror),l.onload=p.bind(null,l.onload),c&&document.head.appendChild(l)}},n.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.nmd=e=>(e.paths=[],e.children||(e.children=[]),e),n.j=5169,(()=>{var e;globalThis.importScripts&&(e=globalThis.location+"");var r=globalThis.document;if(!e&&r&&(r.currentScript&&"SCRIPT"===r.currentScript.tagName.toUpperCase()&&(e=r.currentScript.src),!e)){var t=r.getElementsByTagName("script");if(t.length)for(var o=t.length-1;o>-1&&(!e||!/^http(s?):/.test(e));)e=t[o--].src}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\?.*$/,"").replace(/\/[^\/]+$/,"/"),n.p=e})(),(()=>{n.b="undefined"!=typeof document&&document.baseURI||self.location.href;var e={5169:0};n.f.j=(r,t)=>{var o=n.o(e,r)?e[r]:void 0;if(0!==o)if(o)t.push(o[2]);else{var a=new Promise((t,a)=>o=e[r]=[t,a]);t.push(o[2]=a);var i=n.p+n.u(r),l=new Error;n.l(i,t=>{if(n.o(e,r)&&(0!==(o=e[r])&&(e[r]=void 0),o)){var a=t&&("load"===t.type?"missing":t.type),i=t&&t.target&&t.target.src;l.message="Loading chunk "+r+" failed.\n("+a+": "+i+")",l.name="ChunkLoadError",l.type=a,l.request=i,o[1](l)}},"chunk-"+r,r)}},n.O.j=r=>0===e[r];var r=(r,t)=>{var o,a,[i,l,c]=t,d=0;if(i.some(r=>0!==e[r])){for(o in l)n.o(l,o)&&(n.m[o]=l[o]);if(c)var u=c(n)}for(r&&r(t);d<i.length;d++)a=i[d],n.o(e,a)&&e[a]&&e[a][0](),e[a]=0;return n.O(u)},t=globalThis.webpackChunknextcloud_ui_legacy=globalThis.webpackChunknextcloud_ui_legacy||[];t.forEach(r.bind(null,0)),t.push=r.bind(null,t.push.bind(t))})(),n.nc=void 0;var i=n.O(void 0,[4208],()=>n(18342));i=n.O(i)})();
|
||||
//# sourceMappingURL=updatenotification-update-notification-legacy.js.map?v=b0b6179e74533d314b0c
|
||||
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
4
dist/weather_status-weather-status.js
vendored
4
dist/weather_status-weather-status.js
vendored
File diff suppressed because one or more lines are too long
2
dist/weather_status-weather-status.js.map
vendored
2
dist/weather_status-weather-status.js.map
vendored
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue