Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add uploadConflictHandler to handle conflict resolution #1442

Merged
merged 2 commits into from
Oct 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading