Skip to content

Commit 49cfd30

Browse files
authored
Merge pull request #50123 from nextcloud/feat/file-conversion-provider-front
2 parents 451a843 + 5dc091a commit 49cfd30

File tree

187 files changed

+1062
-236
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

187 files changed

+1062
-236
lines changed

apps/files/lib/Controller/ConversionApiController.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ public function __construct(
4848
* @param string $targetMimeType The MIME type to which you want to convert the file
4949
* @param string|null $destination The target path of the converted file. Written to a temporary file if left empty
5050
*
51-
* @return DataResponse<Http::STATUS_CREATED, array{path: string}, array{}>
51+
* @return DataResponse<Http::STATUS_CREATED, array{path: string, fileId: int}, array{}>
5252
*
5353
* 201: File was converted and written to the destination or temporary file
5454
*
@@ -98,8 +98,12 @@ public function convert(int $fileId, string $targetMimeType, ?string $destinatio
9898
throw new OCSNotFoundException($this->l10n->t('Could not get relative path to converted file'));
9999
}
100100

101+
$file = $userFolder->get($convertedFileRelativePath);
102+
$fileId = $file->getId();
103+
101104
return new DataResponse([
102105
'path' => $convertedFileRelativePath,
106+
'fileId' => $fileId,
103107
], Http::STATUS_CREATED);
104108
}
105109
}

