Skip to content

Commit

Permalink
Merge pull request #1442 from nextcloud-libraries/feat/conflict-handler
Browse files Browse the repository at this point in the history
  • Loading branch information
skjnldsv authored Oct 17, 2024
2 parents d86dd2c + 5265207 commit b73b7a2
Show file tree
Hide file tree
Showing 5 changed files with 228 additions and 134 deletions.
78 changes: 78 additions & 0 deletions __tests__/utils/conflicts.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { File, Folder } from '@nextcloud/files'
import { describe, expect, it } from 'vitest'
import { getConflicts, hasConflict } from '../../lib'

describe('hasConflict', () => {
const file = new File({ owner: 'user', source: 'https://cloud.example.com/remote.php/dav/user/files/text.md', mime: 'text/markdown' })
const folder = new Folder({ owner: 'user', source: 'https://cloud.example.com/remote.php/dav/user/files/folder' })

it('no conflicts with empty files', () => {
expect(hasConflict([], [file])).toBe(false)
})

it('no conflicts with empty content', () => {
expect(hasConflict([file], [])).toBe(false)
})

it('no conflicts with both empty files and content', () => {
expect(hasConflict([], [])).toBe(false)
})

it('has conflicts with same file', () => {
expect(hasConflict([file], [file])).toBe(true)
})

it('has conflicts with ES file', () => {
const esFile = new window.File([], 'text.md', { type: 'text/markdown' })
expect(hasConflict([esFile], [file])).toBe(true)
})

it('has no conflicts with folder', () => {
const esFile = new window.File([], 'text.md', { type: 'text/markdown' })
const otherFile = new window.File([], 'other.txt', { type: 'text/plain' })
expect(hasConflict([esFile, otherFile], [folder])).toBe(false)
})
})

