-
-
Notifications
You must be signed in to change notification settings - Fork 4.2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #50123 from nextcloud/feat/file-conversion-provide…
…r-front
- Loading branch information
Showing
187 changed files
with
1,062 additions
and
236 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>` | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.