From a5bb66f4a723bce5c5fbe919a48cd5133204ef62 Mon Sep 17 00:00:00 2001 From: Vincent Petry Date: Tue, 18 Nov 2014 18:53:45 +0100 Subject: [PATCH] Added favorites feature to the files app --- apps/files/appinfo/application.php | 40 +++++- apps/files/appinfo/routes.php | 29 +++- apps/files/controller/apicontroller.php | 51 ++++++- apps/files/css/files.css | 17 ++- apps/files/index.php | 17 +++ apps/files/js/app.js | 2 + apps/files/js/favoritesfilelist.js | 99 ++++++++++++++ apps/files/js/favoritesplugin.js | 116 ++++++++++++++++ apps/files/js/tagsplugin.js | 135 +++++++++++++++++++ apps/files/lib/helper.php | 25 ++++ apps/files/service/tagservice.php | 94 +++++++++++++ apps/files/simplelist.php | 30 +++++ apps/files/templates/simplelist.php | 36 +++++ apps/files/tests/js/favoritesfilelistspec.js | 109 +++++++++++++++ apps/files/tests/js/favoritespluginspec.js | 130 ++++++++++++++++++ apps/files/tests/js/tagspluginspec.js | 84 ++++++++++++ apps/files/tests/service/tagservice.php | 121 +++++++++++++++++ core/js/js.js | 19 +++ 18 files changed, 1146 insertions(+), 8 deletions(-) create mode 100644 apps/files/js/favoritesfilelist.js create mode 100644 apps/files/js/favoritesplugin.js create mode 100644 apps/files/js/tagsplugin.js create mode 100644 apps/files/service/tagservice.php create mode 100644 apps/files/simplelist.php create mode 100644 apps/files/templates/simplelist.php create mode 100644 apps/files/tests/js/favoritesfilelistspec.js create mode 100644 apps/files/tests/js/favoritespluginspec.js create mode 100644 apps/files/tests/js/tagspluginspec.js create mode 100644 apps/files/tests/service/tagservice.php diff --git a/apps/files/appinfo/application.php b/apps/files/appinfo/application.php index 7ca48bab47..fcf974a701 100644 --- a/apps/files/appinfo/application.php +++ b/apps/files/appinfo/application.php @@ -11,6 +11,8 @@ namespace OCA\Files\Appinfo; use OC\AppFramework\Utility\SimpleContainer; use OCA\Files\Controller\ApiController; use OCP\AppFramework\App; +use \OCA\Files\Service\TagService; +use \OCP\IContainer; class Application extends App { public function __construct(array $urlParams=array()) { @@ -21,10 +23,44 @@ class Application extends App { /** * Controllers */ - $container->registerService('APIController', function (SimpleContainer $c) { + $container->registerService('APIController', function (IContainer $c) { return new ApiController( $c->query('AppName'), - $c->query('Request') + $c->query('Request'), + $c->query('TagService') + ); + }); + + /** + * Core + */ + $container->registerService('L10N', function(IContainer $c) { + return $c->query('ServerContainer')->getL10N($c->query('AppName')); + }); + + /** + * Services + */ + $container->registerService('Tagger', function(IContainer $c) { + return $c->query('ServerContainer')->getTagManager()->load('files'); + }); + $container->registerService('TagService', function(IContainer $c) { + $homeFolder = $c->query('ServerContainer')->getUserFolder(); + return new TagService( + $c->query('ServerContainer')->getUserSession(), + $c->query('Tagger'), + $homeFolder + ); + }); + + /** + * Controllers + */ + $container->registerService('APIController', function (IContainer $c) { + return new ApiController( + $c->query('AppName'), + $c->query('Request'), + $c->query('TagService') ); }); } diff --git a/apps/files/appinfo/routes.php b/apps/files/appinfo/routes.php index 96790a0485..349284ec52 100644 --- a/apps/files/appinfo/routes.php +++ b/apps/files/appinfo/routes.php @@ -9,10 +9,31 @@ namespace OCA\Files\Appinfo; $application = new Application(); -$application->registerRoutes($this, array('routes' => array( - array('name' => 'API#getThumbnail', 'url' => '/api/v1/thumbnail/{x}/{y}/{file}', 'verb' => 'GET', 'requirements' => array('file' => '.+')), -))); - +$application->registerRoutes( + $this, + array( + 'routes' => array( + array( + 'name' => 'API#getThumbnail', + 'url' => '/api/v1/thumbnail/{x}/{y}/{file}', + 'verb' => 'GET', + 'requirements' => array('file' => '.+') + ), + array( + 'name' => 'API#updateFileTags', + 'url' => '/api/v1/files/{path}', + 'verb' => 'POST', + 'requirements' => array('path' => '.+'), + ), + array( + 'name' => 'API#getFilesByTag', + 'url' => '/api/v1/tags/{tagName}/files', + 'verb' => 'GET', + 'requirements' => array('tagName' => '.+'), + ), + ) + ) +); /** @var $this \OC\Route\Router */ diff --git a/apps/files/controller/apicontroller.php b/apps/files/controller/apicontroller.php index 89d24a5c47..1990971438 100644 --- a/apps/files/controller/apicontroller.php +++ b/apps/files/controller/apicontroller.php @@ -12,13 +12,16 @@ use OCP\AppFramework\Http; use OCP\AppFramework\Controller; use OCP\IRequest; use OCP\AppFramework\Http\JSONResponse; +use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\Http\DownloadResponse; use OC\Preview; +use OCA\Files\Service\TagService; class ApiController extends Controller { - public function __construct($appName, IRequest $request){ + public function __construct($appName, IRequest $request, TagService $tagService){ parent::__construct($appName, $request); + $this->tagService = $tagService; } @@ -49,4 +52,50 @@ class ApiController extends Controller { } } + /** + * Updates the info of the specified file path + * The passed tags are absolute, which means they will + * replace the actual tag selection. + * + * @NoAdminRequired + * @CORS + * + * @param string $path path + * @param array $tags array of tags + */ + public function updateFileTags($path, $tags = null) { + $result = array(); + // if tags specified or empty array, update tags + if (!is_null($tags)) { + try { + $this->tagService->updateFileTags($path, $tags); + } catch (\OCP\Files\NotFoundException $e) { + return new DataResponse($e->getMessage(), Http::STATUS_NOT_FOUND); + } + $result['tags'] = $tags; + } + return new DataResponse($result, Http::STATUS_OK); + } + + /** + * Returns a list of all files tagged with the given tag. + * + * @NoAdminRequired + * @CORS + * + * @param array $tagName tag name to filter by + */ + public function getFilesByTag($tagName) { + $files = array(); + $fileInfos = $this->tagService->getFilesByTag($tagName); + foreach ($fileInfos as &$fileInfo) { + $file = \OCA\Files\Helper::formatFileInfo($fileInfo); + $parts = explode('/', dirname($fileInfo->getPath()), 4); + $file['path'] = '/' . $parts[3]; + $file['tags'] = array($tagName); + $files[] = $file; + } + return new DataResponse(array('files' => $files), Http::STATUS_OK); + } + } diff --git a/apps/files/css/files.css b/apps/files/css/files.css index d8336847e0..03496f4ffb 100644 --- a/apps/files/css/files.css +++ b/apps/files/css/files.css @@ -286,6 +286,9 @@ table td.filename .nametext { max-width: 800px; height: 100%; } +#fileList.has-favorites td.filename a.name { + left: 50px; +} table td.filename .nametext .innernametext { text-overflow: ellipsis; @@ -417,6 +420,18 @@ table td.filename .uploadtext { height: 50px; } +#fileList tr td.filename .favorite { + display: inline-block; + float: left; +} +#fileList tr td.filename .action-favorite { + display: block; + float: left; + width: 30px; + line-height: 100%; + text-align: center; +} + #uploadsize-message,#delete-confirm { display:none; } /* File actions */ @@ -442,7 +457,7 @@ table td.filename .uploadtext { padding: 17px 14px; } -#fileList .action.action-share-notification span, #fileList a { +#fileList .action.action-share-notification span, #fileList a.name { cursor: default !important; } diff --git a/apps/files/index.php b/apps/files/index.php index 929bc5e79d..86cf2e04a5 100644 --- a/apps/files/index.php +++ b/apps/files/index.php @@ -20,6 +20,7 @@ * License along with this library. If not, see . * */ +use OCA\Files\Appinfo\Application; // Check if we are a user OCP\User::checkLoggedIn(); @@ -38,8 +39,14 @@ OCP\Util::addscript('files', 'filesummary'); OCP\Util::addscript('files', 'breadcrumb'); OCP\Util::addscript('files', 'filelist'); +\OCP\Util::addScript('files', 'favoritesfilelist'); +\OCP\Util::addScript('files', 'tagsplugin'); +\OCP\Util::addScript('files', 'favoritesplugin'); + OCP\App::setActiveNavigationEntry('files_index'); +$l = \OC::$server->getL10N('files'); + $isIE8 = false; preg_match('/MSIE (.*?);/', $_SERVER['HTTP_USER_AGENT'], $matches); if (count($matches) > 0 && $matches[1] <= 9) { @@ -79,6 +86,16 @@ function sortNavigationItems($item1, $item2) { return $item1['order'] - $item2['order']; } +\OCA\Files\App::getNavigationManager()->add( + array( + "id" => 'favorites', + "appname" => 'files', + "script" => 'simplelist.php', + "order" => 50, + "name" => $l->t('Favorites') + ) +); + $navItems = \OCA\Files\App::getNavigationManager()->getAll(); usort($navItems, 'sortNavigationItems'); $nav->assign('navigationItems', $navItems); diff --git a/apps/files/js/app.js b/apps/files/js/app.js index ee5330485e..adb1893bb0 100644 --- a/apps/files/js/app.js +++ b/apps/files/js/app.js @@ -80,6 +80,8 @@ // refer to the one of the "files" view window.FileList = this.fileList; + OC.Plugins.attach('OCA.Files.App', this); + this._setupEvents(); // trigger URL change event handlers this._onPopState(urlParams); diff --git a/apps/files/js/favoritesfilelist.js b/apps/files/js/favoritesfilelist.js new file mode 100644 index 0000000000..0d555ce609 --- /dev/null +++ b/apps/files/js/favoritesfilelist.js @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2014 Vincent Petry + * + * 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) +$(document).ready(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 #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: '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('#emptycontent').toggleClass('hidden', !this.isEmpty); + this.$el.find('#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() { + var tagName = OC.TAG_FAVORITE; + this.showMask(); + if (this._reloadCall) { + this._reloadCall.abort(); + } + this._reloadCall = $.ajax({ + url: OC.generateUrl('/apps/files/api/v1/tags/{tagName}/files', {tagName: tagName}), + type: 'GET', + dataType: 'json' + }); + var callBack = this.reloadCallback.bind(this); + return this._reloadCall.then(callBack, callBack); + }, + + reloadCallback: function(result) { + delete this._reloadCall; + this.hideMask(); + + if (result.files) { + this.setFiles(result.files.sort(this._sortComparator)); + } + else { + // TODO: error handling + } + } + }); + + OCA.Files.FavoritesFileList = FavoritesFileList; + })(OCA); +}); + diff --git a/apps/files/js/favoritesplugin.js b/apps/files/js/favoritesplugin.js new file mode 100644 index 0000000000..417a32ef80 --- /dev/null +++ b/apps/files/js/favoritesplugin.js @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2014 Vincent Petry + * + * This file is licensed under the Affero General Public License version 3 + * or later. + * + * See the COPYING-README file. + * + */ + +(function(OCA) { + /** + * @namespace OCA.Files.FavoritesPlugin + * + * Registers the favorites file list from the files app sidebar. + */ + 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, + scrollContainer: $('#app-content') + } + ); + }, + + _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(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/tagsplugin.js b/apps/files/js/tagsplugin.js new file mode 100644 index 0000000000..77b3167ab5 --- /dev/null +++ b/apps/files/js/tagsplugin.js @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2014 Vincent Petry + * + * This file is licensed under the Affero General Public License version 3 + * or later. + * + * See the COPYING-README file. + * + */ +(function(OCA) { + + OCA.Files = OCA.Files || {}; + + /** + * @namespace OCA.Files.TagsPlugin + * + * Extends the file actions and file list to include a favorite action icon + * and addition "data-tags" and "data-favorite" attributes. + */ + OCA.Files.TagsPlugin = { + name: 'Tags', + + allowedLists: [ + 'files', + 'favorites' + ], + + _extendFileActions: function(fileActions) { + var self = this; + // register "star" action + fileActions.registerAction({ + name: 'favorite', + displayName: 'Favorite', + mime: 'all', + permissions: OC.PERMISSION_READ, + render: function(actionSpec, isDefault, context) { + // TODO: use proper icon + var $file = context.$file; + var isFavorite = $file.data('favorite') === true; + var starState = isFavorite ? '★' : '☆'; + var $icon = $( + '' + + starState + '' + ); + $file.find('td:first>.favorite').prepend($icon); + return $icon; + }, + actionHandler: function(fileName, context) { + var $actionEl = context.$file.find('.action-favorite'); + var $file = context.$file; + var dir = context.dir || context.fileList.getCurrentDirectory(); + var tags = $file.attr('data-tags'); + if (_.isUndefined(tags)) { + tags = ''; + } + tags = tags.split('|'); + tags = _.without(tags, ''); + var isFavorite = tags.indexOf(OC.TAG_FAVORITE) >= 0; + if (isFavorite) { + // remove tag from list + tags = _.without(tags, OC.TAG_FAVORITE); + } else { + tags.push(OC.TAG_FAVORITE); + } + if ($actionEl.hasClass('icon-loading')) { + // do nothing + return; + } + $actionEl.addClass('icon-loading permanent'); + self.applyFileTags( + dir + '/' + fileName, + tags + ).then(function() { + // TODO: read from result + $actionEl.removeClass('icon-loading'); + $actionEl.html(isFavorite ? '☆' : '★'); + $actionEl.toggleClass('permanent', !isFavorite); + $file.attr('data-tags', tags.join('|')); + $file.attr('data-favorite', !isFavorite); + }); + } + }); + }, + + _extendFileList: function(fileList) { + // extend row prototype + fileList.$fileList.addClass('has-favorites'); + var oldCreateRow = fileList._createRow; + fileList._createRow = function(fileData) { + var $tr = oldCreateRow.apply(this, arguments); + if (fileData.tags) { + $tr.attr('data-tags', fileData.tags.join('|')); + if (fileData.tags.indexOf(OC.TAG_FAVORITE) >= 0) { + $tr.attr('data-favorite', true); + } + } + $tr.find('td:first').prepend('
'); + return $tr; + }; + }, + + attach: function(fileList) { + if (this.allowedLists.indexOf(fileList.id) < 0) { + return; + } + this._extendFileActions(fileList.fileActions); + this._extendFileList(fileList); + }, + + /** + * Replaces the given files' tags with the specified ones. + * + * @param {String} fileName path to the file or folder to tag + * @param {Array.} tagNames array of tag names + */ + applyFileTags: function(fileName, tagNames) { + var encodedPath = OC.encodePath(fileName); + while (encodedPath[0] === '/') { + encodedPath = encodedPath.substr(1); + } + return $.ajax({ + url: OC.generateUrl('/apps/files/api/v1/files/') + encodedPath, + contentType: 'application/json', + data: JSON.stringify({ + tags: tagNames || [] + }), + dataType: 'json', + type: 'POST' + }); + } + }; +})(OCA); + +OC.Plugins.register('OCA.Files.FileList', OCA.Files.TagsPlugin); + diff --git a/apps/files/lib/helper.php b/apps/files/lib/helper.php index 97b9d8e704..7adca3ffa6 100644 --- a/apps/files/lib/helper.php +++ b/apps/files/lib/helper.php @@ -122,6 +122,9 @@ class Helper $entry['size'] = $i['size']; $entry['type'] = $i['type']; $entry['etag'] = $i['etag']; + if (isset($i['tags'])) { + $entry['tags'] = $i['tags']; + } if (isset($i['displayname_owner'])) { $entry['shareOwner'] = $i['displayname_owner']; } @@ -171,10 +174,32 @@ class Helper */ public static function getFiles($dir, $sortAttribute = 'name', $sortDescending = false) { $content = \OC\Files\Filesystem::getDirectoryContent($dir); + $content = self::populateTags($content); return self::sortFiles($content, $sortAttribute, $sortDescending); } + /** + * Populate the result set with file tags + * + * @param array file list + * @return file list populated with tags + */ + public static function populateTags($fileList) { + $filesById = array(); + foreach ($fileList as $fileData) { + $filesById[$fileData['fileid']] = $fileData; + } + $tagger = \OC::$server->getTagManager()->load('files'); + $tags = $tagger->getTagsForObjects(array_keys($filesById)); + if ($tags) { + foreach ($tags as $fileId => $fileTags) { + $filesById[$fileId]['tags'] = $fileTags; + } + } + return $fileList; + } + /** * Sort the given file info array * diff --git a/apps/files/service/tagservice.php b/apps/files/service/tagservice.php new file mode 100644 index 0000000000..86885e38dd --- /dev/null +++ b/apps/files/service/tagservice.php @@ -0,0 +1,94 @@ + + * This file is licensed under the Affero General Public License version 3 or + * later. + * See the COPYING-README file. + */ + +namespace OCA\Files\Service; + +/** + * Service class to manage tags on files. + */ +class TagService { + + /** + * @var \OCP\IUserSession + */ + private $userSession; + + /** + * @var \OCP\ITags + */ + private $tagger; + + /** + * @var \OCP\Files\Folder + */ + private $homeFolder; + + public function __construct( + \OCP\IUserSession $userSession, + \OCP\ITags $tagger, + \OCP\Files\Folder $homeFolder + ) { + $this->userSession = $userSession; + $this->tagger = $tagger; + $this->homeFolder = $homeFolder; + } + + /** + * Updates the tags of the specified file path. + * The passed tags are absolute, which means they will + * replace the actual tag selection. + * + * @param string $path path + * @param array $tags array of tags + * @return array list of tags + * @throws \OCP\NotFoundException if the file does not exist + */ + public function updateFileTags($path, $tags) { + $fileId = $this->homeFolder->get($path)->getId(); + + $currentTags = $this->tagger->getTagsForObjects(array($fileId)); + + if (!empty($currentTags)) { + $currentTags = current($currentTags); + } + + $newTags = array_diff($tags, $currentTags); + foreach ($newTags as $tag) { + $this->tagger->tagAs($fileId, $tag); + } + $deletedTags = array_diff($currentTags, $tags); + foreach ($deletedTags as $tag) { + $this->tagger->unTag($fileId, $tag); + } + + // TODO: re-read from tagger to make sure the + // list is up to date, in case of concurrent changes ? + return $tags; + } + + /** + * Updates the tags of the specified file path. + * The passed tags are absolute, which means they will + * replace the actual tag selection. + * + * @param array $tagName tag name to filter by + * @return FileInfo[] list of matching files + * @throws \Exception if the tag does not exist + */ + public function getFilesByTag($tagName) { + $nodes = $this->homeFolder->searchByTag( + $tagName, $this->userSession->getUser()->getUId() + ); + foreach ($nodes as &$node) { + $node = $node->getFileInfo(); + } + + return $nodes; + } +} + diff --git a/apps/files/simplelist.php b/apps/files/simplelist.php new file mode 100644 index 0000000000..e3fdf6cd1d --- /dev/null +++ b/apps/files/simplelist.php @@ -0,0 +1,30 @@ + + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE + * License as published by the Free Software Foundation; either + * version 3 of the License, or any later version. + * + * This library 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 library. If not, see . + * + */ + +// Check if we are a user +OCP\User::checkLoggedIn(); + +// renders the controls and table headers template +$tmpl = new OCP\Template('files', 'simplelist', ''); +$tmpl->printPage(); + diff --git a/apps/files/templates/simplelist.php b/apps/files/templates/simplelist.php new file mode 100644 index 0000000000..c00febce65 --- /dev/null +++ b/apps/files/templates/simplelist.php @@ -0,0 +1,36 @@ +
+
+
+
+ + + + + + + + + + + + + + + + + +
+ diff --git a/apps/files/tests/js/favoritesfilelistspec.js b/apps/files/tests/js/favoritesfilelistspec.js new file mode 100644 index 0000000000..608ddaca18 --- /dev/null +++ b/apps/files/tests/js/favoritesfilelistspec.js @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2014 Vincent Petry + * + * This file is licensed under the Affero General Public License version 3 + * or later. + * + * See the COPYING-README file. + * + */ + +describe('OCA.Files.FavoritesFileList tests', function() { + var fileList; + + beforeEach(function() { + // init parameters and test table elements + $('#testArea').append( + '
' + + // init horrible parameters + '' + + '' + + // dummy controls + '
' + + '
' + + '
' + + '
' + + // dummy table + // TODO: at some point this will be rendered by the fileList class itself! + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
' + + '
Empty content message
' + + '
' + ); + }); + afterEach(function() { + fileList.destroy(); + fileList = undefined; + }); + + describe('loading file list', function() { + var response; + + beforeEach(function() { + fileList = new OCA.Files.FavoritesFileList( + $('#app-content-container') + ); + OCA.Files.FavoritesPlugin.attach(fileList); + + fileList.reload(); + + /* jshint camelcase: false */ + response = { + files: [{ + id: 7, + name: 'test.txt', + path: '/somedir', + size: 123, + mtime: 11111000, + tags: [OC.TAG_FAVORITE], + permissions: OC.PERMISSION_ALL, + mimetype: 'text/plain' + }] + }; + }); + it('render files', function() { + var request; + + expect(fakeServer.requests.length).toEqual(1); + request = fakeServer.requests[0]; + expect(request.url).toEqual( + OC.generateUrl('apps/files/api/v1/tags/{tagName}/files', {tagName: OC.TAG_FAVORITE}) + ); + + fakeServer.requests[0].respond( + 200, + { 'Content-Type': 'application/json' }, + JSON.stringify(response) + ); + + 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.webroot + + '/index.php/apps/files/ajax/download.php' + + '?dir=%2Fsomedir&files=test.txt' + ); + expect($tr.find('.nametext').text().trim()).toEqual('test.txt'); + }); + }); +}); diff --git a/apps/files/tests/js/favoritespluginspec.js b/apps/files/tests/js/favoritespluginspec.js new file mode 100644 index 0000000000..90b40ede74 --- /dev/null +++ b/apps/files/tests/js/favoritespluginspec.js @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2014 Vincent Petry + * + * This file is licensed under the Affero General Public License version 3 + * or later. + * + * See the COPYING-README file. + * + */ + +describe('OCA.Files.FavoritesPlugin tests', function() { + var Plugin = OCA.Files.FavoritesPlugin; + var fileList; + + beforeEach(function() { + $('#testArea').append( + '
' + + '
  • Files
  • ' + + '
  • ' + + '
  • ' + + '
' + + '
' + + '' + + '' + + '
' + + '' + ); + 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() { + var oldLegacyFileActions; + + beforeEach(function() { + oldLegacyFileActions = window.FileActions; + window.FileActions = new OCA.Files.FileActions(); + }); + + afterEach(function() { + window.FileActions = oldLegacyFileActions; + }); + 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('does not provide legacy file actions', function() { + var actionStub = sinon.stub(); + // legacy file action + window.FileActions.register( + 'all', + 'LegacyTest', + OC.PERMISSION_READ, + OC.imagePath('core', 'actions/shared'), + actionStub + ); + + Plugin.favoritesFileList = null; + fileList = Plugin.showFileList($('#app-content-favorites')); + + expect(fileList.fileActions.actions.all.LegacyTest).not.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($('
')); + + var setActiveViewStub = sinon.stub(OCA.Files.App, 'setActiveView'); + // create dummy table so we can click the dom + var $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 a.name').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; + }); + }); +}); + diff --git a/apps/files/tests/js/tagspluginspec.js b/apps/files/tests/js/tagspluginspec.js new file mode 100644 index 0000000000..424017a9dc --- /dev/null +++ b/apps/files/tests/js/tagspluginspec.js @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2014 Vincent Petry + * + * This file is licensed under the Affero General Public License version 3 + * or later. + * + * See the COPYING-README file. + * + */ + +describe('OCA.Files.TagsPlugin tests', function() { + var fileList; + var testFiles; + + beforeEach(function() { + var $content = $('
'); + $('#testArea').append($content); + // dummy file list + var $div = $( + '
' + + '' + + '' + + '' + + '
' + + '
'); + $('#content').append($div); + + fileList = new OCA.Files.FileList($div); + OCA.Files.TagsPlugin.attach(fileList); + + testFiles = [{ + id: 1, + type: 'file', + name: 'One.txt', + path: '/subdir', + mimetype: 'text/plain', + size: 12, + permissions: OC.PERMISSION_ALL, + etag: 'abc', + shareOwner: 'User One', + isShareMountPoint: false, + tags: ['tag1', 'tag2'] + }]; + }); + afterEach(function() { + fileList.destroy(); + fileList = null; + }); + + describe('Favorites icon', function() { + it('renders favorite icon and extra data', function() { + var $action, $tr; + fileList.setFiles(testFiles); + $tr = fileList.$el.find('tbody tr:first'); + $action = $tr.find('.action-favorite'); + expect($action.length).toEqual(1); + expect($action.hasClass('permanent')).toEqual(false); + + expect($tr.attr('data-tags').split('|')).toEqual(['tag1', 'tag2']); + expect($tr.attr('data-favorite')).not.toBeDefined(); + }); + it('renders permanent favorite icon and extra data', function() { + var $action, $tr; + testFiles[0].tags.push(OC.TAG_FAVORITE); + fileList.setFiles(testFiles); + $tr = fileList.$el.find('tbody tr:first'); + $action = $tr.find('.action-favorite'); + expect($action.length).toEqual(1); + expect($action.hasClass('permanent')).toEqual(true); + + expect($tr.attr('data-tags').split('|')).toEqual(['tag1', 'tag2', OC.TAG_FAVORITE]); + expect($tr.attr('data-favorite')).toEqual('true'); + }); + }); + describe('Applying tags', function() { + it('sends request to server and updates icon', function() { + // TODO + fileList.setFiles(testFiles); + }); + it('sends all tags to server when applyFileTags() is called ', function() { + // TODO + }); + }); +}); diff --git a/apps/files/tests/service/tagservice.php b/apps/files/tests/service/tagservice.php new file mode 100644 index 0000000000..158dd77e85 --- /dev/null +++ b/apps/files/tests/service/tagservice.php @@ -0,0 +1,121 @@ + + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE + * License as published by the Free Software Foundation; either + * version 3 of the License, or any later version. + * + * This library 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 library. If not, see . + * + */ +namespace OCA\Files; + +use \OCA\Files\Service\TagService; + +class TagServiceTest extends \Test\TestCase { + + /** + * @var string + */ + private $user; + + /** + * @var \OCP\Files\Folder + */ + private $root; + + /** + * @var \OCA\Files\Service\TagService + */ + private $tagService; + + /** + * @var \OCP\ITags + */ + private $tagger; + + protected function setUp() { + parent::setUp(); + $this->user = $this->getUniqueId('user'); + \OC_User::createUser($this->user, 'test'); + \OC_User::setUserId($this->user); + \OC_Util::setupFS($this->user); + /** + * @var \OCP\IUser + */ + $user = new \OC\User\User($this->user, null); + /** + * @var \OCP\IUserSession + */ + $userSession = $this->getMock('\OCP\IUserSession'); + $userSession->expects($this->any()) + ->method('getUser') + ->withAnyParameters() + ->will($this->returnValue($user)); + + $this->root = \OC::$server->getUserFolder(); + + $this->tagger = \OC::$server->getTagManager()->load('files'); + $this->tagService = new TagService( + $userSession, + $this->tagger, + $this->root + ); + } + + protected function tearDown() { + \OC_User::setUserId(''); + \OC_User::deleteUser($this->user); + } + + public function testUpdateFileTags() { + $tag1 = 'tag1'; + $tag2 = 'tag2'; + + $subdir = $this->root->newFolder('subdir'); + $testFile = $subdir->newFile('test.txt'); + $testFile->putContent('test contents'); + + $fileId = $testFile->getId(); + + // set tags + $this->tagService->updateFileTags('subdir/test.txt', array($tag1, $tag2)); + + $this->assertEquals(array($fileId), $this->tagger->getIdsForTag($tag1)); + $this->assertEquals(array($fileId), $this->tagger->getIdsForTag($tag2)); + + // remove tag + $result = $this->tagService->updateFileTags('subdir/test.txt', array($tag2)); + $this->assertEquals(array(), $this->tagger->getIdsForTag($tag1)); + $this->assertEquals(array($fileId), $this->tagger->getIdsForTag($tag2)); + + // clear tags + $result = $this->tagService->updateFileTags('subdir/test.txt', array()); + $this->assertEquals(array(), $this->tagger->getIdsForTag($tag1)); + $this->assertEquals(array(), $this->tagger->getIdsForTag($tag2)); + + // non-existing file + $caught = false; + try { + $this->tagService->updateFileTags('subdir/unexist.txt', array($tag1)); + } catch (\OCP\Files\NotFoundException $e) { + $caught = true; + } + $this->assertTrue($caught); + + $subdir->delete(); + } +} + diff --git a/core/js/js.js b/core/js/js.js index cc3a548de2..d1713bf14a 100644 --- a/core/js/js.js +++ b/core/js/js.js @@ -66,6 +66,7 @@ var OC={ PERMISSION_DELETE:8, PERMISSION_SHARE:16, PERMISSION_ALL:31, + TAG_FAVORITE: '_$!!$_', /* jshint camelcase: false */ webroot:oc_webroot, appswebroots:(typeof oc_appswebroots !== 'undefined') ? oc_appswebroots:false, @@ -211,6 +212,24 @@ var OC={ return OC.filePath(app,'img',file); }, + /** + * URI-Encodes a file path but keep the path slashes. + * + * @param path path + * @return encoded path + */ + encodePath: function(path) { + if (!path) { + return path; + } + var parts = path.split('/'); + var result = []; + for (var i = 0; i < parts.length; i++) { + result.push(encodeURIComponent(parts[i])); + } + return result.join('/'); + }, + /** * Load a script for the server and load it. If the script is already loaded, * the event handler will be called directly