diff --git a/apps/files/js/favoritesfilelist.js b/apps/files/js/favoritesfilelist.js index 2c6b3c63e1559..503c990b34e97 100644 --- a/apps/files/js/favoritesfilelist.js +++ b/apps/files/js/favoritesfilelist.js @@ -27,8 +27,8 @@ window.addEventListener('DOMContentLoaded', function() { }; FavoritesFileList.prototype = _.extend({}, OCA.Files.FileList.prototype, /** @lends OCA.Files.FavoritesFileList.prototype */ { - id: 'favorites', - appName: t('files','Favorites'), + id: 'oldfavorites', + appName: t('files','Favorites (old)'), _clientSideSort: true, _allowSelection: false, diff --git a/apps/files/js/favoritesplugin.js b/apps/files/js/favoritesplugin.js index 5964d71a46940..20aa1772a97c8 100644 --- a/apps/files/js/favoritesplugin.js +++ b/apps/files/js/favoritesplugin.js @@ -24,10 +24,10 @@ attach: function() { var self = this; - $('#app-content-favorites').on('show.plugin-favorites', function(e) { + $('#app-content-oldfavorites').on('show.plugin-oldfavorites', function(e) { self.showFileList($(e.target)); }); - $('#app-content-favorites').on('hide.plugin-favorites', function() { + $('#app-content-oldfavorites').on('hide.plugin-oldfavorites', function() { self.hideFileList(); }); }, @@ -35,9 +35,9 @@ detach: function() { if (this.favoritesFileList) { this.favoritesFileList.destroy(); - OCA.Files.fileActions.off('setDefault.plugin-favorites', this._onActionsUpdated); - OCA.Files.fileActions.off('registerAction.plugin-favorites', this._onActionsUpdated); - $('#app-content-favorites').off('.plugin-favorites'); + OCA.Files.fileActions.off('setDefault.plugin-oldfavorites', this._onActionsUpdated); + OCA.Files.fileActions.off('registerAction.plugin-oldfavorites', this._onActionsUpdated); + $('#app-content-oldfavorites').off('.plugin-oldfavorites'); this.favoritesFileList = null; } }, diff --git a/apps/files/js/navigation.js b/apps/files/js/navigation.js index d7ae7dd7fee87..38af9d1e11220 100644 --- a/apps/files/js/navigation.js +++ b/apps/files/js/navigation.js @@ -42,7 +42,7 @@ /** * Key for the quick-acces-list */ - $quickAccessListKey: 'sublist-favorites', + $quickAccessListKey: 'sublist-oldfavorites', /** * Initializes the navigation from the given container * diff --git a/apps/files/js/tagsplugin.js b/apps/files/js/tagsplugin.js index 6759aa7002b5c..cda188803db46 100644 --- a/apps/files/js/tagsplugin.js +++ b/apps/files/js/tagsplugin.js @@ -194,13 +194,25 @@ tags = tags.split('|'); tags = _.without(tags, ''); var isFavorite = tags.indexOf(OC.TAG_FAVORITE) >= 0; + + // Fake Node object for vue compatibility + const node = { + type: 'folder', + path: (dir + '/' + fileName).replace(/\/\/+/g, '/'), + root: '/files/' + OC.getCurrentUser().uid + } + if (isFavorite) { // remove tag from list tags = _.without(tags, OC.TAG_FAVORITE); removeFavoriteFromList(dir + '/' + fileName); + // vue compatibility + window._nc_event_bus.emit('files:favorites:removed', node) } else { tags.push(OC.TAG_FAVORITE); addFavoriteToList(dir + '/' + fileName); + // vue compatibility + window._nc_event_bus.emit('files:favorites:added', node) } // pre-toggle the star diff --git a/apps/files/lib/AppInfo/Application.php b/apps/files/lib/AppInfo/Application.php index 0d366e66fe8b3..573e52b071e12 100644 --- a/apps/files/lib/AppInfo/Application.php +++ b/apps/files/lib/AppInfo/Application.php @@ -174,11 +174,11 @@ private function registerNavigation(IL10N $l10n): void { }); \OCA\Files\App::getNavigationManager()->add(function () use ($l10n) { return [ - 'id' => 'favorites', + 'id' => 'oldfavorites', 'appname' => 'files', 'script' => 'simplelist.php', 'order' => 5, - 'name' => $l10n->t('Favorites'), + 'name' => $l10n->t('Favorites (old)'), ]; }); } diff --git a/apps/files/lib/Controller/ViewController.php b/apps/files/lib/Controller/ViewController.php index 70e0fd4845654..a1688af82ffb6 100644 --- a/apps/files/lib/Controller/ViewController.php +++ b/apps/files/lib/Controller/ViewController.php @@ -230,8 +230,8 @@ public function index($dir = '', $view = '', $fileid = null, $fileNotFound = fal $navItems = \OCA\Files\App::getNavigationManager()->getAll(); // add the favorites entry in menu - $navItems['favorites']['sublist'] = $favoritesSublistArray; - $navItems['favorites']['classes'] = $collapseClasses; + $navItems['oldfavorites']['sublist'] = $favoritesSublistArray; + $navItems['oldfavorites']['classes'] = $collapseClasses; // parse every menu and add the expanded user value foreach ($navItems as $key => $item) { @@ -253,6 +253,7 @@ public function index($dir = '', $view = '', $fileid = null, $fileNotFound = fal $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/lib/Service/UserConfig.php b/apps/files/lib/Service/UserConfig.php index e405b02c07a91..c39719ae8ed2c 100644 --- a/apps/files/lib/Service/UserConfig.php +++ b/apps/files/lib/Service/UserConfig.php @@ -41,6 +41,12 @@ class UserConfig { 'default' => false, 'allowed' => [true, false], ], + [ + // Whether to sort favorites first in the list or not + 'key' => 'sort_favorites_first', + 'default' => true, + 'allowed' => [true, false], + ], ]; protected IConfig $config; @@ -133,7 +139,7 @@ public function getConfigs(): array { $userConfigs = array_map(function(string $key) use ($userId) { $value = $this->config->getUserValue($userId, Application::APP_ID, $key, $this->getDefaultConfigValue($key)); // If the default is expected to be a boolean, we need to cast the value - if (is_bool($this->getDefaultConfigValue($key))) { + if (is_bool($this->getDefaultConfigValue($key)) && is_string($value)) { return $value === '1'; } return $value; diff --git a/apps/files/src/actions/deleteAction.ts b/apps/files/src/actions/deleteAction.ts index a633e477b1f94..52bcbddf1beb0 100644 --- a/apps/files/src/actions/deleteAction.ts +++ b/apps/files/src/actions/deleteAction.ts @@ -25,7 +25,7 @@ import { translate as t } from '@nextcloud/l10n' import axios from '@nextcloud/axios' import TrashCan from '@mdi/svg/svg/trash-can.svg?raw' -import { registerFileAction, FileAction } from '../services/FileAction.ts' +import { registerFileAction, FileAction } from '../services/FileAction' import logger from '../logger.js' import type { Navigation } from '../services/Navigation.ts' diff --git a/apps/files/src/actions/downloadAction.ts b/apps/files/src/actions/downloadAction.ts new file mode 100644 index 0000000000000..7b54468f3d916 --- /dev/null +++ b/apps/files/src/actions/downloadAction.ts @@ -0,0 +1,80 @@ +/** + * @copyright Copyright (c) 2023 John Molakvoæ + * + * @author John Molakvoæ + * + * @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 . + * + */ +import { emit } from '@nextcloud/event-bus' +import { Permission, Node, FileType } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +import ArrowDown from '@mdi/svg/svg/arrow-down.svg?raw' + +import { registerFileAction, FileAction } from '../services/FileAction' +import { generateUrl } from '@nextcloud/router' +import type { Navigation } from '../services/Navigation.ts' + +const triggerDownload = function(url: string) { + const hiddenElement = document.createElement('a') + hiddenElement.download = '' + hiddenElement.href = url + hiddenElement.click() +} + +const downloadNodes = function(dir = '/', nodes: Node[]) { + const secret = Math.random().toString(36).substring(2) + const url = generateUrl('/apps/files/ajax/download.php?dir={dir}&files={files}&downloadStartSecret={secret}', { + dir, + secret, + files: JSON.stringify(nodes.map(node => node.basename)), + }) + triggerDownload(url) +} + +registerFileAction(new FileAction({ + id: 'download', + displayName: () => t('files', 'Download'), + iconSvgInline: () => ArrowDown, + + enabled(nodes: Node[]) { + return nodes.length > 0 && nodes + .map(node => node.permissions) + .every(permission => (permission & Permission.READ) !== 0) + }, + + async exec(node: Node, view: Navigation, dir: string) { + if (node.type === FileType.Folder) { + downloadNodes(dir, [node]) + return null + } + + triggerDownload(node.source) + return null + }, + + async execBatch(nodes: Node[], view: Navigation, dir: string) { + if (nodes.length === 1) { + this.exec(nodes[0], view, dir) + return [null] + } + + downloadNodes(dir, nodes) + return new Array(nodes.length).fill(null) + }, + + order: 30, +})) diff --git a/apps/files/src/actions/editLocallyAction.ts b/apps/files/src/actions/editLocallyAction.ts new file mode 100644 index 0000000000000..60a5ed452e6bd --- /dev/null +++ b/apps/files/src/actions/editLocallyAction.ts @@ -0,0 +1,78 @@ +/** + * @copyright Copyright (c) 2023 John Molakvoæ + * + * @author John Molakvoæ + * + * @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 . + * + */ +import { encodePath } from '@nextcloud/paths' +import { Permission, type Node } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +import axios from '@nextcloud/axios' +import DevicesSvg from '@mdi/svg/svg/devices.svg?raw' + +import { generateOcsUrl } from '@nextcloud/router' +import { getCurrentUser } from '@nextcloud/auth' +import { registerFileAction, FileAction } from '../services/FileAction' +import { showError } from '@nextcloud/dialogs' + +const openLocalClient = function(path: string) { + const link = generateOcsUrl('apps/files/api/v1') + '/openlocaleditor?format=json' + + axios.post(link, { path }) + .then(function(result) { + const scheme = 'nc://' + const command = 'open' + const uid = getCurrentUser()?.uid + let url = scheme + command + '/' + uid + '@' + window.location.host + encodePath(path) + url += '?token=' + result.data.ocs.data.token + + window.location.href = url + }) + .catch(function() { + showError(t('files', 'Failed to redirect to client')) + }) +} + +if (!/Android|iPhone|iPad|iPod/i.test(navigator.userAgent)) { + registerFileAction(new FileAction({ + id: 'edit-locally', + displayName: () => t('files', 'Edit locally'), + iconSvgInline: () => DevicesSvg, + + // Only works on single files + enabled(nodes: Node[]) { + // Only works on single node + if (nodes.length !== 1) { + return false + } + + return (nodes[0].permissions & Permission.UPDATE) !== 0 + }, + + async exec(node: Node) { + if (!node.path) { + return false + } + openLocalClient(node.path) + return null + }, + + default: true, + order: 25, + })) +} diff --git a/apps/files/src/actions/favoriteAction.ts b/apps/files/src/actions/favoriteAction.ts new file mode 100644 index 0000000000000..8173dd2bc6f3d --- /dev/null +++ b/apps/files/src/actions/favoriteAction.ts @@ -0,0 +1,95 @@ +/** + * @copyright Copyright (c) 2023 John Molakvoæ + * + * @author John Molakvoæ + * + * @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 . + * + */ +import { emit } from '@nextcloud/event-bus' +import { translate as t } from '@nextcloud/l10n' +import axios from '@nextcloud/axios' +import Star from '@mdi/svg/svg/star.svg?raw' +import type { Node } from '@nextcloud/files' + +import { generateUrl } from '@nextcloud/router' +import { registerFileAction, FileAction } from '../services/FileAction' +import logger from '../logger.js' +import type { Navigation } from '../services/Navigation' + +/** + * If any of the nodes is not favorited + * we display the favorite action. + */ +const shouldFavorite = (nodes: Node[]): boolean => { + return nodes.some(node => node.attributes.favorite === 0) +} + +registerFileAction(new FileAction({ + id: 'favorite', + displayName(nodes: Node[]) { + return shouldFavorite(nodes) + ? t('files', 'Add to favorites') + : t('files', 'Remove from favorites') + }, + iconSvgInline: () => Star, + + enabled(nodes: Node[]) { + // We can only favorite nodes within files + return !nodes.some(node => !node.root?.startsWith?.('/files')) + }, + + async exec(node: Node, view: Navigation) { + const willFavorite = shouldFavorite([node]) + try { + // TODO: migrate to webdav tags plugin + const url = generateUrl('/apps/files/api/v1/files/') + node.path + await axios.post(url, { + tags: willFavorite + ? [OC.TAG_FAVORITE] + : [], + }) + + // Let's delete even if we are in the favourites view + // AND if it is removed from the user favorites + // AND it's in the root of the favorites view + if (view.id === 'favorites' && !willFavorite && node.dirname === '/') { + emit('files:node:deleted', node) + } + + // Update the node webdav attribute + node.attributes.favorite = willFavorite ? 1 : 0 + + // Dispatch event to whoever is interested + if (willFavorite) { + emit('files:favorites:added', node) + } else { + emit('files:favorites:removed', node) + } + + return true + } catch (error) { + const action = willFavorite ? 'adding a file to favourites' : 'removing a file from favourites' + logger.error('Error while ' + action, { error, source: node.source, node }) + return false + } + }, + async execBatch(nodes: Node[], view: Navigation) { + return Promise.all(nodes.map(node => this.exec(node, view))) + }, + + order: -50, +})) diff --git a/apps/files/src/actions/openFolderAction.ts b/apps/files/src/actions/openFolderAction.ts new file mode 100644 index 0000000000000..ee6b188da6ec4 --- /dev/null +++ b/apps/files/src/actions/openFolderAction.ts @@ -0,0 +1,70 @@ +/** + * @copyright Copyright (c) 2023 John Molakvoæ + * + * @author John Molakvoæ + * + * @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 . + * + */ +import { emit } from '@nextcloud/event-bus' +import { Permission, Node, FileType } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +import Folder from '@mdi/svg/svg/folder.svg?raw' + +import { registerFileAction, FileAction } from '../services/FileAction' +import type { Navigation } from '../services/Navigation.js' +import { join } from 'path' + +registerFileAction(new FileAction({ + id: 'open-folder', + displayName(files: Node[]) { + // Only works on single node + const displayName = files[0].attributes.displayName || files[0].basename + return t('files', 'Open folder {displayName}', { displayName }) + }, + iconSvgInline: () => Folder, + + enabled(nodes: Node[]) { + // Only works on single node + if (nodes.length !== 1) { + return false + } + + const node = nodes[0] + return node.type === FileType.Folder + && (node.permissions & Permission.READ) !== 0 + }, + + async exec(node: Node, view: Navigation, dir: string) { + if (!node || node.type !== FileType.Folder) { + return false + } + + window.OCP.Files.Router.goToRoute( + null, + null, + { dir: join(dir, node.basename) }, + ) + return null + }, + async execBatch(nodes: Node[], view: Navigation, dir: string) { + return Promise.all(nodes.map(node => this.exec(node, view, dir))) + }, + + // Main action if enabled, meaning folders only + order: -100, + default: true, +})) diff --git a/apps/files/src/actions/renameAction.ts b/apps/files/src/actions/renameAction.ts new file mode 100644 index 0000000000000..bd139d0e8469f --- /dev/null +++ b/apps/files/src/actions/renameAction.ts @@ -0,0 +1,49 @@ +/** + * @copyright Copyright (c) 2023 John Molakvoæ + * + * @author John Molakvoæ + * + * @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 . + * + */ +import { Permission, type Node } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +import PencilSvg from '@mdi/svg/svg/pencil.svg?raw' + +import { emit } from '@nextcloud/event-bus' +import { registerFileAction, FileAction } from '../services/FileAction' + +export const ACTION_DETAILS = 'details' + +registerFileAction(new FileAction({ + id: 'rename', + displayName: () => t('files', 'Rename'), + iconSvgInline: () => PencilSvg, + + enabled: (nodes: Node[]) => { + return nodes.length > 0 && nodes + .map(node => node.permissions) + .every(permission => (permission & Permission.UPDATE) !== 0) + }, + + async exec(node: Node) { + // Renaming is a built-in feature of the files app + emit('files:node:rename', node) + return null + }, + + order: 10, +})) diff --git a/apps/files/src/actions/sidebarAction.ts b/apps/files/src/actions/sidebarAction.ts index f56d3a9475f42..0cdd74b3523b2 100644 --- a/apps/files/src/actions/sidebarAction.ts +++ b/apps/files/src/actions/sidebarAction.ts @@ -23,14 +23,14 @@ import { translate as t } from '@nextcloud/l10n' import InformationSvg from '@mdi/svg/svg/information-variant.svg?raw' import type { Node } from '@nextcloud/files' -import { registerFileAction, FileAction } from '../services/FileAction.ts' +import { registerFileAction, FileAction } from '../services/FileAction' import logger from '../logger.js' export const ACTION_DETAILS = 'details' registerFileAction(new FileAction({ id: ACTION_DETAILS, - displayName: () => t('files', 'Details'), + displayName: () => t('files', 'Open details'), iconSvgInline: () => InformationSvg, // Sidebar currently supports user folder only, /files/USER @@ -50,5 +50,5 @@ registerFileAction(new FileAction({ }, default: true, - order: -50, + order: 0, })) diff --git a/apps/files/src/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue index 8dc067a407d66..52782dfa1fb45 100644 --- a/apps/files/src/components/FileEntry.vue +++ b/apps/files/src/components/FileEntry.vue @@ -33,38 +33,63 @@ - - - - - - - - - - - - - - - + + + + + + + + + + + + + + - + + + +
+ + + +
- {{ displayName }} + + + {{ displayName }} + - + @@ -81,6 +106,7 @@