describe('getConflicts', () => {
const file = new File({ owner: 'user', source: 'https://cloud.example.com/remote.php/dav/user/files/text.md', mime: 'text/markdown' })
const folder = new Folder({ owner: 'user', source: 'https://cloud.example.com/remote.php/dav/user/files/folder' })

it('no conflicts with empty files', () => {
expect(getConflicts([], [file])).to.eql([])
})

it('no conflicts with empty content', () => {
expect(getConflicts([file], [])).to.eql([])
})

it('no conflicts with both empty files and content', () => {
expect(getConflicts([], [])).to.eql([])
})

it('has conflicts with same file', () => {
expect(getConflicts([file], [file])).to.eql([file])
})

it('has conflicts with ES file', () => {
const esFile = new window.File([], 'text.md', { type: 'text/markdown' })
expect(getConflicts([esFile], [file])).to.eql([esFile])
})

it('returns only the conflicting file', () => {
const esFile = new window.File([], 'text.md', { type: 'text/markdown' })
const otherFile = new window.File([], 'other.txt', { type: 'text/plain' })
expect(getConflicts([esFile, otherFile], [file])).to.eql([esFile])
})

it('has no conflicts with folder', () => {
const esFile = new window.File([], 'text.md', { type: 'text/markdown' })
const otherFile = new window.File([], 'other.txt', { type: 'text/plain' })
expect(getConflicts([esFile, otherFile], [folder])).to.eql([])
})
})
110 changes: 4 additions & 106 deletions lib/components/UploadPicker.vue
Original file line number Diff line number Diff line change
Expand Up @@ -141,11 +141,8 @@
import type { Entry, Node } from '@nextcloud/files'
import type { PropType } from 'vue'
import type { Upload } from '../upload.ts'
import type { Directory } from '../utils/fileTree'
import { showInfo, showWarning, spawnDialog } from '@nextcloud/dialogs'
import { Folder, InvalidFilenameError, InvalidFilenameErrorReason, NewMenuEntryCategory, getNewFileMenuEntries, getUniqueName, validateFilename } from '@nextcloud/files'
import { basename } from '@nextcloud/paths'
import { Folder, NewMenuEntryCategory, getNewFileMenuEntries } from '@nextcloud/files'
import makeEta from 'simple-eta'
import Vue from 'vue'
Expand All @@ -162,12 +159,12 @@ import IconFolderUpload from 'vue-material-design-icons/FolderUpload.vue'
import IconPlus from 'vue-material-design-icons/Plus.vue'
import IconUpload from 'vue-material-design-icons/Upload.vue'
import { getUploader, openConflictPicker, getConflicts } from '../index.ts'
import { getUploader } from '../index.ts'
import { Status } from '../uploader.ts'
import { Status as UploadStatus } from '../upload.ts'
import { t } from '../utils/l10n.ts'
import logger from '../utils/logger.ts'
import InvalidFilenameDialog from './InvalidFilenameDialog.vue'
import { uploadConflictHandler } from '../utils/conflicts.ts'
export default Vue.extend({
name: 'UploadPicker',
Expand Down Expand Up @@ -397,105 +394,6 @@ export default Vue.extend({
return Array.isArray(this.content) ? this.content : await this.content(path)
},
/**
* Show a dialog to let the user decide how to proceed with invalid filenames.
* The returned promise resolves to false if the file should be skipped, and resolves to a string if it should be renamed.
* The promise rejects when the user want to abort the operation.
*
* @param error the validation error
*/
showInvalidFileNameDialog(error: InvalidFilenameError): Promise<string | false> {
const { promise, reject, resolve } = Promise.withResolvers<string | false>()
spawnDialog(
InvalidFilenameDialog,
{
error,
validateFilename: this.validateFilename.bind(this),
},
(...rest) => {
const [{ skip, rename }] = rest as [{ cancel?: true, skip?: true, rename?: string }]
if (skip) {
resolve(false)
} else if (rename) {
resolve(rename)
} else {
reject()
}
},
)
return promise
},
/**
* Wrapper to allow overwriting forbidden characters
* Remove with next major
* @param filename name to validate
*/
validateFilename(filename: string) {
// just for legacy reasons, remove with next major
if (this.forbiddenCharacters.length > 0) {
for (const c of this.forbiddenCharacters) {
if (filename.includes(c)) {
throw new InvalidFilenameError({
filename,
reason: InvalidFilenameErrorReason.Character,
segment: c,
})
}
}
} else {
validateFilename(filename)
}
},
async handleConflicts(nodes: Array<File|Directory>, path: string): Promise<Array<File|Directory>|false> {
try {
const content = await this.getContent(path).catch(() => [])
const conflicts = getConflicts(nodes, content)
// First handle conflicts as this might already remove invalid files
if (conflicts.length > 0) {
const { selected, renamed } = await openConflictPicker(path, conflicts, content, { recursive: true })
nodes = [...selected, ...renamed]
}
// We need to check all files for invalid characters
const filesToUpload: Array<File|Directory> = []
for (const file of nodes) {
try {
this.validateFilename(file.name)
// No invalid name used on this file, so just continue
filesToUpload.push(file)
} catch (error) {
// do not handle other errors
if (!(error instanceof InvalidFilenameError)) {
logger.error(`Unexpected error while validating ${file.name}`, { error })
throw error
}
// Handle invalid path
let newName = await this.showInvalidFileNameDialog(error)
if (newName !== false) {
// create a new valid path name
newName = getUniqueName(newName, nodes.map((node) => node.name))
Object.defineProperty(file, 'name', { value: newName })
filesToUpload.push(file)
}
}
}
if (filesToUpload.length === 0 && nodes.length > 0) {
const folder = basename(path)
showInfo(folder
? t('Upload of "{folder}" has been skipped', { folder })
: t('Upload has been skipped'),
)
}
return filesToUpload
} catch (error) {
logger.debug('Upload has been cancelled', { error })
showWarning(t('Upload has been cancelled'))
return false
}
},
/**
* Start uploading
Expand All @@ -505,7 +403,7 @@ export default Vue.extend({
const files = input.files ? Array.from(input.files) : []
this.uploadManager
.batchUpload('', files, this.handleConflicts)
.batchUpload('', files, uploadConflictHandler(this.getContent))
.catch((error) => logger.debug('Error while uploading', { error }))
.finally(() => this.resetForm())
},
Expand Down
32 changes: 4 additions & 28 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ import { Uploader } from './uploader'
import UploadPicker from './components/UploadPicker.vue'

export type { IDirectory, Directory } from './utils/fileTree'
export { getConflicts, hasConflict, uploadConflictHandler } from './utils/conflicts'
export { Upload, Status as UploadStatus } from './upload'
export { Uploader, Status as UploaderStatus } from './uploader'

export type ConflictResolutionResult<T extends File|FileSystemEntry|Node> = {
selected: T[],
renamed: T[],
}

/**
* Get the global Uploader instance.
*
Expand Down Expand Up @@ -55,8 +57,8 @@ export function upload(destinationPath: string, file: File): Uploader {

export interface ConflictPickerOptions {
/**
* When this is set to true a hint is shown that conflicts in directories are handles recursivly
* You still need to call this function for each directory separatly.
* When this is set to true a hint is shown that conflicts in directories are handles recursively
* You still need to call this function for each directory separately.
*/
recursive?: boolean
}
Expand Down Expand Up @@ -113,31 +115,5 @@ export async function openConflictPicker<T extends File|FileSystemEntry|Node>(
})
}

