aboutsummaryrefslogtreecommitdiffstats
path: root/apps/files/src/utils
diff options
context:
space:
mode:
Diffstat (limited to 'apps/files/src/utils')
-rw-r--r--apps/files/src/utils/actionUtils.ts74
-rw-r--r--apps/files/src/utils/davUtils.js40
-rw-r--r--apps/files/src/utils/davUtils.ts41
-rw-r--r--apps/files/src/utils/dragUtils.ts21
-rw-r--r--apps/files/src/utils/fileUtils.ts89
-rw-r--r--apps/files/src/utils/filenameValidity.ts41
-rw-r--r--apps/files/src/utils/filesViews.spec.ts73
-rw-r--r--apps/files/src/utils/filesViews.ts30
-rw-r--r--apps/files/src/utils/hashUtils.ts35
-rw-r--r--apps/files/src/utils/newNodeDialog.ts40
-rw-r--r--apps/files/src/utils/permissions.ts37
11 files changed, 372 insertions, 149 deletions
diff --git a/apps/files/src/utils/actionUtils.ts b/apps/files/src/utils/actionUtils.ts
new file mode 100644
index 00000000000..adacf621b4c
--- /dev/null
+++ b/apps/files/src/utils/actionUtils.ts
@@ -0,0 +1,74 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { FileAction } from '@nextcloud/files'
+
+import { NodeStatus } from '@nextcloud/files'
+import { showError, showSuccess } from '@nextcloud/dialogs'
+import { t } from '@nextcloud/l10n'
+import Vue from 'vue'
+
+import { getPinia } from '../store'
+import { useActiveStore } from '../store/active'
+import logger from '../logger'
+
+/**
+ * Execute an action on the current active node
+ *
+ * @param action The action to execute
+ */
+export const executeAction = async (action: FileAction) => {
+ const activeStore = useActiveStore(getPinia())
+ const currentDir = (window?.OCP?.Files?.Router?.query?.dir || '/') as string
+ const currentNode = activeStore.activeNode
+ const currentView = activeStore.activeView
+
+ if (!currentNode || !currentView) {
+ logger.error('No active node or view', { node: currentNode, view: currentView })
+ return
+ }
+
+ if (currentNode.status === NodeStatus.LOADING) {
+ logger.debug('Node is already loading', { node: currentNode })
+ return
+ }
+
+ if (!action.enabled!([currentNode], currentView)) {
+ logger.debug('Action is not not available for the current context', { action, node: currentNode, view: currentView })
+ return
+ }
+
+ let displayName = action.id
+ try {
+ displayName = action.displayName([currentNode], currentView)
+ } catch (error) {
+ logger.error('Error while getting action display name', { action, error })
+ }
+
+ try {
+ // Set the loading marker
+ Vue.set(currentNode, 'status', NodeStatus.LOADING)
+ activeStore.activeAction = action
+
+ const success = await action.exec(currentNode, currentView, currentDir)
+
+ // If the action returns null, we stay silent
+ if (success === null || success === undefined) {
+ return
+ }
+
+ if (success) {
+ showSuccess(t('files', '{displayName}: done', { displayName }))
+ return
+ }
+ showError(t('files', '{displayName}: failed', { displayName }))
+ } catch (error) {
+ logger.error('Error while executing action', { action, error })
+ showError(t('files', '{displayName}: failed', { displayName }))
+ } finally {
+ // Reset the loading marker
+ Vue.set(currentNode, 'status', undefined)
+ activeStore.activeAction = undefined
+ }
+}
diff --git a/apps/files/src/utils/davUtils.js b/apps/files/src/utils/davUtils.js
deleted file mode 100644
index 22367d09a1a..00000000000
--- a/apps/files/src/utils/davUtils.js
+++ /dev/null
@@ -1,40 +0,0 @@
-/**
- * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-import { generateRemoteUrl } from '@nextcloud/router'
-import { getCurrentUser } from '@nextcloud/auth'
-
-export const getRootPath = function() {
- if (getCurrentUser()) {
- return generateRemoteUrl(`dav/files/${getCurrentUser().uid}`)
- } else {
- return generateRemoteUrl('webdav').replace('/remote.php', '/public.php')
- }
-}
-
-export const isPublic = function() {
- return !getCurrentUser()
-}
-
-export const getToken = function() {
- return document.getElementById('sharingToken') && document.getElementById('sharingToken').value
-}
diff --git a/apps/files/src/utils/davUtils.ts b/apps/files/src/utils/davUtils.ts
new file mode 100644
index 00000000000..54c1a6ea966
--- /dev/null
+++ b/apps/files/src/utils/davUtils.ts
@@ -0,0 +1,41 @@
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { t } from '@nextcloud/l10n'
+import type { WebDAVClientError } from 'webdav'
+
+/**
+ * Whether error is a WebDAVClientError
+ * @param error - Any exception
+ * @return {boolean} - Whether error is a WebDAVClientError
+ */
+function isWebDAVClientError(error: unknown): error is WebDAVClientError {
+ return error instanceof Error && 'status' in error && 'response' in error
+}
+
+/**
+ * Get a localized error message from webdav request
+ * @param error - An exception from webdav request
+ * @return {string} Localized error message for end user
+ */
+export function humanizeWebDAVError(error: unknown) {
+ if (error instanceof Error) {
+ if (isWebDAVClientError(error)) {
+ const status = error.status || error.response?.status || 0
+ if ([400, 404, 405].includes(status)) {
+ return t('files', 'Folder not found')
+ } else if (status === 403) {
+ return t('files', 'This operation is forbidden')
+ } else if (status === 500) {
+ return t('files', 'This folder is unavailable, please try again later or contact the administration')
+ } else if (status === 503) {
+ return t('files', 'Storage is temporarily not available')
+ }
+ }
+ return t('files', 'Unexpected error: {error}', { error: error.message })
+ }
+
+ return t('files', 'Unknown error')
+}
diff --git a/apps/files/src/utils/dragUtils.ts b/apps/files/src/utils/dragUtils.ts
index fc4b33d847d..0722e313089 100644
--- a/apps/files/src/utils/dragUtils.ts
+++ b/apps/files/src/utils/dragUtils.ts
@@ -1,23 +1,6 @@
/**
- * @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Node } from '@nextcloud/files'
import DragAndDropPreview from '../components/DragAndDropPreview.vue'
diff --git a/apps/files/src/utils/fileUtils.ts b/apps/files/src/utils/fileUtils.ts
index 126739242a0..f0b974be21d 100644
--- a/apps/files/src/utils/fileUtils.ts
+++ b/apps/files/src/utils/fileUtils.ts
@@ -1,64 +1,17 @@
/**
- * @copyright Copyright (c) 2021 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { FileType, type Node } from '@nextcloud/files'
-import { translate as t, translatePlural as n } from '@nextcloud/l10n'
-import { basename, extname } from 'path'
-
-// TODO: move to @nextcloud/files
-/**
- * Create an unique file name
- * @param name The initial name to use
- * @param otherNames Other names that are already used
- * @param suffix A function that takes an index an returns a suffix to add, defaults to '(index)'
- * @return Either the initial name, if unique, or the name with the suffix so that the name is unique
- */
-export const getUniqueName = (name: string, otherNames: string[], suffix = (n: number) => `(${n})`): string => {
- let newName = name
- let i = 1
- while (otherNames.includes(newName)) {
- const ext = extname(name)
- newName = `${basename(name, ext)} ${suffix(i++)}${ext}`
- }
- return newName
-}
-
-export const encodeFilePath = function(path) {
- const pathSections = (path.startsWith('/') ? path : `/${path}`).split('/')
- let relativePath = ''
- pathSections.forEach((section) => {
- if (section !== '') {
- relativePath += '/' + encodeURIComponent(section)
- }
- })
- return relativePath
-}
+import { n } from '@nextcloud/l10n'
/**
* Extract dir and name from file path
*
- * @param {string} path the full path
- * @return {string[]} [dirPath, fileName]
+ * @param path - The full path
+ * @return [dirPath, fileName]
*/
-export const extractFilePaths = function(path) {
+export function extractFilePaths(path: string): [string, string] {
const pathSections = path.split('/')
const fileName = pathSections[pathSections.length - 1]
const dirPath = pathSections.slice(0, pathSections.length - 1).join('/')
@@ -67,26 +20,28 @@ export const extractFilePaths = function(path) {
/**
* Generate a translated summary of an array of nodes
- * @param {Node[]} nodes the nodes to summarize
- * @return {string}
+ *
+ * @param nodes - The nodes to summarize
+ * @param hidden - The number of hidden nodes
*/
-export const getSummaryFor = (nodes: Node[]): string => {
+export function getSummaryFor(nodes: Node[], hidden = 0): string {
const fileCount = nodes.filter(node => node.type === FileType.File).length
const folderCount = nodes.filter(node => node.type === FileType.Folder).length
- if (fileCount === 0) {
- return n('files', '{folderCount} folder', '{folderCount} folders', folderCount, { folderCount })
- } else if (folderCount === 0) {
- return n('files', '{fileCount} file', '{fileCount} files', fileCount, { fileCount })
+ const summary: string[] = []
+ if (fileCount > 0 || folderCount === 0) {
+ const fileSummary = n('files', '%n file', '%n files', fileCount)
+ summary.push(fileSummary)
}
-
- if (fileCount === 1) {
- return n('files', '1 file and {folderCount} folder', '1 file and {folderCount} folders', folderCount, { folderCount })
+ if (folderCount > 0) {
+ const folderSummary = n('files', '%n folder', '%n folders', folderCount)
+ summary.push(folderSummary)
}
-
- if (folderCount === 1) {
- return n('files', '{fileCount} file and 1 folder', '{fileCount} files and 1 folder', fileCount, { fileCount })
+ if (hidden > 0) {
+ // TRANSLATORS: This is the number of hidden files or folders
+ const hiddenSummary = n('files', '%n hidden', '%n hidden', hidden)
+ summary.push(hiddenSummary)
}
- return t('files', '{fileCount} files and {folderCount} folders', { fileCount, folderCount })
+ return summary.join(' · ')
}
diff --git a/apps/files/src/utils/filenameValidity.ts b/apps/files/src/utils/filenameValidity.ts
new file mode 100644
index 00000000000..2666d530052
--- /dev/null
+++ b/apps/files/src/utils/filenameValidity.ts
@@ -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.')
+ }
+ }
+}
diff --git a/apps/files/src/utils/filesViews.spec.ts b/apps/files/src/utils/filesViews.spec.ts
new file mode 100644
index 00000000000..03b0bb9aeb0
--- /dev/null
+++ b/apps/files/src/utils/filesViews.spec.ts
@@ -0,0 +1,73 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { beforeEach, describe, expect, test } from 'vitest'
+import { defaultView, hasPersonalFilesView } from './filesViews.ts'
+
+describe('hasPersonalFilesView', () => {
+ beforeEach(() => removeInitialState())
+
+ test('enabled if user has unlimited quota', () => {
+ mockInitialState('files', 'storageStats', { quota: -1 })
+ expect(hasPersonalFilesView()).toBe(true)
+ })
+
+ test('enabled if user has limited quota', () => {
+ mockInitialState('files', 'storageStats', { quota: 1234 })
+ expect(hasPersonalFilesView()).toBe(true)
+ })
+
+ test('disabled if user has no quota', () => {
+ mockInitialState('files', 'storageStats', { quota: 0 })
+ expect(hasPersonalFilesView()).toBe(false)
+ })
+})
+
+describe('defaultView', () => {
+ beforeEach(removeInitialState)
+
+ test('Returns files view if set', () => {
+ mockInitialState('files', 'config', { default_view: 'files' })
+ expect(defaultView()).toBe('files')
+ })
+
+ test('Returns personal view if set and enabled', () => {
+ mockInitialState('files', 'config', { default_view: 'personal' })
+ mockInitialState('files', 'storageStats', { quota: -1 })
+ expect(defaultView()).toBe('personal')
+ })
+
+ test('Falls back to files if personal view is disabled', () => {
+ mockInitialState('files', 'config', { default_view: 'personal' })
+ mockInitialState('files', 'storageStats', { quota: 0 })
+ expect(defaultView()).toBe('files')
+ })
+})
+
+/**
+ * Remove the mocked initial state
+ */
+function removeInitialState(): void {
+ document.querySelectorAll('input[type="hidden"]').forEach((el) => {
+ el.remove()
+ })
+ // clear the cache
+ delete globalThis._nc_initial_state
+}
+
+/**
+ * Helper to mock an initial state value
+ * @param app - The app
+ * @param key - The key
+ * @param value - The value
+ */
+function mockInitialState(app: string, key: string, value: unknown): void {
+ const el = document.createElement('input')
+ el.value = btoa(JSON.stringify(value))
+ el.id = `initial-state-${app}-${key}`
+ el.type = 'hidden'
+
+ document.head.appendChild(el)
+}
diff --git a/apps/files/src/utils/filesViews.ts b/apps/files/src/utils/filesViews.ts
new file mode 100644
index 00000000000..9489c35cbde
--- /dev/null
+++ b/apps/files/src/utils/filesViews.ts
@@ -0,0 +1,30 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { UserConfig } from '../types.ts'
+
+import { loadState } from '@nextcloud/initial-state'
+
+/**
+ * Check whether the personal files view can be shown
+ */
+export function hasPersonalFilesView(): boolean {
+ const storageStats = loadState('files', 'storageStats', { quota: -1 })
+ // Don't show this view if the user has no storage quota
+ return storageStats.quota !== 0
+}
+
+/**
+ * Get the default files view
+ */
+export function defaultView() {
+ const { default_view: defaultView } = loadState<Partial<UserConfig>>('files', 'config', { default_view: 'files' })
+
+ // the default view - only use the personal one if it is enabled
+ if (defaultView !== 'personal' || hasPersonalFilesView()) {
+ return defaultView
+ }
+ return 'files'
+}
diff --git a/apps/files/src/utils/hashUtils.ts b/apps/files/src/utils/hashUtils.ts
index 55cf8b9f51a..2e1fadff067 100644
--- a/apps/files/src/utils/hashUtils.ts
+++ b/apps/files/src/utils/hashUtils.ts
@@ -1,28 +1,17 @@
/**
- * @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
+/**
+ * Simple non-secure hashing function similar to Java's `hashCode`
+ * @param str The string to hash
+ * @return {number} a non secure hash of the string
+ */
export const hashCode = function(str: string): number {
- return str.split('').reduce(function(a, b) {
- a = ((a << 5) - a) + b.charCodeAt(0)
- return a & a
- }, 0)
+ let hash = 0
+ for (let i = 0; i < str.length; i++) {
+ hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0
+ }
+ return (hash >>> 0)
}
diff --git a/apps/files/src/utils/newNodeDialog.ts b/apps/files/src/utils/newNodeDialog.ts
new file mode 100644
index 00000000000..a81fa9f4e17
--- /dev/null
+++ b/apps/files/src/utils/newNodeDialog.ts
@@ -0,0 +1,40 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { Node } from '@nextcloud/files'
+import { spawnDialog } from '@nextcloud/dialogs'
+import NewNodeDialog from '../components/NewNodeDialog.vue'
+
+interface ILabels {
+ /**
+ * Dialog heading, defaults to "New folder name"
+ */
+ name?: string
+ /**
+ * Label for input box, defaults to "New folder"
+ */
+ label?: string
+}
+
+/**
+ * Ask user for file or folder name
+ * @param defaultName Default name to use
+ * @param folderContent Nodes with in the current folder to check for unique name
+ * @param labels Labels to set on the dialog
+ * @return string if successful otherwise null if aborted
+ */
+export function newNodeName(defaultName: string, folderContent: Node[], labels: ILabels = {}) {
+ const contentNames = folderContent.map((node: Node) => node.basename)
+
+ return new Promise<string|null>((resolve) => {
+ spawnDialog(NewNodeDialog, {
+ ...labels,
+ defaultName,
+ otherNames: contentNames,
+ }, (folderName) => {
+ resolve(folderName as string|null)
+ })
+ })
+}
diff --git a/apps/files/src/utils/permissions.ts b/apps/files/src/utils/permissions.ts
new file mode 100644
index 00000000000..9b4c42bf49c
--- /dev/null
+++ b/apps/files/src/utils/permissions.ts
@@ -0,0 +1,37 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { Node } from '@nextcloud/files'
+import type { ShareAttribute } from '../../../files_sharing/src/sharing.ts'
+
+import { Permission } from '@nextcloud/files'
+
+/**
+ * Check permissions on the node if it can be downloaded
+ * @param node The node to check
+ * @return True if downloadable, false otherwise
+ */
+export function isDownloadable(node: Node): boolean {
+ if ((node.permissions & Permission.READ) === 0) {
+ return false
+ }
+
+ // check hide-download property of shares
+ if (node.attributes['hide-download'] === true
+ || node.attributes['hide-download'] === 'true'
+ ) {
+ return false
+ }
+
+ // If the mount type is a share, ensure it got download permissions.
+ if (node.attributes['share-attributes']) {
+ const shareAttributes = JSON.parse(node.attributes['share-attributes'] || '[]') as Array<ShareAttribute>
+ const downloadAttribute = shareAttributes.find(({ scope, key }: ShareAttribute) => scope === 'permissions' && key === 'download')
+ if (downloadAttribute !== undefined) {
+ return downloadAttribute.value === true
+ }
+ }
+
+ return true
+}