aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristopher Ng <chrng8@gmail.com>2024-05-03 13:10:59 -0700
committerChristopher Ng <chrng8@gmail.com>2024-05-03 13:10:59 -0700
commitb7151afad53ad7943b1b4c1b54fe5e6a84987e86 (patch)
tree4fcb8734ce3d58ebf67715c78c2ed5d4e93f165c
parent125e397a82917b2d5fea918c7a8dec2634836a66 (diff)
downloadnextcloud-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.ts138
-rw-r--r--apps/files_trashbin/src/columns.ts8
-rw-r--r--apps/files_trashbin/src/components/RestoreParentsDialog.vue147
-rw-r--r--apps/files_trashbin/src/services/restoreDialog.ts36
-rw-r--r--apps/files_trashbin/src/store/trashbin.ts51
-rw-r--r--apps/files_trashbin/src/utils.ts59
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
+}