-
-
Notifications
You must be signed in to change notification settings - Fork 4.7k
feat(api): File conversion Files action #50123
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
Changes from all commits
7d8bb60
2c13259
31dd808
966738b
6673c12
eb92120
5dc091a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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>` | ||
| } |
| 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. | ||
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 | ||
| */ | ||
| 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 | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
|
||
| */ | ||
| 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
|
||
| */ | ||
| getNodes: (state) => (sources: FileSource[]): Node[] => sources | ||
| .map(source => state.files[source]) | ||
|
|
@@ -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
|
||
| */ | ||
| 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
|
||
| */ | ||
| 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
|
||
| */ | ||
| getNodesByPath(service: string, path?: string): Node[] { | ||
| const pathsStore = usePathsStore() | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.