apps/files/openapi.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2330,11 +2330,16 @@
23302330
"data": {
23312331
"type": "object",
23322332
"required": [
2333-
"path"
2333+
"path",
2334+
"fileId"
23342335
],
23352336
"properties": {
23362337
"path": {
23372338
"type": "string"
2339+
},
2340+
"fileId": {
2341+
"type": "integer",
2342+
"format": "int64"
23382343
}
23392344
}
23402345
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
import type { Node, View } from '@nextcloud/files'
6+
7+
import { FileAction, registerFileAction } from '@nextcloud/files'
8+
import { generateUrl } from '@nextcloud/router'
9+
import { getCapabilities } from '@nextcloud/capabilities'
10+
import { t } from '@nextcloud/l10n'
11+
12+
import AutoRenewSvg from '@mdi/svg/svg/autorenew.svg?raw'
13+
14+
import { convertFile, convertFiles, getParentFolder } from './convertUtils'
15+
16+
type ConversionsProvider = {
17+
from: string,
18+
to: string,
19+
displayName: string,
20+
}
21+
22+
export const ACTION_CONVERT = 'convert'
23+
export const registerConvertActions = () => {
24+
// Generate sub actions
25+
const convertProviders = getCapabilities()?.files?.file_conversions as ConversionsProvider[] ?? []
26+
const actions = convertProviders.map(({ to, from, displayName }) => {
27+
return new FileAction({
28+
id: `convert-${from}-${to}`,
29+
displayName: () => t('files', 'Save as {displayName}', { displayName }),
30+
iconSvgInline: () => generateIconSvg(to),
31+
enabled: (nodes: Node[]) => {
32+
// Check that all nodes have the same mime type
33+
return nodes.every(node => from === node.mime)
34+
},
35+
36+
async exec(node: Node, view: View, dir: string) {
37+
// If we're here, we know that the node has a fileid
38+
convertFile(node.fileid as number, to, getParentFolder(view, dir))
39+
40+
// Silently terminate, we'll handle the UI in the background
41+
return null
42+
},
43+
44+
async execBatch(nodes: Node[], view: View, dir: string) {
45+
const fileIds = nodes.map(node => node.fileid).filter(Boolean) as number[]
46+
convertFiles(fileIds, to, getParentFolder(view, dir))
47+
48+
// Silently terminate, we'll handle the UI in the background
49+
return Array(nodes.length).fill(null)
50+
},
51+
52+
parent: ACTION_CONVERT,
53+
})
54+
})
55+
56+
// Register main action
57+
registerFileAction(new FileAction({
58+
id: ACTION_CONVERT,
59+
displayName: () => t('files', 'Save as …'),
60+
iconSvgInline: () => AutoRenewSvg,
61+
enabled: (nodes: Node[], view: View) => {
62+
return actions.some(action => action.enabled!(nodes, view))
63+
},
64+
async exec() {
65+
return null
66+
},
67+
order: 25,
68+
}))
69+
70+
// Register sub actions
71+
actions.forEach(registerFileAction)
72+
}
73+
74+
export const generateIconSvg = (mime: string) => {
75+
// Generate icon based on mime type
76+
const url = generateUrl('/core/mimeicon?mime=' + encodeURIComponent(mime))
77+
return `<svg width="32" height="32" viewBox="0 0 32 32"
78+
xmlns="http://www.w3.org/2000/svg">
79+
<image href="${url}" height="32" width="32" />
80+
</svg>`
81+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
import type { AxiosResponse } from '@nextcloud/axios'
6+
import type { Folder, View } from '@nextcloud/files'
7+
8+
import { emit } from '@nextcloud/event-bus'
9+
import { generateOcsUrl } from '@nextcloud/router'
10+
import { showError, showLoading, showSuccess } from '@nextcloud/dialogs'
11+
import { t } from '@nextcloud/l10n'
12+
import axios from '@nextcloud/axios'
13+
import PQueue from 'p-queue'
14+
15+
import logger from '../logger'
16+
import { useFilesStore } from '../store/files'
17+
import { getPinia } from '../store'
18+
import { usePathsStore } from '../store/paths'
19+
20+
const queue = new PQueue({ concurrency: 5 })
21+
22+
const requestConversion = function(fileId: number, targetMimeType: string): Promise<AxiosResponse> {
23+
return axios.post(generateOcsUrl('/apps/files/api/v1/convert'), {
24+
fileId,
25+
targetMimeType,
26+
})
27+
}
28+
29+
export const convertFiles = async function(fileIds: number[], targetMimeType: string, parentFolder: Folder | null) {
30+
const conversions = fileIds.map(fileId => queue.add(() => requestConversion(fileId, targetMimeType)))
31+
32+
// Start conversion
33+
const toast = showLoading(t('files', 'Converting files…'))
34+
35+
// Handle results
36+
try {
37+
const results = await Promise.allSettled(conversions)
38+
const failed = results.filter(result => result.status === 'rejected')
39+
if (failed.length > 0) {
40+
const messages = failed.map(result => result.reason?.response?.data?.ocs?.meta?.message) as string[]
41+
logger.error('Failed to convert files', { fileIds, targetMimeType, messages })
42+
43+
// If all failed files have the same error message, show it
44+
if (new Set(messages).size === 1) {
45+
showError(t('files', 'Failed to convert files: {message}', { message: messages[0] }))
46+
return
47+
}
48+
49+
if (failed.length === fileIds.length) {
50+
showError(t('files', 'All files failed to be converted'))
51+
return
52+
}
53+
54+
// A single file failed
55+
if (failed.length === 1) {
56+
// If we have a message for the failed file, show it
57+
if (messages[0]) {
58+
showError(t('files', 'One file could not be converted: {message}', { message: messages[0] }))
59+
return
60+
}
61+
62+
// Otherwise, show a generic error
63+
showError(t('files', 'One file could not be converted'))
64+
return
65+
}
66+
67+
// We already check above when all files failed
68+
// if we're here, we have a mix of failed and successful files
69+
showError(t('files', '{count} files could not be converted', { count: failed.length }))
70+
showSuccess(t('files', '{count} files successfully converted', { count: fileIds.length - failed.length }))
71+
return
72+
}
73+
74+
// All files converted
75+
showSuccess(t('files', 'Files successfully converted'))
76+
77+
// Trigger a reload of the file list
78+
if (parentFolder) {
79+
emit('files:node:updated', parentFolder)
80+
}
81+
82+
// Switch to the new files
83+
const firstSuccess = results[0] as PromiseFulfilledResult<AxiosResponse>
84+
const newFileId = firstSuccess.value.data.ocs.data.fileId
85+
window.OCP.Files.Router.goToRoute(null, { ...window.OCP.Files.Router.params, fileid: newFileId }, window.OCP.Files.Router.query)
86+
} catch (error) {
87+
// Should not happen as we use allSettled and handle errors above
88+
showError(t('files', 'Failed to convert files'))
89+
logger.error('Failed to convert files', { fileIds, targetMimeType, error })
90+
} finally {
91+
// Hide loading toast
92+
toast.hideToast()
93+
}
94+
}
95+
96+
export const convertFile = async function(fileId: number, targetMimeType: string, parentFolder: Folder | null) {
97+
const toast = showLoading(t('files', 'Converting file…'))
98+
99+
try {
100+
const result = await queue.add(() => requestConversion(fileId, targetMimeType)) as AxiosResponse
101+
showSuccess(t('files', 'File successfully converted'))
102+
103+
// Trigger a reload of the file list
104+
if (parentFolder) {
105+
emit('files:node:updated', parentFolder)
106+
}
107+
108+
// Switch to the new file
109+
const newFileId = result.data.ocs.data.fileId
110+
window.OCP.Files.Router.goToRoute(null, { ...window.OCP.Files.Router.params, fileid: newFileId }, window.OCP.Files.Router.query)
111+
} catch (error) {
112+
// If the server returned an error message, show it
113+
if (error.response?.data?.ocs?.meta?.message) {
114+
showError(t('files', 'Failed to convert file: {message}', { message: error.response.data.ocs.meta.message }))
115+
return
116+
}
117+
118+
logger.error('Failed to convert file', { fileId, targetMimeType, error })
119+
showError(t('files', 'Failed to convert file'))
120+
} finally {
121+
// Hide loading toast
122+
toast.hideToast()
123+
}
124+
}
125+
126+
/**
127+
* Get the parent folder of a path
128+
*
129+
* TODO: replace by the parent node straight away when we
130+
* update the Files actions api accordingly.
131+
*
132+
* @param view The current view
133+
* @param path The path to the file
134+
* @returns The parent folder
135+
*/
136+
export const getParentFolder = function(view: View, path: string): Folder | null {
137+
const filesStore = useFilesStore(getPinia())
138+
const pathsStore = usePathsStore(getPinia())
139+
140+
const parentSource = pathsStore.getPath(view.id, path)
141+
if (!parentSource) {
142+
return null
143+
}
144+
145+
const parentFolder = filesStore.getNode(parentSource) as Folder | undefined
146+
return parentFolder ?? null
147+
}

apps/files/src/init.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,10 @@ import registerPreviewServiceWorker from './services/ServiceWorker.js'
3232

3333
import { initLivePhotos } from './services/LivePhotos'
3434
import { isPublicShare } from '@nextcloud/sharing/public'
35+
import { registerConvertActions } from './actions/convertAction.ts'
3536

3637
// Register file actions
38+
registerConvertActions()
3739
registerFileAction(deleteAction)
3840
registerFileAction(downloadAction)
3941
registerFileAction(editLocallyAction)

apps/files/src/store/files.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export const useFilesStore = function(...args) {
5454

5555
actions: {
5656
/**
57-
* Get cached nodes within a given path
57+
* Get cached child nodes within a given path
5858
*
5959
* @param service The service (files view)
6060
* @param path The path relative within the service

apps/files/tests/Controller/ConversionApiControllerTest.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,12 +78,16 @@ public function testConvert() {
7878

7979
$this->userFolder->method('getFirstNodeById')->with(42)->willReturn($this->file);
8080
$this->userFolder->method('getRelativePath')->with($convertedFileAbsolutePath)->willReturn('/test.png');
81+
$this->userFolder->method('get')->with('/test.png')->willReturn($this->file);
82+
83+
$this->file->method('getId')->willReturn(42);
8184

8285
$this->fileConversionManager->method('convert')->with($this->file, 'image/png', null)->willReturn($convertedFileAbsolutePath);
8386

8487
$actual = $this->conversionApiController->convert(42, 'image/png', null);
8588
$expected = new DataResponse([
8689
'path' => '/test.png',
90+
'fileId' => 42,
8791
], Http::STATUS_CREATED);
8892

8993
$this->assertEquals($expected, $actual);

core/Controller/PreviewController.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
1515
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
1616
use OCP\AppFramework\Http\Attribute\OpenAPI;
17+
use OCP\AppFramework\Http\Attribute\PublicPage;
1718
use OCP\AppFramework\Http\DataResponse;
1819
use OCP\AppFramework\Http\FileDisplayResponse;
1920
use OCP\AppFramework\Http\RedirectResponse;
@@ -183,4 +184,25 @@ private function fetchPreview(
183184
return new DataResponse([], Http::STATUS_BAD_REQUEST);
184185
}
185186
}
187+
188+
/**
189+
* Get a preview by mime
190+
*
191+
* @param string $mime Mime type
192+
* @return RedirectResponse<Http::STATUS_SEE_OTHER, array{}>
193+
*
194+
* 303: The mime icon url
195+
*/
196+
#[NoCSRFRequired]
197+
#[PublicPage]
198+
#[FrontpageRoute(verb: 'GET', url: '/core/mimeicon')]
199+
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
200+
public function getMimeIconUrl(string $mime = 'application/octet-stream') {
201+
$url = $this->mimeIconProvider->getMimeIconUrl($mime);
202+
if ($url === null) {
203+
$url = $this->mimeIconProvider->getMimeIconUrl('application/octet-stream');
204+
}
205+
206+
return new RedirectResponse($url);
207+
}
186208
}

0 commit comments

Comments
 (0)