diff options
31 files changed, 626 insertions, 237 deletions
@@ -5,6 +5,7 @@ New features: * Add LDAP group sync * Add swimlane description +* New plugin system (alpha) Improvements: diff --git a/app/Core/PluginBase.php b/app/Core/PluginBase.php new file mode 100644 index 00000000..9c3d6e32 --- /dev/null +++ b/app/Core/PluginBase.php @@ -0,0 +1,31 @@ +<?php + +namespace Core; + +/** + * Plugin Base class + * + * @package core + * @author Frederic Guillot + */ +abstract class PluginBase extends Base +{ + /** + * Method called for each request + * + * @abstract + * @access public + */ + abstract public function initialize(); + + /** + * Returns all classes that needs to be stored in the DI container + * + * @access public + * @return array + */ + public function getClasses() + { + return array(); + } +} diff --git a/app/Core/PluginLoader.php b/app/Core/PluginLoader.php new file mode 100644 index 00000000..6030ded4 --- /dev/null +++ b/app/Core/PluginLoader.php @@ -0,0 +1,144 @@ +<?php + +namespace Core; + +use DirectoryIterator; +use PDOException; + +/** + * Plugin Loader + * + * @package core + * @author Frederic Guillot + */ +class PluginLoader extends Base +{ + /** + * Schema version table for plugins + * + * @var string + */ + const TABLE_SCHEMA = 'plugin_schema_versions'; + + /** + * Plugin folder + * + * @var string + */ + const PATH = __DIR__.'/../../plugins'; + + /** + * Scan plugin folder and load plugins + * + * @access public + */ + public function scan() + { + if (file_exists(self::PATH)) { + $dir = new DirectoryIterator(self::PATH); + + foreach ($dir as $fileinfo) { + if (! $fileinfo->isDot() && $fileinfo->isDir()) { + $plugin = $fileinfo->getFilename(); + $this->loadSchema($plugin); + $this->load($plugin); + } + } + } + } + + /** + * Load plugin + * + * @access public + */ + public function load($plugin) + { + $class = '\Plugin\\'.$plugin.'\\Plugin'; + $instance = new $class($this->container); + + Tool::buildDic($this->container, $instance->getClasses()); + + $instance->initialize(); + } + + /** + * Load plugin schema + * + * @access public + * @param string $plugin + */ + public function loadSchema($plugin) + { + $filename = __DIR__.'/../../plugins/'.$plugin.'/Schema/'.ucfirst(DB_DRIVER).'.php'; + + if (file_exists($filename)) { + require($filename); + $this->migrateSchema($plugin); + } + } + + /** + * Execute plugin schema migrations + * + * @access public + * @param string $plugin + */ + public function migrateSchema($plugin) + { + $last_version = constant('\Plugin\\'.$plugin.'\Schema\VERSION'); + $current_version = $this->getSchemaVersion($plugin); + + try { + + $this->db->startTransaction(); + $this->db->getDriver()->disableForeignKeys(); + + for ($i = $current_version + 1; $i <= $last_version; $i++) { + $function_name = '\Plugin\\'.$plugin.'\Schema\version_'.$i; + + if (function_exists($function_name)) { + call_user_func($function_name, $this->db->getConnection()); + } + } + + $this->db->getDriver()->enableForeignKeys(); + $this->db->closeTransaction(); + $this->setSchemaVersion($plugin, $i - 1); + } + catch (PDOException $e) { + $this->db->cancelTransaction(); + $this->db->getDriver()->enableForeignKeys(); + die('Unable to migrate schema for the plugin: '.$plugin.' => '.$e->getMessage()); + } + } + + /** + * Get current plugin schema version + * + * @access public + * @param string $plugin + * @return integer + */ + public function getSchemaVersion($plugin) + { + return (int) $this->db->table(self::TABLE_SCHEMA)->eq('plugin', strtolower($plugin))->findOneColumn('version'); + } + + /** + * Save last plugin schema version + * + * @access public + * @param string $plugin + * @param integer $version + * @return boolean + */ + public function setSchemaVersion($plugin, $version) + { + $dictionary = array( + strtolower($plugin) => $version + ); + + return $this->db->getDriver()->upsert(self::TABLE_SCHEMA, 'plugin', 'version', $dictionary); + } +} diff --git a/app/Core/Router.php b/app/Core/Router.php index 6e7576d6..36bbfd55 100644 --- a/app/Core/Router.php +++ b/app/Core/Router.php @@ -213,49 +213,17 @@ class Router extends Base if (! empty($_GET['controller']) && ! empty($_GET['action'])) { $controller = $this->sanitize($_GET['controller'], 'app'); $action = $this->sanitize($_GET['action'], 'index'); + $plugin = ! empty($_GET['plugin']) ? $this->sanitize($_GET['plugin'], '') : ''; } else { - list($controller, $action) = $this->findRoute($this->getPath($uri, $query_string)); + list($controller, $action) = $this->findRoute($this->getPath($uri, $query_string)); // TODO: add plugin for routes + $plugin = ''; } - return $this->load( - __DIR__.'/../Controller/'.ucfirst($controller).'.php', - $controller, - '\Controller\\'.ucfirst($controller), - $action - ); - } - - /** - * Load a controller and execute the action - * - * @access private - * @param string $filename - * @param string $controller - * @param string $class - * @param string $method - * @return bool - */ - private function load($filename, $controller, $class, $method) - { - if (file_exists($filename)) { - - require $filename; - - if (! method_exists($class, $method)) { - return false; - } - - $this->action = $method; - $this->controller = $controller; - - $instance = new $class($this->container); - $instance->beforeAction($controller, $method); - $instance->$method(); - - return true; - } + $class = empty($plugin) ? '\Controller\\'.ucfirst($controller) : '\Plugin\\'.ucfirst($plugin).'\Controller\\'.ucfirst($controller); - return false; + $instance = new $class($this->container); + $instance->beforeAction($controller, $action); + $instance->$action(); } } diff --git a/app/Core/Template.php b/app/Core/Template.php index ba869ee6..b75f7da1 100644 --- a/app/Core/Template.php +++ b/app/Core/Template.php @@ -13,11 +13,12 @@ use LogicException; class Template extends Helper { /** - * Template path + * List of template overrides * - * @var string + * @access private + * @var array */ - const PATH = 'app/Template/'; + private $overrides = array(); /** * Render a template @@ -33,16 +34,10 @@ class Template extends Helper */ public function render($__template_name, array $__template_args = array()) { - $__template_file = self::PATH.$__template_name.'.php'; - - if (! file_exists($__template_file)) { - throw new LogicException('Unable to load the template: "'.$__template_name.'"'); - } - extract($__template_args); ob_start(); - include $__template_file; + include $this->getTemplateFile($__template_name); return ob_get_clean(); } @@ -62,4 +57,41 @@ class Template extends Helper $template_args + array('content_for_layout' => $this->render($template_name, $template_args)) ); } + + /** + * Define a new template override + * + * @access public + * @param string $original_template + * @param string $new_template + */ + public function setTemplateOverride($original_template, $new_template) + { + $this->overrides[$original_template] = $new_template; + } + + /** + * Find template filename + * + * Core template name: 'task/show' + * Plugin template name: 'myplugin:task/show' + * + * @access public + * @param string $template_name + * @return string + */ + public function getTemplateFile($template_name) + { + $template_name = isset($this->overrides[$template_name]) ? $this->overrides[$template_name] : $template_name; + + if (strpos($template_name, ':') !== false) { + list($plugin, $template) = explode(':', $template_name); + $path = __DIR__.'/../../plugins/'.ucfirst($plugin).'/Template/'.$template.'.php'; + } + else { + $path = __DIR__.'/../Template/'.$template_name.'.php'; + } + + return $path; + } } diff --git a/app/Core/Tool.php b/app/Core/Tool.php index 84e42ba8..7939a80e 100644 --- a/app/Core/Tool.php +++ b/app/Core/Tool.php @@ -2,6 +2,8 @@ namespace Core; +use Pimple\Container; + /** * Tool class * @@ -23,7 +25,6 @@ class Tool $fp = fopen($filename, 'w'); if (is_resource($fp)) { - foreach ($rows as $fields) { fputcsv($fp, $fields); } @@ -51,4 +52,24 @@ class Tool return $identifier; } + + /** + * Build dependency injection container from an array + * + * @static + * @access public + * @param Container $container + * @param array $namespaces + */ + public static function buildDIC(Container $container, array $namespaces) + { + foreach ($namespaces as $namespace => $classes) { + foreach ($classes as $name) { + $class = '\\'.$namespace.'\\'.$name; + $container[lcfirst($name)] = function ($c) use ($class) { + return new $class($c); + }; + } + } + } } diff --git a/app/Core/Translator.php b/app/Core/Translator.php index e3d19692..e9aa1f3f 100644 --- a/app/Core/Translator.php +++ b/app/Core/Translator.php @@ -15,7 +15,7 @@ class Translator * * @var string */ - const PATH = 'app/Locale/'; + const PATH = 'app/Locale'; /** * Locale @@ -196,18 +196,27 @@ class Translator * @static * @access public * @param string $language Locale code: fr_FR + * @param string $path Locale folder */ - public static function load($language) + public static function load($language, $path = self::PATH) { setlocale(LC_TIME, $language.'.UTF-8', $language); - $filename = self::PATH.$language.DIRECTORY_SEPARATOR.'translations.php'; + $filename = $path.DIRECTORY_SEPARATOR.$language.DIRECTORY_SEPARATOR.'translations.php'; if (file_exists($filename)) { - self::$locales = require $filename; - } - else { - self::$locales = array(); + self::$locales = array_merge(self::$locales, require($filename)); } } + + /** + * Clear locales stored in memory + * + * @static + * @access public + */ + public static function unload() + { + self::$locales = array(); + } } diff --git a/app/Helper/Hook.php b/app/Helper/Hook.php new file mode 100644 index 00000000..77756757 --- /dev/null +++ b/app/Helper/Hook.php @@ -0,0 +1,49 @@ +<?php + +namespace Helper; + +/** + * Template Hook helpers + * + * @package helper + * @author Frederic Guillot + */ +class Hook extends \Core\Base +{ + private $hooks = array(); + + /** + * Render all attached hooks + * + * @access public + * @param string $hook + * @param array $variables + * @return string + */ + public function render($hook, array $variables = array()) + { + $buffer = ''; + + foreach ($this->hooks as $name => $template) { + if ($hook === $name) { + $buffer .= $this->template->render($template, $variables); + } + } + + return $buffer; + } + + /** + * Attach a template to a hook + * + * @access public + * @param string $hook + * @param string $template + * @return \Helper\Hook + */ + public function attach($hook, $template) + { + $this->hooks[$hook] = $template; + return $this; + } +} diff --git a/app/Model/Acl.php b/app/Model/Acl.php index 8c28cb1a..6042bc29 100644 --- a/app/Model/Acl.php +++ b/app/Model/Acl.php @@ -95,6 +95,18 @@ class Acl extends Base ); /** + * Extend ACL rules + * + * @access public + * @param string $acl_name + * @param aray $rules + */ + public function extend($acl_name, array $rules) + { + $this->$acl_name = array_merge($this->$acl_name, $rules); + } + + /** * Return true if the specified controller/action match the given acl * * @access public diff --git a/app/Schema/Mysql.php b/app/Schema/Mysql.php index efdb159b..5a12bb3c 100644 --- a/app/Schema/Mysql.php +++ b/app/Schema/Mysql.php @@ -6,7 +6,18 @@ use PDO; use Core\Security; use Model\Link; -const VERSION = 86; +const VERSION = 87; + +function version_87($pdo) +{ + $pdo->exec(" + CREATE TABLE plugin_schema_versions ( + plugin VARCHAR(80) NOT NULL, + version INT NOT NULL DEFAULT 0, + PRIMARY KEY(plugin) + ) ENGINE=InnoDB CHARSET=utf8 + "); +} function version_86($pdo) { diff --git a/app/Schema/Postgres.php b/app/Schema/Postgres.php index a5d28dcf..ad460cc7 100644 --- a/app/Schema/Postgres.php +++ b/app/Schema/Postgres.php @@ -6,7 +6,17 @@ use PDO; use Core\Security; use Model\Link; -const VERSION = 66; +const VERSION = 67; + +function version_67($pdo) +{ + $pdo->exec(" + CREATE TABLE plugin_schema_versions ( + plugin VARCHAR(80) NOT NULL PRIMARY KEY, + version INTEGER NOT NULL DEFAULT 0 + ) + "); +} function version_66($pdo) { diff --git a/app/Schema/Sqlite.php b/app/Schema/Sqlite.php index 8efa016c..16fe0649 100644 --- a/app/Schema/Sqlite.php +++ b/app/Schema/Sqlite.php @@ -6,7 +6,17 @@ use Core\Security; use PDO; use Model\Link; -const VERSION = 82; +const VERSION = 83; + +function version_83($pdo) +{ + $pdo->exec(" + CREATE TABLE plugin_schema_versions ( + plugin TEXT NOT NULL PRIMARY KEY, + version INTEGER NOT NULL DEFAULT 0 + ) + "); +} function version_82($pdo) { diff --git a/app/ServiceProvider/ClassProvider.php b/app/ServiceProvider/ClassProvider.php index b570f5cf..53bddc1b 100644 --- a/app/ServiceProvider/ClassProvider.php +++ b/app/ServiceProvider/ClassProvider.php @@ -4,6 +4,7 @@ namespace ServiceProvider; use Core\Paginator; use Core\OAuth2; +use Core\Tool; use Model\Config; use Model\Project; use Model\Webhook; @@ -94,17 +95,7 @@ class ClassProvider implements ServiceProviderInterface public function register(Container $container) { - foreach ($this->classes as $namespace => $classes) { - - foreach ($classes as $name) { - - $class = '\\'.$namespace.'\\'.$name; - - $container[lcfirst($name)] = function ($c) use ($class) { - return new $class($c); - }; - } - } + Tool::buildDIC($container, $this->classes); $container['paginator'] = $container->factory(function ($c) { return new Paginator($c); diff --git a/app/Template/app/sidebar.php b/app/Template/app/sidebar.php index 2d966009..f4a455f8 100644 --- a/app/Template/app/sidebar.php +++ b/app/Template/app/sidebar.php @@ -19,6 +19,7 @@ <li <?= $this->app->getRouterAction() === 'activity' ? 'class="active"' : '' ?>> <?= $this->url->link(t('My activity stream'), 'app', 'activity', array('user_id' => $user['id'])) ?> </li> + <?= $this->hook->render('dashboard:sidebar') ?> </ul> <div class="sidebar-collapse"><a href="#" title="<?= t('Hide sidebar') ?>"><i class="fa fa-chevron-left"></i></a></div> <div class="sidebar-expand" style="display: none"><a href="#" title="<?= t('Expand sidebar') ?>"><i class="fa fa-chevron-right"></i></a></div> diff --git a/app/Template/config/sidebar.php b/app/Template/config/sidebar.php index 3617979a..083da283 100644 --- a/app/Template/config/sidebar.php +++ b/app/Template/config/sidebar.php @@ -34,6 +34,7 @@ <li> <?= $this->url->link(t('Documentation'), 'doc', 'show') ?> </li> + <?= $this->hook->render('config:sidebar') ?> </ul> <div class="sidebar-collapse"><a href="#" title="<?= t('Hide sidebar') ?>"><i class="fa fa-chevron-left"></i></a></div> <div class="sidebar-expand" style="display: none"><a href="#" title="<?= t('Expand sidebar') ?>"><i class="fa fa-chevron-right"></i></a></div> diff --git a/app/Template/export/sidebar.php b/app/Template/export/sidebar.php index f204d29d..7e39a5af 100644 --- a/app/Template/export/sidebar.php +++ b/app/Template/export/sidebar.php @@ -13,6 +13,7 @@ <li <?= $this->app->getRouterAction() === 'summary' ? 'class="active"' : '' ?>> <?= $this->url->link(t('Daily project summary'), 'export', 'summary', array('project_id' => $project['id'])) ?> </li> + <?= $this->hook->render('export:sidebar') ?> </ul> <div class="sidebar-collapse"><a href="#" title="<?= t('Hide sidebar') ?>"><i class="fa fa-chevron-left"></i></a></div> <div class="sidebar-expand" style="display: none"><a href="#" title="<?= t('Expand sidebar') ?>"><i class="fa fa-chevron-right"></i></a></div> diff --git a/app/Template/header.php b/app/Template/header.php new file mode 100644 index 00000000..0bcfdbbc --- /dev/null +++ b/app/Template/header.php @@ -0,0 +1,33 @@ +<header> + <nav> + <h1><?= $this->url->link('K<span>B</span>', 'app', 'index', array(), false, 'logo', t('Dashboard')).' '.$this->e($title) ?> + <?php if (! empty($description)): ?> + <span class="tooltip" title='<?= $this->e($this->text->markdown($description)) ?>'> + <i class="fa fa-info-circle"></i> + </span> + <?php endif ?> + </h1> + <ul> + <?php if (isset($board_selector) && ! empty($board_selector)): ?> + <li> + <select id="board-selector" + class="chosen-select select-auto-redirect" + tabindex="-1" + data-notfound="<?= t('No results match:') ?>" + data-placeholder="<?= t('Display another project') ?>" + data-redirect-regex="PROJECT_ID" + data-redirect-url="<?= $this->url->href('board', 'show', array('project_id' => 'PROJECT_ID')) ?>"> + <option value=""></option> + <?php foreach($board_selector as $board_id => $board_name): ?> + <option value="<?= $board_id ?>"><?= $this->e($board_name) ?></option> + <?php endforeach ?> + </select> + </li> + <?php endif ?> + <li> + <?= $this->url->link(t('Logout'), 'auth', 'logout') ?> + <span class="username hide-tablet">(<?= $this->user->getProfileLink() ?>)</span> + </li> + </ul> + </nav> +</header>
\ No newline at end of file diff --git a/app/Template/layout.php b/app/Template/layout.php index 60108175..934fb62c 100644 --- a/app/Template/layout.php +++ b/app/Template/layout.php @@ -28,6 +28,8 @@ <link rel="apple-touch-icon" sizes="144x144" href="<?= $this->url->dir() ?>assets/img/touch-icon-ipad-retina.png"> <title><?= isset($title) ? $this->e($title) : 'Kanboard' ?></title> + + <?= $this->hook->render('layout:head') ?> </head> <body data-status-url="<?= $this->url->href('app', 'status') ?>" data-login-url="<?= $this->url->href('auth', 'login') ?>" @@ -38,43 +40,17 @@ <?php if (isset($no_layout) && $no_layout): ?> <?= $content_for_layout ?> <?php else: ?> - <header> - <nav> - <h1><?= $this->url->link('K<span>B</span>', 'app', 'index', array(), false, 'logo', t('Dashboard')).' '.$this->e($title) ?> - <?php if (! empty($description)): ?> - <span class="tooltip" title='<?= $this->e($this->text->markdown($description)) ?>'> - <i class="fa fa-info-circle"></i> - </span> - <?php endif ?> - </h1> - <ul> - <?php if (isset($board_selector) && ! empty($board_selector)): ?> - <li> - <select id="board-selector" - class="chosen-select select-auto-redirect" - tabindex="-1" - data-notfound="<?= t('No results match:') ?>" - data-placeholder="<?= t('Display another project') ?>" - data-redirect-regex="PROJECT_ID" - data-redirect-url="<?= $this->url->href('board', 'show', array('project_id' => 'PROJECT_ID')) ?>"> - <option value=""></option> - <?php foreach($board_selector as $board_id => $board_name): ?> - <option value="<?= $board_id ?>"><?= $this->e($board_name) ?></option> - <?php endforeach ?> - </select> - </li> - <?php endif ?> - <li> - <?= $this->url->link(t('Logout'), 'auth', 'logout') ?> - <span class="username hide-tablet">(<?= $this->user->getProfileLink() ?>)</span> - </li> - </ul> - </nav> - </header> + <?= $this->hook->render('layout:top') ?> + <?= $this->render('header', array( + 'title' => $title, + 'description' => isset($description) ? $description : '', + 'board_selector' => $board_selector, + )) ?> <section class="page"> <?= $this->app->flashMessage() ?> <?= $content_for_layout ?> </section> + <?= $this->hook->render('layout:bottom') ?> <?php endif ?> </body> </html> diff --git a/app/Template/project/dropdown.php b/app/Template/project/dropdown.php index 0a53cc05..c9563a4f 100644 --- a/app/Template/project/dropdown.php +++ b/app/Template/project/dropdown.php @@ -9,21 +9,23 @@ </li> <?php endif ?> +<?= $this->hook->render('project:dropdown', array('project' => $project)) ?> + <?php if ($this->user->isProjectManagementAllowed($project['id'])): ?> -<li> - <i class="fa fa-line-chart fa-fw"></i> - <?= $this->url->link(t('Analytics'), 'analytic', 'tasks', array('project_id' => $project['id'])) ?> -</li> -<li> - <i class="fa fa-pie-chart fa-fw"></i> - <?= $this->url->link(t('Budget'), 'budget', 'index', array('project_id' => $project['id'])) ?> -</li> -<li> - <i class="fa fa-download fa-fw"></i> - <?= $this->url->link(t('Exports'), 'export', 'tasks', array('project_id' => $project['id'])) ?> -</li> -<li> - <i class="fa fa-cog fa-fw"></i> - <?= $this->url->link(t('Settings'), 'project', 'show', array('project_id' => $project['id'])) ?> -</li> + <li> + <i class="fa fa-line-chart fa-fw"></i> + <?= $this->url->link(t('Analytics'), 'analytic', 'tasks', array('project_id' => $project['id'])) ?> + </li> + <li> + <i class="fa fa-pie-chart fa-fw"></i> + <?= $this->url->link(t('Budget'), 'budget', 'index', array('project_id' => $project['id'])) ?> + </li> + <li> + <i class="fa fa-download fa-fw"></i> + <?= $this->url->link(t('Exports'), 'export', 'tasks', array('project_id' => $project['id'])) ?> + </li> + <li> + <i class="fa fa-cog fa-fw"></i> + <?= $this->url->link(t('Settings'), 'project', 'show', array('project_id' => $project['id'])) ?> + </li> <?php endif ?> diff --git a/app/Template/project/sidebar.php b/app/Template/project/sidebar.php index 7b5d976f..84bbb6b1 100644 --- a/app/Template/project/sidebar.php +++ b/app/Template/project/sidebar.php @@ -48,6 +48,8 @@ </li> <?php endif ?> <?php endif ?> + + <?= $this->hook->render('project:sidebar') ?> </ul> <div class="sidebar-collapse"><a href="#" title="<?= t('Hide sidebar') ?>"><i class="fa fa-chevron-left"></i></a></div> <div class="sidebar-expand" style="display: none"><a href="#" title="<?= t('Expand sidebar') ?>"><i class="fa fa-chevron-right"></i></a></div> diff --git a/app/Template/project_user/sidebar.php b/app/Template/project_user/sidebar.php index 8cc3f41b..98219a87 100644 --- a/app/Template/project_user/sidebar.php +++ b/app/Template/project_user/sidebar.php @@ -24,5 +24,7 @@ <li <?= $this->app->getRouterAction() === 'closed' ? 'class="active"' : '' ?>> <?= $this->url->link(t('Closed tasks'), 'projectuser', 'closed', $filter) ?> </li> + + <?= $this->hook->render('project-user:sidebar') ?> </ul> </div>
\ No newline at end of file diff --git a/app/Template/task/sidebar.php b/app/Template/task/sidebar.php index 1f06ab8c..cf0e9f76 100644 --- a/app/Template/task/sidebar.php +++ b/app/Template/task/sidebar.php @@ -18,6 +18,8 @@ <?= $this->url->link(t('Time tracking'), 'task', 'timetracking', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?> </li> <?php endif ?> + + <?= $this->hook->render('task:sidebar:information') ?> </ul> <h2><?= t('Actions') ?></h2> <ul> @@ -66,6 +68,8 @@ <?= $this->url->link(t('Remove'), 'task', 'remove', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?> </li> <?php endif ?> + + <?= $this->hook->render('task:sidebar:actions') ?> </ul> <div class="sidebar-collapse"><a href="#" title="<?= t('Hide sidebar') ?>"><i class="fa fa-chevron-left"></i></a></div> <div class="sidebar-expand" style="display: none"><a href="#" title="<?= t('Expand sidebar') ?>"><i class="fa fa-chevron-right"></i></a></div> diff --git a/app/Template/user/sidebar.php b/app/Template/user/sidebar.php index cd1c85c1..77612d0f 100644 --- a/app/Template/user/sidebar.php +++ b/app/Template/user/sidebar.php @@ -20,6 +20,8 @@ <?= $this->url->link(t('Persistent connections'), 'user', 'sessions', array('user_id' => $user['id'])) ?> </li> <?php endif ?> + + <?= $this->hook->render('user:sidebar:information') ?> </ul> <h2><?= t('Actions') ?></h2> @@ -68,6 +70,8 @@ </li> <?php endif ?> + <?= $this->hook->render('user:sidebar:actions', array('user' => $user)) ?> + <?php if ($this->user->isAdmin() && ! $this->user->isCurrentUser($user['id'])): ?> <li <?= $this->app->getRouterController() === 'user' && $this->app->getRouterAction() === 'remove' ? 'class="active"' : '' ?>> <?= $this->url->link(t('Remove'), 'user', 'remove', array('user_id' => $user['id'])) ?> diff --git a/app/common.php b/app/common.php index 1f1c7273..ea38ab36 100644 --- a/app/common.php +++ b/app/common.php @@ -30,120 +30,8 @@ $container->register(new ServiceProvider\ClassProvider); $container->register(new ServiceProvider\EventDispatcherProvider); if (ENABLE_URL_REWRITE) { - - // Dashboard - $container['router']->addRoute('dashboard', 'app', 'index'); - $container['router']->addRoute('dashboard/:user_id', 'app', 'index', array('user_id')); - $container['router']->addRoute('dashboard/:user_id/projects', 'app', 'projects', array('user_id')); - $container['router']->addRoute('dashboard/:user_id/tasks', 'app', 'tasks', array('user_id')); - $container['router']->addRoute('dashboard/:user_id/subtasks', 'app', 'subtasks', array('user_id')); - $container['router']->addRoute('dashboard/:user_id/calendar', 'app', 'calendar', array('user_id')); - $container['router']->addRoute('dashboard/:user_id/activity', 'app', 'activity', array('user_id')); - - // Search routes - $container['router']->addRoute('search', 'search', 'index'); - $container['router']->addRoute('search/:search', 'search', 'index', array('search')); - - // Project routes - $container['router']->addRoute('projects', 'project', 'index'); - $container['router']->addRoute('project/create', 'project', 'create'); - $container['router']->addRoute('project/create/:private', 'project', 'create', array('private')); - $container['router']->addRoute('project/:project_id', 'project', 'show', array('project_id')); - $container['router']->addRoute('p/:project_id', 'project', 'show', array('project_id')); - $container['router']->addRoute('project/:project_id/share', 'project', 'share', array('project_id')); - $container['router']->addRoute('project/:project_id/edit', 'project', 'edit', array('project_id')); - $container['router']->addRoute('project/:project_id/integration', 'project', 'integration', array('project_id')); - $container['router']->addRoute('project/:project_id/users', 'project', 'users', array('project_id')); - $container['router']->addRoute('project/:project_id/duplicate', 'project', 'duplicate', array('project_id')); - $container['router']->addRoute('project/:project_id/remove', 'project', 'remove', array('project_id')); - $container['router']->addRoute('project/:project_id/disable', 'project', 'disable', array('project_id')); - $container['router']->addRoute('project/:project_id/enable', 'project', 'enable', array('project_id')); - - // Action routes - $container['router']->addRoute('project/:project_id/actions', 'action', 'index', array('project_id')); - $container['router']->addRoute('project/:project_id/action/:action_id/confirm', 'action', 'confirm', array('project_id', 'action_id')); - - // Column routes - $container['router']->addRoute('project/:project_id/columns', 'column', 'index', array('project_id')); - $container['router']->addRoute('project/:project_id/column/:column_id/edit', 'column', 'edit', array('project_id', 'column_id')); - $container['router']->addRoute('project/:project_id/column/:column_id/confirm', 'column', 'confirm', array('project_id', 'column_id')); - $container['router']->addRoute('project/:project_id/column/:column_id/move/:direction', 'column', 'move', array('project_id', 'column_id', 'direction')); - - // Swimlane routes - $container['router']->addRoute('project/:project_id/swimlanes', 'swimlane', 'index', array('project_id')); - $container['router']->addRoute('project/:project_id/swimlane/:swimlane_id/edit', 'swimlane', 'edit', array('project_id', 'swimlane_id')); - $container['router']->addRoute('project/:project_id/swimlane/:swimlane_id/confirm', 'swimlane', 'confirm', array('project_id', 'swimlane_id')); - $container['router']->addRoute('project/:project_id/swimlane/:swimlane_id/disable', 'swimlane', 'disable', array('project_id', 'swimlane_id')); - $container['router']->addRoute('project/:project_id/swimlane/:swimlane_id/enable', 'swimlane', 'enable', array('project_id', 'swimlane_id')); - $container['router']->addRoute('project/:project_id/swimlane/:swimlane_id/up', 'swimlane', 'moveup', array('project_id', 'swimlane_id')); - $container['router']->addRoute('project/:project_id/swimlane/:swimlane_id/down', 'swimlane', 'movedown', array('project_id', 'swimlane_id')); - - // Category routes - $container['router']->addRoute('project/:project_id/categories', 'category', 'index', array('project_id')); - $container['router']->addRoute('project/:project_id/category/:category_id/edit', 'category', 'edit', array('project_id', 'category_id')); - $container['router']->addRoute('project/:project_id/category/:category_id/confirm', 'category', 'confirm', array('project_id', 'category_id')); - - // Task routes - $container['router']->addRoute('project/:project_id/task/:task_id', 'task', 'show', array('project_id', 'task_id')); - $container['router']->addRoute('t/:task_id', 'task', 'show', array('task_id')); - $container['router']->addRoute('public/task/:task_id/:token', 'task', 'readonly', array('task_id', 'token')); - - $container['router']->addRoute('project/:project_id/task/:task_id/activity', 'activity', 'task', array('project_id', 'task_id')); - $container['router']->addRoute('project/:project_id/task/:task_id/screenshot', 'file', 'screenshot', array('project_id', 'task_id')); - $container['router']->addRoute('project/:project_id/task/:task_id/upload', 'file', 'create', array('project_id', 'task_id')); - $container['router']->addRoute('project/:project_id/task/:task_id/comment', 'comment', 'create', array('project_id', 'task_id')); - $container['router']->addRoute('project/:project_id/task/:task_id/link', 'tasklink', 'create', array('project_id', 'task_id')); - $container['router']->addRoute('project/:project_id/task/:task_id/transitions', 'task', 'transitions', array('project_id', 'task_id')); - $container['router']->addRoute('project/:project_id/task/:task_id/analytics', 'task', 'analytics', array('project_id', 'task_id')); - $container['router']->addRoute('project/:project_id/task/:task_id/remove', 'task', 'remove', array('project_id', 'task_id')); - - $container['router']->addRoute('project/:project_id/task/:task_id/edit', 'taskmodification', 'edit', array('project_id', 'task_id')); - $container['router']->addRoute('project/:project_id/task/:task_id/description', 'taskmodification', 'description', array('project_id', 'task_id')); - $container['router']->addRoute('project/:project_id/task/:task_id/recurrence', 'taskmodification', 'recurrence', array('project_id', 'task_id')); - - $container['router']->addRoute('project/:project_id/task/:task_id/close', 'taskstatus', 'close', array('task_id', 'project_id')); - $container['router']->addRoute('project/:project_id/task/:task_id/open', 'taskstatus', 'open', array('task_id', 'project_id')); - - $container['router']->addRoute('project/:project_id/task/:task_id/duplicate', 'taskduplication', 'duplicate', array('task_id', 'project_id')); - $container['router']->addRoute('project/:project_id/task/:task_id/copy', 'taskduplication', 'copy', array('task_id', 'project_id')); - $container['router']->addRoute('project/:project_id/task/:task_id/copy/:dst_project_id', 'taskduplication', 'copy', array('task_id', 'project_id', 'dst_project_id')); - $container['router']->addRoute('project/:project_id/task/:task_id/move', 'taskduplication', 'move', array('task_id', 'project_id')); - $container['router']->addRoute('project/:project_id/task/:task_id/move/:dst_project_id', 'taskduplication', 'move', array('task_id', 'project_id', 'dst_project_id')); - - // Board routes - $container['router']->addRoute('board/:project_id', 'board', 'show', array('project_id')); - $container['router']->addRoute('b/:project_id', 'board', 'show', array('project_id')); - $container['router']->addRoute('public/board/:token', 'board', 'readonly', array('token')); - - // Calendar routes - $container['router']->addRoute('calendar/:project_id', 'calendar', 'show', array('project_id')); - $container['router']->addRoute('c/:project_id', 'calendar', 'show', array('project_id')); - - // Listing routes - $container['router']->addRoute('list/:project_id', 'listing', 'show', array('project_id')); - $container['router']->addRoute('l/:project_id', 'listing', 'show', array('project_id')); - - // Gantt routes - $container['router']->addRoute('gantt/:project_id', 'gantt', 'project', array('project_id')); - $container['router']->addRoute('gantt/:project_id/sort/:sorting', 'gantt', 'project', array('project_id', 'sorting')); - - // Subtask routes - $container['router']->addRoute('project/:project_id/task/:task_id/subtask/create', 'subtask', 'create', array('project_id', 'task_id')); - $container['router']->addRoute('project/:project_id/task/:task_id/subtask/:subtask_id/remove', 'subtask', 'confirm', array('project_id', 'task_id', 'subtask_id')); - $container['router']->addRoute('project/:project_id/task/:task_id/subtask/:subtask_id/edit', 'subtask', 'edit', array('project_id', 'task_id', 'subtask_id')); - - // Feed routes - $container['router']->addRoute('feed/project/:token', 'feed', 'project', array('token')); - $container['router']->addRoute('feed/user/:token', 'feed', 'user', array('token')); - - // Ical routes - $container['router']->addRoute('ical/project/:token', 'ical', 'project', array('token')); - $container['router']->addRoute('ical/user/:token', 'ical', 'user', array('token')); - - // Auth routes - $container['router']->addRoute('oauth/google', 'oauth', 'google'); - $container['router']->addRoute('oauth/github', 'oauth', 'github'); - $container['router']->addRoute('oauth/gitlab', 'oauth', 'gitlab'); - $container['router']->addRoute('login', 'auth', 'login'); - $container['router']->addRoute('logout', 'auth', 'logout'); + require __DIR__.'/routes.php'; } + +$plugin = new Core\PluginLoader($container); +$plugin->scan(); diff --git a/app/routes.php b/app/routes.php new file mode 100644 index 00000000..159e8f6e --- /dev/null +++ b/app/routes.php @@ -0,0 +1,117 @@ +<?php + +// Dashboard +$container['router']->addRoute('dashboard', 'app', 'index'); +$container['router']->addRoute('dashboard/:user_id', 'app', 'index', array('user_id')); +$container['router']->addRoute('dashboard/:user_id/projects', 'app', 'projects', array('user_id')); +$container['router']->addRoute('dashboard/:user_id/tasks', 'app', 'tasks', array('user_id')); +$container['router']->addRoute('dashboard/:user_id/subtasks', 'app', 'subtasks', array('user_id')); +$container['router']->addRoute('dashboard/:user_id/calendar', 'app', 'calendar', array('user_id')); +$container['router']->addRoute('dashboard/:user_id/activity', 'app', 'activity', array('user_id')); + +// Search routes +$container['router']->addRoute('search', 'search', 'index'); +$container['router']->addRoute('search/:search', 'search', 'index', array('search')); + +// Project routes +$container['router']->addRoute('projects', 'project', 'index'); +$container['router']->addRoute('project/create', 'project', 'create'); +$container['router']->addRoute('project/create/:private', 'project', 'create', array('private')); +$container['router']->addRoute('project/:project_id', 'project', 'show', array('project_id')); +$container['router']->addRoute('p/:project_id', 'project', 'show', array('project_id')); +$container['router']->addRoute('project/:project_id/share', 'project', 'share', array('project_id')); +$container['router']->addRoute('project/:project_id/edit', 'project', 'edit', array('project_id')); +$container['router']->addRoute('project/:project_id/integration', 'project', 'integration', array('project_id')); +$container['router']->addRoute('project/:project_id/users', 'project', 'users', array('project_id')); +$container['router']->addRoute('project/:project_id/duplicate', 'project', 'duplicate', array('project_id')); +$container['router']->addRoute('project/:project_id/remove', 'project', 'remove', array('project_id')); +$container['router']->addRoute('project/:project_id/disable', 'project', 'disable', array('project_id')); +$container['router']->addRoute('project/:project_id/enable', 'project', 'enable', array('project_id')); + +// Action routes +$container['router']->addRoute('project/:project_id/actions', 'action', 'index', array('project_id')); +$container['router']->addRoute('project/:project_id/action/:action_id/confirm', 'action', 'confirm', array('project_id', 'action_id')); + +// Column routes +$container['router']->addRoute('project/:project_id/columns', 'column', 'index', array('project_id')); +$container['router']->addRoute('project/:project_id/column/:column_id/edit', 'column', 'edit', array('project_id', 'column_id')); +$container['router']->addRoute('project/:project_id/column/:column_id/confirm', 'column', 'confirm', array('project_id', 'column_id')); +$container['router']->addRoute('project/:project_id/column/:column_id/move/:direction', 'column', 'move', array('project_id', 'column_id', 'direction')); + +// Swimlane routes +$container['router']->addRoute('project/:project_id/swimlanes', 'swimlane', 'index', array('project_id')); +$container['router']->addRoute('project/:project_id/swimlane/:swimlane_id/edit', 'swimlane', 'edit', array('project_id', 'swimlane_id')); +$container['router']->addRoute('project/:project_id/swimlane/:swimlane_id/confirm', 'swimlane', 'confirm', array('project_id', 'swimlane_id')); +$container['router']->addRoute('project/:project_id/swimlane/:swimlane_id/disable', 'swimlane', 'disable', array('project_id', 'swimlane_id')); +$container['router']->addRoute('project/:project_id/swimlane/:swimlane_id/enable', 'swimlane', 'enable', array('project_id', 'swimlane_id')); +$container['router']->addRoute('project/:project_id/swimlane/:swimlane_id/up', 'swimlane', 'moveup', array('project_id', 'swimlane_id')); +$container['router']->addRoute('project/:project_id/swimlane/:swimlane_id/down', 'swimlane', 'movedown', array('project_id', 'swimlane_id')); + +// Category routes +$container['router']->addRoute('project/:project_id/categories', 'category', 'index', array('project_id')); +$container['router']->addRoute('project/:project_id/category/:category_id/edit', 'category', 'edit', array('project_id', 'category_id')); +$container['router']->addRoute('project/:project_id/category/:category_id/confirm', 'category', 'confirm', array('project_id', 'category_id')); + +// Task routes +$container['router']->addRoute('project/:project_id/task/:task_id', 'task', 'show', array('project_id', 'task_id')); +$container['router']->addRoute('t/:task_id', 'task', 'show', array('task_id')); +$container['router']->addRoute('public/task/:task_id/:token', 'task', 'readonly', array('task_id', 'token')); + +$container['router']->addRoute('project/:project_id/task/:task_id/activity', 'activity', 'task', array('project_id', 'task_id')); +$container['router']->addRoute('project/:project_id/task/:task_id/screenshot', 'file', 'screenshot', array('project_id', 'task_id')); +$container['router']->addRoute('project/:project_id/task/:task_id/upload', 'file', 'create', array('project_id', 'task_id')); +$container['router']->addRoute('project/:project_id/task/:task_id/comment', 'comment', 'create', array('project_id', 'task_id')); +$container['router']->addRoute('project/:project_id/task/:task_id/link', 'tasklink', 'create', array('project_id', 'task_id')); +$container['router']->addRoute('project/:project_id/task/:task_id/transitions', 'task', 'transitions', array('project_id', 'task_id')); +$container['router']->addRoute('project/:project_id/task/:task_id/analytics', 'task', 'analytics', array('project_id', 'task_id')); +$container['router']->addRoute('project/:project_id/task/:task_id/remove', 'task', 'remove', array('project_id', 'task_id')); + +$container['router']->addRoute('project/:project_id/task/:task_id/edit', 'taskmodification', 'edit', array('project_id', 'task_id')); +$container['router']->addRoute('project/:project_id/task/:task_id/description', 'taskmodification', 'description', array('project_id', 'task_id')); +$container['router']->addRoute('project/:project_id/task/:task_id/recurrence', 'taskmodification', 'recurrence', array('project_id', 'task_id')); + +$container['router']->addRoute('project/:project_id/task/:task_id/close', 'taskstatus', 'close', array('task_id', 'project_id')); +$container['router']->addRoute('project/:project_id/task/:task_id/open', 'taskstatus', 'open', array('task_id', 'project_id')); + +$container['router']->addRoute('project/:project_id/task/:task_id/duplicate', 'taskduplication', 'duplicate', array('task_id', 'project_id')); +$container['router']->addRoute('project/:project_id/task/:task_id/copy', 'taskduplication', 'copy', array('task_id', 'project_id')); +$container['router']->addRoute('project/:project_id/task/:task_id/copy/:dst_project_id', 'taskduplication', 'copy', array('task_id', 'project_id', 'dst_project_id')); +$container['router']->addRoute('project/:project_id/task/:task_id/move', 'taskduplication', 'move', array('task_id', 'project_id')); +$container['router']->addRoute('project/:project_id/task/:task_id/move/:dst_project_id', 'taskduplication', 'move', array('task_id', 'project_id', 'dst_project_id')); + +// Board routes +$container['router']->addRoute('board/:project_id', 'board', 'show', array('project_id')); +$container['router']->addRoute('b/:project_id', 'board', 'show', array('project_id')); +$container['router']->addRoute('public/board/:token', 'board', 'readonly', array('token')); + +// Calendar routes +$container['router']->addRoute('calendar/:project_id', 'calendar', 'show', array('project_id')); +$container['router']->addRoute('c/:project_id', 'calendar', 'show', array('project_id')); + +// Listing routes +$container['router']->addRoute('list/:project_id', 'listing', 'show', array('project_id')); +$container['router']->addRoute('l/:project_id', 'listing', 'show', array('project_id')); + +// Gantt routes +$container['router']->addRoute('gantt/:project_id', 'gantt', 'project', array('project_id')); +$container['router']->addRoute('gantt/:project_id/sort/:sorting', 'gantt', 'project', array('project_id', 'sorting')); + +// Subtask routes +$container['router']->addRoute('project/:project_id/task/:task_id/subtask/create', 'subtask', 'create', array('project_id', 'task_id')); +$container['router']->addRoute('project/:project_id/task/:task_id/subtask/:subtask_id/remove', 'subtask', 'confirm', array('project_id', 'task_id', 'subtask_id')); +$container['router']->addRoute('project/:project_id/task/:task_id/subtask/:subtask_id/edit', 'subtask', 'edit', array('project_id', 'task_id', 'subtask_id')); + +// Feed routes +$container['router']->addRoute('feed/project/:token', 'feed', 'project', array('token')); +$container['router']->addRoute('feed/user/:token', 'feed', 'user', array('token')); + +// Ical routes +$container['router']->addRoute('ical/project/:token', 'ical', 'project', array('token')); +$container['router']->addRoute('ical/user/:token', 'ical', 'user', array('token')); + +// Auth routes +$container['router']->addRoute('oauth/google', 'oauth', 'google'); +$container['router']->addRoute('oauth/github', 'oauth', 'github'); +$container['router']->addRoute('oauth/gitlab', 'oauth', 'gitlab'); +$container['router']->addRoute('login', 'auth', 'login'); +$container['router']->addRoute('logout', 'auth', 'logout'); diff --git a/composer.json b/composer.json index 60073fd5..223aa1fc 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,10 @@ "gregwar/captcha": "1.*" }, "autoload" : { + "classmap" : ["app/"], + "psr-4" : { + "Plugin\\": "plugins/" + }, "psr-0" : { "" : "app/" }, diff --git a/plugins/.gitignore b/plugins/.gitignore new file mode 100644 index 00000000..aa0e8eb1 --- /dev/null +++ b/plugins/.gitignore @@ -0,0 +1,2 @@ +* +!/.gitignore
\ No newline at end of file diff --git a/tests/units/Core/PluginLoaderTest.php b/tests/units/Core/PluginLoaderTest.php new file mode 100644 index 00000000..62327f01 --- /dev/null +++ b/tests/units/Core/PluginLoaderTest.php @@ -0,0 +1,23 @@ +<?php + +require_once __DIR__.'/../Base.php'; + +use Core\PluginLoader; + +class PluginLoaderTest extends Base +{ + public function testGetSchemaVersion() + { + $p = new PluginLoader($this->container); + $this->assertEquals(0, $p->getSchemaVersion('not_found')); + + $this->assertTrue($p->setSchemaVersion('plugin1', 1)); + $this->assertEquals(1, $p->getSchemaVersion('plugin1')); + + $this->assertTrue($p->setSchemaVersion('plugin2', 33)); + $this->assertEquals(33, $p->getSchemaVersion('plugin2')); + + $this->assertTrue($p->setSchemaVersion('plugin1', 2)); + $this->assertEquals(2, $p->getSchemaVersion('plugin1')); + } +} diff --git a/tests/units/Core/TemplateTest.php b/tests/units/Core/TemplateTest.php new file mode 100644 index 00000000..9833397b --- /dev/null +++ b/tests/units/Core/TemplateTest.php @@ -0,0 +1,28 @@ +<?php + +require_once __DIR__.'/../Base.php'; + +use Core\Template; + +class TemplateTest extends Base +{ + public function testGetTemplateFile() + { + $t = new Template($this->container); + $this->assertStringEndsWith('app/Core/../Template/a/b.php', $t->getTemplateFile('a/b')); + } + + public function testGetPluginTemplateFile() + { + $t = new Template($this->container); + $this->assertStringEndsWith('app/Core/../../plugins/Myplugin/Template/a/b.php', $t->getTemplateFile('myplugin:a/b')); + } + + public function testGetOverridedTemplateFile() + { + $t = new Template($this->container); + $t->setTemplateOverride('a/b', 'myplugin:c'); + $this->assertStringEndsWith('app/Core/../../plugins/Myplugin/Template/c.php', $t->getTemplateFile('a/b')); + $this->assertStringEndsWith('app/Core/../Template/d.php', $t->getTemplateFile('d')); + } +} diff --git a/tests/units/Model/AclTest.php b/tests/units/Model/AclTest.php index fef03990..3cb28a77 100644 --- a/tests/units/Model/AclTest.php +++ b/tests/units/Model/AclTest.php @@ -290,4 +290,16 @@ class AclTest extends Base $this->assertFalse($acl->isAllowed('task', 'remove', 1)); $this->assertTrue($acl->isAllowed('app', 'index', 1)); } + + public function testExtend() + { + $acl = new Acl($this->container); + + $this->assertFalse($acl->isProjectManagerAction('plop', 'show')); + + $acl->extend('project_manager_acl', array('plop' => '*')); + + $this->assertTrue($acl->isProjectManagerAction('plop', 'show')); + $this->assertTrue($acl->isProjectManagerAction('swimlane', 'index')); + } } diff --git a/tests/units/Model/ProjectTest.php b/tests/units/Model/ProjectTest.php index 97357796..9d7b6c0d 100644 --- a/tests/units/Model/ProjectTest.php +++ b/tests/units/Model/ProjectTest.php @@ -26,7 +26,7 @@ class ProjectTest extends Base $this->assertNotFalse($p->create(array('name' => 'UnitTest '.$locale)), 'Unable to create project with '.$locale.':'.$language); } - Translator::load('en_US'); + Translator::unload(); } public function testCreation() |