diff options
Diffstat (limited to 'apps/files_trashbin/src')
20 files changed, 1095 insertions, 549 deletions
diff --git a/apps/files_trashbin/src/app.js b/apps/files_trashbin/src/app.js deleted file mode 100644 index 2cd4cca8fa6..00000000000 --- a/apps/files_trashbin/src/app.js +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Copyright (c) 2014 - * - * @author Abijeet <abijeetpatro@gmail.com> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Calviño Sánchez <danxuliu@gmail.com> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Vincent Petry <vincent@nextcloud.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/>. - * - */ - -OCA.Trashbin = {} -/** - * @namespace OCA.Trashbin.App - */ -OCA.Trashbin.App = { - _initialized: false, - /** @type {OC.Files.Client} */ - client: null, - - initialize($el) { - if (this._initialized) { - return - } - this._initialized = true - - this.client = new OC.Files.Client({ - host: OC.getHost(), - port: OC.getPort(), - root: OC.linkToRemoteBase('dav') + '/trashbin/' + OC.getCurrentUser().uid, - useHTTPS: OC.getProtocol() === 'https', - }) - const urlParams = OC.Util.History.parseUrlQuery() - this.fileList = new OCA.Trashbin.FileList( - $('#app-content-trashbin'), { - fileActions: this._createFileActions(), - detailsViewEnabled: false, - scrollTo: urlParams.scrollto, - config: OCA.Files.App.getFilesConfig(), - multiSelectMenu: [ - { - name: 'restore', - displayName: t('files_trashbin', 'Restore'), - iconClass: 'icon-history', - }, - { - name: 'delete', - displayName: t('files_trashbin', 'Delete permanently'), - iconClass: 'icon-delete', - }, - ], - client: this.client, - // The file list is created when a "show" event is handled, so - // it should be marked as "shown" like it would have been done - // if handling the event with the file list already created. - shown: true, - } - ) - }, - - _createFileActions() { - const client = this.client - const fileActions = new OCA.Files.FileActions() - fileActions.register('dir', 'Open', OC.PERMISSION_READ, '', function(filename, context) { - const dir = context.fileList.getCurrentDirectory() - context.fileList.changeDirectory(OC.joinPaths(dir, filename)) - }) - - fileActions.setDefault('dir', 'Open') - - fileActions.registerAction({ - name: 'Restore', - displayName: t('files_trashbin', 'Restore'), - type: OCA.Files.FileActions.TYPE_INLINE, - mime: 'all', - permissions: OC.PERMISSION_READ, - iconClass: 'icon-history', - actionHandler(filename, context) { - const fileList = context.fileList - const tr = fileList.findFileEl(filename) - fileList.showFileBusyState(tr, true) - const dir = context.fileList.getCurrentDirectory() - client.move(OC.joinPaths('trash', dir, filename), OC.joinPaths('restore', filename), true) - .then( - fileList._removeCallback.bind(fileList, [filename]), - function() { - fileList.showFileBusyState(tr, false) - OC.Notification.show(t('files_trashbin', 'Error while restoring file from trash bin')) - } - ) - }, - }) - - fileActions.registerAction({ - name: 'Delete', - displayName: t('files_trashbin', 'Delete permanently'), - mime: 'all', - permissions: OC.PERMISSION_READ, - iconClass: 'icon-delete', - render(actionSpec, isDefault, context) { - const $actionLink = fileActions._makeActionLink(actionSpec, context) - $actionLink.attr('original-title', t('files_trashbin', 'Delete permanently')) - $actionLink.children('img').attr('alt', t('files_trashbin', 'Delete permanently')) - context.$file.find('td:last').append($actionLink) - return $actionLink - }, - actionHandler(filename, context) { - const fileList = context.fileList - $('.tipsy').remove() - const tr = fileList.findFileEl(filename) - fileList.showFileBusyState(tr, true) - const dir = context.fileList.getCurrentDirectory() - client.remove(OC.joinPaths('trash', dir, filename)) - .then( - fileList._removeCallback.bind(fileList, [filename]), - function() { - fileList.showFileBusyState(tr, false) - OC.Notification.show(t('files_trashbin', 'Error while removing file from trash bin')) - } - ) - }, - }) - return fileActions - }, -} - -window.addEventListener('DOMContentLoaded', function() { - $('#app-content-trashbin').one('show', function() { - const App = OCA.Trashbin.App - App.initialize($('#app-content-trashbin')) - // force breadcrumb init - // App.fileList.changeDirectory(App.fileList.getCurrentDirectory(), false, true); - }) -}) diff --git a/apps/files_trashbin/src/filelist.js b/apps/files_trashbin/src/filelist.js deleted file mode 100644 index 8920dcbf8b9..00000000000 --- a/apps/files_trashbin/src/filelist.js +++ /dev/null @@ -1,348 +0,0 @@ -/** - * Copyright (c) 2014 - * - * @author Azul <azul@riseup.net> - * @author Gary Kim <gary@garykim.dev> - * @author Jan C. Borchardt <hey@jancborchardt.net> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Robin Appelman <robin@icewind.nl> - * @author Vincent Petry <vincent@nextcloud.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/>. - * - */ - -/* eslint-disable */ -(function() { - var DELETED_REGEXP = new RegExp(/^(.+)\.d[0-9]+$/) - var FILENAME_PROP = '{http://nextcloud.org/ns}trashbin-filename' - var DELETION_TIME_PROP = '{http://nextcloud.org/ns}trashbin-deletion-time' - var TRASHBIN_ORIGINAL_LOCATION = '{http://nextcloud.org/ns}trashbin-original-location' - var TRASHBIN_TITLE = '{http://nextcloud.org/ns}trashbin-title' - - /** - * Convert a file name in the format filename.d12345 to the real file name. - * This will use basename. - * The name will not be changed if it has no ".d12345" suffix. - * @param {String} name file name - * @returns {String} converted file name - */ - function getDeletedFileName(name) { - name = OC.basename(name) - var match = DELETED_REGEXP.exec(name) - if (match && match.length > 1) { - name = match[1] - } - return name - } - - /** - * @class OCA.Trashbin.FileList - * @augments OCA.Files.FileList - * @classdesc List of deleted files - * - * @param $el container element with existing markup for the .files-controls - * and a table - * @param [options] map of options - */ - var FileList = function($el, options) { - this.client = options.client - this.initialize($el, options) - } - FileList.prototype = _.extend({}, OCA.Files.FileList.prototype, - /** @lends OCA.Trashbin.FileList.prototype */ { - id: 'trashbin', - appName: t('files_trashbin', 'Deleted files'), - /** @type {OC.Files.Client} */ - client: null, - - /** - * @private - */ - initialize: function() { - this.client.addFileInfoParser(function(response, data) { - var props = response.propStat[0].properties - var path = props[TRASHBIN_ORIGINAL_LOCATION] - var title = props[TRASHBIN_TITLE] - return { - displayName: props[FILENAME_PROP], - mtime: parseInt(props[DELETION_TIME_PROP], 10) * 1000, - hasPreview: true, - path: path, - extraData: title - } - }) - - var result = OCA.Files.FileList.prototype.initialize.apply(this, arguments) - this.$el.find('.undelete').click('click', _.bind(this._onClickRestoreSelected, this)) - - // Sort by most recently deleted first - this.setSort('mtime', 'desc') - - /** - * Override crumb making to add "Deleted Files" entry - * and convert files with ".d" extensions to a more - * user friendly name. - */ - this.breadcrumb._makeCrumbs = function() { - var parts = OCA.Files.BreadCrumb.prototype._makeCrumbs.apply(this, [...arguments, 'icon-delete no-hover']) - for (var i = 1; i < parts.length; i++) { - parts[i].name = getDeletedFileName(parts[i].name) - } - return parts - } - - OC.Plugins.attach('OCA.Trashbin.FileList', this) - return result - }, - - /** - * Override to only return read permissions - */ - getDirectoryPermissions: function() { - return OC.PERMISSION_READ | OC.PERMISSION_DELETE - }, - - _setCurrentDir: function(targetDir) { - OCA.Files.FileList.prototype._setCurrentDir.apply(this, arguments) - - var baseDir = OC.basename(targetDir) - if (baseDir !== '') { - this.setPageTitle(getDeletedFileName(baseDir)) - } - }, - - _createRow: function() { - // FIXME: MEGAHACK until we find a better solution - var tr = OCA.Files.FileList.prototype._createRow.apply(this, arguments) - tr.find('td.filesize').remove() - return tr - }, - - getAjaxUrl: function(action, params) { - var q = '' - if (params) { - q = '?' + OC.buildQueryString(params) - } - return OC.filePath('files_trashbin', 'ajax', action + '.php') + q - }, - - setupUploadEvents: function() { - // override and do nothing - }, - - linkTo: function(dir) { - return OC.linkTo('files', 'index.php') + '?view=trashbin&dir=' + encodeURIComponent(dir).replace(/%2F/g, '/') - }, - - elementToFile: function($el) { - var fileInfo = OCA.Files.FileList.prototype.elementToFile($el) - if (this.getCurrentDirectory() === '/') { - fileInfo.displayName = getDeletedFileName(fileInfo.name) - } - // no size available - delete fileInfo.size - return fileInfo - }, - - updateEmptyContent: function() { - var exists = this.$fileList.find('tr:first').exists() - this.$el.find('.emptyfilelist.emptycontent').toggleClass('hidden', exists) - this.$el.find('.files-filestable th').toggleClass('hidden', !exists) - }, - - _removeCallback: function(files) { - var $el - for (var i = 0; i < files.length; i++) { - $el = this.remove(OC.basename(files[i]), { updateSummary: false }) - this.fileSummary.remove({ type: $el.attr('data-type'), size: $el.attr('data-size') }) - } - this.fileSummary.update() - this.updateEmptyContent() - }, - - _onClickRestoreSelected: function(event) { - event.preventDefault() - var self = this - var files = _.pluck(this.getSelectedFiles(), 'name') - for (var i = 0; i < files.length; i++) { - var tr = this.findFileEl(files[i]) - this.showFileBusyState(tr, true) - } - - this.fileMultiSelectMenu.toggleLoading('restore', true) - var restorePromises = files.map(function(file) { - return self.client.move(OC.joinPaths('trash', self.getCurrentDirectory(), file), OC.joinPaths('restore', file), true) - .then( - function() { - self._removeCallback([file]) - } - ) - }) - return Promise.all(restorePromises).then( - function() { - self.fileMultiSelectMenu.toggleLoading('restore', false) - }, - function() { - OC.Notification.show(t('files_trashbin', 'Error while restoring files from trash bin')) - } - ) - }, - - _onClickDeleteSelected: function(event) { - event.preventDefault() - var self = this - var allFiles = this.$el.find('.select-all').is(':checked') - var files = _.pluck(this.getSelectedFiles(), 'name') - for (var i = 0; i < files.length; i++) { - var tr = this.findFileEl(files[i]) - this.showFileBusyState(tr, true) - } - - if (allFiles) { - return this.client.remove(OC.joinPaths('trash', this.getCurrentDirectory())) - .then( - function() { - self.hideMask() - self.setFiles([]) - }, - function() { - OC.Notification.show(t('files_trashbin', 'Error while emptying trash bin')) - } - ) - } else { - this.fileMultiSelectMenu.toggleLoading('delete', true) - var deletePromises = files.map(function(file) { - return self.client.remove(OC.joinPaths('trash', self.getCurrentDirectory(), file)) - .then( - function() { - self._removeCallback([file]) - } - ) - }) - return Promise.all(deletePromises).then( - function() { - self.fileMultiSelectMenu.toggleLoading('delete', false) - }, - function() { - OC.Notification.show(t('files_trashbin', 'Error while removing files from trash bin')) - } - ) - } - }, - - _onClickFile: function(event) { - var mime = $(this).parent().parent().data('mime') - if (mime !== 'httpd/unix-directory') { - event.preventDefault() - } - return OCA.Files.FileList.prototype._onClickFile.apply(this, arguments) - }, - - generatePreviewUrl: function(urlSpec) { - return OC.generateUrl('/apps/files_trashbin/preview?') + $.param(urlSpec) - }, - - getDownloadUrl: function() { - // no downloads - return '#' - }, - - getDefaultActionUrl: function() { - // no default action - return '#' - }, - - updateStorageStatistics: function() { - // no op because the trashbin doesn't have - // storage info like free space / used space - }, - - isSelectedDeletable: function() { - return true - }, - - /** - * Returns list of webdav properties to request - */ - _getWebdavProperties: function() { - return [FILENAME_PROP, DELETION_TIME_PROP, TRASHBIN_ORIGINAL_LOCATION, TRASHBIN_TITLE].concat(this.filesClient.getPropfindProperties()) - }, - - /** - * Reloads the file list using ajax call - * - * @returns ajax call object - */ - reload: function() { - this._selectedFiles = {} - this._selectionSummary.clear() - this.$el.find('.select-all').prop('checked', false) - this.showMask() - if (this._reloadCall?.abort) { - this._reloadCall.abort() - } - this._reloadCall = this.client.getFolderContents( - 'trash/' + this.getCurrentDirectory(), { - includeParent: false, - properties: this._getWebdavProperties() - } - ) - var callBack = this.reloadCallback.bind(this) - return this._reloadCall.then(callBack, callBack) - }, - reloadCallback: function(status, result) { - delete this._reloadCall - this.hideMask() - - if (status === 401) { - return false - } - - // Firewall Blocked request? - if (status === 403) { - // Go home - this.changeDirectory('/') - OC.Notification.show(t('files', 'This operation is forbidden')) - return false - } - - // Did share service die or something else fail? - if (status === 500) { - // Go home - this.changeDirectory('/') - OC.Notification.show(t('files', 'This directory is unavailable, please check the logs or contact the administrator')) - return false - } - - if (status === 404) { - // go back home - this.changeDirectory('/') - return false - } - // aborted ? - if (status === 0) { - return true - } - - this.setFiles(result) - return true - } - - }) - - OCA.Trashbin.FileList = FileList -})() diff --git a/apps/files_trashbin/src/files-init.ts b/apps/files_trashbin/src/files-init.ts new file mode 100644 index 00000000000..edb09027804 --- /dev/null +++ b/apps/files_trashbin/src/files-init.ts @@ -0,0 +1,17 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { getNavigation, registerFileAction, registerFileListAction } from '@nextcloud/files' +import { restoreAction } from './files_actions/restoreAction.ts' +import { emptyTrashAction } from './files_listActions/emptyTrashAction.ts' +import { trashbinView } from './files_views/trashbinView.ts' + +import './trashbin.scss' + +const Navigation = getNavigation() +Navigation.register(trashbinView) + +registerFileListAction(emptyTrashAction) +registerFileAction(restoreAction) diff --git a/apps/files_trashbin/src/files_actions/restoreAction.spec.ts b/apps/files_trashbin/src/files_actions/restoreAction.spec.ts new file mode 100644 index 00000000000..4863eb6d00a --- /dev/null +++ b/apps/files_trashbin/src/files_actions/restoreAction.spec.ts @@ -0,0 +1,145 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { Folder } from '@nextcloud/files' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as ncEventBus from '@nextcloud/event-bus' +import isSvg from 'is-svg' + +import { trashbinView } from '../files_views/trashbinView.ts' +import { restoreAction } from './restoreAction.ts' +import { PERMISSION_ALL, PERMISSION_NONE } from '../../../../core/src/OC/constants.js' + +const axiosMock = vi.hoisted(() => ({ + request: vi.fn(), +})) +vi.mock('@nextcloud/axios', () => ({ default: axiosMock })) +vi.mock('@nextcloud/auth') + +describe('files_trashbin: file actions - restore action', () => { + it('has id set', () => { + expect(restoreAction.id).toBe('restore') + }) + + it('has order set', () => { + // very high priority! + expect(restoreAction.order).toBe(1) + }) + + it('is an inline action', () => { + const node = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/' }) + + expect(restoreAction.inline).toBeTypeOf('function') + expect(restoreAction.inline!(node, trashbinView)).toBe(true) + }) + + it('has the display name set', () => { + const node = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/' }) + + expect(restoreAction.displayName([node], trashbinView)).toBe('Restore') + }) + + it('has an icon set', () => { + const node = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/' }) + + const icon = restoreAction.iconSvgInline([node], trashbinView) + expect(icon).toBeTypeOf('string') + expect(isSvg(icon)).toBe(true) + }) + + it('is enabled for trashbin view', () => { + const nodes = [ + new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/', permissions: PERMISSION_ALL }), + ] + + expect(restoreAction.enabled).toBeTypeOf('function') + expect(restoreAction.enabled!(nodes, trashbinView)).toBe(true) + }) + + it('is not enabled when permissions are missing', () => { + const nodes = [ + new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/', permissions: PERMISSION_NONE }), + ] + + expect(restoreAction.enabled).toBeTypeOf('function') + expect(restoreAction.enabled!(nodes, trashbinView)).toBe(false) + }) + + it('is not enabled when no nodes are selected', () => { + expect(restoreAction.enabled).toBeTypeOf('function') + expect(restoreAction.enabled!([], trashbinView)).toBe(false) + }) + + it('is not enabled for other views', () => { + const nodes = [ + new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/', permissions: PERMISSION_ALL }), + ] + + const otherView = new Proxy(trashbinView, { + get(target, p) { + if (p === 'id') { + return 'other-view' + } + return target[p] + }, + }) + + expect(restoreAction.enabled).toBeTypeOf('function') + expect(restoreAction.enabled!(nodes, otherView)).toBe(false) + }) + + describe('execute', () => { + beforeEach(() => { + axiosMock.request.mockReset() + }) + + it('send restore request', async () => { + const node = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/', permissions: PERMISSION_ALL }) + + expect(await restoreAction.exec(node, trashbinView, '/')).toBe(true) + expect(axiosMock.request).toBeCalled() + expect(axiosMock.request.mock.calls[0][0].method).toBe('MOVE') + expect(axiosMock.request.mock.calls[0][0].url).toBe(node.encodedSource) + expect(axiosMock.request.mock.calls[0][0].headers.destination).toContain('/restore/') + }) + + it('deletes node from current view after successfull request', async () => { + const node = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/', permissions: PERMISSION_ALL }) + + const emitSpy = vi.spyOn(ncEventBus, 'emit') + + expect(await restoreAction.exec(node, trashbinView, '/')).toBe(true) + expect(axiosMock.request).toBeCalled() + expect(emitSpy).toBeCalled() + expect(emitSpy).toBeCalledWith('files:node:deleted', node) + }) + + it('does not delete node from view if reuest failed', async () => { + const node = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/', permissions: PERMISSION_ALL }) + + axiosMock.request.mockImplementationOnce(() => { throw new Error() }) + const emitSpy = vi.spyOn(ncEventBus, 'emit') + + expect(await restoreAction.exec(node, trashbinView, '/')).toBe(false) + expect(axiosMock.request).toBeCalled() + expect(emitSpy).not.toBeCalled() + }) + + it('batch: only returns success if all requests worked', async () => { + const node = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/', permissions: PERMISSION_ALL }) + + expect(await restoreAction.execBatch!([node, node], trashbinView, '/')).toStrictEqual([true, true]) + expect(axiosMock.request).toBeCalledTimes(2) + }) + + it('batch: only returns success if all requests worked - one failed', async () => { + const node = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/', permissions: PERMISSION_ALL }) + + axiosMock.request.mockImplementationOnce(() => { throw new Error() }) + expect(await restoreAction.execBatch!([node, node], trashbinView, '/')).toStrictEqual([false, true]) + expect(axiosMock.request).toBeCalledTimes(2) + }) + }) +}) diff --git a/apps/files_trashbin/src/files_actions/restoreAction.ts b/apps/files_trashbin/src/files_actions/restoreAction.ts new file mode 100644 index 00000000000..3aeeceea7b3 --- /dev/null +++ b/apps/files_trashbin/src/files_actions/restoreAction.ts @@ -0,0 +1,71 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { getCurrentUser } from '@nextcloud/auth' +import { showError } from '@nextcloud/dialogs' +import { emit } from '@nextcloud/event-bus' +import { Permission, Node, View, FileAction } from '@nextcloud/files' +import { t } from '@nextcloud/l10n' +import { encodePath } from '@nextcloud/paths' +import { generateRemoteUrl } from '@nextcloud/router' +import axios from '@nextcloud/axios' +import svgHistory from '@mdi/svg/svg/history.svg?raw' + +import { TRASHBIN_VIEW_ID } from '../files_views/trashbinView.ts' +import logger from '../../../files/src/logger.ts' + +export const restoreAction = new FileAction({ + id: 'restore', + + displayName() { + return t('files_trashbin', 'Restore') + }, + + iconSvgInline: () => svgHistory, + + enabled(nodes: Node[], view) { + // Only available in the trashbin view + if (view.id !== TRASHBIN_VIEW_ID) { + return false + } + + // Only available if all nodes have read permission + return nodes.length > 0 + && nodes + .map((node) => node.permissions) + .every((permission) => Boolean(permission & Permission.READ)) + }, + + async exec(node: Node) { + try { + const destination = generateRemoteUrl(encodePath(`dav/trashbin/${getCurrentUser()!.uid}/restore/${node.basename}`)) + await axios.request({ + 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) { + if (error.response?.status === 507) { + showError(t('files_trashbin', 'Not enough free space to restore the file/folder')) + } + logger.error('Failed to restore node', { error, node }) + return false + } + }, + + async execBatch(nodes: Node[], view: View, dir: string) { + return Promise.all(nodes.map(node => this.exec(node, view, dir))) + }, + + order: 1, + + inline: () => true, +}) diff --git a/apps/files_trashbin/src/files_listActions/emptyTrashAction.spec.ts b/apps/files_trashbin/src/files_listActions/emptyTrashAction.spec.ts new file mode 100644 index 00000000000..399c0f60043 --- /dev/null +++ b/apps/files_trashbin/src/files_listActions/emptyTrashAction.spec.ts @@ -0,0 +1,174 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { Folder } from '@nextcloud/files' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { emptyTrashAction } from './emptyTrashAction.ts' +import { trashbinView } from '../files_views/trashbinView.ts' +import * as ncDialogs from '@nextcloud/dialogs' +import * as ncEventBus from '@nextcloud/event-bus' +import * as ncInitialState from '@nextcloud/initial-state' +import * as api from '../services/api.ts' + +describe('files_trashbin: file list actions - empty trashbin', () => { + it('has id set', () => { + expect(emptyTrashAction.id).toBe('empty-trash') + }) + + it('has display name set', () => { + expect(emptyTrashAction.displayName(trashbinView)).toBe('Empty deleted files') + }) + + it('has order set', () => { + // expect highest priority! + expect(emptyTrashAction.order).toBe(0) + }) + + it('is enabled on trashbin view', () => { + const spy = vi.spyOn(ncInitialState, 'loadState').mockImplementationOnce(() => ({ allow_delete: true })) + + const root = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/', root: '/trashbin/test/' }) + const nodes = [ + new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/' }), + ] + + expect(emptyTrashAction.enabled).toBeTypeOf('function') + expect(emptyTrashAction.enabled!(trashbinView, nodes, root)).toBe(true) + expect(spy).toHaveBeenCalled() + expect(spy).toHaveBeenCalledWith('files_trashbin', 'config') + }) + + it('is not enabled on another view enabled', () => { + vi.spyOn(ncInitialState, 'loadState').mockImplementationOnce(() => ({ allow_delete: true })) + + const root = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/', root: '/trashbin/test/' }) + const nodes = [ + new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/' }), + ] + + const otherView = new Proxy(trashbinView, { + get(target, p) { + if (p === 'id') { + return 'other-view' + } + return target[p] + }, + }) + + expect(emptyTrashAction.enabled).toBeTypeOf('function') + expect(emptyTrashAction.enabled!(otherView, nodes, root)).toBe(false) + }) + + it('is not enabled when deletion is forbidden', () => { + const spy = vi.spyOn(ncInitialState, 'loadState').mockImplementationOnce(() => ({ allow_delete: false })) + + const root = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/', root: '/trashbin/test/' }) + const nodes = [ + new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/' }), + ] + + expect(emptyTrashAction.enabled).toBeTypeOf('function') + expect(emptyTrashAction.enabled!(trashbinView, nodes, root)).toBe(false) + expect(spy).toHaveBeenCalled() + expect(spy).toHaveBeenCalledWith('files_trashbin', 'config') + }) + + it('is not enabled when not in trashbin root', () => { + vi.spyOn(ncInitialState, 'loadState').mockImplementationOnce(() => ({ allow_delete: true })) + + const root = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/other-folder', root: '/trashbin/test/' }) + const nodes = [ + new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/' }), + ] + + expect(emptyTrashAction.enabled).toBeTypeOf('function') + expect(emptyTrashAction.enabled!(trashbinView, nodes, root)).toBe(false) + }) + + describe('execute', () => { + const root = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/', root: '/trashbin/test/' }) + const nodes = [ + new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/' }), + ] + + let dialogBuilder = { + setSeverity: vi.fn(), + setText: vi.fn(), + setButtons: vi.fn(), + build: vi.fn(), + } + + beforeEach(() => { + dialogBuilder = { + setSeverity: vi.fn(() => dialogBuilder), + setText: vi.fn(() => dialogBuilder), + setButtons: vi.fn(() => dialogBuilder), + build: vi.fn(() => dialogBuilder), + } + + vi.spyOn(ncDialogs, 'getDialogBuilder') + // @ts-expect-error This is a mock + .mockImplementationOnce(() => dialogBuilder) + }) + + it('can cancel the deletion by closing the dialog', async () => { + const apiSpy = vi.spyOn(api, 'emptyTrash') + + dialogBuilder.build.mockImplementationOnce(() => ({ show: async () => false })) + expect(await emptyTrashAction.exec(trashbinView, nodes, root)).toBe(null) + expect(apiSpy).not.toBeCalled() + }) + + it('can cancel the deletion', async () => { + const apiSpy = vi.spyOn(api, 'emptyTrash') + + dialogBuilder.build.mockImplementationOnce(() => ({ + show: async () => { + const buttons = dialogBuilder.setButtons.mock.calls[0][0] + const cancel = buttons.find(({ label }) => label === 'Cancel') + await cancel.callback() + }, + })) + expect(await emptyTrashAction.exec(trashbinView, nodes, root)).toBe(null) + expect(apiSpy).not.toBeCalled() + }) + + it('will trigger the API request if confirmed', async () => { + const apiSpy = vi.spyOn(api, 'emptyTrash').mockImplementationOnce(async () => true) + const dialogSpy = vi.spyOn(ncDialogs, 'showInfo') + const eventBusSpy = vi.spyOn(ncEventBus, 'emit') + + dialogBuilder.build.mockImplementationOnce(() => ({ + show: async () => { + const buttons = dialogBuilder.setButtons.mock.calls[0][0] + const cancel = buttons.find(({ label }) => label === 'Empty deleted files') + await cancel.callback() + }, + })) + expect(await emptyTrashAction.exec(trashbinView, nodes, root)).toBe(null) + expect(apiSpy).toBeCalled() + expect(dialogSpy).not.toBeCalled() + expect(eventBusSpy).toBeCalledWith('files:node:deleted', nodes[0]) + }) + + it('will not emit files deleted event if API request failed', async () => { + const apiSpy = vi.spyOn(api, 'emptyTrash').mockImplementationOnce(async () => false) + const dialogSpy = vi.spyOn(ncDialogs, 'showInfo') + const eventBusSpy = vi.spyOn(ncEventBus, 'emit') + + dialogBuilder.build.mockImplementationOnce(() => ({ + show: async () => { + const buttons = dialogBuilder.setButtons.mock.calls[0][0] + const cancel = buttons.find(({ label }) => label === 'Empty deleted files') + await cancel.callback() + }, + })) + expect(await emptyTrashAction.exec(trashbinView, nodes, root)).toBe(null) + expect(apiSpy).toBeCalled() + expect(dialogSpy).not.toBeCalled() + expect(eventBusSpy).not.toBeCalled() + }) + }) +}) diff --git a/apps/files_trashbin/src/files_listActions/emptyTrashAction.ts b/apps/files_trashbin/src/files_listActions/emptyTrashAction.ts new file mode 100644 index 00000000000..2b6ff171adf --- /dev/null +++ b/apps/files_trashbin/src/files_listActions/emptyTrashAction.ts @@ -0,0 +1,75 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { Node, View, Folder } from '@nextcloud/files' + +import { emit } from '@nextcloud/event-bus' +import { FileListAction } from '@nextcloud/files' +import { loadState } from '@nextcloud/initial-state' +import { t } from '@nextcloud/l10n' +import { + DialogSeverity, + getDialogBuilder, +} from '@nextcloud/dialogs' +import { emptyTrash } from '../services/api.ts' +import { TRASHBIN_VIEW_ID } from '../files_views/trashbinView.ts' + +export type FilesTrashbinConfigState = { + allow_delete: boolean; +} + +export const emptyTrashAction = new FileListAction({ + id: 'empty-trash', + + displayName: () => t('files_trashbin', 'Empty deleted files'), + order: 0, + + enabled(view: View, nodes: Node[], folder: Folder) { + if (view.id !== TRASHBIN_VIEW_ID) { + return false + } + + const config = loadState<FilesTrashbinConfigState>('files_trashbin', 'config') + if (!config.allow_delete) { + return false + } + + return nodes.length > 0 && folder.path === '/' + }, + + async exec(view: View, nodes: Node[]): Promise<null> { + const askConfirmation = new Promise<boolean>((resolve) => { + const dialog = getDialogBuilder(t('files_trashbin', 'Confirm permanent deletion')) + .setSeverity(DialogSeverity.Warning) + // TODO Add note for groupfolders + .setText(t('files_trashbin', 'Are you sure you want to permanently delete all files and folders in the trash? This cannot be undone.')) + .setButtons([ + { + label: t('files_trashbin', 'Cancel'), + type: 'secondary', + callback: () => resolve(false), + }, + { + label: t('files_trashbin', 'Empty deleted files'), + type: 'error', + callback: () => resolve(true), + }, + ]) + .build() + dialog.show().then(() => { + resolve(false) + }) + }) + + const result = await askConfirmation + if (result === true) { + if (await emptyTrash()) { + nodes.forEach((node) => emit('files:node:deleted', node)) + } + return null + } + + return null + }, +}) diff --git a/apps/files_trashbin/src/files_trashbin.js b/apps/files_trashbin/src/files_trashbin.js deleted file mode 100644 index 84829341b31..00000000000 --- a/apps/files_trashbin/src/files_trashbin.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * @copyright Copyright (c) 2016 Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @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 './app' -import './filelist' -import './trash.scss' - -window.OCA.Trashbin = OCA.Trashbin diff --git a/apps/files_trashbin/src/files_views/columns.spec.ts b/apps/files_trashbin/src/files_views/columns.spec.ts new file mode 100644 index 00000000000..a22ef17ea6b --- /dev/null +++ b/apps/files_trashbin/src/files_views/columns.spec.ts @@ -0,0 +1,217 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { File } from '@nextcloud/files' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { deleted, deletedBy, originalLocation } from './columns.ts' +import { trashbinView } from './trashbinView.ts' +import * as ncAuth from '@nextcloud/auth' + +vi.mock('@nextcloud/l10n', async (originalModule) => ({ + ...(await originalModule()), + getLanguage: () => 'en', + getCanonicalLocale: () => 'en-US', +})) + +describe('files_trashbin: file list columns', () => { + + describe('column: original location', () => { + it('has id set', () => { + expect(originalLocation.id).toBe('files_trashbin--original-location') + }) + + it('has title set', () => { + expect(originalLocation.title).toBe('Original location') + }) + + it('correctly sorts nodes by original location', () => { + const nodeA = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-original-location': 'z-folder/a.txt' } }) + const nodeB = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/b.txt', mime: 'text/plain', attributes: { 'trashbin-original-location': 'folder/b.txt' } }) + + expect(originalLocation.sort).toBeTypeOf('function') + expect(originalLocation.sort!(nodeA, nodeB)).toBeGreaterThan(0) + expect(originalLocation.sort!(nodeB, nodeA)).toBeLessThan(0) + }) + + it('renders a node with original location', () => { + const node = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-original-location': 'folder/a.txt' } }) + const el: HTMLElement = originalLocation.render(node, trashbinView) + expect(el).toBeInstanceOf(HTMLElement) + expect(el.textContent).toBe('folder') + expect(el.title).toBe('folder') + }) + + it('renders a node when original location is missing', () => { + const node = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain' }) + const el: HTMLElement = originalLocation.render(node, trashbinView) + expect(el).toBeInstanceOf(HTMLElement) + expect(el.textContent).toBe('Unknown') + expect(el.title).toBe('Unknown') + }) + + it('renders a node when original location is the root', () => { + const node = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-original-location': 'a.txt' } }) + const el: HTMLElement = originalLocation.render(node, trashbinView) + expect(el).toBeInstanceOf(HTMLElement) + expect(el.textContent).toBe('All files') + expect(el.title).toBe('All files') + }) + }) + + describe('column: deleted time', () => { + it('has id set', () => { + expect(deleted.id).toBe('files_trashbin--deleted') + }) + + it('has title set', () => { + expect(deleted.title).toBe('Deleted') + }) + + it('correctly sorts nodes by deleted time', () => { + const nodeA = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-deletion-time': 1741684522 } }) + const nodeB = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/b.txt', mime: 'text/plain', attributes: { 'trashbin-deletion-time': 1741684422 } }) + + expect(deleted.sort).toBeTypeOf('function') + expect(deleted.sort!(nodeA, nodeB)).toBeLessThan(0) + expect(deleted.sort!(nodeB, nodeA)).toBeGreaterThan(0) + }) + + it('correctly sorts nodes by deleted time and falls back to mtime', () => { + const nodeA = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-deletion-time': 1741684522 } }) + const nodeB = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/b.txt', mime: 'text/plain', mtime: new Date(1741684422000) }) + + expect(deleted.sort).toBeTypeOf('function') + expect(deleted.sort!(nodeA, nodeB)).toBeLessThan(0) + expect(deleted.sort!(nodeB, nodeA)).toBeGreaterThan(0) + }) + + it('correctly sorts nodes even if no deletion date is provided', () => { + const nodeA = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain' }) + const nodeB = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/b.txt', mime: 'text/plain', mtime: new Date(1741684422000) }) + + expect(deleted.sort).toBeTypeOf('function') + expect(deleted.sort!(nodeA, nodeB)).toBeGreaterThan(0) + expect(deleted.sort!(nodeB, nodeA)).toBeLessThan(0) + }) + + describe('rendering', () => { + afterAll(() => { + vi.useRealTimers() + }) + + beforeEach(() => { + vi.useFakeTimers({ now: 1741684582000 }) + }) + + it('renders a node with deletion date', () => { + const node = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-deletion-time': (Date.now() / 1000) - 120 } }) + const el: HTMLElement = deleted.render(node, trashbinView) + expect(el).toBeInstanceOf(HTMLElement) + expect(el.textContent).toBe('2 minutes ago') + expect(el.title).toBe('March 11, 2025 at 9:14 AM') + }) + + it('renders a node when deletion date is missing and falls back to mtime', () => { + const node = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', mtime: new Date(Date.now() - 60000) }) + const el: HTMLElement = deleted.render(node, trashbinView) + expect(el).toBeInstanceOf(HTMLElement) + expect(el.textContent).toBe('1 minute ago') + expect(el.title).toBe('March 11, 2025 at 9:15 AM') + }) + + it('renders a node when deletion date is missing', () => { + const node = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain' }) + const el: HTMLElement = deleted.render(node, trashbinView) + expect(el).toBeInstanceOf(HTMLElement) + expect(el.textContent).toBe('A long time ago') + }) + }) + + describe('column: deleted by', () => { + it('has id set', () => { + expect(deletedBy.id).toBe('files_trashbin--deleted-by') + }) + + it('has title set', () => { + expect(deletedBy.title).toBe('Deleted by') + }) + + it('correctly sorts nodes by user-id of deleting user', () => { + const nodeA = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-deleted-by-id': 'zzz' } }) + const nodeB = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/b.txt', mime: 'text/plain', attributes: { 'trashbin-deleted-by-id': 'aaa' } }) + + expect(deletedBy.sort).toBeTypeOf('function') + expect(deletedBy.sort!(nodeA, nodeB)).toBeGreaterThan(0) + expect(deletedBy.sort!(nodeB, nodeA)).toBeLessThan(0) + }) + + it('correctly sorts nodes by display name of deleting user', () => { + const nodeA = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-deleted-by-display-name': 'zzz' } }) + const nodeB = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/b.txt', mime: 'text/plain', attributes: { 'trashbin-deleted-by-display-name': 'aaa' } }) + + expect(deletedBy.sort).toBeTypeOf('function') + expect(deletedBy.sort!(nodeA, nodeB)).toBeGreaterThan(0) + expect(deletedBy.sort!(nodeB, nodeA)).toBeLessThan(0) + }) + + it('correctly sorts nodes by display name of deleting user before user id', () => { + const nodeA = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-deleted-by-display-name': '000', 'trashbin-deleted-by-id': 'zzz' } }) + const nodeB = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/b.txt', mime: 'text/plain', attributes: { 'trashbin-deleted-by-display-name': 'aaa', 'trashbin-deleted-by-id': '999' } }) + + expect(deletedBy.sort).toBeTypeOf('function') + expect(deletedBy.sort!(nodeA, nodeB)).toBeLessThan(0) + expect(deletedBy.sort!(nodeB, nodeA)).toBeGreaterThan(0) + }) + + it('correctly sorts nodes even when one is missing', () => { + const nodeA = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-deleted-by-id': 'aaa' } }) + const nodeB = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-deleted-by-id': 'zzz' } }) + const nodeC = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/b.txt', mime: 'text/plain' }) + + expect(deletedBy.sort).toBeTypeOf('function') + // aaa is less then "Unknown" + expect(deletedBy.sort!(nodeA, nodeC)).toBeLessThan(0) + // zzz is greater than "Unknown" + expect(deletedBy.sort!(nodeB, nodeC)).toBeGreaterThan(0) + }) + + it('renders a node with deleting user', () => { + const node = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-deleted-by-id': 'user-id' } }) + const el: HTMLElement = deletedBy.render(node, trashbinView) + expect(el).toBeInstanceOf(HTMLElement) + expect(el.textContent).toMatch(/\suser-id\s/) + }) + + it('renders a node with deleting user display name', () => { + const node = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-deleted-by-display-name': 'user-name', 'trashbin-deleted-by-id': 'user-id' } }) + const el: HTMLElement = deletedBy.render(node, trashbinView) + expect(el).toBeInstanceOf(HTMLElement) + expect(el.textContent).toMatch(/\suser-name\s/) + }) + + it('renders a node even when information is missing', () => { + const node = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain' }) + const el: HTMLElement = deletedBy.render(node, trashbinView) + expect(el).toBeInstanceOf(HTMLElement) + expect(el.textContent).toBe('Unknown') + }) + + it('renders a node when current user is the deleting user', () => { + vi.spyOn(ncAuth, 'getCurrentUser').mockImplementationOnce(() => ({ + uid: 'user-id', + displayName: 'user-display-name', + isAdmin: false, + })) + + const node = new File({ owner: 'test', source: 'https://example.com/remote.php/dav/files/test/a.txt', mime: 'text/plain', attributes: { 'trashbin-deleted-by-id': 'user-id' } }) + const el: HTMLElement = deletedBy.render(node, trashbinView) + expect(el).toBeInstanceOf(HTMLElement) + expect(el.textContent).toBe('You') + }) + }) + + }) + +}) diff --git a/apps/files_trashbin/src/files_views/columns.ts b/apps/files_trashbin/src/files_views/columns.ts new file mode 100644 index 00000000000..085d22c67a6 --- /dev/null +++ b/apps/files_trashbin/src/files_views/columns.ts @@ -0,0 +1,144 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { getCurrentUser } from '@nextcloud/auth' +import { Column, Node } from '@nextcloud/files' +import { formatRelativeTime, getCanonicalLocale, getLanguage, t } from '@nextcloud/l10n' +import { dirname } from '@nextcloud/paths' + +import Vue from 'vue' +import NcUserBubble from '@nextcloud/vue/components/NcUserBubble' + +export const originalLocation = new Column({ + id: 'files_trashbin--original-location', + title: t('files_trashbin', 'Original location'), + render(node) { + const originalLocation = parseOriginalLocation(node) + const span = document.createElement('span') + span.title = originalLocation + span.textContent = originalLocation + return span + }, + sort(nodeA, nodeB) { + const locationA = parseOriginalLocation(nodeA) + const locationB = parseOriginalLocation(nodeB) + return locationA.localeCompare(locationB, [getLanguage(), getCanonicalLocale()], { numeric: true, usage: 'sort' }) + }, +}) + +export const deletedBy = new Column({ + id: 'files_trashbin--deleted-by', + title: t('files_trashbin', 'Deleted by'), + render(node) { + const { userId, displayName, label } = parseDeletedBy(node) + if (label) { + const span = document.createElement('span') + span.textContent = label + return span + } + + const UserBubble = Vue.extend(NcUserBubble) + const propsData = { + size: 32, + user: userId ?? undefined, + displayName: displayName ?? userId, + } + const userBubble = new UserBubble({ propsData }).$mount().$el + return userBubble as HTMLElement + }, + sort(nodeA, nodeB) { + const deletedByA = parseDeletedBy(nodeA) + const deletedbyALabel = deletedByA.label ?? deletedByA.displayName ?? deletedByA.userId + const deletedByB = parseDeletedBy(nodeB) + const deletedByBLabel = deletedByB.label ?? deletedByB.displayName ?? deletedByB.userId + // label is set if uid and display name are unset - if label is unset at least uid or display name is set. + return deletedbyALabel!.localeCompare(deletedByBLabel!, [getLanguage(), getCanonicalLocale()], { numeric: true, usage: 'sort' }) + }, +}) + +export const deleted = new Column({ + id: 'files_trashbin--deleted', + title: t('files_trashbin', 'Deleted'), + + render(node) { + const deletionTime = node.attributes?.['trashbin-deletion-time'] || ((node?.mtime?.getTime() ?? 0) / 1000) + const span = document.createElement('span') + if (deletionTime) { + const formatter = Intl.DateTimeFormat([getCanonicalLocale()], { dateStyle: 'long', timeStyle: 'short' }) + const timestamp = new Date(deletionTime * 1000) + + span.title = formatter.format(timestamp) + span.textContent = formatRelativeTime(timestamp, { ignoreSeconds: t('files', 'few seconds ago') }) + return span + } + + // Unknown deletion time + span.textContent = t('files_trashbin', 'A long time ago') + return span + }, + + sort(nodeA, nodeB) { + // deletion time is a unix timestamp while mtime is a JS Date -> we need to align the numbers (seconds vs milliseconds) + const deletionTimeA = nodeA.attributes?.['trashbin-deletion-time'] || ((nodeA?.mtime?.getTime() ?? 0) / 1000) + const deletionTimeB = nodeB.attributes?.['trashbin-deletion-time'] || ((nodeB?.mtime?.getTime() ?? 0) / 1000) + return deletionTimeB - deletionTimeA + }, +}) + +/** + * Get the original file location of a trashbin file. + * + * @param node The node to parse + */ +function parseOriginalLocation(node: Node): string { + const path = stringOrNull(node.attributes?.['trashbin-original-location']) + if (!path) { + return t('files_trashbin', 'Unknown') + } + + const dir = dirname(path) + if (dir === path) { // Node is in root folder + return t('files_trashbin', 'All files') + } + + return dir.replace(/^\//, '') +} + +/** + * Parse a trashbin file to get information about the user that deleted the file. + * + * @param node The node to parse + */ +function parseDeletedBy(node: Node) { + const userId = stringOrNull(node.attributes?.['trashbin-deleted-by-id']) + const displayName = stringOrNull(node.attributes?.['trashbin-deleted-by-display-name']) + + let label: string|undefined + const currentUserId = getCurrentUser()?.uid + if (userId === currentUserId) { + label = t('files_trashbin', 'You') + } + if (!userId && !displayName) { + label = t('files_trashbin', 'Unknown') + } + + return { + userId, + displayName, + label, + } +} + +/** + * If the attribute is given it will be stringified and returned - otherwise null is returned. + * + * @param attribute The attribute to check + */ +function stringOrNull(attribute: unknown): string | null { + if (attribute) { + return String(attribute) + } + return null +} diff --git a/apps/files_trashbin/src/files_views/trashbinView.spec.ts b/apps/files_trashbin/src/files_views/trashbinView.spec.ts new file mode 100644 index 00000000000..7f5a45ee9cd --- /dev/null +++ b/apps/files_trashbin/src/files_views/trashbinView.spec.ts @@ -0,0 +1,52 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { describe, expect, it } from 'vitest' +import isSvg from 'is-svg' + +import { deleted, deletedBy, originalLocation } from './columns' +import { TRASHBIN_VIEW_ID, trashbinView } from './trashbinView.ts' +import { getContents } from '../services/trashbin.ts' + +describe('files_trasbin: trashbin files view', () => { + it('has correct strings', () => { + expect(trashbinView.id).toBe(TRASHBIN_VIEW_ID) + expect(trashbinView.name).toBe('Deleted files') + expect(trashbinView.caption).toBe('List of files that have been deleted.') + expect(trashbinView.emptyTitle).toBe('No deleted files') + expect(trashbinView.emptyCaption).toBe('Files and folders you have deleted will show up here') + }) + + it('sorts by deleted time', () => { + expect(trashbinView.defaultSortKey).toBe('deleted') + }) + + it('is sticky to the bottom in the view list', () => { + expect(trashbinView.sticky).toBe(true) + }) + + it('has order defined', () => { + expect(trashbinView.order).toBeTypeOf('number') + expect(trashbinView.order).toBe(50) + }) + + it('has valid icon', () => { + expect(trashbinView.icon).toBeTypeOf('string') + expect(isSvg(trashbinView.icon)).toBe(true) + }) + + it('has custom columns', () => { + expect(trashbinView.columns).toHaveLength(3) + expect(trashbinView.columns).toEqual([ + originalLocation, + deletedBy, + deleted, + ]) + }) + + it('has get content method', () => { + expect(trashbinView.getContents).toBeTypeOf('function') + expect(trashbinView.getContents).toBe(getContents) + }) +}) diff --git a/apps/files_trashbin/src/files_views/trashbinView.ts b/apps/files_trashbin/src/files_views/trashbinView.ts new file mode 100644 index 00000000000..f55c6b71595 --- /dev/null +++ b/apps/files_trashbin/src/files_views/trashbinView.ts @@ -0,0 +1,35 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { View } from '@nextcloud/files' +import { t } from '@nextcloud/l10n' +import { deleted, deletedBy, originalLocation } from './columns.ts' +import { getContents } from '../services/trashbin.ts' + +import svgDelete from '@mdi/svg/svg/delete-outline.svg?raw' + +export const TRASHBIN_VIEW_ID = 'trashbin' + +export const trashbinView = new View({ + id: TRASHBIN_VIEW_ID, + name: t('files_trashbin', 'Deleted files'), + caption: t('files_trashbin', 'List of files that have been deleted.'), + + emptyTitle: t('files_trashbin', 'No deleted files'), + emptyCaption: t('files_trashbin', 'Files and folders you have deleted will show up here'), + + icon: svgDelete, + order: 50, + sticky: true, + + defaultSortKey: 'deleted', + + columns: [ + originalLocation, + deletedBy, + deleted, + ], + + getContents, +}) diff --git a/apps/files_trashbin/src/logger.spec.ts b/apps/files_trashbin/src/logger.spec.ts new file mode 100644 index 00000000000..5558419ba9d --- /dev/null +++ b/apps/files_trashbin/src/logger.spec.ts @@ -0,0 +1,20 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it, vi } from 'vitest' +import { logger } from './logger.ts' + +describe('files_trashbin: logger', () => { + // Rest of the logger is not under our responsibility but nextcloud-logger + it('has correct app name set up', () => { + const consoleSpy = vi.spyOn(globalThis.console, 'error').mockImplementationOnce(() => {}) + + logger.error('<message>') + expect(consoleSpy).toBeCalledTimes(1) + expect(consoleSpy.mock.calls[0][0]).toContain('<message>') + expect(consoleSpy.mock.calls[0][0]).toContain('files_trashbin') + expect(consoleSpy.mock.calls[0][1].app).toBe('files_trashbin') + }) +}) diff --git a/apps/files_trashbin/src/logger.ts b/apps/files_trashbin/src/logger.ts new file mode 100644 index 00000000000..064351c2fb5 --- /dev/null +++ b/apps/files_trashbin/src/logger.ts @@ -0,0 +1,11 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { getLoggerBuilder } from '@nextcloud/logger' + +export const logger = getLoggerBuilder() + .setApp('files_trashbin') + .detectUser() + .build() diff --git a/apps/files_trashbin/src/services/api.spec.ts b/apps/files_trashbin/src/services/api.spec.ts new file mode 100644 index 00000000000..b50a53b8e07 --- /dev/null +++ b/apps/files_trashbin/src/services/api.spec.ts @@ -0,0 +1,43 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { emptyTrash } from './api.ts' +import * as ncAuth from '@nextcloud/auth' +import * as ncDialogs from '@nextcloud/dialogs' +import * as logger from '../logger.ts' + +const axiosMock = vi.hoisted(() => ({ + delete: vi.fn(), +})) +vi.mock('@nextcloud/axios', () => ({ default: axiosMock })) + +describe('files_trashbin: API - emptyTrash', () => { + beforeEach(() => { + vi.spyOn(ncAuth, 'getCurrentUser').mockImplementationOnce(() => ({ + uid: 'test', + displayName: 'Test', + isAdmin: false, + })) + }) + + it('shows success', async () => { + const dialogSpy = vi.spyOn(ncDialogs, 'showSuccess') + expect(await emptyTrash()).toBe(true) + expect(axiosMock.delete).toBeCalled() + expect(dialogSpy).toBeCalledWith('All files have been permanently deleted') + }) + + it('shows failure', async () => { + axiosMock.delete.mockImplementationOnce(() => { throw new Error() }) + const dialogSpy = vi.spyOn(ncDialogs, 'showError') + const loggerSpy = vi.spyOn(logger.logger, 'error').mockImplementationOnce(() => {}) + + expect(await emptyTrash()).toBe(false) + expect(axiosMock.delete).toBeCalled() + expect(dialogSpy).toBeCalledWith('Failed to empty deleted files') + expect(loggerSpy).toBeCalled() + }) +}) diff --git a/apps/files_trashbin/src/services/api.ts b/apps/files_trashbin/src/services/api.ts new file mode 100644 index 00000000000..b1f2e98b2d9 --- /dev/null +++ b/apps/files_trashbin/src/services/api.ts @@ -0,0 +1,28 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { getCurrentUser } from '@nextcloud/auth' +import { showError, showSuccess } from '@nextcloud/dialogs' +import { defaultRemoteURL } from '@nextcloud/files/dav' +import { t } from '@nextcloud/l10n' +import axios from '@nextcloud/axios' + +import { logger } from '../logger.ts' + +/** + * Send API request to empty the trashbin. + * Returns true if request succeeded - otherwise false is returned. + */ +export async function emptyTrash(): Promise<boolean> { + try { + await axios.delete(`${defaultRemoteURL}/trashbin/${getCurrentUser()!.uid}/trash`) + showSuccess(t('files_trashbin', 'All files have been permanently deleted')) + return true + } catch (error) { + showError(t('files_trashbin', 'Failed to empty deleted files')) + logger.error('Failed to empty deleted files', { error }) + return false + } +} diff --git a/apps/files_trashbin/src/services/client.ts b/apps/files_trashbin/src/services/client.ts new file mode 100644 index 00000000000..5ee25a6a94f --- /dev/null +++ b/apps/files_trashbin/src/services/client.ts @@ -0,0 +1,12 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { getCurrentUser } from '@nextcloud/auth' +import { davGetClient } from '@nextcloud/files' + +// init webdav client +export const rootPath = `/trashbin/${getCurrentUser()?.uid}/trash` + +export const client = davGetClient() diff --git a/apps/files_trashbin/src/services/trashbin.ts b/apps/files_trashbin/src/services/trashbin.ts new file mode 100644 index 00000000000..9fef16d032f --- /dev/null +++ b/apps/files_trashbin/src/services/trashbin.ts @@ -0,0 +1,44 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { FileStat, ResponseDataDetailed } from 'webdav' +import type { ContentsWithRoot } from '@nextcloud/files' + +import { File, Folder, davResultToNode, getDavNameSpaces, getDavProperties } from '@nextcloud/files' +import { client, rootPath } from './client' +import { generateUrl } from '@nextcloud/router' + +const data = `<?xml version="1.0"?> +<d:propfind ${getDavNameSpaces()}> + <d:prop> + <nc:trashbin-deletion-time /> + <nc:trashbin-original-location /> + <nc:trashbin-title /> + <nc:trashbin-deleted-by-id /> + <nc:trashbin-deleted-by-display-name /> + ${getDavProperties()} + </d:prop> +</d:propfind>` + +const resultToNode = (stat: FileStat): File | Folder => { + const node = davResultToNode(stat, rootPath) + node.attributes.previewUrl = generateUrl('/apps/files_trashbin/preview?fileId={fileid}&x=32&y=32', { fileid: node.fileid }) + return node +} + +export const getContents = async (path = '/'): Promise<ContentsWithRoot> => { + const contentsResponse = await client.getDirectoryContents(`${rootPath}${path}`, { + details: true, + data, + includeSelf: true, + }) as ResponseDataDetailed<FileStat[]> + + const contents = contentsResponse.data.map(resultToNode) + const [folder] = contents.splice(contents.findIndex((node) => node.path === path), 1) + + return { + folder: folder as Folder, + contents, + } +} diff --git a/apps/files_trashbin/src/trash.scss b/apps/files_trashbin/src/trash.scss deleted file mode 100644 index 633107c9d6d..00000000000 --- a/apps/files_trashbin/src/trash.scss +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright (c) 2014 - * - * This file is licensed under the Affero General Public License version 3 - * or later. - * - * See the COPYING-README file. - * - */ -#app-content-trashbin tbody tr[data-type="file"] td a.name, -#app-content-trashbin tbody tr[data-type="file"] td a.name span.nametext, -#app-content-trashbin tbody tr[data-type="file"] td a.name span.nametext span { - cursor: default; -} - -#app-content-trashbin .summary :last-child { - padding: 0; -} -#app-content-trashbin .files-filestable .summary .filesize { - display: none; -} - diff --git a/apps/files_trashbin/src/trashbin.scss b/apps/files_trashbin/src/trashbin.scss new file mode 100644 index 00000000000..d0e48e52278 --- /dev/null +++ b/apps/files_trashbin/src/trashbin.scss @@ -0,0 +1,7 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +.files-list__row-trashbin-original-location { + width: 150px !important; +} |