diff options
Diffstat (limited to 'apps/files_trashbin/src')
18 files changed, 1065 insertions, 244 deletions
diff --git a/apps/files_trashbin/src/actions/restoreAction.ts b/apps/files_trashbin/src/actions/restoreAction.ts deleted file mode 100644 index e03573a75a6..00000000000 --- a/apps/files_trashbin/src/actions/restoreAction.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.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 { emit } from '@nextcloud/event-bus' -import { generateRemoteUrl } from '@nextcloud/router' -import { getCurrentUser } from '@nextcloud/auth' -import { Permission, Node, View, registerFileAction, FileAction } 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' - -registerFileAction(new FileAction({ - id: 'restore', - displayName() { - return t('files_trashbin', 'Restore') - }, - iconSvgInline: () => History, - - enabled(nodes: Node[], view) { - // Only available in the trashbin view - if (view.id !== 'trashbin') { - return false - } - - // Only available if all nodes have read permission - return nodes.length > 0 && nodes - .map(node => node.permissions) - .every(permission => (permission & Permission.READ) !== 0) - }, - - 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 - } - }, - 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-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_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/main.ts b/apps/files_trashbin/src/main.ts deleted file mode 100644 index 5c625e7bf4f..00000000000 --- a/apps/files_trashbin/src/main.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.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 { translate as t, translate } from '@nextcloud/l10n' -import DeleteSvg from '@mdi/svg/svg/delete.svg?raw' -import moment from '@nextcloud/moment' - -import { getContents } from './services/trashbin' - -// Register restore action -import './actions/restoreAction' -import { Column, View, getNavigation } from '@nextcloud/files' - -const Navigation = getNavigation() -Navigation.register(new View({ - id: 'trashbin', - 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: DeleteSvg, - order: 50, - sticky: true, - - defaultSortKey: 'deleted', - - columns: [ - new Column({ - id: 'deleted', - title: t('files_trashbin', 'Deleted'), - render(node) { - const deletionTime = node.attributes?.['trashbin-deletion-time'] - const span = document.createElement('span') - if (deletionTime) { - span.title = moment.unix(deletionTime).format('LLL') - span.textContent = moment.unix(deletionTime).fromNow() - return span - } - - // Unknown deletion time - span.textContent = translate('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 - }, - }), - ], - - getContents, -})) 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 index 9fb3361839a..5ee25a6a94f 100644 --- a/apps/files_trashbin/src/services/client.ts +++ b/apps/files_trashbin/src/services/client.ts @@ -1,33 +1,12 @@ /** - * @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.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/>. - * + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { createClient } from 'webdav' -import { generateRemoteUrl } from '@nextcloud/router' -import { getCurrentUser, getRequestToken } from '@nextcloud/auth' +import { getCurrentUser } from '@nextcloud/auth' +import { davGetClient } from '@nextcloud/files' + +// init webdav client export const rootPath = `/trashbin/${getCurrentUser()?.uid}/trash` -export const rootUrl = generateRemoteUrl('dav' + rootPath) -const client = createClient(rootUrl, { - headers: { - requesttoken: getRequestToken(), - }, -}) -export default client + +export const client = davGetClient() diff --git a/apps/files_trashbin/src/services/trashbin.ts b/apps/files_trashbin/src/services/trashbin.ts index 9aef75ef6d5..9fef16d032f 100644 --- a/apps/files_trashbin/src/services/trashbin.ts +++ b/apps/files_trashbin/src/services/trashbin.ts @@ -1,90 +1,44 @@ /** - * @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.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/>. - * + * 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, davParsePermissions, getDavNameSpaces, getDavProperties } from '@nextcloud/files' -import { generateRemoteUrl, generateUrl } from '@nextcloud/router' -import { getCurrentUser } from '@nextcloud/auth' - -import client, { rootPath } from './client' +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-filename /> <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 = function(node: FileStat): File | Folder { - const permissions = davParsePermissions(node.props?.permissions) - const owner = getCurrentUser()?.uid as string - const previewUrl = generateUrl('/apps/files_trashbin/preview?fileId={fileid}&x=32&y=32', node.props) - - const nodeData = { - id: node.props?.fileid as number || 0, - source: generateRemoteUrl('dav' + rootPath + node.filename), - // do not show the mtime column - // mtime: new Date(node.lastmod), - mime: node.mime as string, - size: node.props?.size as number || 0, - permissions, - owner, - root: rootPath, - attributes: { - ...node, - ...node.props, - // Override displayed name on the list - displayName: node.props?.['trashbin-filename'], - previewUrl, - }, - } - - delete nodeData.attributes.props - - return node.type === 'file' - ? new File(nodeData) - : new Folder(nodeData) +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> => { - // TODO: use only one request when webdav-client supports it - // @see https://github.com/perry-mitchell/webdav-client/pull/334 - const rootResponse = await client.stat(path, { - details: true, - data, - }) as ResponseDataDetailed<FileStat> - - const contentsResponse = await client.getDirectoryContents(path, { + 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: resultToNode(rootResponse.data) as Folder, - contents: contentsResponse.data.map(resultToNode), + folder: folder as Folder, + contents, } } 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; +} |