diff options
-rw-r--r-- | apps/files/lib/Controller/ViewController.php | 1 | ||||
-rw-r--r-- | apps/files/src/actions/sidebarAction.ts | 2 | ||||
-rw-r--r-- | apps/files/src/main.ts | 4 | ||||
-rw-r--r-- | apps/files/src/services/DavProperties.ts | 128 | ||||
-rw-r--r-- | apps/files/src/services/Favorites.ts | 100 | ||||
-rw-r--r-- | apps/files/src/services/WebdavClient.ts | 51 | ||||
-rw-r--r-- | apps/files/src/views/favorites.ts | 114 | ||||
-rw-r--r-- | apps/files_trashbin/lib/Controller/PreviewController.php | 2 | ||||
-rw-r--r-- | apps/files_trashbin/src/services/trashbin.ts | 16 | ||||
-rw-r--r-- | apps/files_trashbin/tests/Controller/PreviewControllerTest.php | 2 |
10 files changed, 406 insertions, 14 deletions
diff --git a/apps/files/lib/Controller/ViewController.php b/apps/files/lib/Controller/ViewController.php index 70e0fd48456..149ce242502 100644 --- a/apps/files/lib/Controller/ViewController.php +++ b/apps/files/lib/Controller/ViewController.php @@ -253,6 +253,7 @@ class ViewController extends Controller { $this->initialState->provideInitialState('navigation', $navItems); $this->initialState->provideInitialState('config', $this->userConfig->getConfigs()); $this->initialState->provideInitialState('viewConfigs', $this->viewConfig->getConfigs()); + $this->initialState->provideInitialState('favoriteFolders', $favElements['folders'] ?? []); // File sorting user config $filesSortingConfig = json_decode($this->config->getUserValue($userId, 'files', 'files_sorting_configs', '{}'), true); diff --git a/apps/files/src/actions/sidebarAction.ts b/apps/files/src/actions/sidebarAction.ts index 4766d2e90df..09a18480790 100644 --- a/apps/files/src/actions/sidebarAction.ts +++ b/apps/files/src/actions/sidebarAction.ts @@ -30,7 +30,7 @@ export const ACTION_DETAILS = 'details' export const action = new FileAction({ id: ACTION_DETAILS, - displayName: () => t('files', 'Details'), + displayName: () => t('files', 'Open details'), iconSvgInline: () => InformationSvg, // Sidebar currently supports user folder only, /files/USER diff --git a/apps/files/src/main.ts b/apps/files/src/main.ts index 1d96c2f6eaa..be04f559372 100644 --- a/apps/files/src/main.ts +++ b/apps/files/src/main.ts @@ -1,6 +1,8 @@ import './templates.js' import './legacy/filelistSearch.js' + import './actions/deleteAction' +import './actions/favoriteAction' import './actions/openFolderAction' import './actions/sidebarAction' @@ -11,6 +13,7 @@ import FilesListView from './views/FilesList.vue' import NavigationService from './services/Navigation' import NavigationView from './views/Navigation.vue' import processLegacyFilesViews from './legacy/navigationMapper.js' +import registerFavoritesView from './views/favorites' import registerPreviewServiceWorker from './services/ServiceWorker.js' import router from './router/router.js' import RouterService from './services/RouterService' @@ -70,6 +73,7 @@ FilesList.$mount('#app-content-vue') // Init legacy and new files views processLegacyFilesViews() +registerFavoritesView() // Register preview service worker registerPreviewServiceWorker() diff --git a/apps/files/src/services/DavProperties.ts b/apps/files/src/services/DavProperties.ts new file mode 100644 index 00000000000..598807511ca --- /dev/null +++ b/apps/files/src/services/DavProperties.ts @@ -0,0 +1,128 @@ +/** + * @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 logger from '../logger' + +type DavProperty = { [key: string]: string } + +declare global { + interface Window { + OC: any; + _nc_dav_properties: string[]; + _nc_dav_namespaces: DavProperty; + } +} + +const defaultDavProperties = [ + 'd:getcontentlength', + 'd:getcontenttype', + 'd:getetag', + 'd:getlastmodified', + 'd:quota-available-bytes', + 'd:resourcetype', + 'nc:has-preview', + 'nc:is-encrypted', + 'nc:mount-type', + 'nc:share-attributes', + 'oc:comments-unread', + 'oc:favorite', + 'oc:fileid', + 'oc:owner-display-name', + 'oc:owner-id', + 'oc:permissions', + 'oc:share-types', + 'oc:size', + 'ocs:share-permissions', +] + +const defaultDavNamespaces = { + d: 'DAV:', + nc: 'http://nextcloud.org/ns', + oc: 'http://owncloud.org/ns', + ocs: 'http://open-collaboration-services.org/ns', +} + +/** + * TODO: remove and move to @nextcloud/files + */ +export const registerDavProperty = function(prop: string, namespace: DavProperty = { nc: 'http://nextcloud.org/ns' }): void { + if (typeof window._nc_dav_properties === 'undefined') { + window._nc_dav_properties = defaultDavProperties + window._nc_dav_namespaces = defaultDavNamespaces + } + + const namespaces = { ...window._nc_dav_namespaces, ...namespace } + + // Check duplicates + if (window._nc_dav_properties.find(search => search === prop)) { + logger.error(`${prop} already registered`, { prop }) + return + } + + if (prop.startsWith('<') || prop.split(':').length !== 2) { + logger.error(`${prop} is not valid. See example: 'oc:fileid'`, { prop }) + return + } + + const ns = prop.split(':')[0] + if (!namespaces[ns]) { + logger.error(`${prop} namespace unknown`, { prop, namespaces }) + return + } + + window._nc_dav_properties.push(prop) + window._nc_dav_namespaces = namespaces +} + +/** + * Get the registered dav properties + */ +export const getDavProperties = function(): string { + if (typeof window._nc_dav_properties === 'undefined') { + window._nc_dav_properties = defaultDavProperties + } + + return window._nc_dav_properties.map(prop => `<${prop} />`).join(' ') +} + +/** + * Get the registered dav namespaces + */ +export const getDavNameSpaces = function(): string { + if (typeof window._nc_dav_namespaces === 'undefined') { + window._nc_dav_namespaces = defaultDavNamespaces + } + + return Object.keys(window._nc_dav_namespaces).map(ns => `xmlns:${ns}="${window._nc_dav_namespaces[ns]}"`).join(' ') +} + +/** + * Get the default PROPFIND request payload + */ +export const getDefaultPropfind = function() { + return `<?xml version="1.0"?> + <d:propfind ${getDavNameSpaces()}> + <d:prop> + ${getDavProperties()} + </d:prop> + </d:propfind>` +} diff --git a/apps/files/src/services/Favorites.ts b/apps/files/src/services/Favorites.ts new file mode 100644 index 00000000000..3837bb221b5 --- /dev/null +++ b/apps/files/src/services/Favorites.ts @@ -0,0 +1,100 @@ +/** + * @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 { File, Folder, parseWebdavPermissions } from '@nextcloud/files' +import { generateRemoteUrl, generateUrl } from '@nextcloud/router' +import { getClient, rootPath } from './WebdavClient' +import { getCurrentUser } from '@nextcloud/auth' +import { getDavNameSpaces, getDavProperties, getDefaultPropfind } from './DavProperties' +import type { ContentsWithRoot } from './Navigation' +import type { FileStat, ResponseDataDetailed } from 'webdav' + +const client = getClient() + +const reportPayload = `<?xml version="1.0"?> +<oc:filter-files ${getDavNameSpaces()}> + <d:prop> + ${getDavProperties()} + </d:prop> + <oc:filter-rules> + <oc:favorite>1</oc:favorite> + </oc:filter-rules> +</oc:filter-files>` + +const resultToNode = function(node: FileStat): File | Folder { + const permissions = parseWebdavPermissions(node.props?.permissions) + const owner = getCurrentUser()?.uid as string + const previewUrl = generateUrl('/core/preview?fileId={fileid}&x=32&y=32&forceIcon=0', node.props) + + const nodeData = { + id: node.props?.fileid as number || 0, + source: generateRemoteUrl('dav' + rootPath + node.filename), + 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, + previewUrl, + }, + } + + delete nodeData.attributes.props + + return node.type === 'file' + ? new File(nodeData) + : new Folder(nodeData) +} + +export const getContents = async (path = '/'): Promise<ContentsWithRoot> => { + const propfindPayload = getDefaultPropfind() + + // Get root folder + let rootResponse + if (path === '/') { + rootResponse = await client.stat(path, { + details: true, + data: getDefaultPropfind(), + }) as ResponseDataDetailed<FileStat> + } + + const contentsResponse = await client.getDirectoryContents(path, { + details: true, + // Only filter favorites if we're at the root + data: path === '/' ? reportPayload : propfindPayload, + headers: { + // Patched in WebdavClient.ts + method: path === '/' ? 'REPORT' : 'PROPFIND', + }, + includeSelf: true, + }) as ResponseDataDetailed<FileStat[]> + + const root = rootResponse?.data || contentsResponse.data[0] + const contents = contentsResponse.data.filter(node => node.filename !== path) + + return { + folder: resultToNode(root) as Folder, + contents: contents.map(resultToNode), + } +} diff --git a/apps/files/src/services/WebdavClient.ts b/apps/files/src/services/WebdavClient.ts new file mode 100644 index 00000000000..0630e695afa --- /dev/null +++ b/apps/files/src/services/WebdavClient.ts @@ -0,0 +1,51 @@ +/** + * @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 { createClient, getPatcher, RequestOptions } from 'webdav' +import { request } from '../../../../node_modules/webdav/dist/node/request.js' +import { generateRemoteUrl } from '@nextcloud/router' +import { getCurrentUser, getRequestToken } from '@nextcloud/auth' + +export const rootPath = `/files/${getCurrentUser()?.uid}` +export const defaultRootUrl = generateRemoteUrl('dav' + rootPath) + +export const getClient = (rootUrl = defaultRootUrl) => { + const client = createClient(rootUrl, { + headers: { + requesttoken: getRequestToken() || '', + }, + }) + + /** + * Allow to override the METHOD to support dav REPORT + * + * @see https://github.com/perry-mitchell/webdav-client/blob/8d9694613c978ce7404e26a401c39a41f125f87f/source/request.ts + */ + const patcher = getPatcher() + patcher.patch('request', (options: RequestOptions) => { + if (options.headers?.method) { + options.method = options.headers.method + delete options.headers.method + } + return request(options) + }) + return client +} diff --git a/apps/files/src/views/favorites.ts b/apps/files/src/views/favorites.ts new file mode 100644 index 00000000000..731fbe14db7 --- /dev/null +++ b/apps/files/src/views/favorites.ts @@ -0,0 +1,114 @@ +/** + * @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 type NavigationService from '../services/Navigation.ts' +import type { Navigation } from '../services/Navigation.ts' +import { translate as t } from '@nextcloud/l10n' +import StarSvg from '@mdi/svg/svg/star.svg?raw' +import FolderSvg from '@mdi/svg/svg/folder.svg?raw' + +import { getContents } from '../services/Favorites.ts' +import { loadState } from '@nextcloud/initial-state' +import { basename } from 'path' +import { hashCode } from '../utils/hashUtils' +import { subscribe } from '@nextcloud/event-bus' +import { Node, FileType } from '@nextcloud/files' +import logger from '../logger' + +const favoriteFolders = loadState('files', 'favoriteFolders', []) + +export default () => { + const Navigation = window.OCP.Files.Navigation as NavigationService + Navigation.register({ + id: 'favorites', + name: t('files', 'Favorites'), + caption: t('files', 'List of favorites files and folders.'), + + icon: StarSvg, + order: 5, + + columns: [], + + getContents, + } as Navigation) + + favoriteFolders.forEach((folder) => { + Navigation.register(generateFolderView(folder)) + }) + + /** + * Update favourites navigation when a new folder is added + */ + subscribe('files:favorites:added', (node: Node) => { + if (node.type !== FileType.Folder) { + return + } + + // Sanity check + if (node.path === null || !node.root?.startsWith('/files')) { + logger.error('Favorite folder is not within user files root', { node }) + return + } + + Navigation.register(generateFolderView(node.path)) + }) + + /** + * Remove favourites navigation when a folder is removed + */ + subscribe('files:favorites:removed', (node: Node) => { + if (node.type !== FileType.Folder) { + return + } + + // Sanity check + if (node.path === null || !node.root?.startsWith('/files')) { + logger.error('Favorite folder is not within user files root', { node }) + return + } + + Navigation.remove(generateIdFromPath(node.path)) + }) +} + +const generateFolderView = function(folder: string): Navigation { + return { + id: generateIdFromPath(folder), + name: basename(folder), + + icon: FolderSvg, + order: -100, // always first + params: { + dir: folder, + view: 'favorites', + }, + + parent: 'favorites', + + columns: [], + + getContents, + } as Navigation +} + +const generateIdFromPath = function(path: string): string { + return `favorite-${hashCode(path)}` +} diff --git a/apps/files_trashbin/lib/Controller/PreviewController.php b/apps/files_trashbin/lib/Controller/PreviewController.php index 652570dccd7..9f60cc8b0b2 100644 --- a/apps/files_trashbin/lib/Controller/PreviewController.php +++ b/apps/files_trashbin/lib/Controller/PreviewController.php @@ -119,7 +119,7 @@ class PreviewController extends Controller { $mimeType = $this->mimeTypeDetector->detectPath($file->getName()); } - $f = $this->previewManager->getPreview($file, $x, $y, $a, IPreview::MODE_FILL, $mimeType); + $f = $this->previewManager->getPreview($file, $x, $y, !$a, IPreview::MODE_FILL, $mimeType); $response = new Http\FileDisplayResponse($f, Http::STATUS_OK, ['Content-Type' => $f->getMimeType()]); // Cache previews for 24H diff --git a/apps/files_trashbin/src/services/trashbin.ts b/apps/files_trashbin/src/services/trashbin.ts index 9982750ba5c..ab48863317a 100644 --- a/apps/files_trashbin/src/services/trashbin.ts +++ b/apps/files_trashbin/src/services/trashbin.ts @@ -25,27 +25,19 @@ import { File, Folder, parseWebdavPermissions } from '@nextcloud/files' import { generateRemoteUrl, generateUrl } from '@nextcloud/router' import type { FileStat, ResponseDataDetailed } from 'webdav' +import { getDavNameSpaces, getDavProperties } from '../../../files/src/services/DavProperties' import type { ContentsWithRoot } from '../../../files/src/services/Navigation.ts' import client, { rootPath } from './client' const data = `<?xml version="1.0"?> -<d:propfind xmlns:d="DAV:" - xmlns:oc="http://owncloud.org/ns" - xmlns:nc="http://nextcloud.org/ns"> +<d:propfind ${getDavNameSpaces()}> <d:prop> <nc:trashbin-filename /> <nc:trashbin-deletion-time /> <nc:trashbin-original-location /> <nc:trashbin-title /> - <d:getlastmodified /> - <d:getetag /> - <d:getcontenttype /> - <d:resourcetype /> - <oc:fileid /> - <oc:permissions /> - <oc:size /> - <d:getcontentlength /> + ${getDavProperties()} </d:prop> </d:propfind>` @@ -73,6 +65,8 @@ const resultToNode = function(node: FileStat): File | Folder { }, } + delete nodeData.attributes.props + return node.type === 'file' ? new File(nodeData) : new Folder(nodeData) diff --git a/apps/files_trashbin/tests/Controller/PreviewControllerTest.php b/apps/files_trashbin/tests/Controller/PreviewControllerTest.php index 4db3d4f613c..3b114484c7f 100644 --- a/apps/files_trashbin/tests/Controller/PreviewControllerTest.php +++ b/apps/files_trashbin/tests/Controller/PreviewControllerTest.php @@ -157,7 +157,7 @@ class PreviewControllerTest extends TestCase { $this->overwriteService(ITimeFactory::class, $this->time); - $res = $this->controller->getPreview(42, 10, 10, true); + $res = $this->controller->getPreview(42, 10, 10, false); $expected = new FileDisplayResponse($preview, Http::STATUS_OK, ['Content-Type' => 'previewMime']); $expected->cacheFor(3600 * 24); |