[WIP] feat(trashbin): Allow restoration of parent folders

Signed-off-by: Christopher Ng <chrng8@gmail.com>
feat/restore-to-original-dir
Christopher Ng 2 weeks ago
parent 125e397a82
commit b7151afad5

@ -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,

@ -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 = [

@ -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>

@ -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))
})
}

@ -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,
}
}

@ -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
}
Loading…
Cancel
Save