diff options
author | Lukas Reschke <lukas@owncloud.com> | 2014-12-15 19:55:18 +0100 |
---|---|---|
committer | Lukas Reschke <lukas@owncloud.com> | 2014-12-15 19:55:18 +0100 |
commit | be3d4fd303569a99554dbc6c62ce8992a45c51ad (patch) | |
tree | 6ef59f62bee77bf49bdd313810e43eaaa67cc21e | |
parent | 76357af2d50b7f040109311286cc57ee53c44cf1 (diff) | |
parent | 207d77e5cdf6386dd22d87a5851adae38ad9f77f (diff) | |
download | nextcloud-server-be3d4fd303569a99554dbc6c62ce8992a45c51ad.tar.gz nextcloud-server-be3d4fd303569a99554dbc6c62ce8992a45c51ad.zip |
Merge pull request #12360 from owncloud/files-tags
Add favorites to files app
-rw-r--r-- | apps/files/appinfo/application.php | 40 | ||||
-rw-r--r-- | apps/files/appinfo/routes.php | 29 | ||||
-rw-r--r-- | apps/files/controller/apicontroller.php | 56 | ||||
-rw-r--r-- | apps/files/css/files.css | 26 | ||||
-rw-r--r-- | apps/files/index.php | 19 | ||||
-rw-r--r-- | apps/files/js/app.js | 2 | ||||
-rw-r--r-- | apps/files/js/favoritesfilelist.js | 99 | ||||
-rw-r--r-- | apps/files/js/favoritesplugin.js | 116 | ||||
-rw-r--r-- | apps/files/js/tagsplugin.js | 172 | ||||
-rw-r--r-- | apps/files/lib/helper.php | 25 | ||||
-rw-r--r-- | apps/files/service/tagservice.php | 94 | ||||
-rw-r--r-- | apps/files/simplelist.php | 29 | ||||
-rw-r--r-- | apps/files/templates/simplelist.php | 36 | ||||
-rw-r--r-- | apps/files/tests/js/favoritesfilelistspec.js | 109 | ||||
-rw-r--r-- | apps/files/tests/js/favoritespluginspec.js | 130 | ||||
-rw-r--r-- | apps/files/tests/js/tagspluginspec.js | 87 | ||||
-rw-r--r-- | apps/files/tests/service/tagservice.php | 121 | ||||
-rw-r--r-- | core/js/core.json | 3 | ||||
-rw-r--r-- | core/js/js.js | 19 | ||||
-rw-r--r-- | lib/private/files/node/node.php | 7 |
20 files changed, 1209 insertions, 10 deletions
diff --git a/apps/files/appinfo/application.php b/apps/files/appinfo/application.php index 7ca48bab474..13ff60daf89 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 96790a04855..349284ec52d 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 89d24a5c47f..902731a0492 100644 --- a/apps/files/controller/apicontroller.php +++ b/apps/files/controller/apicontroller.php @@ -12,13 +12,21 @@ 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){ + /** + * @var TagService $tagService + */ + private $tagService; + + public function __construct($appName, IRequest $request, TagService $tagService){ parent::__construct($appName, $request); + $this->tagService = $tagService; } @@ -49,4 +57,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 d8336847e00..3829759a14e 100644 --- a/apps/files/css/files.css +++ b/apps/files/css/files.css @@ -191,10 +191,15 @@ table th#headerName { width: 9999px; /* not really sure why this works better than 100% … table styling */ padding: 0; } + #headerName-container { position: relative; height: 50px; } +.has-favorites #headerName-container { + padding-left: 50px; +} + table th#headerSize, table td.filesize { text-align: right; } @@ -286,6 +291,10 @@ table td.filename .nametext { max-width: 800px; height: 100%; } +.has-favorites #fileList td.filename a.name { + left: 50px; + margin-right: 50px; +} table td.filename .nametext .innernametext { text-overflow: ellipsis; @@ -403,6 +412,9 @@ table td.filename .uploadtext { left: 18px; z-index: 10; } +.has-favorites .select-all { + left: 68px; +} #fileList tr td.filename { position: relative; @@ -417,6 +429,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 +466,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 929bc5e79da..02076226c1a 100644 --- a/apps/files/index.php +++ b/apps/files/index.php @@ -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,16 @@ 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'); + +\OC_Util::addVendorScript('core', 'handlebars/handlebars'); + 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 +88,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 ee5330485e7..adb1893bb0e 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 00000000000..0d555ce609d --- /dev/null +++ b/apps/files/js/favoritesfilelist.js @@ -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 index 00000000000..417a32ef804 --- /dev/null +++ b/apps/files/js/favoritesplugin.js @@ -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 index 00000000000..a6757431ffa --- /dev/null +++ b/apps/files/js/tagsplugin.js @@ -0,0 +1,172 @@ +/* + * 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. + * + */ + +/* global Handlebars */ + +(function(OCA) { + + var TEMPLATE_FAVORITE_ACTION = + '<a href="#" ' + + 'class="action action-favorite {{#isFavorite}}permanent{{/isFavorite}}">' + + '<img class="svg" alt="{{altText}}" src="{{imgFile}}" />' + + '</a>'; + + /** + * Returns the path to the star image + * + * @param {boolean} state true if starred, false otherwise + * @return {string} path to star image + */ + function getStarImage(state) { + return OC.imagePath('core', state ? 'actions/starred' : 'actions/star'); + } + + /** + * Render the star icon with the given state + * + * @param {boolean} state true if starred, false otherwise + * @return {Object} jQuery object + */ + function renderStar(state) { + if (!this._template) { + this._template = Handlebars.compile(TEMPLATE_FAVORITE_ACTION); + } + return this._template({ + isFavorite: state, + altText: state ? t('core', 'Favorited') : t('core', 'Favorite'), + imgFile: getStarImage(state) + }); + } + + /** + * Toggle star icon on action element + * + * @param {Object} action element + * @param {boolean} state true if starred, false otherwise + */ + function toggleStar($actionEl, state) { + $actionEl.find('img').attr('src', getStarImage(state)); + $actionEl.toggleClass('permanent', state); + } + + 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) { + var $file = context.$file; + var isFavorite = $file.data('favorite') === true; + var $icon = $(renderStar(isFavorite)); + $file.find('td:first>.favorite').replaceWith($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); + } + toggleStar($actionEl, !isFavorite); + + self.applyFileTags( + dir + '/' + fileName, + tags + ).then(function(result) { + // read latest state from result + toggleStar($actionEl, (result.tags.indexOf(OC.TAG_FAVORITE) >= 0)); + $file.attr('data-tags', tags.join('|')); + $file.attr('data-favorite', !isFavorite); + }); + } + }); + }, + + _extendFileList: function(fileList) { + // extend row prototype + fileList.$el.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); + diff --git a/apps/files/lib/helper.php b/apps/files/lib/helper.php index 97b9d8e7044..7adca3ffa6d 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,11 +174,33 @@ 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 * * @param \OCP\Files\FileInfo[] $files files to sort diff --git a/apps/files/service/tagservice.php b/apps/files/service/tagservice.php new file mode 100644 index 00000000000..86885e38ddd --- /dev/null +++ b/apps/files/service/tagservice.php @@ -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 index 00000000000..53e56b4ed32 --- /dev/null +++ b/apps/files/simplelist.php @@ -0,0 +1,29 @@ +<?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/>. + * + */ + +// TODO: move to handlebars + +// 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 00000000000..c00febce653 --- /dev/null +++ b/apps/files/templates/simplelist.php @@ -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 index 00000000000..608ddaca18b --- /dev/null +++ b/apps/files/tests/js/favoritesfilelistspec.js @@ -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 index 00000000000..90b40ede74b --- /dev/null +++ b/apps/files/tests/js/favoritespluginspec.js @@ -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 index 00000000000..66240575a5c --- /dev/null +++ b/apps/files/tests/js/tagspluginspec.js @@ -0,0 +1,87 @@ +/* + * 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'); + }); + it('adds has-favorites class on table', function() { + expect(fileList.$el.hasClass('has-favorites')).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 00000000000..158dd77e858 --- /dev/null +++ b/apps/files/tests/service/tagservice.php @@ -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(); + } +} + diff --git a/core/js/core.json b/core/js/core.json index e529315fdb1..d3a9e2404e8 100644 --- a/core/js/core.json +++ b/core/js/core.json @@ -4,7 +4,8 @@ "jquery/jquery-migrate.min.js", "jquery-ui/ui/jquery-ui.custom.js", "underscore/underscore.js", - "moment/min/moment-with-locales.js" + "moment/min/moment-with-locales.js", + "handlebars/handlebars.js" ], "libraries": [ "jquery-showpassword.js", diff --git a/core/js/js.js b/core/js/js.js index a4f66ac7d90..f01c0eb77c1 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: '_$!<Favorite>!$_', /* jshint camelcase: false */ webroot:oc_webroot, appswebroots:(typeof oc_appswebroots !== 'undefined') ? oc_appswebroots:false, @@ -212,6 +213,24 @@ var OC={ }, /** + * 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 * @param {string} app the app id to which the script belongs diff --git a/lib/private/files/node/node.php b/lib/private/files/node/node.php index b80db28e8ec..87d4a4b9156 100644 --- a/lib/private/files/node/node.php +++ b/lib/private/files/node/node.php @@ -43,7 +43,12 @@ class Node implements \OCP\Files\Node, FileInfo { $this->path = $path; } - private function getFileInfo() { + /** + * Returns the matching file info + * + * @return \OCP\Files\FileInfo + */ + public function getFileInfo() { if (!$this->fileInfo) { $this->fileInfo = $this->view->getFileInfo($this->path); } |