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

[stable29] Update @nextcloud/files to 3.7.0 and fix display name handling of folders (breadcrumbs and filename) #46728

Merged
merged 6 commits into from
Jul 26, 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
2 changes: 1 addition & 1 deletion apps/files/src/actions/openFolderAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export const action = new FileAction({
id: 'open-folder',
displayName(files: Node[]) {
// Only works on single node
const displayName = files[0].attributes.displayname || files[0].basename
const displayName = files[0].displayname
return t('files', 'Open folder {displayName}', { displayName })
},
iconSvgInline: () => FolderSvg,
Expand Down
6 changes: 3 additions & 3 deletions apps/files/src/components/BreadCrumbs.vue
Original file line number Diff line number Diff line change
Expand Up @@ -175,9 +175,9 @@ export default defineComponent({
return this.$navigation?.active?.name || t('files', 'Home')
}

const source: FileSource | null = this.getFileSourceFromPath(path)
const node: Node | undefined = source ? this.getNodeFromSource(source) : undefined
return node?.attributes?.displayname || basename(path)
const source = this.getFileSourceFromPath(path)
const node = source ? this.getNodeFromSource(source) : undefined
return node?.displayname || basename(path)
},

onClick(to) {
Expand Down
2 changes: 1 addition & 1 deletion apps/files/src/components/FileEntry.vue
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
@click.native="execDefaultAction" />

<FileEntryName ref="name"
:display-name="displayName"
:basename="basename"
:extension="extension"
:files-list-width="filesListWidth"
:nodes="nodes"
Expand Down
116 changes: 48 additions & 68 deletions apps/files/src/components/FileEntry/FileEntryName.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
<template>
<!-- Rename input -->
<form v-if="isRenaming"
v-on-click-outside="stopRenaming"
ref="renameForm"
v-on-click-outside="onRename"
:aria-label="t('files', 'Rename file')"
class="files-list__row-rename"
@submit.prevent.stop="onRename">
Expand All @@ -33,7 +34,6 @@
:required="true"
:value.sync="newName"
enterkeyhint="done"
@keyup="checkInputValidity"
@keyup.esc="stopRenaming" />
</form>

Expand All @@ -46,8 +46,8 @@
v-bind="linkTo.params">
<!-- File name -->
<span class="files-list__row-name-text">
<!-- Keep the displayName stuck to the extension to avoid whitespace rendering issues-->
<span class="files-list__row-name-" v-text="displayName" />
<!-- Keep the filename stuck to the extension to avoid whitespace rendering issues-->
<span class="files-list__row-name-" v-text="basename" />
<span class="files-list__row-name-ext" v-text="extension" />
</span>
</component>
Expand All @@ -57,34 +57,38 @@
import type { Node } from '@nextcloud/files'
import type { PropType } from 'vue'

import axios 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 { showError, showSuccess } from '@nextcloud/dialogs'
import { translate as t } from '@nextcloud/l10n'
import axios from '@nextcloud/axios'
import Vue from 'vue'
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', '').split('')

export default Vue.extend({
export default defineComponent({
name: 'FileEntryName',

components: {
NcTextField,
},

props: {
displayName: {
/**
* The filename without extension
*/
basename: {
type: String,
required: true,
},
/**
* The extension of the filename
*/
extension: {
type: String,
required: true,
Expand Down Expand Up @@ -172,7 +176,7 @@ export default Vue.extend({
params: {
download: this.source.basename,
href: this.source.source,
title: t('files', 'Download file {name}', { name: this.displayName }),
title: t('files', 'Download file {name}', { name: `${this.basename}${this.extension}` }),
tabindex: '0',
},
}
Expand All @@ -198,70 +202,51 @@ export default Vue.extend({
}
},
},
},

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) {
input.setCustomValidity(e.message)
input.title = e.message
} finally {
input.reportValidity()
}
},
isFileNameValid(name) {
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(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 }))
const input = (this.$refs.renameInput as Vue|undefined)?.$el.querySelector('input')
if (!input) {
return
}

return true
let validity = getFilenameValidity(newName)
// Checking if already exists
if (validity === '' && this.checkIfNodeExists(newName)) {
validity = t('files', 'Another entry with the same name already exists.')
}
this.$nextTick(() => {
if (this.isRenaming) {
input.setCustomValidity(validity)
input.reportValidity()
}
})
},
checkIfNodeExists(name) {
},

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 @@ -273,28 +258,23 @@ export default Vue.extend({

// 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.loading = 'renaming'
Vue.set(this.source, 'status', NodeStatus.LOADING)
this.$set(this.source, 'status', NodeStatus.LOADING)

// Update node
this.source.rename(newName)
Expand Down Expand Up @@ -338,7 +318,7 @@ export default Vue.extend({
showError(t('files', 'Could not rename "{oldName}"', { oldName }))
} finally {
this.loading = false
Vue.set(this.source, 'status', undefined)
this.$set(this.source, 'status', undefined)
}
},

Expand Down
2 changes: 1 addition & 1 deletion apps/files/src/components/FileEntryGrid.vue
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
@click.native="execDefaultAction" />

<FileEntryName ref="name"
:display-name="displayName"
:basename="basename"
:extension="extension"
:files-list-width="filesListWidth"
:grid-mode="true"
Expand Down
31 changes: 22 additions & 9 deletions apps/files/src/components/FileEntryMixin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,18 +83,31 @@ export default defineComponent({
return this.source.status === NodeStatus.LOADING
},

extension() {
if (this.source.attributes?.displayname) {
return extname(this.source.attributes.displayname)
/**
* The display name of the current node
* Either the nodes filename or a custom display name (e.g. for shares)
*/
displayName() {
return this.source.displayname
},
/**
* The display name without extension
*/
basename() {
if (this.extension === '') {
return this.displayName
}
return this.source.extension || ''
return this.displayName.slice(0, 0 - this.extension.length)
},
displayName() {
const ext = this.extension
const name = String(this.source.attributes.displayname || this.source.basename)
/**
* The extension of the file
*/
extension() {
if (this.source.type === FileType.Folder) {
return ''
}

// Strip extension from name if defined
return !ext ? name : name.slice(0, 0 - ext.length)
return extname(this.displayName)
},

draggingFiles() {
Expand Down
63 changes: 10 additions & 53 deletions apps/files/src/services/Files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,64 +23,21 @@ import type { ContentsWithRoot } from '@nextcloud/files'
import type { FileStat, ResponseDataDetailed, DAVResultResponseProps } from 'webdav'

import { CancelablePromise } from 'cancelable-promise'
import { File, Folder, davParsePermissions, davGetDefaultPropfind } from '@nextcloud/files'
import { generateRemoteUrl } from '@nextcloud/router'
import { getCurrentUser } from '@nextcloud/auth'

import { getClient, rootPath } from './WebdavClient'
import { hashCode } from '../utils/hashUtils'
import { File, Folder, davGetClient, davGetDefaultPropfind, davResultToNode, davRootPath } from '@nextcloud/files'
import logger from '../logger'

const client = getClient()

interface ResponseProps extends DAVResultResponseProps {
permissions: string,
fileid: number,
size: number,
/**
* Slim wrapper over `@nextcloud/files` `davResultToNode` to allow using the function with `Array.map`
* @param node The node returned by the webdav library
*/
export const resultToNode = (node: FileStat): File | Folder => {
return davResultToNode(node)
}

export const resultToNode = function(node: FileStat): File | Folder {
const userId = getCurrentUser()?.uid
if (!userId) {
throw new Error('No user id found')
}

const props = node.props as ResponseProps
const permissions = davParsePermissions(props?.permissions)
const owner = String(props['owner-id'] || userId)

const source = generateRemoteUrl('dav' + rootPath + node.filename)
const id = props?.fileid < 0
? hashCode(source)
: props?.fileid as number || 0

const nodeData = {
id,
source,
mtime: new Date(node.lastmod),
mime: node.mime || 'application/octet-stream',
size: props?.size as number || 0,
permissions,
owner,
root: rootPath,
attributes: {
...node,
...props,
'owner-id': owner,
'owner-display-name': String(props['owner-display-name']),
hasPreview: !!props?.['has-preview'],
failed: props?.fileid < 0,
},
}

delete nodeData.attributes.props

return node.type === 'file'
? new File(nodeData)
: new Folder(nodeData)
}
const client = davGetClient()

export const getContents = (path = '/'): Promise<ContentsWithRoot> => {
path = `${davRootPath}${path}`
const controller = new AbortController()
const propfindPayload = davGetDefaultPropfind()

Expand All @@ -96,7 +53,7 @@ export const getContents = (path = '/'): Promise<ContentsWithRoot> => {

const root = contentsResponse.data[0]
const contents = contentsResponse.data.slice(1)
if (root.filename !== path) {
if (root.filename !== path && `${root.filename}/` !== path) {
throw new Error('Root node does not match requested path')
}

Expand Down
Loading
Loading