Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(api): File conversion Files action #50123

Merged
merged 7 commits into from
Jan 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>
skjnldsv marked this conversation as resolved.
Show resolved Hide resolved
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.
provokateurin marked this conversation as resolved.
Show resolved Hide resolved
*
* @param view The current view
* @param path The path to the file
* @returns The parent folder

Check warning on line 134 in apps/files/src/actions/convertUtils.ts

View workflow job for this annotation

GitHub Actions / NPM lint

Invalid JSDoc tag (preference). Replace "returns" JSDoc tag with "return"
*/
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 @@ -24,14 +24,14 @@
getters: {
/**
* Get a file or folder by its source
* @param state

Check warning on line 27 in apps/files/src/store/files.ts

View workflow job for this annotation

GitHub Actions / NPM lint

Missing JSDoc @param "state" description
*/
getNode: (state) => (source: FileSource): Node|undefined => state.files[source],

/**
* Get a list of files or folders by their IDs
* Note: does not return undefined values
* @param state

Check warning on line 34 in apps/files/src/store/files.ts

View workflow job for this annotation

GitHub Actions / NPM lint

Missing JSDoc @param "state" description
*/
getNodes: (state) => (sources: FileSource[]): Node[] => sources
.map(source => state.files[source])
Expand All @@ -41,24 +41,24 @@
* Get files or folders by their file ID
* Multiple nodes can have the same file ID but different sources
* (e.g. in a shared context)
* @param state

Check warning on line 44 in apps/files/src/store/files.ts

View workflow job for this annotation

GitHub Actions / NPM lint

Missing JSDoc @param "state" description
*/
getNodesById: (state) => (fileId: number): Node[] => Object.values(state.files).filter(node => node.fileid === fileId),

/**
* Get the root folder of a service
* @param state

Check warning on line 50 in apps/files/src/store/files.ts

View workflow job for this annotation

GitHub Actions / NPM lint

Missing JSDoc @param "state" description
*/
getRoot: (state) => (service: Service): Folder|undefined => state.roots[service],
},

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
* @return Array of cached nodes within the path

Check warning on line 61 in apps/files/src/store/files.ts

View workflow job for this annotation

GitHub Actions / NPM lint

Missing JSDoc @return type
*/
getNodesByPath(service: string, path?: string): Node[] {
const pathsStore = usePathsStore()
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');
}
skjnldsv marked this conversation as resolved.
Show resolved Hide resolved

return new RedirectResponse($url);
}
}
Loading
Loading