]> source.dussan.org Git - nextcloud-server.git/commitdiff
Added favorites feature to the files app
authorVincent Petry <pvince81@owncloud.com>
Tue, 18 Nov 2014 17:53:45 +0000 (18:53 +0100)
committerVincent Petry <pvince81@owncloud.com>
Mon, 15 Dec 2014 11:10:54 +0000 (12:10 +0100)
18 files changed:
apps/files/appinfo/application.php
apps/files/appinfo/routes.php
apps/files/controller/apicontroller.php
apps/files/css/files.css
apps/files/index.php
apps/files/js/app.js
apps/files/js/favoritesfilelist.js [new file with mode: 0644]
apps/files/js/favoritesplugin.js [new file with mode: 0644]
apps/files/js/tagsplugin.js [new file with mode: 0644]
apps/files/lib/helper.php
apps/files/service/tagservice.php [new file with mode: 0644]
apps/files/simplelist.php [new file with mode: 0644]
apps/files/templates/simplelist.php [new file with mode: 0644]
apps/files/tests/js/favoritesfilelistspec.js [new file with mode: 0644]
apps/files/tests/js/favoritespluginspec.js [new file with mode: 0644]
apps/files/tests/js/tagspluginspec.js [new file with mode: 0644]
apps/files/tests/service/tagservice.php [new file with mode: 0644]
core/js/js.js

index 7ca48bab47445769a6c00d2f164bb82e2ab76bb6..fcf974a701b6a39c89d0f0f60954925bcab37c16 100644 (file)
@@ -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')
                        );
                });
        }
index 96790a048555aca11a79e4c919d427631ee9408e..349284ec52d9786b3d43fd96ea45d41d0ce9b4fd 100644 (file)
@@ -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 */
 
index 89d24a5c47f02c5cf92725ef89e1d114e3dd0c79..1990971438bd046d9883719bb9f79a97811ca2e1 100644 (file)
@@ -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);
+       }
+
 }
index d8336847e005f6b2f1b985987a4124d5aee816d0..03496f4ffb75360f9873a26170cb4eea61274cfc 100644 (file)
@@ -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;
 }
 
index 929bc5e79da383a089e22dd829b70794e221200e..86cf2e04a568f7bc2535f59261f99328f91737a3 100644 (file)
@@ -20,6 +20,7 @@
  * License along with this library.  If not, see <http://www.gnu.org/licenses/>.
  *
  */
