From aac91a8df9b90782df302bf1413d96d447db9dc3 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 5 Feb 2026 15:28:33 +0100 Subject: [PATCH] refactor(files): adjust for files library interfaces Signed-off-by: Ferdinand Thiessen --- apps/files/src/actions/convertAction.ts | 68 +++--- apps/files/src/actions/deleteAction.spec.ts | 12 +- apps/files/src/actions/deleteAction.ts | 11 +- apps/files/src/actions/downloadAction.spec.ts | 9 +- apps/files/src/actions/downloadAction.ts | 220 +++++++++--------- apps/files/src/actions/favoriteAction.spec.ts | 45 ++-- apps/files/src/actions/favoriteAction.ts | 114 ++++----- apps/files/src/actions/moveOrCopyAction.ts | 24 +- .../src/actions/openFolderAction.spec.ts | 9 +- apps/files/src/actions/openFolderAction.ts | 15 +- .../src/actions/openInFilesAction.spec.ts | 11 +- apps/files/src/actions/openInFilesAction.ts | 11 +- .../src/actions/openLocallyAction.spec.ts | 27 ++- apps/files/src/actions/openLocallyAction.ts | 12 +- apps/files/src/actions/renameAction.spec.ts | 7 +- apps/files/src/actions/renameAction.ts | 11 +- apps/files/src/actions/sidebarAction.spec.ts | 5 +- apps/files/src/actions/sidebarAction.ts | 8 +- .../src/actions/viewInFolderAction.spec.ts | 12 +- apps/files/src/actions/viewInFolderAction.ts | 11 +- .../src/components/CustomElementRender.vue | 10 +- .../components/FileEntry/FileEntryActions.vue | 8 +- .../components/FileEntry/FileEntryName.vue | 4 +- apps/files/src/components/FileEntryMixin.ts | 6 +- .../FilesListTableHeaderActions.vue | 8 +- apps/files/src/mixins/actionsMixin.ts | 16 +- apps/files/src/store/active.ts | 8 +- apps/files/src/types.ts | 21 +- apps/files/src/utils/actionUtils.ts | 4 +- apps/files/src/utils/permissions.ts | 7 +- cypress/e2e/files/files-actions.cy.ts | 40 ++-- 31 files changed, 395 insertions(+), 379 deletions(-) diff --git a/apps/files/src/actions/convertAction.ts b/apps/files/src/actions/convertAction.ts index 0feae7e87dd..e9fa7862380 100644 --- a/apps/files/src/actions/convertAction.ts +++ b/apps/files/src/actions/convertAction.ts @@ -1,10 +1,13 @@ -/** +/*! * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ + +import type { IFileAction } from '@nextcloud/files' + import AutoRenewSvg from '@mdi/svg/svg/autorenew.svg?raw' import { getCapabilities } from '@nextcloud/capabilities' -import { FileAction, registerFileAction } from '@nextcloud/files' +import { registerFileAction } from '@nextcloud/files' import { t } from '@nextcloud/l10n' import { generateUrl } from '@nextcloud/router' import { convertFile, convertFiles } from './convertUtils.ts' @@ -18,47 +21,45 @@ type ConversionsProvider = { export const ACTION_CONVERT = 'convert' /** - * + * Registers the convert actions based on the capabilities provided by the server. */ export function registerConvertActions() { // Generate sub actions const convertProviders = getCapabilities()?.files?.file_conversions as ConversionsProvider[] ?? [] - const actions = convertProviders.map(({ to, from, displayName }) => { - return new FileAction({ - id: `convert-${from}-${to}`, - displayName: () => t('files', 'Save as {displayName}', { displayName }), - iconSvgInline: () => generateIconSvg(to), - enabled: ({ nodes }) => { - // Check that all nodes have the same mime type - return nodes.every((node) => from === node.mime) - }, + const actions = convertProviders.map(({ to, from, displayName }) => ({ + id: `convert-${from}-${to}`, + displayName: () => t('files', 'Save as {displayName}', { displayName }), + iconSvgInline: () => generateIconSvg(to), + enabled: ({ nodes }) => { + // Check that all nodes have the same mime type + return nodes.every((node) => from === node.mime) + }, - async exec({ nodes }) { - if (!nodes[0]) { - return false - } + async exec({ nodes }) { + if (!nodes[0]) { + return false + } - // If we're here, we know that the node has a fileid - convertFile(nodes[0].fileid as number, to) + // If we're here, we know that the node has a fileid + convertFile(nodes[0].fileid as number, to) - // Silently terminate, we'll handle the UI in the background - return null - }, + // Silently terminate, we'll handle the UI in the background + return null + }, - async execBatch({ nodes }) { - const fileIds = nodes.map((node) => node.fileid).filter(Boolean) as number[] - convertFiles(fileIds, to) + async execBatch({ nodes }) { + const fileIds = nodes.map((node) => node.fileid).filter(Boolean) as number[] + convertFiles(fileIds, to) - // Silently terminate, we'll handle the UI in the background - return Array(nodes.length).fill(null) - }, + // Silently terminate, we'll handle the UI in the background + return Array(nodes.length).fill(null) + }, - parent: ACTION_CONVERT, - }) - }) + parent: ACTION_CONVERT, + } satisfies IFileAction)) // Register main action - registerFileAction(new FileAction({ + registerFileAction({ id: ACTION_CONVERT, displayName: () => t('files', 'Save as …'), iconSvgInline: () => AutoRenewSvg, @@ -69,15 +70,16 @@ export function registerConvertActions() { return null }, order: 25, - })) + } satisfies IFileAction) // Register sub actions actions.forEach(registerFileAction) } /** + * Generates an SVG icon for a given mime type by using the server's mime icon endpoint. * - * @param mime + * @param mime - The mime type to generate the icon for */ export function generateIconSvg(mime: string) { // Generate icon based on mime type diff --git a/apps/files/src/actions/deleteAction.spec.ts b/apps/files/src/actions/deleteAction.spec.ts index 3d6ff7ff770..89c1d763e6e 100644 --- a/apps/files/src/actions/deleteAction.spec.ts +++ b/apps/files/src/actions/deleteAction.spec.ts @@ -1,13 +1,14 @@ -/** +/*! * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { View } from '@nextcloud/files' + +import type { IView } from '@nextcloud/files' import axios from '@nextcloud/axios' import * as capabilities from '@nextcloud/capabilities' import * as eventBus from '@nextcloud/event-bus' -import { File, FileAction, Folder, Permission } from '@nextcloud/files' +import { File, Folder, Permission } from '@nextcloud/files' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import logger from '../logger.ts' import { action } from './deleteAction.ts' @@ -20,12 +21,12 @@ vi.mock('@nextcloud/capabilities') const view = { id: 'files', name: 'Files', -} as View +} as IView const trashbinView = { id: 'trashbin', name: 'Trashbin', -} as View +} as IView describe('Delete action conditions tests', () => { beforeEach(() => { @@ -90,7 +91,6 @@ describe('Delete action conditions tests', () => { }) test('Default values', () => { - expect(action).toBeInstanceOf(FileAction) expect(action.id).toBe('delete') expect(action.displayName({ nodes: [file], diff --git a/apps/files/src/actions/deleteAction.ts b/apps/files/src/actions/deleteAction.ts index 013d954b1dd..dff1cdafdd6 100644 --- a/apps/files/src/actions/deleteAction.ts +++ b/apps/files/src/actions/deleteAction.ts @@ -1,11 +1,14 @@ -/** +/*! * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ + +import type { IFileAction } from '@nextcloud/files' + import CloseSvg from '@mdi/svg/svg/close.svg?raw' import NetworkOffSvg from '@mdi/svg/svg/network-off.svg?raw' import TrashCanSvg from '@mdi/svg/svg/trash-can-outline.svg?raw' -import { FileAction, Permission } from '@nextcloud/files' +import { Permission } from '@nextcloud/files' import { loadState } from '@nextcloud/initial-state' import { t } from '@nextcloud/l10n' import PQueue from 'p-queue' @@ -20,7 +23,7 @@ const queue = new PQueue({ concurrency: 5 }) export const ACTION_DELETE = 'delete' -export const action = new FileAction({ +export const action: IFileAction = { id: ACTION_DELETE, displayName, iconSvgInline: ({ nodes }) => { @@ -117,4 +120,4 @@ export const action = new FileAction({ description: t('files', 'Delete'), key: 'Delete', }, -}) +} diff --git a/apps/files/src/actions/downloadAction.spec.ts b/apps/files/src/actions/downloadAction.spec.ts index 880053ccf14..b3bbc0dd8ff 100644 --- a/apps/files/src/actions/downloadAction.spec.ts +++ b/apps/files/src/actions/downloadAction.spec.ts @@ -1,14 +1,14 @@ -/** +/*! * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { View } from '@nextcloud/files' +import type { IView } from '@nextcloud/files' import axios from '@nextcloud/axios' import * as dialogs from '@nextcloud/dialogs' import * as eventBus from '@nextcloud/event-bus' -import { DefaultType, File, FileAction, Folder, Permission } from '@nextcloud/files' +import { DefaultType, File, Folder, Permission } from '@nextcloud/files' import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest' import { action } from './downloadAction.ts' @@ -19,7 +19,7 @@ vi.mock('@nextcloud/event-bus') const view = { id: 'files', name: 'Files', -} as View +} as IView // Mock webroot variable beforeAll(() => { @@ -28,7 +28,6 @@ beforeAll(() => { describe('Download action conditions tests', () => { test('Default values', () => { - expect(action).toBeInstanceOf(FileAction) expect(action.id).toBe('download') expect(action.displayName({ nodes: [], diff --git a/apps/files/src/actions/downloadAction.ts b/apps/files/src/actions/downloadAction.ts index 17d01303f44..847188bb79f 100644 --- a/apps/files/src/actions/downloadAction.ts +++ b/apps/files/src/actions/downloadAction.ts @@ -1,15 +1,15 @@ -/** +/*! * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Node, View } from '@nextcloud/files' +import type { IFileAction, INode, IView } from '@nextcloud/files' import ArrowDownSvg from '@mdi/svg/svg/arrow-down.svg?raw' import axios from '@nextcloud/axios' import { showError } from '@nextcloud/dialogs' import { emit } from '@nextcloud/event-bus' -import { DefaultType, FileAction, FileType } from '@nextcloud/files' +import { DefaultType, FileType } from '@nextcloud/files' import { t } from '@nextcloud/l10n' import logger from '../logger.ts' import { useFilesStore } from '../store/files.ts' @@ -17,112 +17,7 @@ import { getPinia } from '../store/index.ts' import { usePathsStore } from '../store/paths.ts' import { isDownloadable } from '../utils/permissions.ts' -/** - * Trigger downloading a file. - * - * @param url The url of the asset to download - * @param name Optionally the recommended name of the download (browsers might ignore it) - */ -async function triggerDownload(url: string, name?: string) { - // try to see if the resource is still available - await axios.head(url) - - const hiddenElement = document.createElement('a') - hiddenElement.download = name ?? '' - hiddenElement.href = url - hiddenElement.click() -} - -/** - * Find the longest common path prefix of both input paths - * - * @param first The first path - * @param second The second path - */ -function longestCommonPath(first: string, second: string): string { - const firstSegments = first.split('/').filter(Boolean) - const secondSegments = second.split('/').filter(Boolean) - let base = '' - for (const [index, segment] of firstSegments.entries()) { - if (index >= second.length) { - break - } - if (segment !== secondSegments[index]) { - break - } - const sep = base === '' ? '' : '/' - base = `${base}${sep}${segment}` - } - return base -} - -/** - * Download the given nodes. - * - * If only one node is given, it will be downloaded directly. - * If multiple nodes are given, they will be zipped and downloaded. - * - * @param nodes The node(s) to download - */ -async function downloadNodes(nodes: Node[]) { - let url: URL - - if (!nodes[0]) { - throw new Error('No nodes to download') - } - - if (nodes.length === 1) { - if (nodes[0].type === FileType.File) { - await triggerDownload(nodes[0].encodedSource, nodes[0].displayname) - return - } else { - url = new URL(nodes[0].encodedSource) - url.searchParams.append('accept', 'zip') - } - } else { - url = new URL(nodes[0].encodedSource) - let base = url.pathname - for (const node of nodes.slice(1)) { - base = longestCommonPath(base, (new URL(node.encodedSource).pathname)) - } - url.pathname = base - - // The URL contains the path encoded so we need to decode as the query.append will re-encode it - const filenames = nodes.map((node) => decodeURIComponent(node.encodedSource.slice(url.href.length + 1))) - url.searchParams.append('accept', 'zip') - url.searchParams.append('files', JSON.stringify(filenames)) - } - - if (url.pathname.at(-1) !== '/') { - url.pathname = `${url.pathname}/` - } - - await triggerDownload(url.href) -} - -/** - * Get the current directory node for the given view and path. - * TODO: ideally the folder would directly be passed as exec params - * - * @param view The current view - * @param directory The directory path - * @return The current directory node or null if not found - */ -function getCurrentDirectory(view: View, directory: string): Node | null { - const filesStore = useFilesStore(getPinia()) - const pathsStore = usePathsStore(getPinia()) - if (!view?.id) { - return null - } - - if (directory === '/') { - return filesStore.getRoot(view.id) || null - } - const fileId = pathsStore.getPath(view.id, directory)! - return filesStore.getNode(fileId) || null -} - -export const action = new FileAction({ +export const action: IFileAction = { id: 'download', default: DefaultType.DEFAULT, @@ -172,4 +67,109 @@ export const action = new FileAction({ }, order: 30, -}) +} + +/** + * Trigger downloading a file. + * + * @param url The url of the asset to download + * @param name Optionally the recommended name of the download (browsers might ignore it) + */ +async function triggerDownload(url: string, name?: string) { + // try to see if the resource is still available + await axios.head(url) + + const hiddenElement = document.createElement('a') + hiddenElement.download = name ?? '' + hiddenElement.href = url + hiddenElement.click() +} + +/** + * Find the longest common path prefix of both input paths + * + * @param first The first path + * @param second The second path + */ +function longestCommonPath(first: string, second: string): string { + const firstSegments = first.split('/').filter(Boolean) + const secondSegments = second.split('/').filter(Boolean) + let base = '' + for (const [index, segment] of firstSegments.entries()) { + if (index >= second.length) { + break + } + if (segment !== secondSegments[index]) { + break + } + const sep = base === '' ? '' : '/' + base = `${base}${sep}${segment}` + } + return base +} + +/** + * Download the given nodes. + * + * If only one node is given, it will be downloaded directly. + * If multiple nodes are given, they will be zipped and downloaded. + * + * @param nodes The node(s) to download + */ +async function downloadNodes(nodes: INode[]) { + let url: URL + + if (!nodes[0]) { + throw new Error('No nodes to download') + } + + if (nodes.length === 1) { + if (nodes[0].type === FileType.File) { + await triggerDownload(nodes[0].encodedSource, nodes[0].displayname) + return + } else { + url = new URL(nodes[0].encodedSource) + url.searchParams.append('accept', 'zip') + } + } else { + url = new URL(nodes[0].encodedSource) + let base = url.pathname + for (const node of nodes.slice(1)) { + base = longestCommonPath(base, (new URL(node.encodedSource).pathname)) + } + url.pathname = base + + // The URL contains the path encoded so we need to decode as the query.append will re-encode it + const filenames = nodes.map((node) => decodeURIComponent(node.encodedSource.slice(url.href.length + 1))) + url.searchParams.append('accept', 'zip') + url.searchParams.append('files', JSON.stringify(filenames)) + } + + if (url.pathname.at(-1) !== '/') { + url.pathname = `${url.pathname}/` + } + + await triggerDownload(url.href) +} + +/** + * Get the current directory node for the given view and path. + * TODO: ideally the folder would directly be passed as exec params + * + * @param view The current view + * @param directory The directory path + * @return The current directory node or null if not found + */ +function getCurrentDirectory(view: IView, directory: string): INode | null { + const filesStore = useFilesStore(getPinia()) + const pathsStore = usePathsStore(getPinia()) + if (!view?.id) { + return null + } + + if (directory === '/') { + return filesStore.getRoot(view.id) || null + } + const fileId = pathsStore.getPath(view.id, directory)! + return filesStore.getNode(fileId) || null +} diff --git a/apps/files/src/actions/favoriteAction.spec.ts b/apps/files/src/actions/favoriteAction.spec.ts index 297e6fea7d9..a9d9c4c54a6 100644 --- a/apps/files/src/actions/favoriteAction.spec.ts +++ b/apps/files/src/actions/favoriteAction.spec.ts @@ -1,13 +1,13 @@ -/** +/*! * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Folder, View } from '@nextcloud/files' +import type { IFolder, IView } from '@nextcloud/files' import axios from '@nextcloud/axios' import * as eventBus from '@nextcloud/event-bus' -import { File, FileAction, Permission } from '@nextcloud/files' +import { File, Permission } from '@nextcloud/files' import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest' import logger from '../logger.ts' import { action } from './favoriteAction.ts' @@ -19,12 +19,12 @@ vi.mock('@nextcloud/axios') const view = { id: 'files', name: 'Files', -} as View +} as IView const favoriteView = { id: 'favorites', name: 'Favorites', -} as View +} as IView // Mock webroot variable beforeAll(() => { @@ -46,18 +46,17 @@ describe('Favorite action conditions tests', () => { root: '/files/admin', }) - expect(action).toBeInstanceOf(FileAction) expect(action.id).toBe('favorite') expect(action.displayName({ nodes: [file], view, - folder: {} as Folder, + folder: {} as IFolder, contents: [], })).toBe('Add to favorites') expect(action.iconSvgInline({ nodes: [], view, - folder: {} as Folder, + folder: {} as IFolder, contents: [], })).toMatch(//) expect(action.default).toBeUndefined() @@ -79,7 +78,7 @@ describe('Favorite action conditions tests', () => { expect(action.displayName({ nodes: [file], view, - folder: {} as Folder, + folder: {} as IFolder, contents: [], })).toBe('Remove from favorites') }) @@ -122,25 +121,25 @@ describe('Favorite action conditions tests', () => { expect(action.displayName({ nodes: [file1, file2, file3], view, - folder: {} as Folder, + folder: {} as IFolder, contents: [], })).toBe('Add to favorites') expect(action.displayName({ nodes: [file2, file3], view, - folder: {} as Folder, + folder: {} as IFolder, contents: [], })).toBe('Add to favorites') expect(action.displayName({ nodes: [file2, file3], view, - folder: {} as Folder, + folder: {} as IFolder, contents: [], })).toBe('Add to favorites') expect(action.displayName({ nodes: [file1, file3], view, - folder: {} as Folder, + folder: {} as IFolder, contents: [], })).toBe('Remove from favorites') }) @@ -161,7 +160,7 @@ describe('Favorite action enabled tests', () => { expect(action.enabled!({ nodes: [file], view, - folder: {} as Folder, + folder: {} as IFolder, contents: [], })).toBe(true) }) @@ -179,7 +178,7 @@ describe('Favorite action enabled tests', () => { expect(action.enabled!({ nodes: [file], view, - folder: {} as Folder, + folder: {} as IFolder, contents: [], })).toBe(false) }) @@ -205,7 +204,7 @@ describe('Favorite action execute tests', () => { const exec = await action.exec({ nodes: [file], view, - folder: {} as Folder, + folder: {} as IFolder, contents: [], }) @@ -239,7 +238,7 @@ describe('Favorite action execute tests', () => { const exec = await action.exec({ nodes: [file], view, - folder: {} as Folder, + folder: {} as IFolder, contents: [], }) @@ -273,7 +272,7 @@ describe('Favorite action execute tests', () => { const exec = await action.exec({ nodes: [file], view: favoriteView, - folder: {} as Folder, + folder: {} as IFolder, contents: [], }) @@ -308,7 +307,7 @@ describe('Favorite action execute tests', () => { const exec = await action.exec({ nodes: [file], view: favoriteView, - folder: {} as Folder, + folder: {} as IFolder, contents: [], }) @@ -345,7 +344,7 @@ describe('Favorite action execute tests', () => { const exec = await action.exec({ nodes: [file], view, - folder: {} as Folder, + folder: {} as IFolder, contents: [], }) @@ -383,7 +382,7 @@ describe('Favorite action execute tests', () => { const exec = await action.exec({ nodes: [file], view, - folder: {} as Folder, + folder: {} as IFolder, contents: [], }) @@ -437,7 +436,7 @@ describe('Favorite action batch execute tests', () => { const exec = await action.execBatch!({ nodes: [file1, file2], view, - folder: {} as Folder, + folder: {} as IFolder, contents: [], }) expect(exec).toStrictEqual([true, true]) @@ -479,7 +478,7 @@ describe('Favorite action batch execute tests', () => { const exec = await action.execBatch!({ nodes: [file1, file2], view, - folder: {} as Folder, + folder: {} as IFolder, contents: [], }) expect(exec).toStrictEqual([true, true]) diff --git a/apps/files/src/actions/favoriteAction.ts b/apps/files/src/actions/favoriteAction.ts index 7892100300e..a72fc383ad2 100644 --- a/apps/files/src/actions/favoriteAction.ts +++ b/apps/files/src/actions/favoriteAction.ts @@ -3,13 +3,13 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { INode, IView } from '@nextcloud/files' +import type { IFileAction, INode, IView } from '@nextcloud/files' import StarOutlineSvg from '@mdi/svg/svg/star-outline.svg?raw' import StarSvg from '@mdi/svg/svg/star.svg?raw' import axios from '@nextcloud/axios' import { emit } from '@nextcloud/event-bus' -import { FileAction, Permission } from '@nextcloud/files' +import { Permission } from '@nextcloud/files' import { t } from '@nextcloud/l10n' import { encodePath } from '@nextcloud/paths' import { generateUrl } from '@nextcloud/router' @@ -18,63 +18,11 @@ import PQueue from 'p-queue' import Vue from 'vue' import logger from '../logger.ts' -export const ACTION_FAVORITE = 'favorite' - const queue = new PQueue({ concurrency: 5 }) -/** - * If any of the nodes is not favorited, we display the favorite action. - * - * @param nodes - The nodes to check - */ -function shouldFavorite(nodes: INode[]): boolean { - return nodes.some((node) => node.attributes.favorite !== 1) -} +export const ACTION_FAVORITE = 'favorite' -/** - * Favorite or unfavorite a node - * - * @param node - The node to favorite/unfavorite - * @param view - The current view - * @param willFavorite - Whether to favorite or unfavorite the node - */ -export async function favoriteNode(node: INode, view: IView, willFavorite: boolean): Promise { - try { - // TODO: migrate to webdav tags plugin - const url = generateUrl('/apps/files/api/v1/files') + encodePath(node.path) - await axios.post(url, { - tags: willFavorite - ? [window.OC.TAG_FAVORITE] - : [], - }) - - // Let's delete if we are in the favourites view - // AND if it is removed from the user favorites - // AND it's in the root of the favorites view - if (view.id === 'favorites' && !willFavorite && node.dirname === '/') { - emit('files:node:deleted', node) - } - - // Update the node webdav attribute - Vue.set(node.attributes, 'favorite', willFavorite ? 1 : 0) - emit('files:node:updated', node) - - // Dispatch event to whoever is interested - if (willFavorite) { - emit('files:favorites:added', node) - } else { - emit('files:favorites:removed', node) - } - - return true - } catch (error) { - const action = willFavorite ? 'adding a file to favourites' : 'removing a file from favourites' - logger.error('Error while ' + action, { error, source: node.source, node }) - return false - } -} - -export const action = new FileAction({ +export const action: IFileAction = { id: ACTION_FAVORITE, displayName({ nodes }) { return shouldFavorite(nodes) @@ -132,4 +80,56 @@ export const action = new FileAction({ description: t('files', 'Add or remove favorite'), key: 'S', }, -}) +} + +/** + * Favorite or unfavorite a node + * + * @param node - The node to favorite/unfavorite + * @param view - The current view + * @param willFavorite - Whether to favorite or unfavorite the node + */ +export async function favoriteNode(node: INode, view: IView, willFavorite: boolean): Promise { + try { + // TODO: migrate to webdav tags plugin + const url = generateUrl('/apps/files/api/v1/files') + encodePath(node.path) + await axios.post(url, { + tags: willFavorite + ? [window.OC.TAG_FAVORITE] + : [], + }) + + // Let's delete if we are in the favourites view + // AND if it is removed from the user favorites + // AND it's in the root of the favorites view + if (view.id === 'favorites' && !willFavorite && node.dirname === '/') { + emit('files:node:deleted', node) + } + + // Update the node webdav attribute + Vue.set(node.attributes, 'favorite', willFavorite ? 1 : 0) + emit('files:node:updated', node) + + // Dispatch event to whoever is interested + if (willFavorite) { + emit('files:favorites:added', node) + } else { + emit('files:favorites:removed', node) + } + + return true + } catch (error) { + const action = willFavorite ? 'adding a file to favourites' : 'removing a file from favourites' + logger.error('Error while ' + action, { error, source: node.source, node }) + return false + } +} + +/** + * If any of the nodes is not favored, we display the favorite action. + * + * @param nodes - The nodes to check + */ +function shouldFavorite(nodes: INode[]): boolean { + return nodes.some((node) => node.attributes.favorite !== 1) +} diff --git a/apps/files/src/actions/moveOrCopyAction.ts b/apps/files/src/actions/moveOrCopyAction.ts index 36a17a4f7b5..a1d6c1a2de4 100644 --- a/apps/files/src/actions/moveOrCopyAction.ts +++ b/apps/files/src/actions/moveOrCopyAction.ts @@ -4,7 +4,7 @@ */ import type { IFilePickerButton } from '@nextcloud/dialogs' -import type { IFolder, INode } from '@nextcloud/files' +import type { IFileAction, IFolder, INode } from '@nextcloud/files' import type { FileStat, ResponseDataDetailed, WebDAVClientError } from 'webdav' import type { MoveCopyResult } from './moveOrCopyActionUtils.ts' @@ -13,7 +13,7 @@ import CopyIconSvg from '@mdi/svg/svg/folder-multiple-outline.svg?raw' import { isAxiosError } from '@nextcloud/axios' import { FilePickerClosed, getFilePickerBuilder, openConflictPicker, showError, showLoading } from '@nextcloud/dialogs' import { emit } from '@nextcloud/event-bus' -import { FileAction, FileType, getUniqueName, NodeStatus, Permission } from '@nextcloud/files' +import { FileType, getUniqueName, NodeStatus, Permission } from '@nextcloud/files' import { defaultRootPath, getClient, getDefaultPropfind, resultToNode } from '@nextcloud/files/dav' import { t } from '@nextcloud/l10n' import { getConflicts } from '@nextcloud/upload' @@ -31,7 +31,7 @@ export class HintException extends Error {} export const ACTION_COPY_MOVE = 'move-copy' -export const action = new FileAction({ +export const action: IFileAction = { id: ACTION_COPY_MOVE, order: 15, displayName({ nodes }) { @@ -84,7 +84,7 @@ export const action = new FileAction({ return nodes.map(() => false) } }, -}) +} /** * Handle the copy/move of a node to a destination @@ -248,11 +248,11 @@ function getActionForNodes(nodes: INode[]): MoveCopyAction { function createLoadingNotification(mode: MoveCopyAction, sources: string[], destination: string): () => void { const text = mode === MoveCopyAction.MOVE ? (sources.length === 1 - ? t('files', 'Moving "{source}" to "{destination}" …', { source: sources[0], destination }) + ? t('files', 'Moving "{source}" to "{destination}" …', { source: sources[0]!, destination }) : t('files', 'Moving {count} files to "{destination}" …', { count: sources.length, destination }) ) : (sources.length === 1 - ? t('files', 'Copying "{source}" to "{destination}" …', { source: sources[0], destination }) + ? t('files', 'Copying "{source}" to "{destination}" …', { source: sources[0]!, destination }) : t('files', 'Copying {count} files to "{destination}" …', { count: sources.length, destination }) ) @@ -277,7 +277,7 @@ async function openFilePickerForAction( const fileIDs = nodes.map((node) => node.fileid).filter(Boolean) const filePicker = getFilePickerBuilder(t('files', 'Choose destination')) .allowDirectories(true) - .setFilter((n: INode) => { + .setFilter((n) => { // We don't want to show the current nodes in the file picker return !fileIDs.includes(n.fileid) }) @@ -288,7 +288,7 @@ async function openFilePickerForAction( .setMimeTypeFilter([]) .setMultiSelect(false) .startAt(dir) - .setButtonFactory((selection: INode[], path: string) => { + .setButtonFactory((selection, path) => { const buttons: IFilePickerButton[] = [] const target = basename(path) @@ -300,9 +300,9 @@ async function openFilePickerForAction( label: target ? t('files', 'Copy to {target}', { target }, { escape: false, sanitize: false }) : t('files', 'Copy'), variant: 'primary', icon: CopyIconSvg, - async callback(destination: INode[]) { + async callback(destination) { resolve({ - destination: destination[0] as IFolder, + destination: destination[0] as unknown as IFolder, action: MoveCopyAction.COPY, } as MoveCopyResult) }, @@ -330,9 +330,9 @@ async function openFilePickerForAction( label: target ? t('files', 'Move to {target}', { target }, undefined, { escape: false, sanitize: false }) : t('files', 'Move'), variant: action === MoveCopyAction.MOVE ? 'primary' : 'secondary', icon: FolderMoveSvg, - async callback(destination: INode[]) { + async callback(destination) { resolve({ - destination: destination[0] as IFolder, + destination: destination[0] as unknown as IFolder, action: MoveCopyAction.MOVE, } as MoveCopyResult) }, diff --git a/apps/files/src/actions/openFolderAction.spec.ts b/apps/files/src/actions/openFolderAction.spec.ts index 17c6fd2e945..33ca3a41421 100644 --- a/apps/files/src/actions/openFolderAction.spec.ts +++ b/apps/files/src/actions/openFolderAction.spec.ts @@ -1,18 +1,18 @@ -/** +/*! * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { View } from '@nextcloud/files' +import type { IView } from '@nextcloud/files' -import { DefaultType, File, FileAction, Folder, Permission } from '@nextcloud/files' +import { DefaultType, File, Folder, Permission } from '@nextcloud/files' import { describe, expect, test, vi } from 'vitest' import { action } from './openFolderAction.ts' const view = { id: 'files', name: 'Files', -} as View +} as IView describe('Open folder action conditions tests', () => { test('Default values', () => { @@ -24,7 +24,6 @@ describe('Open folder action conditions tests', () => { root: '/files/admin', }) - expect(action).toBeInstanceOf(FileAction) expect(action.id).toBe('open-folder') expect(action.displayName({ nodes: [folder], diff --git a/apps/files/src/actions/openFolderAction.ts b/apps/files/src/actions/openFolderAction.ts index 801c68aad67..29ec4efc7b2 100644 --- a/apps/files/src/actions/openFolderAction.ts +++ b/apps/files/src/actions/openFolderAction.ts @@ -1,12 +1,15 @@ -/** +/*! * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import FolderSvg from '@mdi/svg/svg/folder.svg?raw' -import { DefaultType, FileAction, FileType, Permission } from '@nextcloud/files' -import { translate as t } from '@nextcloud/l10n' -export const action = new FileAction({ +import type { IFileAction } from '@nextcloud/files' + +import FolderSvg from '@mdi/svg/svg/folder.svg?raw' +import { DefaultType, FileType, Permission } from '@nextcloud/files' +import { t } from '@nextcloud/l10n' + +export const action: IFileAction = { id: 'open-folder', displayName({ nodes }) { if (nodes.length !== 1 || !nodes[0]) { @@ -51,4 +54,4 @@ export const action = new FileAction({ // Main action if enabled, meaning folders only default: DefaultType.HIDDEN, order: -100, -}) +} diff --git a/apps/files/src/actions/openInFilesAction.spec.ts b/apps/files/src/actions/openInFilesAction.spec.ts index 9950eef6883..bccebeb95b5 100644 --- a/apps/files/src/actions/openInFilesAction.spec.ts +++ b/apps/files/src/actions/openInFilesAction.spec.ts @@ -1,27 +1,26 @@ -/** +/*! * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { View } from '@nextcloud/files' +import type { IView } from '@nextcloud/files' -import { DefaultType, File, FileAction, Folder, Permission } from '@nextcloud/files' +import { DefaultType, File, Folder, Permission } from '@nextcloud/files' import { describe, expect, test, vi } from 'vitest' import { action } from './openInFilesAction.ts' const view = { id: 'files', name: 'Files', -} as View +} as IView const recentView = { id: 'recent', name: 'Recent', -} as View +} as IView describe('Open in files action conditions tests', () => { test('Default values', () => { - expect(action).toBeInstanceOf(FileAction) expect(action.id).toBe('open-in-files') expect(action.displayName({ nodes: [], diff --git a/apps/files/src/actions/openInFilesAction.ts b/apps/files/src/actions/openInFilesAction.ts index 38229bff7f4..e183898ad21 100644 --- a/apps/files/src/actions/openInFilesAction.ts +++ b/apps/files/src/actions/openInFilesAction.ts @@ -1,12 +1,15 @@ -/** +/*! * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { DefaultType, FileAction, FileType } from '@nextcloud/files' + +import type { IFileAction } from '@nextcloud/files' + +import { DefaultType, FileType } from '@nextcloud/files' import { t } from '@nextcloud/l10n' import { VIEW_ID as SEARCH_VIEW_ID } from '../views/search.ts' -export const action = new FileAction({ +export const action: IFileAction = { id: 'open-in-files', displayName: () => t('files', 'Open in Files'), iconSvgInline: () => '', @@ -36,4 +39,4 @@ export const action = new FileAction({ // Before openFolderAction order: -1000, default: DefaultType.HIDDEN, -}) +} diff --git a/apps/files/src/actions/openLocallyAction.spec.ts b/apps/files/src/actions/openLocallyAction.spec.ts index 65413c9ed79..bb30aecb913 100644 --- a/apps/files/src/actions/openLocallyAction.spec.ts +++ b/apps/files/src/actions/openLocallyAction.spec.ts @@ -1,13 +1,13 @@ -/** +/*! * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Folder, View } from '@nextcloud/files' +import type { IFolder, IView } from '@nextcloud/files' import axios from '@nextcloud/axios' import * as nextcloudDialogs from '@nextcloud/dialogs' -import { File, FileAction, Permission } from '@nextcloud/files' +import { File, Permission } from '@nextcloud/files' import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest' import { action } from './openLocallyAction.ts' @@ -17,7 +17,7 @@ vi.mock('@nextcloud/axios') const view = { id: 'files', name: 'Files', -} as View +} as IView // Mock web root variable beforeAll(() => { @@ -28,18 +28,17 @@ beforeAll(() => { describe('Open locally action conditions tests', () => { test('Default values', () => { - expect(action).toBeInstanceOf(FileAction) expect(action.id).toBe('edit-locally') expect(action.displayName({ nodes: [], view, - folder: {} as any, + folder: {} as IFolder, contents: [], })).toBe('Open locally') expect(action.iconSvgInline({ nodes: [], view, - folder: {} as any, + folder: {} as IFolder, contents: [], })).toMatch(//) expect(action.default).toBeUndefined() @@ -62,7 +61,7 @@ describe('Open locally action enabled tests', () => { expect(action.enabled!({ nodes: [file], view, - folder: {} as any, + folder: {} as IFolder, contents: [], })).toBe(true) }) @@ -80,7 +79,7 @@ describe('Open locally action enabled tests', () => { expect(action.enabled!({ nodes: [file], view, - folder: {} as any, + folder: {} as IFolder, contents: [], })).toBe(false) }) @@ -107,7 +106,7 @@ describe('Open locally action enabled tests', () => { expect(action.enabled!({ nodes: [file1, file2], view, - folder: {} as any, + folder: {} as IFolder, contents: [], })).toBe(false) }) @@ -125,7 +124,7 @@ describe('Open locally action enabled tests', () => { expect(action.enabled!({ nodes: [file], view, - folder: {} as any, + folder: {} as IFolder, contents: [], })).toBe(false) }) @@ -144,7 +143,7 @@ describe('Open locally action enabled tests', () => { expect(action.enabled!({ nodes: [file], view, - folder: {} as any, + folder: {} as IFolder, contents: [], })).toBe(false) }) @@ -177,7 +176,7 @@ describe('Open locally action execute tests', () => { const exec = await action.exec({ nodes: [file], view, - folder: {} as Folder, + folder: {} as IFolder, contents: [], }) @@ -207,7 +206,7 @@ describe('Open locally action execute tests', () => { const exec = await action.exec({ nodes: [file], view, - folder: {} as Folder, + folder: {} as IFolder, contents: [], }) diff --git a/apps/files/src/actions/openLocallyAction.ts b/apps/files/src/actions/openLocallyAction.ts index b0b4c34aaa7..d4dd63cad9d 100644 --- a/apps/files/src/actions/openLocallyAction.ts +++ b/apps/files/src/actions/openLocallyAction.ts @@ -1,13 +1,15 @@ -/** +/*! * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ + +import type { IFileAction } from '@nextcloud/files' + import LaptopSvg from '@mdi/svg/svg/laptop.svg?raw' import IconWeb from '@mdi/svg/svg/web.svg?raw' import { getCurrentUser } from '@nextcloud/auth' import axios from '@nextcloud/axios' import { DialogBuilder, showError } from '@nextcloud/dialogs' -import { FileAction } from '@nextcloud/files' import { translate as t } from '@nextcloud/l10n' import { encodePath } from '@nextcloud/paths' import { generateOcsUrl } from '@nextcloud/router' @@ -15,7 +17,7 @@ import { isPublicShare } from '@nextcloud/sharing/public' import logger from '../logger.ts' import { isSyncable } from '../utils/permissions.ts' -export const action = new FileAction({ +export const action: IFileAction = { id: 'edit-locally', displayName: () => t('files', 'Open locally'), iconSvgInline: () => LaptopSvg, @@ -32,7 +34,7 @@ export const action = new FileAction({ return false } - return isSyncable(nodes[0]) + return isSyncable(nodes[0]!) }, async exec({ nodes }) { @@ -41,7 +43,7 @@ export const action = new FileAction({ }, order: 25, -}) +} /** * Try to open the path in the Nextcloud client. diff --git a/apps/files/src/actions/renameAction.spec.ts b/apps/files/src/actions/renameAction.spec.ts index ba2d66fd90d..1f25b86d4eb 100644 --- a/apps/files/src/actions/renameAction.spec.ts +++ b/apps/files/src/actions/renameAction.spec.ts @@ -3,10 +3,10 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { View } from '@nextcloud/files' +import type { IView } from '@nextcloud/files' import * as eventBus from '@nextcloud/event-bus' -import { File, FileAction, Folder, Permission } from '@nextcloud/files' +import { File, Folder, Permission } from '@nextcloud/files' import { beforeEach, describe, expect, test, vi } from 'vitest' import { useFilesStore } from '../store/files.ts' import { getPinia } from '../store/index.ts' @@ -15,7 +15,7 @@ import { action } from './renameAction.ts' const view = { id: 'files', name: 'Files', -} as View +} as IView beforeEach(() => { const root = new Folder({ @@ -31,7 +31,6 @@ beforeEach(() => { describe('Rename action conditions tests', () => { test('Default values', () => { - expect(action).toBeInstanceOf(FileAction) expect(action.id).toBe('rename') expect(action.displayName({ nodes: [], diff --git a/apps/files/src/actions/renameAction.ts b/apps/files/src/actions/renameAction.ts index 7bf6b1e6b5e..fb03416c9ee 100644 --- a/apps/files/src/actions/renameAction.ts +++ b/apps/files/src/actions/renameAction.ts @@ -1,10 +1,13 @@ -/** +/*! * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ + +import type { IFileAction } from '@nextcloud/files' + import PencilSvg from '@mdi/svg/svg/pencil-outline.svg?raw' import { emit } from '@nextcloud/event-bus' -import { FileAction, Permission } from '@nextcloud/files' +import { Permission } from '@nextcloud/files' import { translate as t } from '@nextcloud/l10n' import { dirname } from 'path' import { useFilesStore } from '../store/files.ts' @@ -12,7 +15,7 @@ import { getPinia } from '../store/index.ts' export const ACTION_RENAME = 'rename' -export const action = new FileAction({ +export const action: IFileAction = { id: ACTION_RENAME, displayName: () => t('files', 'Rename'), iconSvgInline: () => PencilSvg, @@ -52,4 +55,4 @@ export const action = new FileAction({ description: t('files', 'Rename'), key: 'F2', }, -}) +} diff --git a/apps/files/src/actions/sidebarAction.spec.ts b/apps/files/src/actions/sidebarAction.spec.ts index 5ed51feabd5..a2870f87baf 100644 --- a/apps/files/src/actions/sidebarAction.spec.ts +++ b/apps/files/src/actions/sidebarAction.spec.ts @@ -1,11 +1,11 @@ -/** +/*! * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ import type { IView } from '@nextcloud/files' -import { File, FileAction, Folder, Permission } from '@nextcloud/files' +import { File, Folder, Permission } from '@nextcloud/files' import { beforeEach, describe, expect, test, vi } from 'vitest' import logger from '../logger.ts' import { action } from './sidebarAction.ts' @@ -32,7 +32,6 @@ beforeEach(() => { describe('Open sidebar action conditions tests', () => { test('Default values', () => { - expect(action).toBeInstanceOf(FileAction) expect(action.id).toBe('details') expect(action.displayName({ nodes: [], diff --git a/apps/files/src/actions/sidebarAction.ts b/apps/files/src/actions/sidebarAction.ts index ad4be012c70..826fe18cbf0 100644 --- a/apps/files/src/actions/sidebarAction.ts +++ b/apps/files/src/actions/sidebarAction.ts @@ -3,15 +3,17 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import type { IFileAction } from '@nextcloud/files' + import InformationSvg from '@mdi/svg/svg/information-outline.svg?raw' -import { FileAction, getSidebar, Permission } from '@nextcloud/files' +import { getSidebar, Permission } from '@nextcloud/files' import { t } from '@nextcloud/l10n' import { isPublicShare } from '@nextcloud/sharing/public' import logger from '../logger.ts' export const ACTION_DETAILS = 'details' -export const action = new FileAction({ +export const action: IFileAction = { id: ACTION_DETAILS, displayName: () => t('files', 'Details'), iconSvgInline: () => InformationSvg, @@ -59,4 +61,4 @@ export const action = new FileAction({ key: 'D', description: t('files', 'Open the details sidebar'), }, -}) +} diff --git a/apps/files/src/actions/viewInFolderAction.spec.ts b/apps/files/src/actions/viewInFolderAction.spec.ts index 8650f042afd..684e98a6bc0 100644 --- a/apps/files/src/actions/viewInFolderAction.spec.ts +++ b/apps/files/src/actions/viewInFolderAction.spec.ts @@ -1,26 +1,26 @@ -/** +/*! * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { View } from '@nextcloud/files' -import { File, FileAction, Folder, Permission } from '@nextcloud/files' +import type { IView } from '@nextcloud/files' + +import { File, Folder, Permission } from '@nextcloud/files' import { describe, expect, test, vi } from 'vitest' import { action } from './viewInFolderAction.ts' const view = { id: 'trashbin', name: 'Trashbin', -} as View +} as IView const viewFiles = { id: 'files', name: 'Files', -} as View +} as IView describe('View in folder action conditions tests', () => { test('Default values', () => { - expect(action).toBeInstanceOf(FileAction) expect(action.id).toBe('view-in-folder') expect(action.displayName({ nodes: [], diff --git a/apps/files/src/actions/viewInFolderAction.ts b/apps/files/src/actions/viewInFolderAction.ts index 9d78b876e30..6b4d94dd7f2 100644 --- a/apps/files/src/actions/viewInFolderAction.ts +++ b/apps/files/src/actions/viewInFolderAction.ts @@ -1,13 +1,16 @@ -/** +/*! * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ + +import type { IFileAction } from '@nextcloud/files' + import FolderMoveSvg from '@mdi/svg/svg/folder-move-outline.svg?raw' -import { FileAction, FileType, Permission } from '@nextcloud/files' +import { FileType, Permission } from '@nextcloud/files' import { t } from '@nextcloud/l10n' import { isPublicShare } from '@nextcloud/sharing/public' -export const action = new FileAction({ +export const action: IFileAction = { id: 'view-in-folder', displayName() { return t('files', 'View in folder') @@ -61,4 +64,4 @@ export const action = new FileAction({ }, order: 80, -}) +} diff --git a/apps/files/src/components/CustomElementRender.vue b/apps/files/src/components/CustomElementRender.vue index 3eb8ff16644..1205d42d993 100644 --- a/apps/files/src/components/CustomElementRender.vue +++ b/apps/files/src/components/CustomElementRender.vue @@ -7,10 +7,10 @@