Skip to content

Commit

Permalink
Merge pull request #50123 from nextcloud/feat/file-conversion-provide…
Browse files Browse the repository at this point in the history
…r-front
  • Loading branch information
skjnldsv authored Jan 22, 2025
2 parents 451a843 + 5dc091a commit 49cfd30
Show file tree
Hide file tree
Showing 187 changed files with 1,062 additions and 236 deletions.
6 changes: 5 additions & 1 deletion apps/files/lib/Controller/ConversionApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<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
*
Expand Down Expand Up @@ -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);
}
}
7 changes: 6 additions & 1 deletion apps/files/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -2330,11 +2330,16 @@
"data": {
"type": "object",
"required": [
"path"
"path",
"fileId"
],
"properties": {
"path": {
"type": "string"
},
"fileId": {
"type": "integer",
"format": "int64"
}
}
}
Expand Down
81 changes: 81 additions & 0 deletions apps/files/src/actions/convertAction.ts
Original file line number Diff line number Diff line change
@@ -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>`
}
147 changes: 147 additions & 0 deletions apps/files/src/actions/convertUtils.ts
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions apps/files/src/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion apps/files/src/store/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions apps/files/tests/Controller/ConversionApiControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,16 @@ public function testConvert() {

$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);
Expand Down
22 changes: 22 additions & 0 deletions core/Controller/PreviewController.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\FileDisplayResponse;
use OCP\AppFramework\Http\RedirectResponse;
Expand Down Expand Up @@ -183,4 +184,25 @@ private function fetchPreview(
return new DataResponse([], Http::STATUS_BAD_REQUEST);
}
}

/**
* Get a preview by mime
*
* @param string $mime Mime type
* @return RedirectResponse<Http::STATUS_SEE_OTHER, array{}>
*
* 303: The mime icon url
*/
#[NoCSRFRequired]
#[PublicPage]
#[FrontpageRoute(verb: 'GET', url: '/core/mimeicon')]
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
public function getMimeIconUrl(string $mime = 'application/octet-stream') {
$url = $this->mimeIconProvider->getMimeIconUrl($mime);
if ($url === null) {
$url = $this->mimeIconProvider->getMimeIconUrl('application/octet-stream');
}

return new RedirectResponse($url);
}
}
Loading

0 comments on commit 49cfd30

Please sign in to comment.