diff options
author | Christopher Ng <chrng8@gmail.com> | 2024-05-03 13:10:59 -0700 |
---|---|---|
committer | Christopher Ng <chrng8@gmail.com> | 2024-05-03 13:10:59 -0700 |
commit | b7151afad53ad7943b1b4c1b54fe5e6a84987e86 (patch) | |
tree | 4fcb8734ce3d58ebf67715c78c2ed5d4e93f165c | |
parent | 125e397a82917b2d5fea918c7a8dec2634836a66 (diff) | |
download | nextcloud-server-feat/restore-to-original-dir.tar.gz nextcloud-server-feat/restore-to-original-dir.zip |
[WIP] feat(trashbin): Allow restoration of parent foldersfeat/restore-to-original-dir
Signed-off-by: Christopher Ng <chrng8@gmail.com>
-rw-r--r-- | apps/files_trashbin/src/actions/restoreAction.ts | 138 | ||||
-rw-r--r-- | apps/files_trashbin/src/columns.ts | 8 | ||||
-rw-r--r-- | apps/files_trashbin/src/components/RestoreParentsDialog.vue | 147 | ||||
-rw-r--r-- | apps/files_trashbin/src/services/restoreDialog.ts | 36 | ||||
-rw-r--r-- | apps/files_trashbin/src/store/trashbin.ts | 51 | ||||
-rw-r--r-- | apps/files_trashbin/src/utils.ts | 59 |
6 files changed, 396 insertions, 43 deletions
diff --git a/apps/files_trashbin/src/actions/restoreAction.ts b/apps/files_trashbin/src/actions/restoreAction.ts index c13156c485b..60e6410a176 100644 --- a/apps/files_trashbin/src/actions/restoreAction.ts +++ b/apps/files_trashbin/src/actions/restoreAction.ts @@ -19,21 +19,107 @@ * along with this program. If not, see <http://www.gnu.org/licenses/>. * */ + +import type { Node } from '@nextcloud/files' + import { emit } from '@nextcloud/event-bus' import { generateRemoteUrl } from '@nextcloud/router' import { getCurrentUser } from '@nextcloud/auth' -import { Permission, Node, View, registerFileAction, FileAction, FileType } from '@nextcloud/files' +import { Permission, registerFileAction, FileAction, FileType } from '@nextcloud/files' import { translate as t } from '@nextcloud/l10n' import axios from '@nextcloud/axios' import History from '@mdi/svg/svg/history.svg?raw' import logger from '../../../files/src/logger.js' import { encodePath } from '@nextcloud/paths' +import { RestoreParents, parseOriginalLocation, sortByDeletionTime } from '../utils.js' +import { confirmRestoration } from '../services/restoreDialog.ts' +import { useTrashbinStore } from '../store/trashbin.ts' + +type Nullable<T> = null | T + +const store = useTrashbinStore() // Use store to reduce DAV calls + +/** + * Return original parents of node sorted by most recently deleted + * + * @param node the node + * @param nodes the other trash nodes + */ +const getOriginalParents = (node: Node, nodes: Node[]) => { + const sortedNodes = nodes.toSorted(sortByDeletionTime) + const originalParents = sortedNodes + .filter(otherNode => { + const originalPath = parseOriginalLocation(node, true) + if (otherNode.type === FileType.File) { + return false + } + const otherPath = parseOriginalLocation(otherNode, true) + if (originalPath === otherPath) { + return false + } + return originalPath.startsWith(otherPath) + }).filter((otherNode, index, arr) => { // Filter out duplicates except the most recently deleted one + const originalPath = parseOriginalLocation(otherNode, true) + const firstIndexOfPath = arr.findIndex(node => originalPath === parseOriginalLocation(node, true)) + return firstIndexOfPath === index + }) + return originalParents +} + +const restore = async (node: Node): Promise<boolean> => { + try { + const destination = generateRemoteUrl(encodePath(`dav/trashbin/${getCurrentUser()?.uid}/restore/${node.basename}`)) + await axios({ + method: 'MOVE', + url: node.encodedSource, + headers: { + destination, + }, + }) -const sortByDeletionTime = (a: Node, b: Node) => { - const deletionTimeA = a.attributes?.['trashbin-deletion-time'] || a?.mtime || 0 - const deletionTimeB = b.attributes?.['trashbin-deletion-time'] || b?.mtime || 0 - return deletionTimeB - deletionTimeA + // Let's pretend the file is deleted since + // we don't know the restored location + emit('files:node:deleted', node) + return true + } catch (error) { + logger.error(error) + return false + } +} + +const restoreSequentially = async (nodes: Node[], withParents: boolean = true): Promise<Nullable<boolean>[]> => { + const results: Nullable<boolean>[] = [] + for (const node of nodes) { + if (withParents) { + results.push(await restoreWithParents(node)) + continue + } + results.push(await restore(node)) + } + return results +} + +const restoreWithParents = async (node: Node): Promise<Nullable<boolean>> => { + const otherNodes = (store.nodes.value as Node[]).filter(trashNode => trashNode.fileid !== node.fileid) + const originalParents = getOriginalParents(node, otherNodes) + if (originalParents.length === 0) { + return restore(node) + } + const result = await confirmRestoration(node, originalParents) + if (result === RestoreParents.Cancel) { + return null + } + if (result === RestoreParents.Skip) { + return restore(node) + } + const parentResults: Nullable<boolean>[] = await restoreSequentially(originalParents, false) // Bypass restoration with parents to avoid attempting to restore duplicates + const restored = await restore(node) + return restored && parentResults.every(Boolean) +} + +const restoreBatchWithParents = async (nodes: Node[]): Promise<Nullable<boolean>[]> => { + return restoreSequentially(nodes) } registerFileAction(new FileAction({ @@ -56,41 +142,17 @@ registerFileAction(new FileAction({ }, async exec(node: Node) { - try { - const destination = generateRemoteUrl(encodePath(`dav/trashbin/${getCurrentUser()?.uid}/restore/${node.basename}`)) - await axios({ - method: 'MOVE', - url: node.encodedSource, - headers: { - destination, - }, - }) - - // Let's pretend the file is deleted since - // we don't know the restored location - emit('files:node:deleted', node) - return true - } catch (error) { - logger.error(error) - return false - } + await store.init() + const result = await restoreWithParents(node) + store.reset() + return result }, - async execBatch(nodes: Node[], view: View, dir: string) { - // Restore folders sequentially by deletion time to preserve original directory structure - const sortedFolderNodes = nodes - .filter(node => node.type === FileType.Folder) - .toSorted(sortByDeletionTime) - const folderResults: boolean[] = [] - for (const node of sortedFolderNodes) { - folderResults.push(await this.exec(node, view, dir) as boolean) - } - const fileResults = await Promise.all( - nodes - .filter(node => node.type === FileType.File) - .map(node => this.exec(node, view, dir)), - ) - return [...folderResults, ...fileResults] + async execBatch(nodes: Node[]) { + await store.init() + const result = await restoreBatchWithParents(nodes) + store.reset() + return result }, order: 1, diff --git a/apps/files_trashbin/src/columns.ts b/apps/files_trashbin/src/columns.ts index fecf250e34e..158c353d7b2 100644 --- a/apps/files_trashbin/src/columns.ts +++ b/apps/files_trashbin/src/columns.ts @@ -29,6 +29,8 @@ import { translate as t } from '@nextcloud/l10n' import Vue from 'vue' import NcUserBubble from '@nextcloud/vue/dist/Components/NcUserBubble.js' +import { sortByDeletionTime } from './utils.ts' + const parseOriginalLocation = (node: Node): string => { const path = node.attributes?.['trashbin-original-location'] !== undefined ? String(node.attributes?.['trashbin-original-location']) : null if (!path) { @@ -129,11 +131,7 @@ const deleted = new Column({ span.textContent = t('files_trashbin', 'A long time ago') return span }, - sort(nodeA, nodeB) { - const deletionTimeA = nodeA.attributes?.['trashbin-deletion-time'] || nodeA?.mtime || 0 - const deletionTimeB = nodeB.attributes?.['trashbin-deletion-time'] || nodeB?.mtime || 0 - return deletionTimeB - deletionTimeA - }, + sort: sortByDeletionTime, }) export const columns = [ diff --git a/apps/files_trashbin/src/components/RestoreParentsDialog.vue b/apps/files_trashbin/src/components/RestoreParentsDialog.vue new file mode 100644 index 00000000000..6e4e3ceb7a3 --- /dev/null +++ b/apps/files_trashbin/src/components/RestoreParentsDialog.vue @@ -0,0 +1,147 @@ +<!-- + - @copyright 2024 Christopher Ng <chrng8@gmail.com> + - + - @author Christopher Ng <chrng8@gmail.com> + - + - @license AGPL-3.0-or-later + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - +--> + +<template> + <NcDialog :name="name" + out-transition + size="normal" + :can-close="false"> + <div class="dialog__content"> + <NcNoteCard class="dialog__note" type="info">{{ message }}</NcNoteCard> + <ul class="dialog__list"> + <li v-for="node in nodes" :key="node.fileid"> + {{ node.attributes.displayName }} + </li> + </ul> + </div> + <template #actions> + <NcButton type="tertiary" @click="cancel"> + {{ t('files_trashbin', 'Cancel') }} + </NcButton> + <NcButton type="secondary" @click="skip"> + {{ t('files_trashbin', 'Skip') }} + </NcButton> + <NcButton type="primary" @click="confirm"> + {{ t('files_trashbin', 'Confirm') }} + </NcButton> + </template> + </NcDialog> +</template> + +<script lang="ts"> +import type { Node } from '@nextcloud/files' +import type { PropType } from 'vue' + +import { defineComponent } from 'vue' +import { translate as t } from '@nextcloud/l10n' + +import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' +import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js' +import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js' + +import { parseOriginalLocation, RestoreParents } from '../utils.ts' + +export default defineComponent({ + name: 'RestoreParentsDialog', + + components: { + NcButton, + NcDialog, + NcNoteCard, + }, + + props: { + node: { + type: Object as PropType<Node>, + default: null, + }, + + nodes: { + type: Array as PropType<Node[]>, + default: () => [], + validator: (value: Node[]) => value?.length > 0, + }, + }, + + data() { + return { + } + }, + + computed: { + name() { + return n( + 'files_trashbin', + 'Confirm restoration of parent folder', + 'Confirm restoration of parent folders', + this.nodes.length, + ) + }, + + message() { + return n( + 'files_trashbin', + '{name} was originally in {location}. You may restore the parent folder listed below or skip parent folder resoration and restore {name} directly to All files.', + '{name} was originally in {location}. You may restore the parent folders listed below or skip parent folder resoration and restore {name} directly to All files.', + this.nodes.length, + { + name: this.node.attributes.displayName, + location: parseOriginalLocation(this.node), + }, + ) + }, + }, + + methods: { + t, + confirm(): Promise<void> { + this.$emit('close', RestoreParents.Confirm) + }, + skip(): Promise<void> { + this.$emit('close', RestoreParents.Skip) + }, + cancel(): Promise<void> { + this.$emit('close', RestoreParents.Cancel) + }, + }, +}) +</script> + +<style lang="scss" scoped> +.dialog { + &__content { + padding: 0 16px; + } + + &__note { + margin-top: 0 !important; + } + + &__list { + list-style-type: disc; + list-style-position: inside; + display: flex; + flex-direction: column; + gap: 4px 0; + } +} +</style> diff --git a/apps/files_trashbin/src/services/restoreDialog.ts b/apps/files_trashbin/src/services/restoreDialog.ts new file mode 100644 index 00000000000..85d1a118747 --- /dev/null +++ b/apps/files_trashbin/src/services/restoreDialog.ts @@ -0,0 +1,36 @@ +/** + * @copyright 2024 Christopher Ng <chrng8@gmail.com> + * + * @author Christopher Ng <chrng8@gmail.com> + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +import type { Node } from '@nextcloud/files' +import { spawnDialog } from '@nextcloud/dialogs' + +import type { RestoreParents } from '../utils.ts' +import RestoreParentsDialog from '../components/RestoreParentsDialog.vue' + +export const confirmRestoration = (node: Node, nodes: Node[]): Promise<RestoreParents> => { + return new Promise((resolve) => { + spawnDialog(RestoreParentsDialog, { + node, + nodes, + }, (result: RestoreParents) => resolve(result)) + }) +} diff --git a/apps/files_trashbin/src/store/trashbin.ts b/apps/files_trashbin/src/store/trashbin.ts new file mode 100644 index 00000000000..4e563262422 --- /dev/null +++ b/apps/files_trashbin/src/store/trashbin.ts @@ -0,0 +1,51 @@ +/** + * @copyright 2024 Christopher Ng <chrng8@gmail.com> + * + * @author Christopher Ng <chrng8@gmail.com> + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +import type { Node } from '@nextcloud/files' + +import { ref } from 'vue' +import { subscribe, unsubscribe } from '@nextcloud/event-bus' +import { getContents } from '../services/trashbin.ts' + +export const useTrashbinStore = () => { + const nodes = ref<Node[]>([]) + + const remove = (file: Node) => { + nodes.value = nodes.value.filter(node => node.fileid !== file.fileid) + } + + const init = async () => { + nodes.value = (await getContents()).contents + subscribe('files:node:deleted', remove) + } + + const reset = () => { + nodes.value = [] + unsubscribe('files:node:deleted', remove) + } + + return { + nodes, + init, + reset, + } +} diff --git a/apps/files_trashbin/src/utils.ts b/apps/files_trashbin/src/utils.ts new file mode 100644 index 00000000000..a7992b20675 --- /dev/null +++ b/apps/files_trashbin/src/utils.ts @@ -0,0 +1,59 @@ +/** + * @copyright 2024 Christopher Ng <chrng8@gmail.com> + * + * @author Christopher Ng <chrng8@gmail.com> + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +import type { Node } from '@nextcloud/files' + +import { translate as t } from '@nextcloud/l10n' +import { dirname } from '@nextcloud/paths' + +export enum RestoreParents { + Confirm = 'Confirm', + Skip = 'Skip', + Cancel = 'Cancel', +} + +export const sortByDeletionTime = (a: Node, b: Node) => { + const deletionTimeA = a.attributes?.['trashbin-deletion-time'] || a?.mtime || 0 + const deletionTimeB = b.attributes?.['trashbin-deletion-time'] || b?.mtime || 0 + return deletionTimeB - deletionTimeA +} + +/** + * @param node the node + * @param fullPath if true will return the full path + */ +export const parseOriginalLocation = (node: Node, fullPath: boolean = false): string => { + const path = node.attributes?.['trashbin-original-location'] !== undefined + ? String(node.attributes?.['trashbin-original-location']).replace(/^\//, '') + : null + if (!path) { + return t('files_trashbin', 'Unknown') + } + if (fullPath) { + return path + } + const dir = dirname(path) + if (dir === path) { // Node is in root folder + return t('files_trashbin', 'All files') + } + return dir +} |