/**
* Check if there is a conflict between two sets of files
* @param {Array<File|FileSystemEntry|Node>} files the incoming files
* @param {Node[]} content all the existing files in the directory
* @return {boolean} true if there is a conflict
*/
export function hasConflict(files: (File|FileSystemEntry|Node)[], content: Node[]): boolean {
return getConflicts(files, content).length > 0
}

/**
* Get the conflicts between two sets of files
* @param {Array<File|FileSystemEntry|Node>} files the incoming files
* @param {Node[]} content all the existing files in the directory
* @return {boolean} true if there is a conflict
*/
export function getConflicts<T extends File|FileSystemEntry|Node>(files: T[], content: Node[]): T[] {
const contentNames = content.map((node: Node) => node.basename)
const conflicts = files.filter((node: File|FileSystemEntry|Node) => {
const name = 'basename' in node ? node.basename : node.name
return contentNames.indexOf(name) !== -1
})

return conflicts
}

/** UploadPicker vue component */
export { UploadPicker }
101 changes: 101 additions & 0 deletions lib/utils/conflicts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type { Node } from '@nextcloud/files'
import type { IDirectory } from '../utils/fileTree'

import { showInfo, showWarning } from '@nextcloud/dialogs'
import { getUniqueName, InvalidFilenameError, validateFilename } from '@nextcloud/files'
import { basename } from '@nextcloud/paths'

import { openConflictPicker } from '../index'
import { showInvalidFilenameDialog } from './dialog'
import { t } from './l10n'
import logger from './logger'

/**
* Check if there is a conflict between two sets of files
* @param {Array<File|FileSystemEntry|Node>} files the incoming files
* @param {Node[]} content all the existing files in the directory
* @return {boolean} true if there is a conflict
*/
export function hasConflict(files: (File|FileSystemEntry|Node)[], content: Node[]): boolean {
return getConflicts(files, content).length > 0
}

/**
* Get the conflicts between two sets of files
* @param {Array<File|FileSystemEntry|Node>} files the incoming files
* @param {Node[]} content all the existing files in the directory
* @return {boolean} true if there is a conflict
*/
export function getConflicts<T extends File|FileSystemEntry|Node>(files: T[], content: Node[]): T[] {
const contentNames = content.map((node: Node) => node.basename)
const conflicts = files.filter((node: File|FileSystemEntry|Node) => {
const name = 'basename' in node ? node.basename : node.name
return contentNames.indexOf(name) !== -1
})

return conflicts
}

/**
* Helper function to create a conflict resolution callback for the `Uploader.batchUpload` method.
*
* This creates a callback that will open the conflict picker to resolve the conflicts.
* In case of a rename the new name is validated and the invalid filename dialog is shown an error happens there.
*
* @param contentsCallback Callback to retrieve contents of a given path
*/
export function uploadConflictHandler(contentsCallback: (path: string) => Promise<Node[]>) {
return async (nodes: Array<File|IDirectory>, path: string): Promise<Array<File|IDirectory>|false> => {
try {
const content = await contentsCallback(path).catch(() => [])
const conflicts = getConflicts(nodes, content)

// First handle conflicts as this might already remove invalid files
if (conflicts.length > 0) {
const { selected, renamed } = await openConflictPicker(path, conflicts, content, { recursive: true })
nodes = [...selected, ...renamed]
}

// We need to check all files for invalid characters
const filesToUpload: Array<File|IDirectory> = []
for (const file of nodes) {
try {
validateFilename(file.name)
// No invalid name used on this file, so just continue
filesToUpload.push(file)
} catch (error) {
// do not handle other errors
if (!(error instanceof InvalidFilenameError)) {
logger.error(`Unexpected error while validating ${file.name}`, { error })
throw error
}
// Handle invalid path
let newName = await showInvalidFilenameDialog(error)
if (newName !== false) {
// create a new valid path name
newName = getUniqueName(newName, nodes.map((node) => node.name))
Object.defineProperty(file, 'name', { value: newName })
filesToUpload.push(file)
}
}
}
if (filesToUpload.length === 0 && nodes.length > 0) {
const folder = basename(path)
showInfo(folder
? t('Upload of "{folder}" has been skipped', { folder })
: t('Upload has been skipped'),
)
}
return filesToUpload
} catch (error) {
logger.debug('Upload has been cancelled', { error })
showWarning(t('Upload has been cancelled'))
return false
}
}
}
Loading

0 comments on commit b73b7a2

Please sign in to comment.