diff options
author | John Molakvoæ <skjnldsv@users.noreply.github.com> | 2025-01-22 19:28:08 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-01-22 19:28:08 +0100 |
commit | 49cfd301f9949e41daefa83307f126bb4b3d06bd (patch) | |
tree | 5c13f8ce82b9304b84252d4265fbea952084fbda /apps/files/src | |
parent | 451a843db7c761a3a446891a3e98dedd609975fe (diff) | |
parent | 5dc091aa58d7cc983a1741bf143767bb46e04542 (diff) | |
download | nextcloud-server-49cfd301f9949e41daefa83307f126bb4b3d06bd.tar.gz nextcloud-server-49cfd301f9949e41daefa83307f126bb4b3d06bd.zip |
Merge pull request #50123 from nextcloud/feat/file-conversion-provider-front
Diffstat (limited to 'apps/files/src')
-rw-r--r-- | apps/files/src/actions/convertAction.ts | 81 | ||||
-rw-r--r-- | apps/files/src/actions/convertUtils.ts | 147 | ||||
-rw-r--r-- | apps/files/src/init.ts | 2 | ||||
-rw-r--r-- | apps/files/src/store/files.ts | 2 |
4 files changed, 231 insertions, 1 deletions
diff --git a/apps/files/src/actions/convertAction.ts b/apps/files/src/actions/convertAction.ts new file mode 100644 index 00000000000..a8b4d537eb2 --- /dev/null +++ b/apps/files/src/actions/convertAction.ts @@ -0,0 +1,81 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { Node, View } from '@nextcloud/files' + +import { FileAction, registerFileAction } from '@nextcloud/files' +import { generateUrl } from '@nextcloud/router' +import { getCapabilities } from '@nextcloud/capabilities' +import { t } from '@nextcloud/l10n' + +import AutoRenewSvg from '@mdi/svg/svg/autorenew.svg?raw' + +import { convertFile, convertFiles, getParentFolder } from './convertUtils' + +type ConversionsProvider = { + from: string, + to: string, + displayName: string, +} + +export const ACTION_CONVERT = 'convert' +export const 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: Node[]) => { + // Check that all nodes have the same mime type + return nodes.every(node => from === node.mime) + }, + + async exec(node: Node, view: View, dir: string) { + // If we're here, we know that the node has a fileid + convertFile(node.fileid as number, to, getParentFolder(view, dir)) + + // Silently terminate, we'll handle the UI in the background + return null + }, + + async execBatch(nodes: Node[], view: View, dir: string) { + const fileIds = nodes.map(node => node.fileid).filter(Boolean) as number[] + convertFiles(fileIds, to, getParentFolder(view, dir)) + + // Silently terminate, we'll handle the UI in the background + return Array(nodes.length).fill(null) + }, + + parent: ACTION_CONVERT, + }) + }) + + // Register main action + registerFileAction(new FileAction({ + id: ACTION_CONVERT, + displayName: () => t('files', 'Save as …'), + iconSvgInline: () => AutoRenewSvg, + enabled: (nodes: Node[], view: View) => { + return actions.some(action => action.enabled!(nodes, view)) + }, + async exec() { + return null + }, + order: 25, + })) + + // Register sub actions + actions.forEach(registerFileAction) +} + +export const generateIconSvg = (mime: string) => { + // Generate icon based on mime type + const url = generateUrl('/core/mimeicon?mime=' + encodeURIComponent(mime)) + return `<svg width="32" height="32" viewBox="0 0 32 32" + xmlns="http://www.w3.org/2000/svg"> + <image href="${url}" height="32" width="32" /> + </svg>` +} diff --git a/apps/files/src/actions/convertUtils.ts b/apps/files/src/actions/convertUtils.ts new file mode 100644 index 00000000000..f32dbed0cd1 --- /dev/null +++ b/apps/files/src/actions/convertUtils.ts @@ -0,0 +1,147 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { AxiosResponse } from '@nextcloud/axios' +import type { Folder, View } from '@nextcloud/files' + +import { emit } from '@nextcloud/event-bus' +import { generateOcsUrl } from '@nextcloud/router' +import { showError, showLoading, showSuccess } from '@nextcloud/dialogs' +import { t } from '@nextcloud/l10n' +import axios from '@nextcloud/axios' +import PQueue from 'p-queue' + +import logger from '../logger' +import { useFilesStore } from '../store/files' +import { getPinia } from '../store' +import { usePathsStore } from '../store/paths' + +const queue = new PQueue({ concurrency: 5 }) + +const requestConversion = function(fileId: number, targetMimeType: string): Promise<AxiosResponse> { + return axios.post(generateOcsUrl('/apps/files/api/v1/convert'), { + fileId, + targetMimeType, + }) +} + +export const convertFiles = async function(fileIds: number[], targetMimeType: string, parentFolder: Folder | null) { + const conversions = fileIds.map(fileId => queue.add(() => requestConversion(fileId, targetMimeType))) + + // Start conversion + const toast = showLoading(t('files', 'Converting files…')) + + // Handle results + try { + const results = await Promise.allSettled(conversions) + const failed = results.filter(result => result.status === 'rejected') + if (failed.length > 0) { + const messages = failed.map(result => result.reason?.response?.data?.ocs?.meta?.message) as string[] + logger.error('Failed to convert files', { fileIds, targetMimeType, messages }) + + // If all failed files have the same error message, show it + if (new Set(messages).size === 1) { + showError(t('files', 'Failed to convert files: {message}', { message: messages[0] })) + return + } + + if (failed.length === fileIds.length) { + showError(t('files', 'All files failed to be converted')) + return + } + + // A single file failed + if (failed.length === 1) { + // If we have a message for the failed file, show it + if (messages[0]) { + showError(t('files', 'One file could not be converted: {message}', { message: messages[0] })) + return + } + + // Otherwise, show a generic error + showError(t('files', 'One file could not be converted')) + return + } + + // We already check above when all files failed + // if we're here, we have a mix of failed and successful files + showError(t('files', '{count} files could not be converted', { count: failed.length })) + showSuccess(t('files', '{count} files successfully converted', { count: fileIds.length - failed.length })) + return + } + + // All files converted + showSuccess(t('files', 'Files successfully converted')) + + // Trigger a reload of the file list + if (parentFolder) { + emit('files:node:updated', parentFolder) + } + + // Switch to the new files + const firstSuccess = results[0] as PromiseFulfilledResult<AxiosResponse> + const newFileId = firstSuccess.value.data.ocs.data.fileId + window.OCP.Files.Router.goToRoute(null, { ...window.OCP.Files.Router.params, fileid: newFileId }, window.OCP.Files.Router.query) + } catch (error) { + // Should not happen as we use allSettled and handle errors above + showError(t('files', 'Failed to convert files')) + logger.error('Failed to convert files', { fileIds, targetMimeType, error }) + } finally { + // Hide loading toast + toast.hideToast() + } +} + +export const convertFile = async function(fileId: number, targetMimeType: string, parentFolder: Folder | null) { + const toast = showLoading(t('files', 'Converting file…')) + + try { + const result = await queue.add(() => requestConversion(fileId, targetMimeType)) as AxiosResponse + showSuccess(t('files', 'File successfully converted')) + + // Trigger a reload of the file list + if (parentFolder) { + emit('files:node:updated', parentFolder) + } + + // Switch to the new file + const newFileId = result.data.ocs.data.fileId + window.OCP.Files.Router.goToRoute(null, { ...window.OCP.Files.Router.params, fileid: newFileId }, window.OCP.Files.Router.query) + } catch (error) { + // If the server returned an error message, show it + if (error.response?.data?.ocs?.meta?.message) { + showError(t('files', 'Failed to convert file: {message}', { message: error.response.data.ocs.meta.message })) + return + } + + logger.error('Failed to convert file', { fileId, targetMimeType, error }) + showError(t('files', 'Failed to convert file')) + } finally { + // Hide loading toast + toast.hideToast() + } +} + +/** + * Get the parent folder of a path + * + * TODO: replace by the parent node straight away when we + * update the Files actions api accordingly. + * + * @param view The current view + * @param path The path to the file + * @returns The parent folder + */ +export const getParentFolder = function(view: View, path: string): Folder | null { + const filesStore = useFilesStore(getPinia()) + const pathsStore = usePathsStore(getPinia()) + + const parentSource = pathsStore.getPath(view.id, path) + if (!parentSource) { + return null + } + + const parentFolder = filesStore.getNode(parentSource) as Folder | undefined + return parentFolder ?? null +} diff --git a/apps/files/src/init.ts b/apps/files/src/init.ts index 7080fe09bb3..fd6533c6c23 100644 --- a/apps/files/src/init.ts +++ b/apps/files/src/init.ts @@ -32,8 +32,10 @@ import registerPreviewServiceWorker from './services/ServiceWorker.js' import { initLivePhotos } from './services/LivePhotos' import { isPublicShare } from '@nextcloud/sharing/public' +import { registerConvertActions } from './actions/convertAction.ts' // Register file actions +registerConvertActions() registerFileAction(deleteAction) registerFileAction(downloadAction) registerFileAction(editLocallyAction) diff --git a/apps/files/src/store/files.ts b/apps/files/src/store/files.ts index 08b5d0757d3..a2413b5a1ba 100644 --- a/apps/files/src/store/files.ts +++ b/apps/files/src/store/files.ts @@ -54,7 +54,7 @@ export const useFilesStore = function(...args) { actions: { /** - * Get cached nodes within a given path + * Get cached child nodes within a given path * * @param service The service (files view) * @param path The path relative within the service |