aboutsummaryrefslogtreecommitdiffstats
path: root/apps/files/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/files/src')
-rw-r--r--apps/files/src/actions/convertAction.ts81
-rw-r--r--apps/files/src/actions/convertUtils.ts147
-rw-r--r--apps/files/src/init.ts2
-rw-r--r--apps/files/src/store/files.ts2
4 files changed, 231 insertions, 1 deletions
diff --git a/apps/files/src/actions/convertAction.ts b/apps/files/src/actions/convertAction.ts
new file mode 100644
index 00000000000..a8b4d537eb2
--- /dev/null
+++ b/apps/files/src/actions/convertAction.ts
@@ -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>`
+}
diff --git a/apps/files/src/actions/convertUtils.ts b/apps/files/src/actions/convertUtils.ts
new file mode 100644
index 00000000000..f32dbed0cd1
--- /dev/null
+++ b/apps/files/src/actions/convertUtils.ts
@@ -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
+}
diff --git a/apps/files/src/init.ts b/apps/files/src/init.ts
index 7080fe09bb3..fd6533c6c23 100644
--- a/apps/files/src/init.ts
+++ b/apps/files/src/init.ts
@@ -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)
diff --git a/apps/files/src/store/files.ts b/apps/files/src/store/files.ts
index 08b5d0757d3..a2413b5a1ba 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 child nodes within a given path
*
* @param service The service (files view)
* @param path The path relative within the service