Browse Source

feat(files): favorites

Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
John Molakvoæ 1 year ago
parent
commit
0984970cd8

+ 1 - 0
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);

+ 1 - 1
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

+ 4 - 0
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()

+ 128 - 0
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>`
+}

+ 100 - 0
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),
+	}
+}

+ 51 - 0
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
+}

+ 114 - 0
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)}`
+}

+ 1 - 1
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

+ 5 - 11
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)

+ 1 - 1
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);