-
-
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.
Signed-off-by: skjnldsv <[email protected]>
- Loading branch information
Showing
5 changed files
with
218 additions
and
2 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
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 if some of the nodes are not of the right type | ||
return !nodes.some(node => from !== node.mime) | ||
}, | ||
|
||
async exec(node: Node, view: View, dir: string) { | ||
// If we're here, we know that the node have 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,129 @@ | ||
/** | ||
* 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) { | ||
const messages = failed.map(result => result.reason?.response?.data?.ocs?.meta?.message) as string[] | ||
|
||
// 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', 'Failed to convert files')) | ||
return | ||
} | ||
showError(t('files', 'Some files could not be converted')) | ||
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