diff options
Diffstat (limited to 'apps/files/src/utils')
-rw-r--r-- | apps/files/src/utils/actionUtils.ts | 74 | ||||
-rw-r--r-- | apps/files/src/utils/davUtils.js | 40 | ||||
-rw-r--r-- | apps/files/src/utils/davUtils.ts | 41 | ||||
-rw-r--r-- | apps/files/src/utils/dragUtils.ts | 21 | ||||
-rw-r--r-- | apps/files/src/utils/fileUtils.ts | 89 | ||||
-rw-r--r-- | apps/files/src/utils/filenameValidity.ts | 41 | ||||
-rw-r--r-- | apps/files/src/utils/filesViews.spec.ts | 73 | ||||
-rw-r--r-- | apps/files/src/utils/filesViews.ts | 30 | ||||
-rw-r--r-- | apps/files/src/utils/hashUtils.ts | 35 | ||||
-rw-r--r-- | apps/files/src/utils/newNodeDialog.ts | 40 | ||||
-rw-r--r-- | apps/files/src/utils/permissions.ts | 37 |
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 +} |