-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1442 from nextcloud-libraries/feat/conflict-handler
- Loading branch information
Showing
5 changed files
with
228 additions
and
134 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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([]) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} | ||
} |
Oops, something went wrong.