mirror of
https://github.com/nextcloud/server.git
synced 2026-05-28 04:32:30 -04:00
feat(files): add conversion action
Signed-off-by: skjnldsv <skjnldsv@protonmail.com>
This commit is contained in:
parent
fe4f40d4db
commit
7d8bb60bfe
6 changed files with 237 additions and 2 deletions
|
|
@ -48,7 +48,7 @@ class ConversionApiController extends OCSController {
|
|||
* @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<Http::STATUS_CREATED, array{path: string}, array{}>
|
||||
* @return DataResponse<Http::STATUS_CREATED, array{path: string, fileId: int}, array{}>
|
||||
*
|
||||
* 201: File was converted and written to the destination or temporary file
|
||||
*
|
||||
|
|
@ -98,8 +98,12 @@ class ConversionApiController extends OCSController {
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
81
apps/files/src/actions/convertAction.ts
Normal file
81
apps/files/src/actions/convertAction.ts
Normal file
|
|
@ -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>`
|
||||
}
|
||||
144
apps/files/src/actions/convertUtils.ts
Normal file
144
apps/files/src/actions/convertUtils.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
/**
|
||||
* 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) {
|
||||
const messages = failed.map(result => result.reason?.response?.data?.ocs?.meta?.message) as string[]
|
||||
logger.error('Failed to convert files', { fileIds, targetMimeType, error })
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
showError(t('files', '{count} files could not be converted', { count: 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -78,12 +78,16 @@ class ConversionApiControllerTest extends TestCase {
|
|||
|
||||
$this->userFolder->method('getFirstNodeById')->with(42)->willReturn($this->file);
|
||||
$this->userFolder->method('getRelativePath')->with($convertedFileAbsolutePath)->willReturn('/test.png');
|
||||
$this->userFolder->method('get')->with('/test.png')->willReturn($this->file);
|
||||
|
||||
$this->file->method('getId')->willReturn(42);
|
||||
|
||||
$this->fileConversionManager->method('convert')->with($this->file, 'image/png', null)->willReturn($convertedFileAbsolutePath);
|
||||
|
||||
$actual = $this->conversionApiController->convert(42, 'image/png', null);
|
||||
$expected = new DataResponse([
|
||||
'path' => '/test.png',
|
||||
'fileId' => 42,
|
||||
], Http::STATUS_CREATED);
|
||||
|
||||
$this->assertEquals($expected, $actual);
|
||||
|
|
|
|||
Loading…
Reference in a new issue