diff --git a/__tests__/utils/conflicts.spec.ts b/__tests__/utils/conflicts.spec.ts new file mode 100644 index 00000000..32c9558d --- /dev/null +++ b/__tests__/utils/conflicts.spec.ts @@ -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([]) + }) +}) diff --git a/lib/components/UploadPicker.vue b/lib/components/UploadPicker.vue index 7d24aa57..bc297e6a 100644 --- a/lib/components/UploadPicker.vue +++ b/lib/components/UploadPicker.vue @@ -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' @@ -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', @@ -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 { - const { promise, reject, resolve } = Promise.withResolvers() - 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, path: string): Promise|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 = [] - 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 @@ -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()) }, diff --git a/lib/index.ts b/lib/index.ts index 885f204b..437653e6 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -12,6 +12,7 @@ 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' @@ -19,6 +20,7 @@ export type ConflictResolutionResult = { selected: T[], renamed: T[], } + /** * Get the global Uploader instance. * @@ -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 } @@ -113,31 +115,5 @@ export async function openConflictPicker( }) } -/** - * Check if there is a conflict between two sets of files - * @param {Array} 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} 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(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 } diff --git a/lib/utils/conflicts.ts b/lib/utils/conflicts.ts new file mode 100644 index 00000000..e0ab3280 --- /dev/null +++ b/lib/utils/conflicts.ts @@ -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} 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} 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(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) { + return async (nodes: Array, path: string): Promise|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 = [] + 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 + } + } +} diff --git a/lib/utils/dialog.ts b/lib/utils/dialog.ts new file mode 100644 index 00000000..d2eb6466 --- /dev/null +++ b/lib/utils/dialog.ts @@ -0,0 +1,41 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { InvalidFilenameError } from '@nextcloud/files' + +import { spawnDialog } from '@nextcloud/dialogs' +import { validateFilename } from '@nextcloud/files' +import { defineAsyncComponent } from 'vue' + +/** + * 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 + */ +export function showInvalidFilenameDialog(error: InvalidFilenameError): Promise { + const InvalidFilenameDialog = defineAsyncComponent(() => import('../components/InvalidFilenameDialog.vue')) + + const { promise, reject, resolve } = Promise.withResolvers() + spawnDialog( + InvalidFilenameDialog, + { + error, + validateFilename, + }, + (...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 +}