diff options
author | John Molakvoæ <skjnldsv@users.noreply.github.com> | 2023-07-05 18:27:10 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-07-05 18:27:10 +0200 |
commit | 6862ff28002c8c282bcca5c198619c03f5cb4e0b (patch) | |
tree | 546086e72ab130e6d0760f84f56999e6a2ea4364 /apps/files | |
parent | 79d24bfb8eebd82dd75b15c5503a4bb33563ee69 (diff) | |
parent | 7f8a390b60003adcf7ec89c3fbf86c3e98134cce (diff) | |
download | nextcloud-server-6862ff28002c8c282bcca5c198619c03f5cb4e0b.tar.gz nextcloud-server-6862ff28002c8c282bcca5c198619c03f5cb4e0b.zip |
Merge pull request #38950 from nextcloud/feat/f2v/favorites
Diffstat (limited to 'apps/files')
52 files changed, 1421 insertions, 1211 deletions
diff --git a/apps/files/appinfo/routes.php b/apps/files/appinfo/routes.php index a97c631d896..05d0a37fd70 100644 --- a/apps/files/appinfo/routes.php +++ b/apps/files/appinfo/routes.php @@ -129,11 +129,6 @@ $application->registerRoutes( 'verb' => 'GET' ], [ - 'name' => 'Api#getNodeType', - 'url' => '/api/v1/quickaccess/get/NodeType', - 'verb' => 'GET', - ], - [ 'name' => 'DirectEditingView#edit', 'url' => '/directEditing/{token}', 'verb' => 'GET' diff --git a/apps/files/js/favoritesfilelist.js b/apps/files/js/favoritesfilelist.js deleted file mode 100644 index 2c6b3c63e15..00000000000 --- a/apps/files/js/favoritesfilelist.js +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright (c) 2014 Vincent Petry <pvince81@owncloud.com> - * - * This file is licensed under the Affero General Public License version 3 - * or later. - * - * See the COPYING-README file. - * - */ - -// HACK: this piece needs to be loaded AFTER the files app (for unit tests) -window.addEventListener('DOMContentLoaded', function() { - (function(OCA) { - /** - * @class OCA.Files.FavoritesFileList - * @augments OCA.Files.FavoritesFileList - * - * @classdesc Favorites file list. - * Displays the list of files marked as favorites - * - * @param $el container element with existing markup for the .files-controls - * and a table - * @param [options] map of options, see other parameters - */ - var FavoritesFileList = function($el, options) { - this.initialize($el, options); - }; - FavoritesFileList.prototype = _.extend({}, OCA.Files.FileList.prototype, - /** @lends OCA.Files.FavoritesFileList.prototype */ { - id: 'favorites', - appName: t('files','Favorites'), - - _clientSideSort: true, - _allowSelection: false, - - /** - * @private - */ - initialize: function($el, options) { - OCA.Files.FileList.prototype.initialize.apply(this, arguments); - if (this.initialized) { - return; - } - OC.Plugins.attach('OCA.Files.FavoritesFileList', this); - }, - - updateEmptyContent: function() { - var dir = this.getCurrentDirectory(); - if (dir === '/') { - // root has special permissions - this.$el.find('.emptyfilelist.emptycontent').toggleClass('hidden', !this.isEmpty); - this.$el.find('.files-filestable thead th').toggleClass('hidden', this.isEmpty); - } - else { - OCA.Files.FileList.prototype.updateEmptyContent.apply(this, arguments); - } - }, - - getDirectoryPermissions: function() { - return OC.PERMISSION_READ | OC.PERMISSION_DELETE; - }, - - updateStorageStatistics: function() { - // no op because it doesn't have - // storage info like free space / used space - }, - - reload: function() { - this.showMask(); - if (this._reloadCall?.abort) { - this._reloadCall.abort(); - } - - // there is only root - this._setCurrentDir('/', false); - - this._reloadCall = this.filesClient.getFilteredFiles( - { - favorite: true - }, - { - properties: this._getWebdavProperties() - } - ); - var callBack = this.reloadCallback.bind(this); - return this._reloadCall.then(callBack, callBack); - }, - - reloadCallback: function(status, result) { - if (result) { - // prepend empty dir info because original handler - result.unshift({}); - } - - return OCA.Files.FileList.prototype.reloadCallback.call(this, status, result); - }, - }); - - OCA.Files.FavoritesFileList = FavoritesFileList; - })(OCA); -}); - diff --git a/apps/files/js/favoritesplugin.js b/apps/files/js/favoritesplugin.js deleted file mode 100644 index 5964d71a469..00000000000 --- a/apps/files/js/favoritesplugin.js +++ /dev/null @@ -1,120 +0,0 @@ -/* - * Copyright (c) 2014 Vincent Petry <pvince81@owncloud.com> - * - * This file is licensed under the Affero General Public License version 3 - * or later. - * - * See the COPYING-README file. - * - */ - -(function(OCA) { - /** - * Registers the favorites file list from the files app sidebar. - * - * @namespace OCA.Files.FavoritesPlugin - */ - OCA.Files.FavoritesPlugin = { - name: 'Favorites', - - /** - * @type OCA.Files.FavoritesFileList - */ - favoritesFileList: null, - - attach: function() { - var self = this; - $('#app-content-favorites').on('show.plugin-favorites', function(e) { - self.showFileList($(e.target)); - }); - $('#app-content-favorites').on('hide.plugin-favorites', function() { - self.hideFileList(); - }); - }, - - 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'); - this.favoritesFileList = null; - } - }, - - showFileList: function($el) { - if (!this.favoritesFileList) { - this.favoritesFileList = this._createFavoritesFileList($el); - } - return this.favoritesFileList; - }, - - hideFileList: function() { - if (this.favoritesFileList) { - this.favoritesFileList.$fileList.empty(); - } - }, - - /** - * Creates the favorites file list. - * - * @param $el container for the file list - * @return {OCA.Files.FavoritesFileList} file list - */ - _createFavoritesFileList: function($el) { - var fileActions = this._createFileActions(); - // register favorite list for sidebar section - return new OCA.Files.FavoritesFileList( - $el, { - fileActions: fileActions, - // The file list is created when a "show" event is handled, - // so it should be marked as "shown" like it would have been - // done if handling the event with the file list already - // created. - shown: true - } - ); - }, - - _createFileActions: function() { - // inherit file actions from the files app - var fileActions = new OCA.Files.FileActions(); - // note: not merging the legacy actions because legacy apps are not - // compatible with the sharing overview and need to be adapted first - fileActions.registerDefaultActions(); - fileActions.merge(OCA.Files.fileActions); - - if (!this._globalActionsInitialized) { - // in case actions are registered later - this._onActionsUpdated = _.bind(this._onActionsUpdated, this); - OCA.Files.fileActions.on('setDefault.plugin-favorites', this._onActionsUpdated); - OCA.Files.fileActions.on('registerAction.plugin-favorites', this._onActionsUpdated); - this._globalActionsInitialized = true; - } - - // when the user clicks on a folder, redirect to the corresponding - // folder in the files app instead of opening it directly - fileActions.register('dir', 'Open', OC.PERMISSION_READ, '', function (filename, context) { - OCA.Files.App.setActiveView('files', {silent: true}); - OCA.Files.App.fileList.changeDirectory(OC.joinPaths(context.$file.attr('data-path'), filename), true, true); - }); - fileActions.setDefault('dir', 'Open'); - return fileActions; - }, - - _onActionsUpdated: function(ev) { - if (ev.action) { - this.favoritesFileList.fileActions.registerAction(ev.action); - } else if (ev.defaultAction) { - this.favoritesFileList.fileActions.setDefault( - ev.defaultAction.mime, - ev.defaultAction.name - ); - } - } - }; - -})(OCA); - -OC.Plugins.register('OCA.Files.App', OCA.Files.FavoritesPlugin); - diff --git a/apps/files/js/merged-index.json b/apps/files/js/merged-index.json index 2b7d6ec7d6d..38b36c16896 100644 --- a/apps/files/js/merged-index.json +++ b/apps/files/js/merged-index.json @@ -4,8 +4,6 @@ "detailfileinfoview.js", "detailsview.js", "detailtabview.js", - "favoritesfilelist.js", - "favoritesplugin.js", "file-upload.js", "fileactions.js", "fileactionsmenu.js", diff --git a/apps/files/js/navigation.js b/apps/files/js/navigation.js deleted file mode 100644 index d7ae7dd7fee..00000000000 --- a/apps/files/js/navigation.js +++ /dev/null @@ -1,347 +0,0 @@ -/* - * @Copyright 2014 Vincent Petry <pvince81@owncloud.com> - * - * @author Vincent Petry - * @author Felix Nüsse <felix.nuesse@t-online.de> - * - * - * This file is licensed under the Affero General Public License version 3 - * or later. - * - * See the COPYING-README file. - * - */ - -(function () { - - /** - * @class OCA.Files.Navigation - * @classdesc Navigation control for the files app sidebar. - * - * @param $el element containing the navigation - */ - var Navigation = function ($el) { - this.initialize($el); - }; - - /** - * @memberof OCA.Files - */ - Navigation.prototype = { - - /** - * Currently selected item in the list - */ - _activeItem: null, - - /** - * Currently selected container - */ - $currentContent: null, - - /** - * Key for the quick-acces-list - */ - $quickAccessListKey: 'sublist-favorites', - /** - * Initializes the navigation from the given container - * - * @private - * @param $el element containing the navigation - */ - initialize: function ($el) { - this.$el = $el; - this._activeItem = null; - this.$currentContent = null; - this._setupEvents(); - - this.setInitialQuickaccessSettings(); - }, - - /** - * Setup UI events - */ - _setupEvents: function () { - this.$el.on('click', 'li a', _.bind(this._onClickItem, this)); - this.$el.on('click', 'li button', _.bind(this._onClickMenuButton, this)); - - var trashBinElement = $('.nav-trashbin'); - trashBinElement.droppable({ - over: function (event, ui) { - trashBinElement.addClass('dropzone-background'); - }, - out: function (event, ui) { - trashBinElement.removeClass('dropzone-background'); - }, - activate: function (event, ui) { - var element = trashBinElement.find('a').first(); - element.addClass('nav-icon-trashbin-starred').removeClass('nav-icon-trashbin'); - }, - deactivate: function (event, ui) { - var element = trashBinElement.find('a').first(); - element.addClass('nav-icon-trashbin').removeClass('nav-icon-trashbin-starred'); - }, - drop: function (event, ui) { - trashBinElement.removeClass('dropzone-background'); - - var $selectedFiles = $(ui.draggable); - - // FIXME: when there are a lot of selected files the helper - // contains only a subset of them; the list of selected - // files should be gotten from the file list instead to - // ensure that all of them are removed. - var item = ui.helper.find('tr'); - for (var i = 0; i < item.length; i++) { - $selectedFiles.trigger('droppedOnTrash', item[i].getAttribute('data-file'), item[i].getAttribute('data-dir')); - } - } - }); - }, - - /** - * Returns the container of the currently active app. - * - * @return app container - */ - getActiveContainer: function () { - return this.$currentContent; - }, - - /** - * Returns the currently active item - * - * @return item ID - */ - getActiveItem: function () { - return this._activeItem; - }, - - /** - * Switch the currently selected item, mark it as selected and - * make the content container visible, if any. - * - * @param string itemId id of the navigation item to select - * @param array options "silent" to not trigger event - */ - setActiveItem: function (itemId, options) { - var currentItem = this.$el.find('li[data-id="' + itemId + '"]'); - var itemDir = currentItem.data('dir'); - var itemView = currentItem.data('view'); - var oldItemId = this._activeItem; - if (itemId === this._activeItem) { - if (!options || !options.silent) { - this.$el.trigger( - new $.Event('itemChanged', { - itemId: itemId, - previousItemId: oldItemId, - dir: itemDir, - view: itemView - }) - ); - } - return; - } - this.$el.find('li a').removeClass('active').removeAttr('aria-current'); - if (this.$currentContent) { - this.$currentContent.addClass('hidden'); - this.$currentContent.trigger(jQuery.Event('hide')); - } - this._activeItem = itemId; - currentItem.children('a').addClass('active').attr('aria-current', 'page'); - this.$currentContent = $('#app-content-' + (typeof itemView === 'string' && itemView !== '' ? itemView : itemId)); - this.$currentContent.removeClass('hidden'); - if (!options || !options.silent) { - this.$currentContent.trigger(jQuery.Event('show', { - itemId: itemId, - previousItemId: oldItemId, - dir: itemDir, - view: itemView - })); - this.$el.trigger( - new $.Event('itemChanged', { - itemId: itemId, - previousItemId: oldItemId, - dir: itemDir, - view: itemView - }) - ); - } - }, - - /** - * Returns whether a given item exists - */ - itemExists: function (itemId) { - return this.$el.find('li[data-id="' + itemId + '"]').length; - }, - - /** - * Event handler for when clicking on an item. - */ - _onClickItem: function (ev) { - var $target = $(ev.target); - var itemId = $target.closest('li').attr('data-id'); - if (!_.isUndefined(itemId)) { - this.setActiveItem(itemId); - } - ev.preventDefault(); - }, - - /** - * Event handler for clicking a button - */ - _onClickMenuButton: function (ev) { - var $target = $(ev.target); - var $menu = $target.parent('li'); - var itemId = $target.closest('button').attr('id'); - - var collapsibleToggles = []; - var dotmenuToggles = []; - - if ($menu.hasClass('collapsible') && $menu.data('expandedstate')) { - $menu.toggleClass('open'); - var targetAriaExpanded = $target.attr('aria-expanded'); - if (targetAriaExpanded === 'false') { - $target.attr('aria-expanded', 'true'); - } else if (targetAriaExpanded === 'true') { - $target.attr('aria-expanded', 'false'); - } - $menu.toggleAttr('data-expanded', 'true', 'false'); - var show = $menu.hasClass('open') ? 1 : 0; - var key = $menu.data('expandedstate'); - $.post(OC.generateUrl("/apps/files/api/v1/toggleShowFolder/" + key), {show: show}); - } - - dotmenuToggles.forEach(function foundToggle (item) { - if (item[0] === ("#" + itemId)) { - document.getElementById(item[1]).classList.toggle('open'); - } - }); - - ev.preventDefault(); - }, - - /** - * Sort initially as setup of sidebar for QuickAccess - */ - setInitialQuickaccessSettings: function () { - var quickAccessKey = this.$quickAccessListKey; - var quickAccessMenu = document.getElementById(quickAccessKey); - if (quickAccessMenu) { - var list = quickAccessMenu.getElementsByTagName('li'); - this.QuickSort(list, 0, list.length - 1); - } - - var favoritesListElement = $(quickAccessMenu).parent(); - favoritesListElement.droppable({ - over: function (event, ui) { - favoritesListElement.addClass('dropzone-background'); - }, - out: function (event, ui) { - favoritesListElement.removeClass('dropzone-background'); - }, - activate: function (event, ui) { - var element = favoritesListElement.find('a').first(); - element.addClass('nav-icon-favorites-starred').removeClass('nav-icon-favorites'); - }, - deactivate: function (event, ui) { - var element = favoritesListElement.find('a').first(); - element.addClass('nav-icon-favorites').removeClass('nav-icon-favorites-starred'); - }, - drop: function (event, ui) { - favoritesListElement.removeClass('dropzone-background'); - - var $selectedFiles = $(ui.draggable); - - if (ui.helper.find('tr').size() === 1) { - var $tr = $selectedFiles.closest('tr'); - if ($tr.attr("data-favorite")) { - return; - } - $selectedFiles.trigger('droppedOnFavorites', $tr.attr('data-file')); - } else { - // FIXME: besides the issue described for dropping on - // the trash bin, for favoriting it is not possible to - // use the data from the helper; due to some bugs the - // tags are not always added to the selected files, and - // thus that data can not be accessed through the helper - // to prevent triggering the favorite action on an - // already favorited file (which would remove it from - // favorites). - OC.Notification.showTemporary(t('files', 'You can only favorite a single file or folder at a time')); - } - } - }); - }, - - /** - * Sorting-Algorithm for QuickAccess - */ - QuickSort: function (list, start, end) { - var lastMatch; - if (list.length > 1) { - lastMatch = this.quicksort_helper(list, start, end); - if (start < lastMatch - 1) { - this.QuickSort(list, start, lastMatch - 1); - } - if (lastMatch < end) { - this.QuickSort(list, lastMatch, end); - } - } - }, - - /** - * Sorting-Algorithm-Helper for QuickAccess - */ - quicksort_helper: function (list, start, end) { - var pivot = Math.floor((end + start) / 2); - var pivotElement = this.getCompareValue(list, pivot); - var i = start; - var j = end; - - while (i <= j) { - while (this.getCompareValue(list, i) < pivotElement) { - i++; - } - while (this.getCompareValue(list, j) > pivotElement) { - j--; - } - if (i <= j) { - this.swap(list, i, j); - i++; - j--; - } - } - return i; - }, - - /** - * Sorting-Algorithm-Helper for QuickAccess - * This method allows easy access to the element which is sorted by. - */ - getCompareValue: function (nodes, int, strategy) { - return nodes[int].getElementsByTagName('a')[0].innerHTML.toLowerCase(); - }, - - /** - * Sorting-Algorithm-Helper for QuickAccess - * This method allows easy swapping of elements. - */ - swap: function (list, j, i) { - var before = function(node, insertNode) { - node.parentNode.insertBefore(insertNode, node); - } - before(list[i], list[j]); - before(list[j], list[i]); - } - - }; - - OCA.Files.Navigation = Navigation; - -})(); - - - - - diff --git a/apps/files/js/tagsplugin.js b/apps/files/js/tagsplugin.js index 6759aa7002b..78bd7eec557 100644 --- a/apps/files/js/tagsplugin.js +++ b/apps/files/js/tagsplugin.js @@ -52,82 +52,6 @@ $favoriteMarkEl.toggleClass('permanent', state); } - /** - * Remove Item from Quickaccesslist - * - * @param {String} appfolder folder to be removed - */ - function removeFavoriteFromList (appfolder) { - var quickAccessList = 'sublist-favorites'; - var listULElements = document.getElementById(quickAccessList); - if (!listULElements) { - return; - } - - var apppath=appfolder; - if(appfolder.startsWith("//")){ - apppath=appfolder.substring(1, appfolder.length); - } - - $(listULElements).find('[data-dir="' + _.escape(apppath) + '"]').remove(); - - if (listULElements.childElementCount === 0) { - var collapsibleButton = $(listULElements).parent().find('button.collapse'); - collapsibleButton.hide(); - $("#button-collapse-parent-favorites").removeClass('collapsible'); - } - } - - /** - * Add Item to Quickaccesslist - * - * @param {String} appfolder folder to be added - */ - function addFavoriteToList (appfolder) { - var quickAccessList = 'sublist-favorites'; - var listULElements = document.getElementById(quickAccessList); - if (!listULElements) { - return; - } - var listLIElements = listULElements.getElementsByTagName('li'); - - var appName = appfolder.substring(appfolder.lastIndexOf("/") + 1, appfolder.length); - var apppath = appfolder; - - if(appfolder.startsWith("//")){ - apppath = appfolder.substring(1, appfolder.length); - } - var url = OC.generateUrl('/apps/files/?dir=' + apppath + '&view=files'); - - var innerTagA = document.createElement('A'); - innerTagA.setAttribute("href", url); - innerTagA.setAttribute("class", "nav-icon-files svg"); - innerTagA.innerHTML = _.escape(appName); - - var length = listLIElements.length + 1; - var innerTagLI = document.createElement('li'); - innerTagLI.setAttribute("data-id", apppath.replace('/', '-')); - innerTagLI.setAttribute("data-dir", apppath); - innerTagLI.setAttribute("data-view", 'files'); - innerTagLI.setAttribute("class", "nav-" + appName); - innerTagLI.setAttribute("folderpos", length.toString()); - innerTagLI.appendChild(innerTagA); - - $.get(OC.generateUrl("/apps/files/api/v1/quickaccess/get/NodeType"),{folderpath: apppath}, function (data, status) { - if (data === "dir") { - if (listULElements.childElementCount <= 0) { - listULElements.appendChild(innerTagLI); - var collapsibleButton = $(listULElements).parent().find('button.collapse'); - collapsibleButton.show(); - $(listULElements).parent().addClass('collapsible'); - } else { - listLIElements[listLIElements.length - 1].after(innerTagLI); - } - } - } - ); - } - OCA.Files = OCA.Files || {}; /** @@ -194,13 +118,23 @@ 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 0d366e66fe8..7021769752e 100644 --- a/apps/files/lib/AppInfo/Application.php +++ b/apps/files/lib/AppInfo/Application.php @@ -172,15 +172,6 @@ class Application extends App implements IBootstrap { 'name' => $l10n->t('Recent') ]; }); - \OCA\Files\App::getNavigationManager()->add(function () use ($l10n) { - return [ - 'id' => 'favorites', - 'appname' => 'files', - 'script' => 'simplelist.php', - 'order' => 5, - 'name' => $l10n->t('Favorites'), - ]; - }); } private function registerHooks(): void { diff --git a/apps/files/lib/Controller/ApiController.php b/apps/files/lib/Controller/ApiController.php index fd0f3bdf261..f8911c4d104 100644 --- a/apps/files/lib/Controller/ApiController.php +++ b/apps/files/lib/Controller/ApiController.php @@ -384,20 +384,6 @@ class ApiController extends Controller { } /** - * Get sorting-order for custom sorting - * - * @NoAdminRequired - * - * @param string $folderpath - * @return string - * @throws \OCP\Files\NotFoundException - */ - public function getNodeType($folderpath) { - $node = $this->userFolder->get($folderpath); - return $node->getType(); - } - - /** * @NoAdminRequired * @NoCSRFRequired */ diff --git a/apps/files/lib/Controller/ViewController.php b/apps/files/lib/Controller/ViewController.php index 70e0fd48456..43be43aa116 100644 --- a/apps/files/lib/Controller/ViewController.php +++ b/apps/files/lib/Controller/ViewController.php @@ -202,37 +202,8 @@ class ViewController extends Controller { $favElements['folders'] = []; } - $collapseClasses = ''; - if (count($favElements['folders']) > 0) { - $collapseClasses = 'collapsible'; - } - - $favoritesSublistArray = []; - - $navBarPositionPosition = 6; - foreach ($favElements['folders'] as $favElement) { - $element = [ - 'id' => str_replace('/', '-', $favElement), - 'dir' => $favElement, - 'order' => $navBarPositionPosition, - 'name' => basename($favElement), - 'icon' => 'folder', - 'params' => [ - 'view' => 'files', - 'dir' => $favElement, - ], - ]; - - array_push($favoritesSublistArray, $element); - $navBarPositionPosition++; - } - $navItems = \OCA\Files\App::getNavigationManager()->getAll(); - // add the favorites entry in menu - $navItems['favorites']['sublist'] = $favoritesSublistArray; - $navItems['favorites']['classes'] = $collapseClasses; - // parse every menu and add the expanded user value foreach ($navItems as $key => $item) { $navItems[$key]['expanded'] = $this->config->getUserValue($userId, 'files', 'show_' . $item['id'], '0') === '1'; @@ -253,6 +224,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/lib/Service/UserConfig.php b/apps/files/lib/Service/UserConfig.php index e405b02c07a..c39719ae8ed 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 @@ class UserConfig { $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.spec.ts b/apps/files/src/actions/deleteAction.spec.ts index af7722008b6..8d99b195c3d 100644 --- a/apps/files/src/actions/deleteAction.spec.ts +++ b/apps/files/src/actions/deleteAction.spec.ts @@ -43,8 +43,8 @@ describe('Delete action conditions tests', () => { expect(action).toBeInstanceOf(FileAction) expect(action.id).toBe('delete') expect(action.displayName([], view)).toBe('Delete') - expect(action.iconSvgInline([], view)).toBe('SvgMock') - expect(action.default).toBe(false) + expect(action.iconSvgInline([], view)).toBe('<svg>SvgMock</svg>') + expect(action.default).toBeUndefined() expect(action.order).toBe(100) }) diff --git a/apps/files/src/actions/deleteAction.ts b/apps/files/src/actions/deleteAction.ts index 20af8573dd9..52dd2f53491 100644 --- a/apps/files/src/actions/deleteAction.ts +++ b/apps/files/src/actions/deleteAction.ts @@ -23,7 +23,7 @@ import { emit } from '@nextcloud/event-bus' import { Permission, Node } from '@nextcloud/files' import { translate as t } from '@nextcloud/l10n' import axios from '@nextcloud/axios' -import TrashCan from '@mdi/svg/svg/trash-can.svg?raw' +import TrashCanSvg from '@mdi/svg/svg/trash-can.svg?raw' import { registerFileAction, FileAction } from '../services/FileAction' import logger from '../logger.js' @@ -36,7 +36,7 @@ export const action = new FileAction({ ? t('files_trashbin', 'Delete permanently') : t('files', 'Delete') }, - iconSvgInline: () => TrashCan, + iconSvgInline: () => TrashCanSvg, enabled(nodes: Node[]) { return nodes.length > 0 && nodes diff --git a/apps/files/src/actions/downloadAction.spec.ts b/apps/files/src/actions/downloadAction.spec.ts index a9b51b39510..abe099af3f8 100644 --- a/apps/files/src/actions/downloadAction.spec.ts +++ b/apps/files/src/actions/downloadAction.spec.ts @@ -38,8 +38,8 @@ describe('Download action conditions tests', () => { expect(action).toBeInstanceOf(FileAction) expect(action.id).toBe('download') expect(action.displayName([], view)).toBe('Download') - expect(action.iconSvgInline([], view)).toBe('SvgMock') - expect(action.default).toBe(false) + expect(action.iconSvgInline([], view)).toBe('<svg>SvgMock</svg>') + expect(action.default).toBeUndefined() expect(action.order).toBe(30) }) }) diff --git a/apps/files/src/actions/downloadAction.ts b/apps/files/src/actions/downloadAction.ts index 3801553aeaa..44e9fa4b379 100644 --- a/apps/files/src/actions/downloadAction.ts +++ b/apps/files/src/actions/downloadAction.ts @@ -22,7 +22,7 @@ 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 ArrowDownSvg from '@mdi/svg/svg/arrow-down.svg?raw' import { registerFileAction, FileAction } from '../services/FileAction' import { generateUrl } from '@nextcloud/router' @@ -48,7 +48,7 @@ const downloadNodes = function(dir: string, nodes: Node[]) { export const action = new FileAction({ id: 'download', displayName: () => t('files', 'Download'), - iconSvgInline: () => ArrowDown, + iconSvgInline: () => ArrowDownSvg, enabled(nodes: Node[]) { return nodes.length > 0 && nodes diff --git a/apps/files/src/actions/editLocallyAction.spec.ts b/apps/files/src/actions/editLocallyAction.spec.ts index 3582c0d9138..f40b3b558db 100644 --- a/apps/files/src/actions/editLocallyAction.spec.ts +++ b/apps/files/src/actions/editLocallyAction.spec.ts @@ -22,7 +22,7 @@ import { action } from './editLocallyAction' import { expect } from '@jest/globals' import { File, Permission } from '@nextcloud/files' -import { FileAction } from '../services/FileAction' +import { DefaultType, FileAction } from '../services/FileAction' import * as ncDialogs from '@nextcloud/dialogs' import axios from '@nextcloud/axios' import type { Navigation } from '../services/Navigation' @@ -37,8 +37,8 @@ describe('Edit locally action conditions tests', () => { expect(action).toBeInstanceOf(FileAction) expect(action.id).toBe('edit-locally') expect(action.displayName([], view)).toBe('Edit locally') - expect(action.iconSvgInline([], view)).toBe('SvgMock') - expect(action.default).toBe(true) + expect(action.iconSvgInline([], view)).toBe('<svg>SvgMock</svg>') + expect(action.default).toBeUndefined() expect(action.order).toBe(25) }) }) @@ -140,7 +140,7 @@ describe('Edit locally action execute tests', () => { test('Edit locally fails and show error', async () => { jest.spyOn(axios, 'post').mockImplementation(async () => ({})) - jest.spyOn(ncDialogs, 'showError').mockImplementation(async () => ({})) + jest.spyOn(ncDialogs, 'showError') const file = new File({ id: 1, diff --git a/apps/files/src/actions/editLocallyAction.ts b/apps/files/src/actions/editLocallyAction.ts index ad7e805ec2e..ce693adc157 100644 --- a/apps/files/src/actions/editLocallyAction.ts +++ b/apps/files/src/actions/editLocallyAction.ts @@ -23,11 +23,11 @@ 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 LaptopSvg from '@mdi/svg/svg/laptop.svg?raw' import { generateOcsUrl } from '@nextcloud/router' import { getCurrentUser } from '@nextcloud/auth' -import { registerFileAction, FileAction } from '../services/FileAction' +import { registerFileAction, FileAction, DefaultType } from '../services/FileAction' import { showError } from '@nextcloud/dialogs' const openLocalClient = async function(path: string) { @@ -48,7 +48,7 @@ const openLocalClient = async function(path: string) { export const action = new FileAction({ id: 'edit-locally', displayName: () => t('files', 'Edit locally'), - iconSvgInline: () => DevicesSvg, + iconSvgInline: () => LaptopSvg, // Only works on single files enabled(nodes: Node[]) { @@ -65,7 +65,6 @@ export const action = new FileAction({ return null }, - default: true, order: 25, }) diff --git a/apps/files/src/actions/favoriteAction.spec.ts b/apps/files/src/actions/favoriteAction.spec.ts index 144c3a51dc8..48a00094a0d 100644 --- a/apps/files/src/actions/favoriteAction.spec.ts +++ b/apps/files/src/actions/favoriteAction.spec.ts @@ -55,8 +55,8 @@ describe('Favorite action conditions tests', () => { expect(action).toBeInstanceOf(FileAction) expect(action.id).toBe('favorite') expect(action.displayName([file], view)).toBe('Add to favorites') - expect(action.iconSvgInline([], view)).toBe('SvgMock') - expect(action.default).toBe(false) + expect(action.iconSvgInline([], view)).toBe('<svg>SvgMock</svg>') + expect(action.default).toBeUndefined() expect(action.order).toBe(-50) }) diff --git a/apps/files/src/actions/favoriteAction.ts b/apps/files/src/actions/favoriteAction.ts index 5b9ba7f95e6..1ae77b6fb21 100644 --- a/apps/files/src/actions/favoriteAction.ts +++ b/apps/files/src/actions/favoriteAction.ts @@ -22,7 +22,8 @@ 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 StarSvg from '@mdi/svg/svg/star.svg?raw' +import StarOutlineSvg from '@mdi/svg/svg/star-outline.svg?raw' import type { Node } from '@nextcloud/files' import { generateUrl } from '@nextcloud/router' @@ -77,7 +78,11 @@ export const action = new FileAction({ ? t('files', 'Add to favorites') : t('files', 'Remove from favorites') }, - iconSvgInline: () => Star, + iconSvgInline: (nodes: Node[]) => { + return shouldFavorite(nodes) + ? StarOutlineSvg + : StarSvg + }, enabled(nodes: Node[]) { // We can only favorite nodes within files diff --git a/apps/files/src/actions/openFolderAction.spec.ts b/apps/files/src/actions/openFolderAction.spec.ts index 140b6722608..5a0ccc98978 100644 --- a/apps/files/src/actions/openFolderAction.spec.ts +++ b/apps/files/src/actions/openFolderAction.spec.ts @@ -22,7 +22,7 @@ import { action } from './openFolderAction' import { expect } from '@jest/globals' import { File, Folder, Node, Permission } from '@nextcloud/files' -import { FileAction } from '../services/FileAction' +import { DefaultType, FileAction } from '../services/FileAction' import type { Navigation } from '../services/Navigation' const view = { @@ -42,8 +42,8 @@ describe('Open folder action conditions tests', () => { expect(action).toBeInstanceOf(FileAction) expect(action.id).toBe('open-folder') expect(action.displayName([folder], view)).toBe('Open folder FooBar') - expect(action.iconSvgInline([], view)).toBe('SvgMock') - expect(action.default).toBe(true) + expect(action.iconSvgInline([], view)).toBe('<svg>SvgMock</svg>') + expect(action.default).toBe(DefaultType.HIDDEN) expect(action.order).toBe(-100) }) }) diff --git a/apps/files/src/actions/openFolderAction.ts b/apps/files/src/actions/openFolderAction.ts index 76467796a2b..ccb3f1a43ea 100644 --- a/apps/files/src/actions/openFolderAction.ts +++ b/apps/files/src/actions/openFolderAction.ts @@ -21,11 +21,11 @@ */ import { Permission, Node, FileType } from '@nextcloud/files' import { translate as t } from '@nextcloud/l10n' -import Folder from '@mdi/svg/svg/folder.svg?raw' +import FolderSvg from '@mdi/svg/svg/folder.svg?raw' import type { Navigation } from '../services/Navigation' import { join } from 'path' -import { registerFileAction, FileAction } from '../services/FileAction' +import { registerFileAction, FileAction, DefaultType } from '../services/FileAction' export const action = new FileAction({ id: 'open-folder', @@ -34,7 +34,7 @@ export const action = new FileAction({ const displayName = files[0].attributes.displayName || files[0].basename return t('files', 'Open folder {displayName}', { displayName }) }, - iconSvgInline: () => Folder, + iconSvgInline: () => FolderSvg, enabled(nodes: Node[]) { // Only works on single node @@ -66,7 +66,7 @@ export const action = new FileAction({ }, // Main action if enabled, meaning folders only - default: true, + default: DefaultType.HIDDEN, order: -100, }) diff --git a/apps/files/src/actions/renameAction.spec.ts b/apps/files/src/actions/renameAction.spec.ts index ae2cfcec7eb..c4d5d45cde9 100644 --- a/apps/files/src/actions/renameAction.spec.ts +++ b/apps/files/src/actions/renameAction.spec.ts @@ -36,8 +36,8 @@ describe('Rename action conditions tests', () => { expect(action).toBeInstanceOf(FileAction) expect(action.id).toBe('rename') expect(action.displayName([], view)).toBe('Rename') - expect(action.iconSvgInline([], view)).toBe('SvgMock') - expect(action.default).toBe(false) + expect(action.iconSvgInline([], view)).toBe('<svg>SvgMock</svg>') + expect(action.default).toBeUndefined() expect(action.order).toBe(10) }) }) diff --git a/apps/files/src/actions/sidebarAction.spec.ts b/apps/files/src/actions/sidebarAction.spec.ts index f6594090c53..c4750092ebc 100644 --- a/apps/files/src/actions/sidebarAction.spec.ts +++ b/apps/files/src/actions/sidebarAction.spec.ts @@ -35,9 +35,9 @@ describe('Open sidebar action conditions tests', () => { test('Default values', () => { expect(action).toBeInstanceOf(FileAction) expect(action.id).toBe('details') - expect(action.displayName([], view)).toBe('Details') - expect(action.iconSvgInline([], view)).toBe('SvgMock') - expect(action.default).toBe(true) + expect(action.displayName([], view)).toBe('Open details') + expect(action.iconSvgInline([], view)).toBe('<svg>SvgMock</svg>') + expect(action.default).toBeUndefined() expect(action.order).toBe(-50) }) }) diff --git a/apps/files/src/actions/sidebarAction.ts b/apps/files/src/actions/sidebarAction.ts index 4766d2e90df..141cd75ff19 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' +import { registerFileAction, FileAction, DefaultType } from '../services/FileAction' import logger from '../logger.js' 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 @@ -60,7 +60,6 @@ export const action = new FileAction({ } }, - default: true, order: -50, }) diff --git a/apps/files/src/actions/viewInFolderAction.spec.ts b/apps/files/src/actions/viewInFolderAction.spec.ts new file mode 100644 index 00000000000..b16f2663f33 --- /dev/null +++ b/apps/files/src/actions/viewInFolderAction.spec.ts @@ -0,0 +1,161 @@ +/** + * @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 { action } from './viewInFolderAction' +import { expect } from '@jest/globals' +import { File, Folder, Node, Permission } from '@nextcloud/files' +import { FileAction } from '../services/FileAction' +import type { Navigation } from '../services/Navigation' + +const view = { + id: 'files', + name: 'Files', +} as Navigation + +describe('View in folder action conditions tests', () => { + test('Default values', () => { + expect(action).toBeInstanceOf(FileAction) + expect(action.id).toBe('view-in-folder') + expect(action.displayName([], view)).toBe('View in folder') + expect(action.iconSvgInline([], view)).toBe('<svg>SvgMock</svg>') + expect(action.default).toBeUndefined() + expect(action.order).toBe(80) + }) +}) + +describe('View in folder action enabled tests', () => { + test('Enabled for files', () => { + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([file], view)).toBe(true) + }) + + test('Disabled for non-dav ressources', () => { + const file = new File({ + id: 1, + source: 'https://domain.com/foobar.txt', + owner: 'admin', + mime: 'text/plain', + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([file], view)).toBe(false) + }) + + test('Disabled if more than one node', () => { + const file1 = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foo.txt', + owner: 'admin', + mime: 'text/plain', + }) + const file2 = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/bar.txt', + owner: 'admin', + mime: 'text/plain', + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([file1, file2], view)).toBe(false) + }) + + test('Disabled for folders', () => { + const folder = new Folder({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/FooBar/', + owner: 'admin', + permissions: Permission.READ, + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([folder], view)).toBe(false) + }) +}) + +describe('View in folder action execute tests', () => { + test('View in folder', async () => { + const goToRouteMock = jest.fn() + window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } } + + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + }) + + const exec = await action.exec(file, view, '/') + // Silent action + expect(exec).toBe(null) + expect(goToRouteMock).toBeCalledTimes(1) + expect(goToRouteMock).toBeCalledWith(null, { view: 'files' }, { dir: '/' }) + }) + + test('View in (sub) folder', async () => { + const goToRouteMock = jest.fn() + window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } } + + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/Bar/foobar.txt', + root: '/files/admin', + owner: 'admin', + mime: 'text/plain', + }) + + const exec = await action.exec(file, view, '/') + // Silent action + expect(exec).toBe(null) + expect(goToRouteMock).toBeCalledTimes(1) + expect(goToRouteMock).toBeCalledWith(null, { view: 'files' }, { dir: '/Foo/Bar' }) + }) + + test('View in folder fails without node', async () => { + const goToRouteMock = jest.fn() + window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } } + + const exec = await action.exec(null as unknown as Node, view, '/') + expect(exec).toBe(false) + expect(goToRouteMock).toBeCalledTimes(0) + }) + + test('View in folder fails without File', async () => { + const goToRouteMock = jest.fn() + window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } } + + const folder = new Folder({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/', + owner: 'admin', + }) + + const exec = await action.exec(folder, view, '/') + expect(exec).toBe(false) + expect(goToRouteMock).toBeCalledTimes(0) + }) +}) diff --git a/apps/files/src/actions/viewInFolderAction.ts b/apps/files/src/actions/viewInFolderAction.ts new file mode 100644 index 00000000000..67e276112dc --- /dev/null +++ b/apps/files/src/actions/viewInFolderAction.ts @@ -0,0 +1,68 @@ +/** + * @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 { Node, FileType } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +import FolderMoveSvg from '@mdi/svg/svg/folder-move.svg?raw' + +import type { Navigation } from '../services/Navigation' +import { join } from 'path' +import { registerFileAction, FileAction } from '../services/FileAction' + +export const action = new FileAction({ + id: 'view-in-folder', + displayName() { + return t('files', 'View in folder') + }, + iconSvgInline: () => FolderMoveSvg, + + enabled(nodes: Node[]) { + // Only works on single node + if (nodes.length !== 1) { + return false + } + + const node = nodes[0] + + if (!node.isDavRessource) { + return false + } + + return node.type === FileType.File + }, + + async exec(node: Node, view: Navigation, dir: string) { + if (!node || node.type !== FileType.File) { + return false + } + + window.OCP.Files.Router.goToRoute( + null, + { view: 'files', fileid: node.fileid }, + { dir: node.dirname, fileid: node.fileid }, + ) + return null + }, + + order: 80, +}) + +registerFileAction(action) diff --git a/apps/files/src/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue index fd61f5e3623..58b914041b2 100644 --- a/apps/files/src/components/FileEntry.vue +++ b/apps/files/src/components/FileEntry.vue @@ -33,38 +33,64 @@ <!-- Link to file --> <td class="files-list__row-name"> - <a ref="name" v-bind="linkAttrs" @click="execDefaultAction"> - <!-- Icon or preview --> - <span class="files-list__row-icon"> - <FolderIcon v-if="source.type === 'folder'" /> - - <!-- Decorative image, should not be aria documented --> - <span v-else-if="previewUrl && !backgroundFailed" - ref="previewImg" - class="files-list__row-icon-preview" - :style="{ backgroundImage }" /> - - <span v-else-if="mimeIconUrl" - class="files-list__row-icon-preview files-list__row-icon-preview--mime" - :style="{ backgroundImage: mimeIconUrl }" /> - - <FileIcon v-else /> - - <!-- Favorite icon --> - <span v-if="isFavorite" - class="files-list__row-icon-favorite" - :aria-label="t('files', 'Favorite')"> - <StarIcon aria-hidden="true" :size="20" /> - </span> + <!-- Icon or preview --> + <span class="files-list__row-icon" @click="execDefaultAction"> + <FolderIcon v-if="source.type === 'folder'" /> + + <!-- Decorative image, should not be aria documented --> + <span v-else-if="previewUrl && !backgroundFailed" + ref="previewImg" + class="files-list__row-icon-preview" + :style="{ backgroundImage }" /> + + <span v-else-if="mimeIconUrl" + class="files-list__row-icon-preview files-list__row-icon-preview--mime" + :style="{ backgroundImage: mimeIconUrl }" /> + + <FileIcon v-else /> + + <!-- Favorite icon --> + <span v-if="isFavorite" + class="files-list__row-icon-favorite" + :aria-label="t('files', 'Favorite')"> + <StarIcon aria-hidden="true" :size="20" /> </span> - + </span> + + <!-- Rename input --> + <form v-show="isRenaming" + v-on-click-outside="stopRenaming" + :aria-hidden="!isRenaming" + :aria-label="t('files', 'Rename file')" + class="files-list__row-rename" + @submit.prevent.stop="onRename"> + <NcTextField ref="renameInput" + :aria-label="t('files', 'File name')" + :autofocus="true" + :minlength="1" + :required="true" + :value.sync="newName" + enterkeyhint="done" + @keyup="checkInputValidity" + @keyup.esc="stopRenaming" /> + </form> + + <a v-show="!isRenaming" + ref="basename" + :aria-hidden="isRenaming" + v-bind="linkTo" + @click="execDefaultAction"> <!-- File name --> - <span class="files-list__row-name-text">{{ displayName }}</span> + <span class="files-list__row-name-text"> + <!-- Keep the displayName stuck to the extension to avoid whitespace rendering issues--> + <span class="files-list__row-name-" v-text="displayName" /> + <span class="files-list__row-name-ext" v-text="source.extension" /> + </span> </a> </td> <!-- Actions --> - <td :class="`files-list__row-actions-${uniqueId}`" class="files-list__row-actions"> + <td v-show="!isRenamingSmallScreen" :class="`files-list__row-actions-${uniqueId}`" class="files-list__row-actions"> <!-- Inline actions --> <!-- TODO: implement CustomElementRender --> @@ -75,12 +101,13 @@ :container="boundariesElement" :disabled="source._loading" :force-title="true" - :force-menu="true" + :force-menu="enabledInlineActions.length === 0 /* forceMenu only if no inline actions */" :inline="enabledInlineActions.length" :open.sync="openedMenu"> <NcActionButton v-for="action in enabledMenuActions" :key="action.id" :class="'files-list__row-action-' + action.id" + :close-after-click="true" @click="onActionClick(action)"> <template #icon> <NcLoadingIcon v-if="loading === action.id" :size="18" /> @@ -99,6 +126,13 @@ <span>{{ size }}</span> </td> + <!-- Mtime --> + <td v-if="isMtimeAvailable" + class="files-list__row-mtime" + @click="openDetailsIfAvailable"> + <span>{{ mtime }}</span> + </td> + <!-- View columns --> <td v-for="column in columns" :key="column.id" @@ -115,11 +149,13 @@ <script lang='ts'> import { debounce } from 'debounce' +import { emit } from '@nextcloud/event-bus' import { formatFileSize } from '@nextcloud/files' import { Fragment } from 'vue-frag' -import { join } from 'path' import { showError, showSuccess } from '@nextcloud/dialogs' import { translate } from '@nextcloud/l10n' +import { vOnClickOutside } from '@vueuse/components' +import axios from '@nextcloud/axios' import CancelablePromise from 'cancelable-promise' import FileIcon from 'vue-material-design-icons/File.vue' import FolderIcon from 'vue-material-design-icons/Folder.vue' @@ -127,18 +163,21 @@ import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' +import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' import StarIcon from 'vue-material-design-icons/Star.vue' import Vue from 'vue' import { ACTION_DETAILS } from '../actions/sidebarAction.ts' -import { getFileActions } from '../services/FileAction.ts' +import { getFileActions, DefaultType } from '../services/FileAction.ts' import { hashCode } from '../utils/hashUtils.ts' import { isCachedPreview } from '../services/PreviewService.ts' import { useActionsMenuStore } from '../store/actionsmenu.ts' import { useFilesStore } from '../store/files.ts' +import type moment from 'moment' import { useKeyboardStore } from '../store/keyboard.ts' import { useSelectionStore } from '../store/selection.ts' import { useUserConfigStore } from '../store/userconfig.ts' +import { useRenamingStore } from '../store/renaming.ts' import CustomElementRender from './CustomElementRender.vue' import CustomSvgIconRender from './CustomSvgIconRender.vue' import logger from '../logger.js' @@ -146,6 +185,8 @@ import logger from '../logger.js' // The registered actions list const actions = getFileActions() +Vue.directive('onClickOutside', vOnClickOutside) + export default Vue.extend({ name: 'FileEntry', @@ -159,6 +200,7 @@ export default Vue.extend({ NcActions, NcCheckboxRadioSwitch, NcLoadingIcon, + NcTextField, StarIcon, }, @@ -167,6 +209,10 @@ export default Vue.extend({ type: Boolean, default: false, }, + isMtimeAvailable: { + type: Boolean, + default: false, + }, isSizeAvailable: { type: Boolean, default: false, @@ -193,12 +239,14 @@ export default Vue.extend({ const actionsMenuStore = useActionsMenuStore() const filesStore = useFilesStore() const keyboardStore = useKeyboardStore() + const renamingStore = useRenamingStore() const selectionStore = useSelectionStore() const userConfigStore = useUserConfigStore() return { actionsMenuStore, filesStore, keyboardStore, + renamingStore, selectionStore, userConfigStore, } @@ -237,8 +285,12 @@ export default Vue.extend({ return this.source?.fileid?.toString?.() }, displayName() { - return this.source.attributes.displayName - || this.source.basename + const ext = (this.source.extension || '') + const name = (this.source.attributes.displayName + || this.source.basename) + + // Strip extension from name if defined + return !ext ? name : name.slice(0, 0 - ext.length) }, size() { @@ -261,32 +313,31 @@ export default Vue.extend({ return minOpacity + (1 - minOpacity) * Math.pow((this.source.size / maxOpacitySize), 2) }, - linkAttrs() { + mtime() { + if (this.source.mtime) { + return moment(this.source.mtime).fromNow() + } + return this.t('files_trashbin', 'A long time ago') + }, + mtimeTitle() { + if (this.source.mtime) { + return moment(this.source.mtime).format('LLL') + } + return '' + }, + + linkTo() { if (this.enabledDefaultActions.length > 0) { const action = this.enabledDefaultActions[0] const displayName = action.displayName([this.source], this.currentView) return { - class: ['files-list__row-default-action', 'files-list__row-action-' + action.id], - role: 'button', title: displayName, - } - } - - /** - * A folder would never reach this point - * as it has open-folder as default action. - * Just to be safe, let's handle it. - */ - if (this.source.type === 'folder') { - const to = { ...this.$route, query: { dir: join(this.dir, this.source.basename) } } - return { - is: 'router-link', - title: this.t('files', 'Open folder {name}', { name: this.displayName }), - to, + role: 'button', } } return { + download: this.source.basename, href: this.source.source, // TODO: Use first action title ? title: this.t('files', 'Download file {name}', { name: this.displayName }), @@ -325,42 +376,29 @@ export default Vue.extend({ return '' }, + // Sorted actions that are enabled for this node enabledActions() { return actions .filter(action => !action.enabled || action.enabled([this.source], this.currentView)) .sort((a, b) => (a.order || 0) - (b.order || 0)) }, + + // Enabled action that are displayed inline enabledInlineActions() { if (this.filesListWidth < 768) { return [] } return this.enabledActions.filter(action => action?.inline?.(this.source, this.currentView)) }, - enabledMenuActions() { - if (this.filesListWidth < 768) { - // If we have a default action, do not render the first one - if (this.enabledDefaultActions.length > 0) { - return this.enabledActions.slice(1) - } - return this.enabledActions - } - - const actions = [ - ...this.enabledInlineActions, - ...this.enabledActions.filter(action => !action.inline), - ] - - // If we have a default action, do not render the first one - if (this.enabledDefaultActions.length > 0) { - return actions.slice(1) - } - return actions - }, + // Default actions enabledDefaultActions() { - return [ - ...this.enabledActions.filter(action => action.default), - ] + return this.enabledActions.filter(action => !!action.default) + }, + + // Actions shown in the menu + enabledMenuActions() { + return this.enabledActions.filter(action => action.default !== DefaultType.HIDDEN) }, openedMenu: { get() { @@ -378,6 +416,21 @@ export default Vue.extend({ isFavorite() { return this.source.attributes.favorite === 1 }, + + isRenaming() { + return this.renamingStore.renamingNode === this.source + }, + isRenamingSmallScreen() { + return this.isRenaming && this.filesListWidth < 512 + }, + newName: { + get() { + return this.renamingStore.newName + }, + set(newName) { + this.renamingStore.newName = newName + }, + }, }, watch: { @@ -400,10 +453,18 @@ export default Vue.extend({ * When the source changes, reset the preview * and fetch the new one. */ - previewUrl() { - this.clearImg() + source() { + this.resetState() this.debounceIfNotCached() }, + + /** + * If renaming starts, select the file name + * in the input, without the extension. + */ + isRenaming() { + this.startRenaming() + }, }, /** @@ -596,6 +657,135 @@ export default Vue.extend({ event.stopPropagation() }, + /** + * Check if the file name is valid and update the + * input validity using browser's native validation. + * @param event the keyup event + */ + checkInputValidity(event: KeyboardEvent) { + const input = event?.target as HTMLInputElement + const newName = this.newName.trim?.() || '' + try { + this.isFileNameValid(newName) + input.setCustomValidity('') + input.title = '' + } catch (e) { + input.setCustomValidity(e.message) + input.title = e.message + } finally { + input.reportValidity() + } + }, + isFileNameValid(name) { + const trimmedName = name.trim() + if (trimmedName === '.' || trimmedName === '..') { + throw new Error(this.t('files', '"{name}" is an invalid file name.', { name })) + } else if (trimmedName.length === 0) { + throw new Error(this.t('files', 'File name cannot be empty.')) + } else if (trimmedName.indexOf('/') !== -1) { + throw new Error(this.t('files', '"/" is not allowed inside a file name.')) + } else if (trimmedName.match(OC.config.blacklist_files_regex)) { + throw new Error(this.t('files', '"{name}" is not an allowed filetype.', { name })) + } else if (this.checkIfNodeExists(name)) { + throw new Error(this.t('files', '{newName} already exists.', { newName: name })) + } + + return true + }, + checkIfNodeExists(name) { + return this.nodes.find(node => node.basename === name && node !== this.source) + }, + + startRenaming() { + this.checkInputValidity() + this.$nextTick(() => { + const extLength = (this.source.extension || '').length + const length = this.source.basename.length - extLength + const input = this.$refs.renameInput?.$refs?.inputField?.$refs?.input + if (!input) { + logger.error('Could not find the rename input') + return + } + input.setSelectionRange(0, length) + input.focus() + }) + }, + stopRenaming() { + if (!this.isRenaming) { + return + } + + // Reset the renaming store + this.renamingStore.$reset() + }, + + // Rename and move the file + async onRename() { + const oldName = this.source.basename + const oldSource = this.source.source + const newName = this.newName.trim?.() || '' + if (newName === '') { + showError(this.t('files', 'Name cannot be empty')) + return + } + + if (oldName === newName) { + this.stopRenaming() + return + } + + // Checking if already exists + if (this.checkIfNodeExists(newName)) { + showError(this.t('files', 'Another entry with the same name already exists')) + return + } + + // Set loading state + this.loading = 'renaming' + Vue.set(this.source, '_loading', true) + + // Update node + this.source.rename(newName) + + try { + await axios({ + method: 'MOVE', + url: oldSource, + headers: { + Destination: encodeURI(this.source.source), + }, + }) + + // Success 🎉 + emit('files:node:updated', this.source) + emit('files:node:renamed', this.source) + showSuccess(this.t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName })) + this.stopRenaming() + this.$nextTick(() => { + this.$refs.basename.focus() + }) + } catch (error) { + logger.error('Error while renaming file', { error }) + this.source.rename(oldName) + this.$refs.renameInput.focus() + + // TODO: 409 means current folder does not exist, redirect ? + if (error?.response?.status === 404) { + showError(this.t('files', 'Could not rename "{oldName}", it does not exist any more', { oldName })) + return + } else if (error?.response?.status === 412) { + showError(this.t('files', 'The name "{newName}"" is already used in the folder "{dir}". Please choose a different name.', { newName, dir: this.dir })) + return + } + + // Unknown error + showError(this.t('files', 'Could not rename "{oldName}"', { oldName })) + } finally { + this.loading = false + Vue.set(this.source, '_loading', false) + } + }, + t: translate, formatFileSize, }, diff --git a/apps/files/src/components/FilesListFooter.vue b/apps/files/src/components/FilesListFooter.vue index 80047f404fc..b4a2d7eda30 100644 --- a/apps/files/src/components/FilesListFooter.vue +++ b/apps/files/src/components/FilesListFooter.vue @@ -43,6 +43,10 @@ <span>{{ totalSize }}</span> </td> + <!-- Mtime --> + <td v-if="isMtimeAvailable" + class="files-list__column files-list__row-mtime" /> + <!-- Custom views columns --> <th v-for="column in columns" :key="column.id" @@ -67,6 +71,10 @@ export default Vue.extend({ }, props: { + isMtimeAvailable: { + type: Boolean, + default: false, + }, isSizeAvailable: { type: Boolean, default: false, diff --git a/apps/files/src/components/FilesListHeader.vue b/apps/files/src/components/FilesListHeader.vue index 2d848d0eefe..d36c9dd46a6 100644 --- a/apps/files/src/components/FilesListHeader.vue +++ b/apps/files/src/components/FilesListHeader.vue @@ -52,6 +52,13 @@ <FilesListHeaderButton :name="t('files', 'Size')" mode="size" /> </th> + <!-- Mtime --> + <th v-if="isMtimeAvailable" + :class="{'files-list__column--sortable': isMtimeAvailable}" + class="files-list__column files-list__row-mtime"> + <FilesListHeaderButton :name="t('files', 'Modified')" mode="mtime" /> + </th> + <!-- Custom views columns --> <th v-for="column in columns" :key="column.id" @@ -91,6 +98,10 @@ export default Vue.extend({ ], props: { + isMtimeAvailable: { + type: Boolean, + default: false, + }, isSizeAvailable: { type: Boolean, default: false, diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue index 866fc6da00d..c1d5c041992 100644 --- a/apps/files/src/components/FilesListVirtual.vue +++ b/apps/files/src/components/FilesListVirtual.vue @@ -35,6 +35,7 @@ <!-- File row --> <FileEntry :active="active" :index="index" + :is-mtime-available="isMtimeAvailable" :is-size-available="isSizeAvailable" :files-list-width="filesListWidth" :nodes="nodes" @@ -50,6 +51,7 @@ <!-- Thead--> <FilesListHeader :files-list-width="filesListWidth" + :is-mtime-available="isMtimeAvailable" :is-size-available="isSizeAvailable" :nodes="nodes" /> </template> @@ -57,6 +59,7 @@ <template #after> <!-- Tfoot--> <FilesListFooter :files-list-width="filesListWidth" + :is-mtime-available="isMtimeAvailable" :is-size-available="isSizeAvailable" :nodes="nodes" :summary="summary" /> @@ -121,6 +124,13 @@ export default Vue.extend({ summary() { return translate('files', '{summaryFile} and {summaryFolder}', this) }, + isMtimeAvailable() { + // Hide mtime column on narrow screens + if (this.filesListWidth < 768) { + return false + } + return this.nodes.some(node => node.mtime !== undefined) + }, isSizeAvailable() { // Hide size column on narrow screens if (this.filesListWidth < 768) { @@ -232,6 +242,7 @@ export default Vue.extend({ } } + // Entry preview or mime icon .files-list__row-icon { position: relative; display: flex; @@ -246,13 +257,18 @@ export default Vue.extend({ margin-right: var(--checkbox-padding); color: var(--color-primary-element); + // Icon is also clickable + * { + cursor: pointer; + } + & > span { justify-content: flex-start; - } - &> span:not(.files-list__row-icon-favorite) svg { - width: var(--icon-preview-size); - height: var(--icon-preview-size); + &:not(.files-list__row-icon-favorite) svg { + width: var(--icon-preview-size); + height: var(--icon-preview-size); + } } &-preview { @@ -270,10 +286,18 @@ export default Vue.extend({ position: absolute; top: 4px; right: -8px; - color: #ffcc00; + color: #a08b00; + // Sow a border around the icon for better contrast + svg path { + stroke: var(--color-main-background); + stroke-width: 10px; + stroke-linejoin: round; + paint-order: stroke + } } } + // Entry link .files-list__row-name { // Prevent link from overflowing overflow: hidden; @@ -286,6 +310,8 @@ export default Vue.extend({ // Fill cell height and width width: 100%; height: 100%; + // Necessary for flex grow to work + min-width: 0; // Keyboard indicator a11y &:focus .files-list__row-name-text, @@ -299,6 +325,31 @@ export default Vue.extend({ // Make some space for the outline padding: 5px 10px; margin-left: -10px; + // Align two name and ext + display: inline-flex; + } + + .files-list__row-name-ext { + color: var(--color-text-maxcontrast); + } + } + + // Rename form + .files-list__row-rename { + width: 100%; + max-width: 600px; + input { + width: 100%; + // Align with text, 0 - padding - border + margin-left: -8px; + padding: 2px 6px; + border-width: 2px; + + &:invalid { + // Show red border on invalid input + border-color: var(--color-error); + color: red; + } } } @@ -323,6 +374,7 @@ export default Vue.extend({ } } + .files-list__row-mtime, .files-list__row-size { // Right align text justify-content: flex-end; @@ -339,6 +391,10 @@ export default Vue.extend({ } } + .files-list__row-mtime { + width: calc(var(--row-height) * 2); + } + .files-list__row-column-custom { width: calc(var(--row-height) * 2); } diff --git a/apps/files/src/main.ts b/apps/files/src/main.ts index 1d96c2f6eaa..2c0fa532570 100644 --- a/apps/files/src/main.ts +++ b/apps/files/src/main.ts @@ -1,8 +1,14 @@ import './templates.js' import './legacy/filelistSearch.js' + import './actions/deleteAction' +import './actions/downloadAction' +import './actions/editLocallyAction' +import './actions/favoriteAction' import './actions/openFolderAction' +import './actions/renameAction' import './actions/sidebarAction' +import './actions/viewInFolderAction' import Vue from 'vue' import { createPinia, PiniaVuePlugin } from 'pinia' @@ -11,6 +17,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 +77,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..79a80706925 --- /dev/null +++ b/apps/files/src/services/DavProperties.ts @@ -0,0 +1,130 @@ +/** + * @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 + * @param prop + * @param namespace + */ +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..0e5411f08d3 --- /dev/null +++ b/apps/files/src/services/Favorites.ts @@ -0,0 +1,106 @@ +/** + * @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 } 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, DAVResultResponseProps } 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>` + +interface ResponseProps extends DAVResultResponseProps { + permissions: string, + fileid: number, + size: number, +} + +const resultToNode = function(node: FileStat): File | Folder { + const props = node.props as ResponseProps + const permissions = parseWebdavPermissions(props?.permissions) + const owner = getCurrentUser()?.uid as string + + const nodeData = { + id: props?.fileid as number || 0, + source: generateRemoteUrl('dav' + rootPath + node.filename), + mtime: new Date(node.lastmod), + mime: node.mime as string, + size: props?.size as number || 0, + permissions, + owner, + root: rootPath, + attributes: { + ...node, + ...props, + hasPreview: props?.['has-preview'], + }, + } + + 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/FileAction.ts b/apps/files/src/services/FileAction.ts index 03cc957e1e5..4798128671c 100644 --- a/apps/files/src/services/FileAction.ts +++ b/apps/files/src/services/FileAction.ts @@ -31,6 +31,11 @@ declare global { } } +export enum DefaultType { + DEFAULT = 'default', + HIDDEN = 'hidden', +} + /** * TODO: remove and move to @nextcloud/files * @see https://github.com/nextcloud/nextcloud-files/pull/608 @@ -60,7 +65,7 @@ interface FileActionData { /** This action order in the list */ order?: number, /** Make this action the default */ - default?: boolean, + default?: DefaultType, /** * If true, the renderInline function will be called */ @@ -110,7 +115,7 @@ export class FileAction { } get default() { - return this._action.default === true + return this._action.default } get inline() { @@ -151,7 +156,7 @@ export class FileAction { throw new Error('Invalid order') } - if ('default' in action && typeof action.default !== 'boolean') { + if (action.default && !Object.values(DefaultType).includes(action.default)) { throw new Error('Invalid default') } diff --git a/apps/files/src/services/Navigation.ts b/apps/files/src/services/Navigation.ts index b2ae3b0b973..767ab197c39 100644 --- a/apps/files/src/services/Navigation.ts +++ b/apps/files/src/services/Navigation.ts @@ -19,7 +19,7 @@ * along with this program. If not, see <http://www.gnu.org/licenses/>. * */ -/* eslint-disable */ +/* eslint-disable no-use-before-define */ import type { Folder, Node } from '@nextcloud/files' import isSvg from 'is-svg' @@ -39,8 +39,10 @@ export interface Column { render: (node: Node, view: Navigation) => HTMLElement /** Function used to sort Nodes between them */ sort?: (nodeA: Node, nodeB: Node) => number - /** Custom summary of the column to display at the end of the list. - Will not be displayed if nothing is provided */ + /** + * Custom summary of the column to display at the end of the list. + * Will not be displayed if nothing is provided + */ summary?: (node: Node[], view: Navigation) => string } @@ -49,6 +51,8 @@ export interface Navigation { id: string /** Translated view name */ name: string + /** Translated view accessible description */ + caption?: string /** * Method return the content of the provided path * This ideally should be a cancellable promise. @@ -62,8 +66,11 @@ export interface Navigation { icon: string /** The view order */ order: number - /** This view column(s). Name and actions are - by default always included */ + + /** + * This view column(s). Name and actions are + * by default always included + */ columns?: Column[] /** The empty view element to render your empty content into */ emptyView?: (div: HTMLDivElement) => void @@ -71,7 +78,9 @@ export interface Navigation { parent?: string /** This view is sticky (sent at the bottom) */ sticky?: boolean - /** This view has children and is expanded or not, + + /** + * This view has children and is expanded or not, * will be overridden by user config. */ expanded?: boolean @@ -79,7 +88,7 @@ export interface Navigation { /** * Will be used as default if the user * haven't customized their sorting column - * */ + */ defaultSortKey?: string /** @@ -88,8 +97,9 @@ export interface Navigation { * @deprecated It will be removed in a near future */ legacy?: boolean + /** - * An icon class. + * An icon class. * @deprecated It will be removed in a near future */ iconClass?: string @@ -171,6 +181,11 @@ const isValidNavigation = function(view: Navigation): boolean { throw new Error('Navigation name is required and must be a string') } + if (view.columns && view.columns.length > 0 + && (!view.caption || typeof view.caption !== 'string')) { + throw new Error('Navigation caption is required for top-level views and must be a string') + } + /** * Legacy handle their content and icon differently * TODO: remove when support for legacy views is removed diff --git a/apps/files/src/services/WebdavClient.ts b/apps/files/src/services/WebdavClient.ts new file mode 100644 index 00000000000..ae2ab27b9db --- /dev/null +++ b/apps/files/src/services/WebdavClient.ts @@ -0,0 +1,56 @@ +/** + * @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 { RequestOptions, Response } from 'webdav' + +import { createClient, getPatcher } from 'webdav' +import { generateRemoteUrl } from '@nextcloud/router' +import { getCurrentUser, getRequestToken } from '@nextcloud/auth' +import { request } from 'webdav/dist/node/request.js' + +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() + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + // https://github.com/perry-mitchell/hot-patcher/issues/6 + patcher.patch('request', (options: RequestOptions): Promise<Response> => { + if (options.headers?.method) { + options.method = options.headers.method + delete options.headers.method + } + return request(options) + }) + return client +} diff --git a/apps/files/src/store/actionsmenu.ts b/apps/files/src/store/actionsmenu.ts index 66b1914ffbd..24689c13f89 100644 --- a/apps/files/src/store/actionsmenu.ts +++ b/apps/files/src/store/actionsmenu.ts @@ -19,7 +19,6 @@ * along with this program. If not, see <http://www.gnu.org/licenses/>. * */ -/* eslint-disable */ import { defineStore } from 'pinia' import type { ActionsMenuStore } from '../types' diff --git a/apps/files/src/store/files.ts b/apps/files/src/store/files.ts index bd7d3202dd9..ac62512988b 100644 --- a/apps/files/src/store/files.ts +++ b/apps/files/src/store/files.ts @@ -19,17 +19,15 @@ * along with this program. If not, see <http://www.gnu.org/licenses/>. * */ -/* eslint-disable */ import type { Folder, Node } from '@nextcloud/files' -import type { FilesStore, RootsStore, RootOptions, Service, FilesState } from '../types.ts' +import type { FilesStore, RootsStore, RootOptions, Service, FilesState, FileId } from '../types' import { defineStore } from 'pinia' import { subscribe } from '@nextcloud/event-bus' import logger from '../logger' -import type { FileId } from '../types' import Vue from 'vue' -export const useFilesStore = function() { +export const useFilesStore = function(...args) { const store = defineStore('files', { state: (): FilesState => ({ files: {} as FilesStore, @@ -40,7 +38,7 @@ export const useFilesStore = function() { /** * Get a file or folder by id */ - getNode: (state) => (id: FileId): Node|undefined => state.files[id], + getNode: (state) => (id: FileId): Node|undefined => state.files[id], /** * Get a list of files or folders by their IDs @@ -52,7 +50,7 @@ export const useFilesStore = function() { /** * Get a file or folder by id */ - getRoot: (state) => (service: Service): Folder|undefined => state.roots[service], + getRoot: (state) => (service: Service): Folder|undefined => state.roots[service], }, actions: { @@ -67,7 +65,7 @@ export const useFilesStore = function() { return acc }, {} as FilesStore) - Vue.set(this, 'files', {...this.files, ...files}) + Vue.set(this, 'files', { ...this.files, ...files }) }, deleteNodes(nodes: Node[]) { @@ -85,10 +83,10 @@ export const useFilesStore = function() { onDeletedNode(node: Node) { this.deleteNodes([node]) }, - } + }, }) - const fileStore = store(...arguments) + const fileStore = store(...args) // Make sure we only register the listeners once if (!fileStore._initialized) { // subscribe('files:node:created', fileStore.onCreatedNode) diff --git a/apps/files/src/store/keyboard.ts b/apps/files/src/store/keyboard.ts index bdce7d55075..6bd40a6dfa7 100644 --- a/apps/files/src/store/keyboard.ts +++ b/apps/files/src/store/keyboard.ts @@ -19,7 +19,6 @@ * along with this program. If not, see <http://www.gnu.org/licenses/>. * */ -/* eslint-disable */ import { defineStore } from 'pinia' import Vue from 'vue' @@ -28,9 +27,9 @@ import Vue from 'vue' * special keys states. Useful for checking the * current status of a key when executing a method. */ -export const useKeyboardStore = function() { +export const useKeyboardStore = function(...args) { const store = defineStore('keyboard', { - state: () => ({ + state: () => ({ altKey: false, ctrlKey: false, metaKey: false, @@ -47,10 +46,10 @@ export const useKeyboardStore = function() { Vue.set(this, 'metaKey', !!event.metaKey) Vue.set(this, 'shiftKey', !!event.shiftKey) }, - } + }, }) - const keyboardStore = store(...arguments) + const keyboardStore = store(...args) // Make sure we only register the listeners once if (!keyboardStore._initialized) { window.addEventListener('keydown', keyboardStore.onEvent) diff --git a/apps/files/src/store/paths.ts b/apps/files/src/store/paths.ts index ecff97bf00c..6164e664498 100644 --- a/apps/files/src/store/paths.ts +++ b/apps/files/src/store/paths.ts @@ -19,18 +19,14 @@ * along with this program. If not, see <http://www.gnu.org/licenses/>. * */ -/* eslint-disable */ -import type { PathOptions, ServicesState } from '../types.ts' - +import type { FileId, PathsStore, PathOptions, ServicesState } from '../types' import { defineStore } from 'pinia' -import { subscribe } from '@nextcloud/event-bus' -import type { FileId, PathsStore } from '../types' import Vue from 'vue' -export const usePathsStore = function() { +export const usePathsStore = function(...args) { const store = defineStore('paths', { state: () => ({ - paths: {} as ServicesState + paths: {} as ServicesState, } as PathsStore), getters: { @@ -54,10 +50,10 @@ export const usePathsStore = function() { // Now we can set the provided path Vue.set(this.paths[payload.service], payload.path, payload.fileid) }, - } + }, }) - const pathsStore = store(...arguments) + const pathsStore = store(...args) // Make sure we only register the listeners once if (!pathsStore._initialized) { // TODO: watch folders to update paths? diff --git a/apps/files/src/store/renaming.ts b/apps/files/src/store/renaming.ts new file mode 100644 index 00000000000..6ba024f18b0 --- /dev/null +++ b/apps/files/src/store/renaming.ts @@ -0,0 +1,47 @@ +/** + * @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 { defineStore } from 'pinia' +import { subscribe } from '@nextcloud/event-bus' +import type { Node } from '@nextcloud/files' +import type { RenamingStore } from '../types' + +export const useRenamingStore = function(...args) { + const store = defineStore('renaming', { + state: () => ({ + renamingNode: undefined, + newName: '', + } as RenamingStore), + }) + + const renamingStore = store(...args) + + // Make sure we only register the listeners once + if (!renamingStore._initialized) { + subscribe('files:node:rename', function(node: Node) { + renamingStore.renamingNode = node + renamingStore.newName = node.basename + }) + renamingStore._initialized = true + } + + return renamingStore +} diff --git a/apps/files/src/store/selection.ts b/apps/files/src/store/selection.ts index 0d67420e963..251bb804b9a 100644 --- a/apps/files/src/store/selection.ts +++ b/apps/files/src/store/selection.ts @@ -19,7 +19,6 @@ * along with this program. If not, see <http://www.gnu.org/licenses/>. * */ -/* eslint-disable */ import { defineStore } from 'pinia' import Vue from 'vue' import { FileId, SelectionStore } from '../types' @@ -55,6 +54,6 @@ export const useSelectionStore = defineStore('selection', { Vue.set(this, 'selected', []) Vue.set(this, 'lastSelection', []) Vue.set(this, 'lastSelectedIndex', null) - } - } + }, + }, }) diff --git a/apps/files/src/store/userconfig.ts b/apps/files/src/store/userconfig.ts index 42821951dbf..42530a3b54d 100644 --- a/apps/files/src/store/userconfig.ts +++ b/apps/files/src/store/userconfig.ts @@ -19,21 +19,21 @@ * along with this program. If not, see <http://www.gnu.org/licenses/>. * */ -/* eslint-disable */ -import { loadState } from '@nextcloud/initial-state' -import { generateUrl } from '@nextcloud/router' +import type { UserConfig, UserConfigStore } from '../types' import { defineStore } from 'pinia' -import Vue from 'vue' -import axios from '@nextcloud/axios' -import type { UserConfig, UserConfigStore } from '../types.ts' import { emit, subscribe } from '@nextcloud/event-bus' +import { generateUrl } from '@nextcloud/router' +import { loadState } from '@nextcloud/initial-state' +import axios from '@nextcloud/axios' +import Vue from 'vue' const userConfig = loadState('files', 'config', { show_hidden: false, crop_image_previews: true, + sort_favorites_first: true, }) as UserConfig -export const useUserConfigStore = function() { +export const useUserConfigStore = function(...args) { const store = defineStore('userconfig', { state: () => ({ userConfig, @@ -56,11 +56,11 @@ export const useUserConfigStore = function() { }) emit('files:config:updated', { key, value }) - } - } + }, + }, }) - const userConfigStore = store(...arguments) + const userConfigStore = store(...args) // Make sure we only register the listeners once if (!userConfigStore._initialized) { @@ -72,4 +72,3 @@ export const useUserConfigStore = function() { return userConfigStore } - diff --git a/apps/files/src/store/viewConfig.ts b/apps/files/src/store/viewConfig.ts index 607596dfd68..7cc0818f8a4 100644 --- a/apps/files/src/store/viewConfig.ts +++ b/apps/files/src/store/viewConfig.ts @@ -19,7 +19,6 @@ * along with this program. If not, see <http://www.gnu.org/licenses/>. * */ -/* eslint-disable */ import { defineStore } from 'pinia' import { emit, subscribe } from '@nextcloud/event-bus' import { generateUrl } from '@nextcloud/router' @@ -27,12 +26,11 @@ import { loadState } from '@nextcloud/initial-state' import axios from '@nextcloud/axios' import Vue from 'vue' -import type { ViewConfigs, ViewConfigStore, ViewId } from '../types' -import type { ViewConfig } from '../types' +import type { ViewConfigs, ViewConfigStore, ViewId, ViewConfig } from '../types' const viewConfig = loadState('files', 'viewConfigs', {}) as ViewConfigs -export const useViewConfigStore = function() { +export const useViewConfigStore = function(...args) { const store = defineStore('viewconfig', { state: () => ({ viewConfig, @@ -69,26 +67,26 @@ export const useViewConfigStore = function() { * The key param must be a valid key of a File object * If not found, will be searched within the File attributes */ - setSortingBy(key: string = 'basename', view: string = 'files') { + setSortingBy(key = 'basename', view = 'files') { // Save new config this.update(view, 'sorting_mode', key) this.update(view, 'sorting_direction', 'asc') }, - + /** * Toggle the sorting direction */ - toggleSortingDirection(view: string = 'files') { - const config = this.getConfig(view) || { 'sorting_direction': 'asc' } + toggleSortingDirection(view = 'files') { + const config = this.getConfig(view) || { sorting_direction: 'asc' } const newDirection = config.sorting_direction === 'asc' ? 'desc' : 'asc' - + // Save new config this.update(view, 'sorting_direction', newDirection) - } - } + }, + }, }) - const viewConfigStore = store(...arguments) + const viewConfigStore = store(...args) // Make sure we only register the listeners once if (!viewConfigStore._initialized) { @@ -100,4 +98,3 @@ export const useViewConfigStore = function() { return viewConfigStore } - diff --git a/apps/files/src/types.ts b/apps/files/src/types.ts index c04a9538827..8035d9dc198 100644 --- a/apps/files/src/types.ts +++ b/apps/files/src/types.ts @@ -19,9 +19,7 @@ * along with this program. If not, see <http://www.gnu.org/licenses/>. * */ -/* eslint-disable */ -import type { Folder } from '@nextcloud/files' -import type { Node } from '@nextcloud/files' +import type { Folder, Node } from '@nextcloud/files' // Global definitions export type Service = string @@ -29,11 +27,6 @@ export type FileId = number export type ViewId = string // Files store -export type FilesState = { - files: FilesStore, - roots: RootsStore, -} - export type FilesStore = { [fileid: FileId]: Node } @@ -42,20 +35,25 @@ export type RootsStore = { [service: Service]: Folder } +export type FilesState = { + files: FilesStore, + roots: RootsStore, +} + export interface RootOptions { root: Folder service: Service } // Paths store -export type ServicesState = { - [service: Service]: PathConfig -} - export type PathConfig = { [path: string]: number } +export type ServicesState = { + [service: Service]: PathConfig +} + export type PathsStore = { paths: ServicesState } @@ -96,3 +94,9 @@ export interface ViewConfigs { export interface ViewConfigStore { viewConfig: ViewConfigs } + +// Renaming store +export interface RenamingStore { + renamingNode?: Node + newName: string +} diff --git a/apps/files/src/views/FilesList.vue b/apps/files/src/views/FilesList.vue index f2a20c18f28..8b42cfe6133 100644 --- a/apps/files/src/views/FilesList.vue +++ b/apps/files/src/views/FilesList.vue @@ -78,6 +78,7 @@ import Vue from 'vue' import { useFilesStore } from '../store/files.ts' import { usePathsStore } from '../store/paths.ts' import { useSelectionStore } from '../store/selection.ts' +import { useUserConfigStore } from '../store/userconfig.ts' import { useViewConfigStore } from '../store/viewConfig.ts' import BreadCrumbs from '../components/BreadCrumbs.vue' import FilesListVirtual from '../components/FilesListVirtual.vue' @@ -103,14 +104,16 @@ export default Vue.extend({ ], setup() { - const pathsStore = usePathsStore() const filesStore = useFilesStore() + const pathsStore = usePathsStore() const selectionStore = useSelectionStore() + const userConfigStore = useUserConfigStore() const viewConfigStore = useViewConfigStore() return { filesStore, pathsStore, selectionStore, + userConfigStore, viewConfigStore, } }, @@ -123,6 +126,10 @@ export default Vue.extend({ }, computed: { + userConfig() { + return this.userConfigStore.userConfig + }, + /** @return {Navigation} */ currentView() { return this.$navigation.active @@ -179,11 +186,13 @@ export default Vue.extend({ return orderBy( [...(this.currentFolder?._children || []).map(this.getNode).filter(file => file)], [ + // Sort favorites first if enabled + ...this.userConfig.sort_favorites_first ? [v => v.attributes?.favorite !== 1] : [], // Sort folders first if sorting by name ...this.sortingMode === 'basename' ? [v => v.type !== 'folder'] : [], // Use sorting mode v => v[this.sortingMode], - // Fallback to name + // Finally, fallback to name v => v.basename, ], this.isAscSorting ? ['asc', 'asc', 'asc'] : ['desc', 'desc', 'desc'], diff --git a/apps/files/src/views/Navigation.cy.ts b/apps/files/src/views/Navigation.cy.ts index 97f1f8ee7b8..65964832e8a 100644 --- a/apps/files/src/views/Navigation.cy.ts +++ b/apps/files/src/views/Navigation.cy.ts @@ -4,7 +4,7 @@ import FolderSvg from '@mdi/svg/svg/folder.svg' import ShareSvg from '@mdi/svg/svg/share-variant.svg' import { createTestingPinia } from '@pinia/testing' -import NavigationService from '../services/Navigation.ts' +import NavigationService from '../services/Navigation' import NavigationView from './Navigation.vue' import router from '../router/router.js' import { useViewConfigStore } from '../store/viewConfig' diff --git a/apps/files/src/views/Settings.vue b/apps/files/src/views/Settings.vue index efd9f8cad22..82580d7c504 100644 --- a/apps/files/src/views/Settings.vue +++ b/apps/files/src/views/Settings.vue @@ -26,6 +26,10 @@ @update:open="onClose"> <!-- Settings API--> <NcAppSettingsSection id="settings" :title="t('files', 'Files settings')"> + <NcCheckboxRadioSwitch :checked="userConfig.sort_favorites_first" + @update:checked="setConfig('sort_favorites_first', $event)"> + {{ t('files', 'Sort favorites first') }} + </NcCheckboxRadioSwitch> <NcCheckboxRadioSwitch :checked="userConfig.show_hidden" @update:checked="setConfig('show_hidden', $event)"> {{ t('files', 'Show hidden files') }} diff --git a/apps/files/src/views/favorites.spec.ts b/apps/files/src/views/favorites.spec.ts new file mode 100644 index 00000000000..a1999624f2f --- /dev/null +++ b/apps/files/src/views/favorites.spec.ts @@ -0,0 +1,193 @@ +/** + * @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 { expect } from '@jest/globals' +import * as initialState from '@nextcloud/initial-state' +import { Folder } from '@nextcloud/files' +import { basename } from 'path' +import * as eventBus from '@nextcloud/event-bus' + +import { action } from '../actions/favoriteAction' +import * as favoritesService from '../services/Favorites' +import NavigationService from '../services/Navigation' +import registerFavoritesView from './favorites' + +jest.mock('webdav/dist/node/request.js', () => ({ + request: jest.fn(), +})) + +global.window.OC = { + TAG_FAVORITE: '_$!<Favorite>!$_', +} + +describe('Favorites view definition', () => { + let Navigation + beforeEach(() => { + Navigation = new NavigationService() + window.OCP = { Files: { Navigation } } + }) + + afterAll(() => { + delete window.OCP + }) + + test('Default empty favorite view', () => { + jest.spyOn(eventBus, 'subscribe') + jest.spyOn(favoritesService, 'getContents').mockReturnValue(Promise.resolve({ folder: {} as Folder, contents: [] })) + + registerFavoritesView() + const favoritesView = Navigation.views.find(view => view.id === 'favorites') + const favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites') + + expect(eventBus.subscribe).toHaveBeenCalledTimes(2) + expect(eventBus.subscribe).toHaveBeenNthCalledWith(1, 'files:favorites:added', expect.anything()) + expect(eventBus.subscribe).toHaveBeenNthCalledWith(2, 'files:favorites:removed', expect.anything()) + + // one main view and no children + expect(Navigation.views.length).toBe(1) + expect(favoritesView).toBeDefined() + expect(favoriteFoldersViews.length).toBe(0) + + expect(favoritesView?.id).toBe('favorites') + expect(favoritesView?.name).toBe('Favorites') + expect(favoritesView?.caption).toBe('List of favorites files and folders.') + expect(favoritesView?.icon).toBe('<svg>SvgMock</svg>') + expect(favoritesView?.order).toBe(5) + expect(favoritesView?.columns).toStrictEqual([]) + expect(favoritesView?.getContents).toBeDefined() + }) + + test('Default with favorites', () => { + const favoriteFolders = [ + '/foo', + '/bar', + '/foo/bar', + ] + jest.spyOn(initialState, 'loadState').mockReturnValue(favoriteFolders) + jest.spyOn(favoritesService, 'getContents').mockReturnValue(Promise.resolve({ folder: {} as Folder, contents: [] })) + + registerFavoritesView() + const favoritesView = Navigation.views.find(view => view.id === 'favorites') + const favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites') + + // one main view and 3 children + expect(Navigation.views.length).toBe(4) + expect(favoritesView).toBeDefined() + expect(favoriteFoldersViews.length).toBe(3) + + favoriteFolders.forEach((folder, index) => { + const favoriteView = favoriteFoldersViews[index] + expect(favoriteView).toBeDefined() + expect(favoriteView?.id).toBeDefined() + expect(favoriteView?.name).toBe(basename(folder)) + expect(favoriteView?.icon).toBe('<svg>SvgMock</svg>') + expect(favoriteView?.order).toBe(index) + expect(favoriteView?.params).toStrictEqual({ + dir: folder, + view: 'favorites', + }) + expect(favoriteView?.parent).toBe('favorites') + expect(favoriteView?.columns).toStrictEqual([]) + expect(favoriteView?.getContents).toBeDefined() + }) + }) +}) + +describe('Dynamic update of favourite folders', () => { + let Navigation + beforeEach(() => { + Navigation = new NavigationService() + window.OCP = { Files: { Navigation } } + }) + + afterAll(() => { + delete window.OCP + }) + + test('Add a favorite folder creates a new entry in the navigation', async () => { + jest.spyOn(eventBus, 'emit') + jest.spyOn(initialState, 'loadState').mockReturnValue([]) + jest.spyOn(favoritesService, 'getContents').mockReturnValue(Promise.resolve({ folder: {} as Folder, contents: [] })) + + registerFavoritesView() + const favoritesView = Navigation.views.find(view => view.id === 'favorites') + const favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites') + + // one main view and no children + expect(Navigation.views.length).toBe(1) + expect(favoritesView).toBeDefined() + expect(favoriteFoldersViews.length).toBe(0) + + // Create new folder to favorite + const folder = new Folder({ + id: 1, + source: 'http://localhost/remote.php/dav/files/admin/Foo/Bar', + owner: 'admin', + }) + + // Exec the action + await action.exec(folder, favoritesView, '/') + + expect(eventBus.emit).toHaveBeenCalledTimes(1) + expect(eventBus.emit).toHaveBeenCalledWith('files:favorites:added', folder) + }) + + test('Remove a favorite folder remove the entry from the navigation column', async () => { + jest.spyOn(eventBus, 'emit') + jest.spyOn(eventBus, 'subscribe') + jest.spyOn(initialState, 'loadState').mockReturnValue(['/Foo/Bar']) + jest.spyOn(favoritesService, 'getContents').mockReturnValue(Promise.resolve({ folder: {} as Folder, contents: [] })) + + registerFavoritesView() + let favoritesView = Navigation.views.find(view => view.id === 'favorites') + let favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites') + + // one main view and no children + expect(Navigation.views.length).toBe(2) + expect(favoritesView).toBeDefined() + expect(favoriteFoldersViews.length).toBe(1) + + // Create new folder to favorite + const folder = new Folder({ + id: 1, + source: 'http://localhost/remote.php/dav/files/admin/Foo/Bar', + owner: 'admin', + root: '/files/admin', + attributes: { + favorite: 1, + }, + }) + + // Exec the action + await action.exec(folder, favoritesView, '/') + + expect(eventBus.emit).toHaveBeenCalledTimes(1) + expect(eventBus.emit).toHaveBeenCalledWith('files:favorites:removed', folder) + + favoritesView = Navigation.views.find(view => view.id === 'favorites') + favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites') + + // one main view and no children + expect(Navigation.views.length).toBe(1) + expect(favoritesView).toBeDefined() + expect(favoriteFoldersViews.length).toBe(0) + }) +}) diff --git a/apps/files/src/views/favorites.ts b/apps/files/src/views/favorites.ts new file mode 100644 index 00000000000..73293668664 --- /dev/null +++ b/apps/files/src/views/favorites.ts @@ -0,0 +1,150 @@ +/** + * @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 { Navigation } from '../services/Navigation' +import type NavigationService from '../services/Navigation' +import { getLanguage, translate as t } from '@nextcloud/l10n' +import FolderSvg from '@mdi/svg/svg/folder.svg?raw' +import StarSvg from '@mdi/svg/svg/star.svg?raw' + +import { basename } from 'path' +import { getContents } from '../services/Favorites' +import { hashCode } from '../utils/hashUtils' +import { loadState } from '@nextcloud/initial-state' +import { Node, FileType } from '@nextcloud/files' +import { subscribe } from '@nextcloud/event-bus' +import logger from '../logger' + +export const generateFolderView = function(folder: string, index = 0): Navigation { + return { + id: generateIdFromPath(folder), + name: basename(folder), + + icon: FolderSvg, + order: index, + params: { + dir: folder, + view: 'favorites', + }, + + parent: 'favorites', + + columns: [], + + getContents, + } as Navigation +} + +export const generateIdFromPath = function(path: string): string { + return `favorite-${hashCode(path)}` +} + +export default () => { + // Load state in function for mock testing purposes + const favoriteFolders = loadState<string[]>('files', 'favoriteFolders', []) + const favoriteFoldersViews = favoriteFolders.map((folder, index) => generateFolderView(folder, index)) + + 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) + + favoriteFoldersViews.forEach(view => Navigation.register(view)) + + /** + * 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 + } + + addPathToFavorites(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 + } + + removePathFromFavorites(node.path) + }) + + /** + * Sort the favorites paths array and + * update the order property of the existing views + */ + const updateAndSortViews = function() { + favoriteFolders.sort((a, b) => a.localeCompare(b, getLanguage(), { ignorePunctuation: true })) + favoriteFolders.forEach((folder, index) => { + const view = favoriteFoldersViews.find(view => view.id === generateIdFromPath(folder)) + if (view) { + view.order = index + } + }) + } + + // Add a folder to the favorites paths array and update the views + const addPathToFavorites = function(path: string) { + const view = generateFolderView(path) + // Update arrays + favoriteFolders.push(path) + favoriteFoldersViews.push(view) + // Update and sort views + updateAndSortViews() + Navigation.register(view) + } + + // Remove a folder from the favorites paths array and update the views + const removePathFromFavorites = function(path: string) { + const id = generateIdFromPath(path) + const index = favoriteFolders.findIndex(f => f === path) + // Update arrays + favoriteFolders.splice(index, 1) + favoriteFoldersViews.splice(index, 1) + Navigation.remove(id) + updateAndSortViews() + } +} diff --git a/apps/files/tests/Controller/ViewControllerTest.php b/apps/files/tests/Controller/ViewControllerTest.php index 64f0f10671c..daafb92e322 100644 --- a/apps/files/tests/Controller/ViewControllerTest.php +++ b/apps/files/tests/Controller/ViewControllerTest.php @@ -199,65 +199,6 @@ class ViewControllerTest extends TestCase { 'expanded' => false, 'unread' => 0, ], - 'favorites' => [ - 'id' => 'favorites', - 'appname' => 'files', - 'script' => 'simplelist.php', - 'order' => 5, - 'name' => \OC::$server->getL10N('files')->t('Favorites'), - 'active' => false, - 'icon' => '', - 'type' => 'link', - 'classes' => 'collapsible', - 'sublist' => [ - [ - 'id' => '-test1', - 'dir' => '/test1', - 'order' => 6, - 'name' => 'test1', - 'icon' => 'folder', - 'params' => [ - 'view' => 'files', - 'dir' => '/test1', - ], - ], - [ - 'name' => 'test2', - 'id' => '-test2-', - 'dir' => '/test2/', - 'order' => 7, - 'icon' => 'folder', - 'params' => [ - 'view' => 'files', - 'dir' => '/test2/', - ], - ], - [ - 'name' => 'sub4', - 'id' => '-test3-sub4', - 'dir' => '/test3/sub4', - 'order' => 8, - 'icon' => 'folder', - 'params' => [ - 'view' => 'files', - 'dir' => '/test3/sub4', - ], - ], - [ - 'name' => 'sub6', - 'id' => '-test5-sub6-', - 'dir' => '/test5/sub6/', - 'order' => 9, - 'icon' => 'folder', - 'params' => [ - 'view' => 'files', - 'dir' => '/test5/sub6/', - ], - ], - ], - 'expanded' => false, - 'unread' => 0, - ], 'systemtagsfilter' => [ 'id' => 'systemtagsfilter', 'appname' => 'systemtags', @@ -347,10 +288,6 @@ class ViewControllerTest extends TestCase { 'id' => 'recent', 'content' => null, ], - 'favorites' => [ - 'id' => 'favorites', - 'content' => null, - ], 'systemtagsfilter' => [ 'id' => 'systemtagsfilter', 'content' => null, @@ -379,22 +316,6 @@ class ViewControllerTest extends TestCase { 'id' => 'shareoverview', 'content' => null, ], - '-test1' => [ - 'id' => '-test1', - 'content' => '', - ], - '-test2-' => [ - 'id' => '-test2-', - 'content' => '', - ], - '-test3-sub4' => [ - 'id' => '-test3-sub4', - 'content' => '', - ], - '-test5-sub6-' => [ - 'id' => '-test5-sub6-', - 'content' => '', - ], ], 'hiddenFields' => [], 'showgridview' => null diff --git a/apps/files/tests/js/favoritesfilelistspec.js b/apps/files/tests/js/favoritesfilelistspec.js deleted file mode 100644 index 5d1ad2312f2..00000000000 --- a/apps/files/tests/js/favoritesfilelistspec.js +++ /dev/null @@ -1,116 +0,0 @@ -/** - * Copyright (c) 2014 Vincent Petry <pvince81@owncloud.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Jan-Christoph Borchardt <hey@jancborchardt.net> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ - -describe('OCA.Files.FavoritesFileList tests', function() { - var fileList; - - beforeEach(function() { - // init parameters and test table elements - $('#testArea').append( - '<div id="app-content">' + - // init horrible parameters - '<input type="hidden" id="permissions" value="31"></input>' + - // dummy controls - '<div class="files-controls">' + - ' <div class="actions creatable"></div>' + - ' <div class="notCreatable"></div>' + - '</div>' + - // dummy table - // TODO: at some point this will be rendered by the fileList class itself! - '<table class="files-filestable list-container view-grid">' + - '<thead><tr>' + - '<th class="hidden column-name">' + - '<a class="name columntitle" data-sort="name"><span>Name</span><span class="sort-indicator"></span></a>' + - '</th>' + - '<th class="hidden column-mtime">' + - '<a class="columntitle" data-sort="mtime"><span class="sort-indicator"></span></a>' + - '</th>' + - '</tr></thead>' + - '<tbody class="files-fileList"></tbody>' + - '<tfoot></tfoot>' + - '</table>' + - '<div class="emptyfilelist emptycontent">Empty content message</div>' + - '</div>' - ); - }); - - describe('loading file list', function() { - var fetchStub; - - beforeEach(function() { - fileList = new OCA.Files.FavoritesFileList( - $('#app-content') - ); - OCA.Files.FavoritesPlugin.attach(fileList); - - fetchStub = sinon.stub(fileList.filesClient, 'getFilteredFiles'); - }); - afterEach(function() { - fetchStub.restore(); - fileList.destroy(); - fileList = undefined; - }); - it('render files', function(done) { - var deferred = $.Deferred(); - fetchStub.returns(deferred.promise()); - - fileList.reload(); - - expect(fetchStub.calledOnce).toEqual(true); - - deferred.resolve(207, [{ - id: 7, - name: 'test.txt', - path: '/somedir', - size: 123, - mtime: 11111000, - tags: [OC.TAG_FAVORITE], - permissions: OC.PERMISSION_ALL, - mimetype: 'text/plain' - }]); - - setTimeout(function() { - var $rows = fileList.$el.find('tbody tr'); - var $tr = $rows.eq(0); - expect($rows.length).toEqual(1); - expect($tr.attr('data-id')).toEqual('7'); - expect($tr.attr('data-type')).toEqual('file'); - expect($tr.attr('data-file')).toEqual('test.txt'); - expect($tr.attr('data-path')).toEqual('/somedir'); - expect($tr.attr('data-size')).toEqual('123'); - expect(parseInt($tr.attr('data-permissions'), 10)) - .toEqual(OC.PERMISSION_ALL); - expect($tr.attr('data-mime')).toEqual('text/plain'); - expect($tr.attr('data-mtime')).toEqual('11111000'); - expect($tr.find('a.name').attr('href')).toEqual( - OC.getRootPath() + - '/remote.php/webdav/somedir/test.txt' - ); - expect($tr.find('.nametext').text().trim()).toEqual('test.txt'); - - done(); - }, 0); - }); - }); -}); diff --git a/apps/files/tests/js/favoritespluginspec.js b/apps/files/tests/js/favoritespluginspec.js deleted file mode 100644 index ca0cea8b29a..00000000000 --- a/apps/files/tests/js/favoritespluginspec.js +++ /dev/null @@ -1,119 +0,0 @@ -/** - * Copyright (c) 2014 Vincent Petry <pvince81@owncloud.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ - -describe('OCA.Files.FavoritesPlugin tests', function() { - var Plugin = OCA.Files.FavoritesPlugin; - var fileList; - - beforeEach(function() { - $('#testArea').append( - '<div id="content">' + - '<div id="app-navigation">' + - '<ul><li data-id="files"><a>Files</a></li>' + - '<li data-id="sharingin"><a></a></li>' + - '<li data-id="sharingout"><a></a></li>' + - '</ul></div>' + - '<div id="app-content">' + - '<div id="app-content-files" class="hidden">' + - '</div>' + - '<div id="app-content-favorites" class="hidden">' + - '</div>' + - '</div>' + - '</div>' + - '</div>' - ); - OC.Plugins.attach('OCA.Files.App', Plugin); - fileList = Plugin.showFileList($('#app-content-favorites')); - }); - afterEach(function() { - OC.Plugins.detach('OCA.Files.App', Plugin); - }); - - describe('initialization', function() { - it('inits favorites list on show', function() { - expect(fileList).toBeDefined(); - }); - }); - describe('file actions', function() { - it('provides default file actions', function() { - var fileActions = fileList.fileActions; - - expect(fileActions.actions.all).toBeDefined(); - expect(fileActions.actions.all.Delete).toBeDefined(); - expect(fileActions.actions.all.Rename).toBeDefined(); - expect(fileActions.actions.all.Download).toBeDefined(); - - expect(fileActions.defaults.dir).toEqual('Open'); - }); - it('provides custom file actions', function() { - var actionStub = sinon.stub(); - // regular file action - OCA.Files.fileActions.register( - 'all', - 'RegularTest', - OC.PERMISSION_READ, - OC.imagePath('core', 'actions/shared'), - actionStub - ); - - Plugin.favoritesFileList = null; - fileList = Plugin.showFileList($('#app-content-favorites')); - - expect(fileList.fileActions.actions.all.RegularTest).toBeDefined(); - }); - it('redirects to files app when opening a directory', function() { - var oldList = OCA.Files.App.fileList; - // dummy new list to make sure it exists - OCA.Files.App.fileList = new OCA.Files.FileList($('<table><thead></thead><tbody></tbody></table>')); - - var setActiveViewStub = sinon.stub(OCA.Files.App, 'setActiveView'); - // create dummy table so we can click the dom - var $table = '<table><thead></thead><tbody class="files-fileList"></tbody></table>'; - $('#app-content-favorites').append($table); - - Plugin.favoritesFileList = null; - fileList = Plugin.showFileList($('#app-content-favorites')); - - fileList.setFiles([{ - name: 'testdir', - type: 'dir', - path: '/somewhere/inside/subdir', - counterParts: ['user2'], - shareOwner: 'user2' - }]); - - fileList.findFileEl('testdir').find('td .nametext').click(); - - expect(OCA.Files.App.fileList.getCurrentDirectory()).toEqual('/somewhere/inside/subdir/testdir'); - - expect(setActiveViewStub.calledOnce).toEqual(true); - expect(setActiveViewStub.calledWith('files')).toEqual(true); - - setActiveViewStub.restore(); - - // restore old list - OCA.Files.App.fileList = oldList; - }); - }); -}); - |