Skip to content
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
6 changes: 3 additions & 3 deletions __tests__/utils/conflicts-handler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@
*/

import { describe, expect, it, vi, beforeEach } from 'vitest'
import { uploadConflictHandler } from '../../lib/utils/conflicts.ts'
import { uploadConflictHandler } from '../../lib/dialogs/utils/uploadConflictHandler.ts'
import { InvalidFilenameError, InvalidFilenameErrorReason, File as NcFile } from '@nextcloud/files'

const validateFilename = vi.hoisted(() => vi.fn(() => true))
const openConflictPicker = vi.hoisted(() => vi.fn())
const showInvalidFilenameDialog = vi.hoisted(() => vi.fn())

vi.mock('../../lib/index.ts', () => ({ openConflictPicker }))
vi.mock('../../lib/utils/dialog.ts', () => ({ showInvalidFilenameDialog }))
vi.mock('../../lib/dialogs/openConflictPicker.ts', () => ({ openConflictPicker }))
vi.mock('../../lib/dialogs/utils/dialog.ts', () => ({ showInvalidFilenameDialog }))
vi.mock('@nextcloud/files', async (getModule) => {
const original = await getModule()
return {
Expand Down
2 changes: 1 addition & 1 deletion cypress/components/ConflictPicker.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { File as NcFile } from '@nextcloud/files'
import ConflictPicker from '../../lib/components/ConflictPicker.vue'
import ConflictPicker from '../../lib/dialogs/components/ConflictPicker.vue'

describe('ConflictPicker rendering', { testIsolation: true }, () => {
let image: File
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@
<script lang="ts">
import type { Node } from '@nextcloud/files'
import type { PropType } from 'vue'
import type { ConflictResolutionResult } from '../index.ts'
import type { ConflictResolutionResult } from '../openConflictPicker.ts'

import { defineComponent } from 'vue'
import { showError } from '@nextcloud/dialogs'
Expand All @@ -115,9 +115,9 @@ import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadi
import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'

import { isFileSystemEntry } from '../utils/filesystem.ts'
import { n, t } from '../utils/l10n.ts'
import logger from '../utils/logger.ts'
import { isFileSystemEntry } from '../../utils/filesystem.ts'
import { n, t } from '../../utils/l10n.ts'
import logger from '../../utils/logger.ts'
import NodesPicker from './NodesPicker.vue'

export type NodesPickerRef = InstanceType<typeof NodesPicker>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import type { ComponentPublicInstance, PropType } from 'vue'

import { InvalidFilenameError, InvalidFilenameErrorReason } from '@nextcloud/files'
import { defineComponent } from 'vue'
import { t } from '../utils/l10n'
import { t } from '../../utils/l10n.ts'
import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js'
import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,8 @@ import FolderSvg from 'vue-material-design-icons/Folder.vue'
import NcDateTime from '@nextcloud/vue/dist/Components/NcDateTime.js'
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'

import { isFileSystemEntry, isFileSystemFileEntry } from '../utils/filesystem'
import { t } from '../utils/l10n.ts'
import { isFileSystemEntry, isFileSystemFileEntry } from '../../utils/filesystem.ts'
import { t } from '../../utils/l10n.ts'

const PREVIEW_SIZE = 64

Expand Down
74 changes: 74 additions & 0 deletions lib/dialogs/openConflictPicker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*!
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type { Node } from '@nextcloud/files'
import type { AsyncComponent } from 'vue'

import Vue, { defineAsyncComponent } from 'vue'

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

export interface ConflictPickerOptions {
/**
* 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
}

/**
* Open the conflict resolver
* @param {string} dirname the directory name
* @param {(File|Node)[]} conflicts the incoming files
* @param {Node[]} content all the existing files in the directory
* @param {ConflictPickerOptions} options Optional settings for the conflict picker
* @return {Promise<ConflictResolutionResult>} the selected and renamed files
*/
export async function openConflictPicker<T extends File|FileSystemEntry|Node>(
dirname: string | undefined,
conflicts: T[],
content: Node[],
options?: ConflictPickerOptions,
): Promise<ConflictResolutionResult<T>> {
const ConflictPicker = defineAsyncComponent(() => import('./components/ConflictPicker.vue')) as AsyncComponent
return new Promise((resolve, reject) => {
const picker = new Vue({
name: 'ConflictPickerRoot',
render: (h) => h(ConflictPicker, {
props: {
dirname,
conflicts,
content,
recursiveUpload: options?.recursive === true,
},
on: {
submit(results: ConflictResolutionResult<T>) {
// Return the results
resolve(results)

// Destroy the component
picker.$destroy()
picker.$el?.parentNode?.removeChild(picker.$el)
},
cancel(error?: Error) {
// Reject the promise
reject(error ?? new Error('Canceled'))

// Destroy the component
picker.$destroy()
picker.$el?.parentNode?.removeChild(picker.$el)
},
},
}),
})

// Mount the component
picker.$mount()
document.body.appendChild(picker.$el)
})
}
File renamed without changes.
80 changes: 80 additions & 0 deletions lib/dialogs/utils/uploadConflictHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*!
* 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.ts'

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

import { getConflicts } from '../../utils/conflicts.ts'
import { openConflictPicker } from '../openConflictPicker.ts'
import { showInvalidFilenameDialog } from './dialog.ts'
import { t } from '../../utils/l10n.ts'
import logger from '../../utils/logger.ts'

/**
* 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 = [
...nodes.filter((node) => !conflicts.includes(node)),
...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
}
}
}
42 changes: 42 additions & 0 deletions lib/getUploader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*!
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { isPublicShare } from '@nextcloud/sharing/public'

import { Uploader } from './uploader/uploader.ts'

/**
* Get the global Uploader instance.
*
* Note: If you need a local uploader you can just create a new instance,
* this global instance will be shared with other apps.
*
* @param isPublic Set to true to use public upload endpoint (by default it is auto detected)
* @param forceRecreate Force a new uploader instance - main purpose is for testing
*/
export function getUploader(isPublic: boolean = isPublicShare(), forceRecreate = false): Uploader {
if (forceRecreate || window._nc_uploader === undefined) {
window._nc_uploader = new Uploader(isPublic)
}

return window._nc_uploader
}

/**
* Upload a file
* This will init an Uploader instance if none exists.
* You will be able to retrieve it with `getUploader`
*
* @param {string} destinationPath the destination path
* @param {File} file the file to upload
* @return {Uploader} the uploader instance
*/
export function upload(destinationPath: string, file: File): Uploader {
// Init uploader and start uploading
const uploader = getUploader()
uploader.upload(destinationPath, file)

return uploader
}
120 changes: 10 additions & 110 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,118 +2,18 @@
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Node } from '@nextcloud/files'
import type { AsyncComponent } from 'vue'

import { isPublicShare } from '@nextcloud/sharing/public'
import Vue, { defineAsyncComponent } from 'vue'
export type { Eta, EtaEventsMap } from './uploader/index.ts'
export type { IDirectory, Directory } from './utils/fileTree.ts'

import { Uploader } from './uploader/uploader'
import UploadPicker from './components/UploadPicker.vue'
export { getUploader, upload } from './getUploader.ts'
export { Upload, Status as UploadStatus } from './upload.ts'
export { Uploader, UploaderStatus, EtaStatus, } from './uploader/index.ts'
export { getConflicts, hasConflict } from './utils/conflicts.ts'

export type { IDirectory, Directory } from './utils/fileTree'
export { getConflicts, hasConflict, uploadConflictHandler } from './utils/conflicts'
export { Upload, Status as UploadStatus } from './upload'
export * from './uploader/index.ts'
export type { ConflictResolutionResult, ConflictPickerOptions } from './dialogs/openConflictPicker.ts'

export type ConflictResolutionResult<T extends File|FileSystemEntry|Node> = {
selected: T[],
renamed: T[],
}
export { openConflictPicker } from './dialogs/openConflictPicker.ts'
export { uploadConflictHandler } from './dialogs/utils/uploadConflictHandler.ts'

/**
* Get the global Uploader instance.
*
* Note: If you need a local uploader you can just create a new instance,
* this global instance will be shared with other apps.
*
* @param isPublic Set to true to use public upload endpoint (by default it is auto detected)
* @param forceRecreate Force a new uploader instance - main purpose is for testing
*/
export function getUploader(isPublic: boolean = isPublicShare(), forceRecreate = false): Uploader {
if (forceRecreate || window._nc_uploader === undefined) {
window._nc_uploader = new Uploader(isPublic)
}

return window._nc_uploader
}

/**
* Upload a file
* This will init an Uploader instance if none exists.
* You will be able to retrieve it with `getUploader`
*
* @param {string} destinationPath the destination path
* @param {File} file the file to upload
* @return {Uploader} the uploader instance
*/
export function upload(destinationPath: string, file: File): Uploader {
// Init uploader and start uploading
const uploader = getUploader()
uploader.upload(destinationPath, file)

return uploader
}

export interface ConflictPickerOptions {
/**
* 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
}

/**
* Open the conflict resolver
* @param {string} dirname the directory name
* @param {(File|Node)[]} conflicts the incoming files
* @param {Node[]} content all the existing files in the directory
* @param {ConflictPickerOptions} options Optional settings for the conflict picker
* @return {Promise<ConflictResolutionResult>} the selected and renamed files
*/
export async function openConflictPicker<T extends File|FileSystemEntry|Node>(
dirname: string | undefined,
conflicts: T[],
content: Node[],
options?: ConflictPickerOptions,
): Promise<ConflictResolutionResult<T>> {
const ConflictPicker = defineAsyncComponent(() => import('./components/ConflictPicker.vue')) as AsyncComponent
return new Promise((resolve, reject) => {
const picker = new Vue({
name: 'ConflictPickerRoot',
render: (h) => h(ConflictPicker, {
props: {
dirname,
conflicts,
content,
recursiveUpload: options?.recursive === true,
},
on: {
submit(results: ConflictResolutionResult<T>) {
// Return the results
resolve(results)

// Destroy the component
picker.$destroy()
picker.$el?.parentNode?.removeChild(picker.$el)
},
cancel(error?: Error) {
// Reject the promise
reject(error ?? new Error('Canceled'))

// Destroy the component
picker.$destroy()
picker.$el?.parentNode?.removeChild(picker.$el)
},
},
}),
})

// Mount the component
picker.$mount()
document.body.appendChild(picker.$el)
})
}

/** UploadPicker vue component */
export { UploadPicker }
export { default as UploadPicker } from './vue/components/UploadPicker.vue'
Loading
Loading