From 8537d3e80c47cac46d3d85312475d1c0555d2b98 Mon Sep 17 00:00:00 2001 From: skjnldsv Date: Fri, 17 Jan 2025 20:33:15 +0100 Subject: [PATCH] fixup! fixup! fixup! feat(files): add conversion action Signed-off-by: skjnldsv --- .../Controller/ConversionApiController.php | 6 +- apps/files/src/actions/convertAction.ts | 37 +++-- apps/files/src/actions/convertUtils.ts | 129 ++++++++++++++++++ apps/files/src/store/files.ts | 2 +- 4 files changed, 159 insertions(+), 15 deletions(-) create mode 100644 apps/files/src/actions/convertUtils.ts diff --git a/apps/files/lib/Controller/ConversionApiController.php b/apps/files/lib/Controller/ConversionApiController.php index afdc44073492b..40a42d6ca4c8f 100644 --- a/apps/files/lib/Controller/ConversionApiController.php +++ b/apps/files/lib/Controller/ConversionApiController.php @@ -48,7 +48,7 @@ public function __construct( * @param string $targetMimeType The MIME type to which you want to convert the file * @param string|null $destination The target path of the converted file. Written to a temporary file if left empty * - * @return DataResponse + * @return DataResponse * * 201: File was converted and written to the destination or temporary file * @@ -98,8 +98,12 @@ public function convert(int $fileId, string $targetMimeType, ?string $destinatio throw new OCSNotFoundException($this->l10n->t('Could not get relative path to converted file')); } + $file = $userFolder->get($convertedFileRelativePath); + $fileId = $file->getId(); + return new DataResponse([ 'path' => $convertedFileRelativePath, + 'fileId' => $fileId, ], Http::STATUS_CREATED); } } diff --git a/apps/files/src/actions/convertAction.ts b/apps/files/src/actions/convertAction.ts index 13e2647569bb8..bf14c2debcf7c 100644 --- a/apps/files/src/actions/convertAction.ts +++ b/apps/files/src/actions/convertAction.ts @@ -11,36 +11,47 @@ import { t } from '@nextcloud/l10n' import AutoRenewSvg from '@mdi/svg/svg/autorenew.svg?raw' -import logger from '../logger' +import { convertFile, convertFiles, getParentFolder } from './convertUtils' type ConversionsProvider = { from: string, - to: string[], + to: string, + displayName: string, } export const ACTION_CONVERT = 'convert' - export const registerConvertActions = () => { // Generate sub actions - const convertProviders = getCapabilities()?.core?.conversions as ConversionsProvider[] ?? [] - const actions = convertProviders.map(provider => { - return provider.to.map(to => new FileAction({ - id: `convert-${provider.from}-${to}`, - displayName: () => t('files', 'Save as {to}', { to }), + 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 if some of the nodes are not of the right type - return !nodes.some(node => provider.from !== node.mime) + return !nodes.some(node => from !== node.mime) }, - async exec(node: Node) { - logger.debug(`Convert to ${provider.from}`, { node }) + async exec(node: Node, view: View, dir: string) { + // If we're here, we know that the node have 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, - })) - }).flat() + }) + }) // Register main action registerFileAction(new FileAction({ diff --git a/apps/files/src/actions/convertUtils.ts b/apps/files/src/actions/convertUtils.ts new file mode 100644 index 0000000000000..d21a1b556f788 --- /dev/null +++ b/apps/files/src/actions/convertUtils.ts @@ -0,0 +1,129 @@ +/** + * 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 { + 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) { + const messages = failed.map(result => result.reason?.response?.data?.ocs?.meta?.message) as string[] + + // 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', 'Failed to convert files')) + return + } + showError(t('files', 'Some files could not be converted')) + 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 + 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/store/files.ts b/apps/files/src/store/files.ts index 08b5d0757d314..e1b80f144b1e1 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 children nodes within a given path * * @param service The service (files view) * @param path The path relative within the service