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
90 changes: 29 additions & 61 deletions apps/files/src/components/FileEntry/FileEntryName.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<template>
<!-- Rename input -->
<form v-if="isRenaming"
ref="renameForm"
v-on-click-outside="onRename"
:aria-label="t('files', 'Rename file')"
class="files-list__row-rename"
Expand All @@ -16,7 +17,6 @@
:required="true"
:value.sync="newName"
enterkeyhint="done"
@keyup="checkInputValidity"
@keyup.esc="stopRenaming" />
</form>

Expand All @@ -40,22 +40,20 @@
import type { Node } from '@nextcloud/files'
import type { PropType } from 'vue'

import axios, { isAxiosError } from '@nextcloud/axios'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { emit } from '@nextcloud/event-bus'
import { FileType, NodeStatus, Permission } from '@nextcloud/files'
import { loadState } from '@nextcloud/initial-state'
import { translate as t } from '@nextcloud/l10n'
import axios, { isAxiosError } from '@nextcloud/axios'
import { defineComponent } from 'vue'

import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'

import { useNavigation } from '../../composables/useNavigation'
import { useRenamingStore } from '../../store/renaming.ts'
import { getFilenameValidity } from '../../utils/filenameValidity.ts'
import logger from '../../logger.js'

const forbiddenCharacters = loadState<string[]>('files', 'forbiddenCharacters', [])

export default defineComponent({
name: 'FileEntryName',

Expand Down Expand Up @@ -187,76 +185,51 @@ export default defineComponent({
}
},
},
},

methods: {
/**
* Check if the file name is valid and update the
* input validity using browser's native validation.
* @param event the keyup event
*/
checkInputValidity(event: KeyboardEvent) {
const input = event.target as HTMLInputElement
newName() {
// Check validity of the new name
const newName = this.newName.trim?.() || ''
logger.debug('Checking input validity', { newName })
try {
this.isFileNameValid(newName)
input.setCustomValidity('')
input.title = ''
} catch (e) {
if (e instanceof Error) {
input.setCustomValidity(e.message)
input.title = e.message
} else {
input.setCustomValidity(t('files', 'Invalid file name'))
}
} finally {
input.reportValidity()
const input = (this.$refs.renameInput as Vue|undefined)?.$el.querySelector('input')
if (!input) {
return
}
},

isFileNameValid(name: string) {
const trimmedName = name.trim()
const char = trimmedName.indexOf('/') !== -1
? '/'
: forbiddenCharacters.find((char) => trimmedName.includes(char))

if (trimmedName === '.' || trimmedName === '..') {
throw new Error(t('files', '"{name}" is an invalid file name.', { name }))
} else if (trimmedName.length === 0) {
throw new Error(t('files', 'File name cannot be empty.'))
} else if (char) {
throw new Error(t('files', '"{char}" is not allowed inside a file name.', { char }))
} else if (trimmedName.match(window.OC.config.blacklist_files_regex)) {
throw new Error(t('files', '"{name}" is not an allowed filetype.', { name }))
} else if (this.checkIfNodeExists(name)) {
throw new Error(t('files', '{newName} already exists.', { newName: name }))
let validity = getFilenameValidity(newName)
// Checking if already exists
if (validity === '' && this.checkIfNodeExists(newName)) {
validity = t('files', 'Another entry with the same name already exists.')
}

return true
this.$nextTick(() => {
if (this.isRenaming) {
input.setCustomValidity(validity)
input.reportValidity()
}
})
},
},

methods: {
checkIfNodeExists(name: string) {
return this.nodes.find(node => node.basename === name && node !== this.source)
},

startRenaming() {
this.$nextTick(() => {
// Using split to get the true string length
const extLength = (this.source.extension || '').split('').length
const length = this.source.basename.split('').length - extLength
const input = this.$refs.renameInput?.$refs?.inputField?.$refs?.input
const input = (this.$refs.renameInput as Vue|undefined)?.$el.querySelector('input')
if (!input) {
logger.error('Could not find the rename input')
return
}
input.setSelectionRange(0, length)
input.focus()
const length = this.source.basename.length - (this.source.extension ?? '').length
input.setSelectionRange(0, length)

// Trigger a keyup event to update the input validity
input.dispatchEvent(new Event('keyup'))
})
},

stopRenaming() {
if (!this.isRenaming) {
return
Expand All @@ -268,25 +241,20 @@ export default defineComponent({

// Rename and move the file
async onRename() {
const oldName = this.source.basename
const oldEncodedSource = this.source.encodedSource
const newName = this.newName.trim?.() || ''
if (newName === '') {
showError(t('files', 'Name cannot be empty'))
const form = this.$refs.renameForm as HTMLFormElement
if (!form.checkValidity()) {
showError(t('files', 'Invalid filename.') + ' ' + getFilenameValidity(newName))
return
}

const oldName = this.source.basename
const oldEncodedSource = this.source.encodedSource
if (oldName === newName) {
this.stopRenaming()
return
}

// Checking if already exists
if (this.checkIfNodeExists(newName)) {
showError(t('files', 'Another entry with the same name already exists'))
return
}

// Set loading state
this.$set(this.source, 'status', NodeStatus.LOADING)

Expand Down
41 changes: 41 additions & 0 deletions apps/files/src/utils/filenameValidity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*!
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { InvalidFilenameError, InvalidFilenameErrorReason, validateFilename } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'

/**
* Get the validity of a filename (empty if valid).
* This can be used for `setCustomValidity` on input elements
* @param name The filename
* @param escape Escape the matched string in the error (only set when used in HTML)
*/
export function getFilenameValidity(name: string, escape = false): string {
if (name.trim() === '') {
return t('files', 'Filename must not be empty.')
}

try {
validateFilename(name)
return ''
} catch (error) {
if (!(error instanceof InvalidFilenameError)) {
throw error
}

switch (error.reason) {
case InvalidFilenameErrorReason.Character:
return t('files', '"{char}" is not allowed inside a filename.', { char: error.segment }, undefined, { escape })
case InvalidFilenameErrorReason.ReservedName:
return t('files', '"{segment}" is a reserved name and not allowed for filenames.', { segment: error.segment }, undefined, { escape: false })
case InvalidFilenameErrorReason.Extension:
if (error.segment.match(/\.[a-z]/i)) {
return t('files', '"{extension}" is not an allowed filetype.', { extension: error.segment }, undefined, { escape: false })
}
return t('files', 'Filenames must not end with "{extension}".', { extension: error.segment }, undefined, { escape: false })
default:
return t('files', 'Invalid filename.')
}
}
}
4 changes: 2 additions & 2 deletions dist/core-common.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/core-common.js.map

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions dist/files-main.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/files-main.js.map

Large diffs are not rendered by default.

Loading