+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);
index ee5330485e7052db2943ab5016a994de83adc0cc..adb1893bb0eee89d8cc8ede3590c0a63d7367c93 100644 (file)
@@ -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 (file)
index 0000000..0d555ce
--- /dev/null
@@ -0,0 +1,99 @@
+/*
+ * 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)
+$(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 (file)
index 0000000..417a32e
--- /dev/null
@@ -0,0 +1,116 @@
+/*
+ * 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) {
+       /**
+        * @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 (file)
index 0000000..77b3167
--- /dev/null
@@ -0,0 +1,135 @@
+/*
+ * 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) {
+
+       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 ? '&#x2605' : '&#x2606;';
+                                       var $icon = $(
+                                               '<a href="#" class="action action-favorite ' + (isFavorite ? 'permanent' : '') + '">' +
+                                               starState + '</a>'
+                                       );
+                                       $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 ? '&#x2606;' : '&#x2605;');
+                                               $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('<div class="favorite"></div>');
+                               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.<String>} 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);
+
index 97b9d8e704427b0c7e2498aad293059de876a426..7adca3ffa6d49ab8231828bbded7d28498480e9c 100644 (file)
@@ -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 (file)
index 0000000..86885e3
--- /dev/null
@@ -0,0 +1,94 @@
+<?php
+/**
+ * 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.
+ */
+
+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 (file)
index 0000000..e3fdf6c
--- /dev/null
@@ -0,0 +1,30 @@
+<?php
+
+/**
+ * ownCloud - Simple files list
+ *
+ * @author Vincent Petry
+ * @copyright 2014 Vincent Petry <pvince81@owncloud.com>
+ *
+ * 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 <http://www.gnu.org/licenses/>.
+ *
+ */
+
+// 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 (file)
index 0000000..c00febc
--- /dev/null
@@ -0,0 +1,36 @@
+<div id="controls">
+       <div id="file_action_panel"></div>
+</div>
+<div id='notification'></div>
+
+<div id="emptycontent" class="hidden"></div>
+
+<input type="hidden" name="dir" value="" id="dir">
+
+<table id="filestable">
+       <thead>
+               <tr>
+                       <th id='headerName' class="hidden column-name">
+                               <div id="headerName-container">
+                                       <a class="name sort columntitle" data-sort="name"><span><?php p($l->t( 'Name' )); ?></span><span class="sort-indicator"></span></a>
+                               </div>
+                       </th>
+                       <th id="headerSize" class="hidden column-size">
+                               <a class="size sort columntitle" data-sort="size"><span><?php p($l->t('Size')); ?></span><span class="sort-indicator"></span></a>
+                       </th>
+                       <th id="headerDate" class="hidden column-mtime">
+                               <a id="modified" class="columntitle" data-sort="mtime"><span><?php p($l->t( 'Modified' )); ?></span><span class="sort-indicator"></span></a>
+                                       <span class="selectedActions"><a href="" class="delete-selected">
+                                               <?php p($l->t('Delete'))?>
+                                               <img class="svg" alt="<?php p($l->t('Delete'))?>"
+                                                        src="<?php print_unescaped(OCP\image_path("core", "actions/delete.svg")); ?>" />
+                                       </a></span>
+                       </th>
+               </tr>
+       </thead>
+       <tbody id="fileList">
+       </tbody>
+       <tfoot>
+       </tfoot>
+</table>
+
diff --git a/apps/files/tests/js/favoritesfilelistspec.js b/apps/files/tests/js/favoritesfilelistspec.js
new file mode 100644 (file)
index 0000000..608ddac
--- /dev/null
@@ -0,0 +1,109 @@
+/*
+ * 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.
+ *
+ */
+
+describe('OCA.Files.FavoritesFileList tests', function() {
+       var fileList;
+
+       beforeEach(function() {
+               // init parameters and test table elements
+               $('#testArea').append(
+                       '<div id="app-content-container">' +
+                       // init horrible parameters
+                       '<input type="hidden" id="dir" value="/"></input>' +
+                       '<input type="hidden" id="permissions" value="31"></input>' +
+                       // dummy controls
+                       '<div id="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 id="filestable">' +
+                       '<thead><tr>' +
+                       '<th id="headerName" 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 id="fileList"></tbody>' +
+                       '<tfoot></tfoot>' +
+                       '</table>' +
+                       '<div id="emptycontent">Empty content message</div>' +
+                       '</div>'
+               );
+       });
+       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 (file)
index 0000000..90b40ed
--- /dev/null
@@ -0,0 +1,130 @@
+/*
+ * 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.
+ *
+ */
+
+describe('OCA.Files.FavoritesPlugin tests', function() {
+       var Plugin = OCA.Files.FavoritesPlugin;
+       var fileList;
+
+       beforeEach(function() {
+               $('#testArea').append(
+                       '<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>'
+               );
+               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($('<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 id="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 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 (file)
index 0000000..424017a
--- /dev/null
@@ -0,0 +1,84 @@
+/*
+ * 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.
+ *
+ */
+
+describe('OCA.Files.TagsPlugin tests', function() {
+       var fileList;
+       var testFiles;
+
+       beforeEach(function() {
+               var $content = $('<div id="content"></div>');
+               $('#testArea').append($content);
+               // dummy file list
+               var $div = $(
+                       '<div>' +
+                       '<table id="filestable">' +
+                       '<thead></thead>' +
+                       '<tbody id="fileList"></tbody>' +
+                       '</table>' +
+                       '</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 (file)
index 0000000..158dd77
--- /dev/null
@@ -0,0 +1,121 @@
+<?php
+
+/**
+ * ownCloud
+ *
+ * @author Vincent Petry
+ * @copyright 2014 Vincent Petry <pvince81@owncloud.com>
+ *
+ * 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 <http://www.gnu.org/licenses/>.
+ *
+ */
+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();
+       }
+}
+
index cc3a548de283a64152c366587b1b266092fe8293..d1713bf14a5d3337b0df64023688458ef62146f4 100644 (file)
@@ -66,6 +66,7 @@ var OC={
        PERMISSION_DELETE:8,
        PERMISSION_SHARE:16,
        PERMISSION_ALL:31,
+       TAG_FAVORITE: '_$!<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