]> git.immae.eu Git - github/shaarli/Shaarli.git/commitdiff
Plugin system - CORE
authorArthurHoaro <arthur@hoa.ro>
Wed, 15 Jul 2015 09:42:15 +0000 (11:42 +0200)
committerArthurHoaro <arthur@hoa.ro>
Sat, 7 Nov 2015 14:27:17 +0000 (15:27 +0100)
see shaarli/Shaarli#275

.gitignore
application/Config.php [changed mode: 0755->0644]
application/PluginManager.php [new file with mode: 0644]
application/Router.php [new file with mode: 0644]
index.php
tests/PluginManagerTest.php [new file with mode: 0755]
tests/RouterTest.php [new file with mode: 0755]
tests/plugins/test/test.php [new file with mode: 0755]

index 3ffedb31160484d614a949a2e651b65685549a34..5a6b92412d9316bf7e32f4d600f5818d98af0518 100644 (file)
@@ -21,3 +21,6 @@ coverage
 tests/datastore.php
 tests/dummycache/
 phpmd.html
+
+# Ignore user plugin configuration
+plugins/*/config.php
\ No newline at end of file
old mode 100755 (executable)
new mode 100644 (file)
index ec799d7..c71ef68
-<?php\r
-/**\r
- * Functions related to configuration management.\r
- */\r
-\r
-/**\r
- * Re-write configuration file according to given array.\r
- * Requires mandatory fields listed in $MANDATORY_FIELDS.\r
- *\r
- * @param array $config     contains all configuration fields.\r
- * @param bool  $isLoggedIn true if user is logged in.\r
- *\r
- * @return void\r
- *\r
- * @throws MissingFieldConfigException: a mandatory field has not been provided in $config.\r
- * @throws UnauthorizedConfigException: user is not authorize to change configuration.\r
- * @throws Exception: an error occured while writing the new config file.\r
- */\r
-function writeConfig($config, $isLoggedIn)\r
-{\r
-    // These fields are required in configuration.\r
-    $MANDATORY_FIELDS = array(\r
-        'login', 'hash', 'salt', 'timezone', 'title', 'titleLink',\r
-        'redirector', 'disablesessionprotection', 'privateLinkByDefault'\r
-    );\r
-\r
-    if (!isset($config['config']['CONFIG_FILE'])) {\r
-        throw new MissingFieldConfigException('CONFIG_FILE');\r
-    }\r
-\r
-    // Only logged in user can alter config.\r
-    if (is_file($config['config']['CONFIG_FILE']) && !$isLoggedIn) {\r
-        throw new UnauthorizedConfigException();\r
-    }\r
-\r
-    // Check that all mandatory fields are provided in $config.\r
-    foreach ($MANDATORY_FIELDS as $field) {\r
-        if (!isset($config[$field])) {\r
-            throw new MissingFieldConfigException($field);\r
-        }\r
-    }\r
-\r
-    $configStr = '<?php '. PHP_EOL;\r
-    $configStr .= '$GLOBALS[\'login\'] = '.var_export($config['login'], true).';'. PHP_EOL;\r
-    $configStr .= '$GLOBALS[\'hash\'] = '.var_export($config['hash'], true).';'. PHP_EOL;\r
-    $configStr .= '$GLOBALS[\'salt\'] = '.var_export($config['salt'], true).'; '. PHP_EOL;\r
-    $configStr .= '$GLOBALS[\'timezone\'] = '.var_export($config['timezone'], true).';'. PHP_EOL;\r
-    $configStr .= 'date_default_timezone_set('.var_export($config['timezone'], true).');'. PHP_EOL;\r
-    $configStr .= '$GLOBALS[\'title\'] = '.var_export($config['title'], true).';'. PHP_EOL;\r
-    $configStr .= '$GLOBALS[\'titleLink\'] = '.var_export($config['titleLink'], true).'; '. PHP_EOL;\r
-    $configStr .= '$GLOBALS[\'redirector\'] = '.var_export($config['redirector'], true).'; '. PHP_EOL;\r
-    $configStr .= '$GLOBALS[\'disablesessionprotection\'] = '.var_export($config['disablesessionprotection'], true).'; '. PHP_EOL;\r
-    $configStr .= '$GLOBALS[\'privateLinkByDefault\'] = '.var_export($config['privateLinkByDefault'], true).'; '. PHP_EOL;\r
-\r
-    // Store all $config['config']\r
-    foreach ($config['config'] as $key => $value) {\r
-        $configStr .= '$GLOBALS[\'config\'][\''. $key .'\'] = '.var_export($config['config'][$key], true).';'. PHP_EOL;\r
-    }\r
-    $configStr .= '?>';\r
-\r
-    if (!file_put_contents($config['config']['CONFIG_FILE'], $configStr)\r
-        || strcmp(file_get_contents($config['config']['CONFIG_FILE']), $configStr) != 0\r
-    ) {\r
-        throw new Exception(\r
-            'Shaarli could not create the config file.\r
-            Please make sure Shaarli has the right to write in the folder is it installed in.'\r
-        );\r
-    }\r
-}\r
-\r
-/**\r
- * Milestone 0.9 - shaarli/Shaarli#41: options.php is not supported anymore.\r
- * ==> if user is loggedIn, merge its content with config.php, then delete options.php.\r
- *\r
- * @param array $config     contains all configuration fields.\r
- * @param bool  $isLoggedIn true if user is logged in.\r
- *\r
- * @return void\r
- */\r
-function mergeDeprecatedConfig($config, $isLoggedIn)\r
-{\r
-    $config_file = $config['config']['CONFIG_FILE'];\r
-\r
-    if (is_file($config['config']['DATADIR'].'/options.php') && $isLoggedIn) {\r
-        include $config['config']['DATADIR'].'/options.php';\r
-\r
-        // Load GLOBALS into config\r
-        foreach ($GLOBALS as $key => $value) {\r
-            $config[$key] = $value;\r
-        }\r
-        $config['config']['CONFIG_FILE'] = $config_file;\r
-        writeConfig($config, $isLoggedIn);\r
-\r
-        unlink($config['config']['DATADIR'].'/options.php');\r
-    }\r
-}\r
-\r
-/**\r
- * Exception used if a mandatory field is missing in given configuration.\r
- */\r
-class MissingFieldConfigException extends Exception\r
-{\r
-    public $field;\r
-\r
-    /**\r
-     * Construct exception.\r
-     *\r
-     * @param string $field field name missing.\r
-     */\r
-    public function __construct($field)\r
-    {\r
-        $this->field = $field;\r
-        $this->message = 'Configuration value is required for '. $this->field;\r
-    }\r
-}\r
-\r
-/**\r
- * Exception used if an unauthorized attempt to edit configuration has been made.\r
- */\r
-class UnauthorizedConfigException extends Exception\r
-{\r
-    /**\r
-     * Construct exception.\r
-     */\r
-    public function __construct()\r
-    {\r
-        $this->message = 'You are not authorized to alter config.';\r
-    }\r
-}\r
+<?php
+/**
+ * Functions related to configuration management.
+ */
+
+/**
+ * Re-write configuration file according to given array.
+ * Requires mandatory fields listed in $MANDATORY_FIELDS.
+ *
+ * @param array $config     contains all configuration fields.
+ * @param bool  $isLoggedIn true if user is logged in.
+ *
+ * @return void
+ *
+ * @throws MissingFieldConfigException: a mandatory field has not been provided in $config.
+ * @throws UnauthorizedConfigException: user is not authorize to change configuration.
+ * @throws Exception: an error occured while writing the new config file.
+ */
+function writeConfig($config, $isLoggedIn)
+{
+    // These fields are required in configuration.
+    $MANDATORY_FIELDS = array(
+        'login', 'hash', 'salt', 'timezone', 'title', 'titleLink',
+        'redirector', 'disablesessionprotection', 'privateLinkByDefault'
+    );
+
+    if (!isset($config['config']['CONFIG_FILE'])) {
+        throw new MissingFieldConfigException('CONFIG_FILE');
+    }
+
+    // Only logged in user can alter config.
+    if (is_file($config['config']['CONFIG_FILE']) && !$isLoggedIn) {
+        throw new UnauthorizedConfigException();
+    }
+
+    // Check that all mandatory fields are provided in $config.
+    foreach ($MANDATORY_FIELDS as $field) {
+        if (!isset($config[$field])) {
+            throw new MissingFieldConfigException($field);
+        }
+    }
+
+    $configStr = '<?php '. PHP_EOL;
+    $configStr .= '$GLOBALS[\'login\'] = '.var_export($config['login'], true).';'. PHP_EOL;
+    $configStr .= '$GLOBALS[\'hash\'] = '.var_export($config['hash'], true).';'. PHP_EOL;
+    $configStr .= '$GLOBALS[\'salt\'] = '.var_export($config['salt'], true).'; '. PHP_EOL;
+    $configStr .= '$GLOBALS[\'timezone\'] = '.var_export($config['timezone'], true).';'. PHP_EOL;
+    $configStr .= 'date_default_timezone_set('.var_export($config['timezone'], true).');'. PHP_EOL;
+    $configStr .= '$GLOBALS[\'title\'] = '.var_export($config['title'], true).';'. PHP_EOL;
+    $configStr .= '$GLOBALS[\'titleLink\'] = '.var_export($config['titleLink'], true).'; '. PHP_EOL;
+    $configStr .= '$GLOBALS[\'redirector\'] = '.var_export($config['redirector'], true).'; '. PHP_EOL;
+    $configStr .= '$GLOBALS[\'disablesessionprotection\'] = '.var_export($config['disablesessionprotection'], true).'; '. PHP_EOL;
+    $configStr .= '$GLOBALS[\'privateLinkByDefault\'] = '.var_export($config['privateLinkByDefault'], true).'; '. PHP_EOL;
+
+    // Store all $config['config']
+    foreach ($config['config'] as $key => $value) {
+        $configStr .= '$GLOBALS[\'config\'][\''. $key .'\'] = '.var_export($config['config'][$key], true).';'. PHP_EOL;
+    }
+
+    if (isset($config['plugins'])) {
+        foreach ($config['plugins'] as $key => $value) {
+            $configStr .= '$GLOBALS[\'plugins\'][\''. $key .'\'] = '.var_export($config['plugins'][$key], true).';'. PHP_EOL;
+        }
+    }
+
+    if (!file_put_contents($config['config']['CONFIG_FILE'], $configStr)
+        || strcmp(file_get_contents($config['config']['CONFIG_FILE']), $configStr) != 0
+    ) {
+        throw new Exception(
+            'Shaarli could not create the config file.
+            Please make sure Shaarli has the right to write in the folder is it installed in.'
+        );
+    }
+}
+
+/**
+ * Milestone 0.9 - shaarli/Shaarli#41: options.php is not supported anymore.
+ * ==> if user is loggedIn, merge its content with config.php, then delete options.php.
+ *
+ * @param array $config     contains all configuration fields.
+ * @param bool  $isLoggedIn true if user is logged in.
+ *
+ * @return void
+ */
+function mergeDeprecatedConfig($config, $isLoggedIn)
+{
+    $config_file = $config['config']['CONFIG_FILE'];
+
+    if (is_file($config['config']['DATADIR'].'/options.php') && $isLoggedIn) {
+        include $config['config']['DATADIR'].'/options.php';
+
+        // Load GLOBALS into config
+        foreach ($GLOBALS as $key => $value) {
+            $config[$key] = $value;
+        }
+        $config['config']['CONFIG_FILE'] = $config_file;
+        writeConfig($config, $isLoggedIn);
+
+        unlink($config['config']['DATADIR'].'/options.php');
+    }
+}
+
+/**
+ * Exception used if a mandatory field is missing in given configuration.
+ */
+class MissingFieldConfigException extends Exception
+{
+    public $field;
+
+    /**
+     * Construct exception.
+     *
+     * @param string $field field name missing.
+     */
+    public function __construct($field)
+    {
+        $this->field = $field;
+        $this->message = 'Configuration value is required for '. $this->field;
+    }
+}
+
+/**
+ * Exception used if an unauthorized attempt to edit configuration has been made.
+ */
+class UnauthorizedConfigException extends Exception
+{
+    /**
+     * Construct exception.
+     */
+    public function __construct()
+    {
+        $this->message = 'You are not authorized to alter config.';
+    }
+}
diff --git a/application/PluginManager.php b/application/PluginManager.php
new file mode 100644 (file)
index 0000000..e572ff7
--- /dev/null
@@ -0,0 +1,184 @@
+<?php
+
+/**
+ * Class PluginManager
+ *
+ * Use to manage, load and execute plugins.
+ *
+ * Using Singleton design pattern.
+ */
+class PluginManager
+{
+    /**
+     * PluginManager singleton instance.
+     * @var PluginManager $instance
+     */
+    private static $instance;
+
+    /**
+     * List of authorized plugins from configuration file.
+     * @var array $authorizedPlugins
+     */
+    private $authorizedPlugins;
+
+    /**
+     * List of loaded plugins.
+     * @var array $loadedPlugins
+     */
+    private $loadedPlugins = array();
+
+    /**
+     * Plugins subdirectory.
+     * @var string $PLUGINS_PATH
+     */
+    public static $PLUGINS_PATH = 'plugins';
+
+    /**
+     * Private constructor: new instances not allowed.
+     */
+    private function __construct()
+    {
+    }
+
+    /**
+     * Cloning isn't allowed either.
+     *
+     * @return void
+     */
+    private function __clone()
+    {
+    }
+
+    /**
+     * Return existing instance of PluginManager, or create it.
+     *
+     * @return PluginManager instance.
+     */
+    public static function getInstance()
+    {
+        if (!(self::$instance instanceof self)) {
+            self::$instance = new self();
+        }
+
+        return self::$instance;
+    }
+
+    /**
+     * Load plugins listed in $authorizedPlugins.
+     *
+     * @param array $authorizedPlugins Names of plugin authorized to be loaded.
+     *
+     * @return void
+     */
+    public function load($authorizedPlugins)
+    {
+        $this->authorizedPlugins = $authorizedPlugins;
+
+        $dirs = glob(self::$PLUGINS_PATH . '/*', GLOB_ONLYDIR);
+        $dirnames = array_map('basename', $dirs);
+        foreach ($this->authorizedPlugins as $plugin) {
+            $index = array_search($plugin, $dirnames);
+
+            // plugin authorized, but its folder isn't listed
+            if ($index === false) {
+                continue;
+            }
+
+            try {
+                $this->loadPlugin($dirs[$index], $plugin);
+            }
+            catch (PluginFileNotFoundException $e) {
+                error_log($e->getMessage());
+            }
+        }
+    }
+
+    /**
+     * Execute all plugins registered hook.
+     *
+     * @param string $hook   name of the hook to trigger.
+     * @param array  $data   list of data to manipulate passed by reference.
+     * @param array  $params additional parameters such as page target.
+     * 
+     * @return void
+     */
+    public function executeHooks($hook, &$data, $params = array())
+    {
+        if (!empty($params['target'])) {
+            $data['_PAGE_'] = $params['target'];
+        }
+
+        if (isset($params['loggedin'])) {
+            $data['_LOGGEDIN_'] = $params['loggedin'];
+        }
+
+        foreach ($this->loadedPlugins as $plugin) {
+            $hookFunction = $this->buildHookName($hook, $plugin);
+
+            if (function_exists($hookFunction)) {
+                $data = call_user_func($hookFunction, $data);
+            }
+        }
+    }
+
+    /**
+     * Load a single plugin from its files.
+     * Add them in $loadedPlugins if successful.
+     *
+     * @param string $dir        plugin's directory.
+     * @param string $pluginName plugin's name.
+     *
+     * @return void
+     * @throws PluginFileNotFoundException - plugin files not found.
+     */
+    private function loadPlugin($dir, $pluginName)
+    {
+        if (!is_dir($dir)) {
+            throw new PluginFileNotFoundException($pluginName);
+        }
+
+        $pluginFilePath = $dir . '/' . $pluginName . '.php';
+        if (!is_file($pluginFilePath)) {
+            throw new PluginFileNotFoundException($pluginName);
+        }
+
+        include_once $pluginFilePath;
+
+        $this->loadedPlugins[] = $pluginName;
+    }
+
+    /**
+     * Construct normalize hook name for a specific plugin.
+     *
+     * Format:
+     *      hook_<plugin_name>_<hook_name>
+     *
+     * @param string $hook       hook name.
+     * @param string $pluginName plugin name.
+     *
+     * @return string - plugin's hook name.
+     */
+    public function buildHookName($hook, $pluginName)
+    {
+        return 'hook_' . $pluginName . '_' . $hook;
+    }
+}
+
+/**
+ * Class PluginFileNotFoundException
+ *
+ * Raise when plugin files can't be found.
+ */
+class PluginFileNotFoundException extends Exception
+{
+    /**
+     * Construct exception with plugin name.
+     * Generate message.
+     *
+     * @param string $pluginName name of the plugin not found
+     */
+    public function __construct($pluginName)
+    {
+        $this->message = 'Plugin "'. $pluginName .'" files not found.';
+    }
+}
\ No newline at end of file
diff --git a/application/Router.php b/application/Router.php
new file mode 100644 (file)
index 0000000..82b2b85
--- /dev/null
@@ -0,0 +1,105 @@
+<?php
+
+/**
+ * Class Router
+ *
+ * (only displayable pages here)
+ */
+class Router
+{
+    public static $PAGE_LOGIN = 'login';
+
+    public static $PAGE_PICWALL = 'picwall';
+
+    public static $PAGE_TAGCLOUD = 'tagcloud';
+
+    public static $PAGE_TOOLS = 'tools';
+
+    public static $PAGE_CHANGEPASSWORD = 'changepasswd';
+
+    public static $PAGE_CONFIGURE = 'configure';
+
+    public static $PAGE_CHANGETAG = 'changetag';
+
+    public static $PAGE_ADDLINK = 'addlink';
+
+    public static $PAGE_EDITLINK = 'edit_link';
+
+    public static $PAGE_EXPORT = 'export';
+
+    public static $PAGE_IMPORT = 'import';
+
+    public static $PAGE_LINKLIST = 'linklist';
+
+    /**
+     * Reproducing renderPage() if hell, to avoid regression.
+     *
+     * This highlights how bad this needs to be rewrite,
+     * but let's focus on plugins for now.
+     *
+     * @param string $query    $_SERVER['QUERY_STRING'].
+     * @param array  $get      $_SERVER['GET'].
+     * @param bool   $loggedIn true if authenticated user.
+     *
+     * @return self::page found.
+     */
+    public static function findPage($query, $get, $loggedIn)
+    {
+        $loggedIn = ($loggedIn === true) ? true : false;
+
+        if (empty($query) && !isset($get['edit_link']) && !isset($get['post'])) {
+            return self::$PAGE_LINKLIST;
+        }
+
+        if (startswith($query, 'do='. self::$PAGE_LOGIN) && $loggedIn === false) {
+            return self::$PAGE_LOGIN;
+        }
+
+        if (startswith($query, 'do='. self::$PAGE_PICWALL)) {
+            return self::$PAGE_PICWALL;
+        }
+
+        if (startswith($query, 'do='. self::$PAGE_TAGCLOUD)) {
+            return self::$PAGE_TAGCLOUD;
+        }
+
+        // At this point, only loggedin pages.
+        if (!$loggedIn) {
+            return self::$PAGE_LINKLIST;
+        }
+
+        if (startswith($query, 'do='. self::$PAGE_TOOLS)) {
+            return self::$PAGE_TOOLS;
+        }
+
+        if (startswith($query, 'do='. self::$PAGE_CHANGEPASSWORD)) {
+            return self::$PAGE_CHANGEPASSWORD;
+        }
+
+        if (startswith($query, 'do='. self::$PAGE_CONFIGURE)) {
+            return self::$PAGE_CONFIGURE;
+        }
+
+        if (startswith($query, 'do='. self::$PAGE_CHANGETAG)) {
+            return self::$PAGE_CHANGETAG;
+        }
+
+        if (startswith($query, 'do='. self::$PAGE_ADDLINK)) {
+            return self::$PAGE_ADDLINK;
+        }
+
+        if (isset($get['edit_link']) || isset($get['post'])) {
+            return self::$PAGE_EDITLINK;
+        }
+
+        if (startswith($query, 'do='. self::$PAGE_EXPORT)) {
+            return self::$PAGE_EXPORT;
+        }
+
+        if (startswith($query, 'do='. self::$PAGE_IMPORT)) {
+            return self::$PAGE_IMPORT;
+        }
+
+        return self::$PAGE_LINKLIST;
+    }
+}
\ No newline at end of file
index c430a2006ee63cac8ed3b8ea751b113bdbb1c9c1..c82735ca4e8f3147789d2fc816db0fc677b4c501 100755 (executable)
--- a/index.php
+++ b/index.php
@@ -45,9 +45,18 @@ $GLOBALS['config']['RAINTPL_TPL'] = 'tpl/' ; // Raintpl template directory (keep
 $GLOBALS['config']['UPDATECHECK_FILENAME'] = $GLOBALS['config']['DATADIR'].'/lastupdatecheck.txt'; // For updates check of Shaarli.
 $GLOBALS['config']['UPDATECHECK_INTERVAL'] = 86400 ; // Updates check frequency for Shaarli. 86400 seconds=24 hours
                                           // Note: You must have publisher.php in the same directory as Shaarli index.php
-$GLOBALS['config']['ARCHIVE_ORG'] = false; // For each link, add a link to an archived version on archive.org
 $GLOBALS['config']['ENABLE_RSS_PERMALINKS'] = true;  // Enable RSS permalinks by default. This corresponds to the default behavior of shaarli before this was added as an option.
 $GLOBALS['config']['HIDE_PUBLIC_LINKS'] = false;
+//$GLOBALS['config']['ENABLED_PLUGINS'] = array(
+//    'qrcode', 'archiveorg', 'readityourself', 'demo_plugin', 'playvideos',
+//    'wallabag', 'markdown', 'addlink_toolbar',
+//);
+// Warning: order matters.
+$GLOBALS['config']['ENABLED_PLUGINS'] = array('qrcode');
+
+// Default plugins, default config - will be overriden by config.php and then plugin's config.php file.
+$GLOBALS['plugins']['READITYOUSELF_URL'] = 'http://someurl.com';
+$GLOBALS['plugins']['WALLABAG_URL'] = 'https://demo.wallabag.org/';
 // -----------------------------------------------------------------------------------------------
 define('shaarli_version', '0.5.4');
 // http://server.com/x/shaarli --> /shaarli/
@@ -75,6 +84,8 @@ require_once 'application/TimeZone.php';
 require_once 'application/Url.php';
 require_once 'application/Utils.php';
 require_once 'application/Config.php';
+require_once 'application/PluginManager.php';
+require_once 'application/Router.php';
 
 // Ensure the PHP version is supported
 try {
@@ -119,6 +130,9 @@ include "inc/rain.tpl.class.php"; //include Rain TPL
 raintpl::$tpl_dir = $GLOBALS['config']['RAINTPL_TPL']; // template directory
 raintpl::$cache_dir = $GLOBALS['config']['RAINTPL_TMP']; // cache directory
 
+$pluginManager = PluginManager::getInstance();
+$pluginManager->load($GLOBALS['config']['ENABLED_PLUGINS']);
+
 ob_start();  // Output buffering for the page cache.
 
 
@@ -962,16 +976,31 @@ function showDaily()
         $fill[$index]+=$length;
     }
     $PAGE = new pageBuilder;
-    $PAGE->assign('linksToDisplay',$linksToDisplay);
-    $PAGE->assign('linkcount',count($LINKSDB));
-    $PAGE->assign('cols', $columns);
-    $PAGE->assign('day',linkdate2timestamp($day.'_000000'));
-    $PAGE->assign('previousday',$previousday);
-    $PAGE->assign('nextday',$nextday);
+    $data = array(
+        'linksToDisplay' => $linksToDisplay,
+        'linkcount' => count($LINKSDB),
+        'cols' => $columns,
+        'day' => linkdate2timestamp($day.'_000000'),
+        'previousday' => $previousday,
+        'nextday' => $nextday,
+    );
+    $pluginManager = PluginManager::getInstance();
+    $pluginManager->executeHooks('render_daily', $data, array('loggedin' => isLoggedIn()));
+
+    foreach ($data as $key => $value) {
+        $PAGE->assign($key, $value);
+    }
+
     $PAGE->renderPage('daily');
     exit;
 }
 
+// Renders the linklist
+function showLinkList($PAGE, $LINKSDB) {
+    buildLinkList($PAGE,$LINKSDB); // Compute list of links to display
+    $PAGE->renderPage('linklist');
+}
+
 
 // ------------------------------------------------------------------------------------------
 // Render HTML page (according to URL parameters and user rights)
@@ -983,12 +1012,36 @@ function renderPage()
         $GLOBALS['config']['HIDE_PUBLIC_LINKS']
     );
 
+    $PAGE = new pageBuilder;
+
+    // Determine which page will be rendered.
+    $query = (isset($_SERVER['QUERY_STRING'])) ? $_SERVER['QUERY_STRING'] : '';
+    $targetPage = Router::findPage($query, $_GET, isLoggedIn());
+
+    // Call plugin hooks for header, footer and includes, specifying which page will be rendered.
+    // Then assign generated data to RainTPL.
+    $common_hooks = array(
+        'header',
+        'footer',
+        'includes',
+    );
+    $pluginManager = PluginManager::getInstance();
+    foreach($common_hooks as $name) {
+        $plugin_data = array();
+        $pluginManager->executeHooks('render_' . $name, $plugin_data,
+            array(
+                'target' => $targetPage,
+                'loggedin' => isLoggedIn()
+            )
+        );
+        $PAGE->assign('plugins_' . $name, $plugin_data);
+    }
+
     // -------- Display login form.
-    if (isset($_SERVER["QUERY_STRING"]) && startswith($_SERVER["QUERY_STRING"],'do=login'))
+    if ($targetPage == Router::$PAGE_LOGIN)
     {
         if ($GLOBALS['config']['OPEN_SHAARLI']) { header('Location: ?'); exit; }  // No need to login for open Shaarli
         $token=''; if (ban_canLogin()) $token=getToken(); // Do not waste token generation if not useful.
-        $PAGE = new pageBuilder;
         $PAGE->assign('token',$token);
         $PAGE->assign('returnurl',(isset($_SERVER['HTTP_REFERER']) ? escape($_SERVER['HTTP_REFERER']):''));
         $PAGE->renderPage('loginform');
@@ -1004,7 +1057,7 @@ function renderPage()
     }
 
     // -------- Picture wall
-    if (isset($_SERVER["QUERY_STRING"]) && startswith($_SERVER["QUERY_STRING"],'do=picwall'))
+    if ($targetPage == Router::$PAGE_PICWALL)
     {
         // Optionally filter the results:
         $links=array();
@@ -1027,15 +1080,22 @@ function renderPage()
             }
         }
 
-        $PAGE = new pageBuilder;
-        $PAGE->assign('linkcount',count($LINKSDB));
-        $PAGE->assign('linksToDisplay',$linksToDisplay);
+        $data = array(
+            'linkcount' => count($LINKSDB),
+            'linksToDisplay' => $linksToDisplay,
+        );
+        $pluginManager->executeHooks('render_picwall', $data, array('loggedin' => isLoggedIn()));
+
+        foreach ($data as $key => $value) {
+            $PAGE->assign($key, $value);
+        }
+
         $PAGE->renderPage('picwall');
         exit;
     }
 
     // -------- Tag cloud
-    if (isset($_SERVER["QUERY_STRING"]) && startswith($_SERVER["QUERY_STRING"],'do=tagcloud'))
+    if ($targetPage == Router::$PAGE_TAGCLOUD)
     {
         $tags= $LINKSDB->allTags();
 
@@ -1049,9 +1109,17 @@ function renderPage()
         {
             $tagList[$key] = array('count'=>$value,'size'=>log($value, 15) / log($maxcount, 30) * (22-6) + 6);
         }
-        $PAGE = new pageBuilder;
-        $PAGE->assign('linkcount',count($LINKSDB));
-        $PAGE->assign('tags',$tagList);
+
+        $data = array(
+            'linkcount' => count($LINKSDB),
+            'tags' => $tagList,
+        );
+        $pluginManager->executeHooks('render_tagcloud', $data, array('loggedin' => isLoggedIn()));
+
+        foreach ($data as $key => $value) {
+            $PAGE->assign($key, $value);
+        }
+
         $PAGE->renderPage('tagcloud');
         exit;
     }
@@ -1164,32 +1232,36 @@ function renderPage()
                        header('Location: ?do=login&post=');
                        exit;
                }
-
+        showLinkList($PAGE, $LINKSDB);
         if (isset($_GET['edit_link'])) {
             header('Location: ?do=login&edit_link='. escape($_GET['edit_link']));
             exit;
         }
 
-        $PAGE = new pageBuilder;
-        buildLinkList($PAGE,$LINKSDB); // Compute list of links to display
-        $PAGE->renderPage('linklist');
         exit; // Never remove this one! All operations below are reserved for logged in user.
     }
 
     // -------- All other functions are reserved for the registered user:
 
     // -------- Display the Tools menu if requested (import/export/bookmarklet...)
-    if (isset($_SERVER["QUERY_STRING"]) && startswith($_SERVER["QUERY_STRING"],'do=tools'))
+    if ($targetPage == Router::$PAGE_TOOLS)
     {
-        $PAGE = new pageBuilder;
-        $PAGE->assign('linkcount',count($LINKSDB));
-        $PAGE->assign('pageabsaddr',index_url($_SERVER));
+        $data = array(
+            'linkcount' => count($LINKSDB),
+            'pageabsaddr' => index_url($_SERVER),
+        );
+        $pluginManager->executeHooks('render_tools', $data);
+
+        foreach ($data as $key => $value) {
+            $PAGE->assign($key, $value);
+        }
+
         $PAGE->renderPage('tools');
         exit;
     }
 
     // -------- User wants to change his/her password.
-    if (isset($_SERVER["QUERY_STRING"]) && startswith($_SERVER["QUERY_STRING"],'do=changepasswd'))
+    if ($targetPage == Router::$PAGE_CHANGEPASSWORD)
     {
         if ($GLOBALS['config']['OPEN_SHAARLI']) die('You are not supposed to change a password on an Open Shaarli.');
         if (!empty($_POST['setpassword']) && !empty($_POST['oldpassword']))
@@ -1220,7 +1292,6 @@ function renderPage()
         }
         else // show the change password form.
         {
-            $PAGE = new pageBuilder;
             $PAGE->assign('linkcount',count($LINKSDB));
             $PAGE->assign('token',getToken());
             $PAGE->renderPage('changepassword');
@@ -1229,7 +1300,7 @@ function renderPage()
     }
 
     // -------- User wants to change configuration
-    if (isset($_SERVER["QUERY_STRING"]) && startswith($_SERVER["QUERY_STRING"],'do=configure'))
+    if ($targetPage == Router::$PAGE_CONFIGURE)
     {
         if (!empty($_POST['title']) )
         {
@@ -1265,7 +1336,6 @@ function renderPage()
         }
         else // Show the configuration form.
         {
-            $PAGE = new pageBuilder;
             $PAGE->assign('linkcount',count($LINKSDB));
             $PAGE->assign('token',getToken());
             $PAGE->assign('title', empty($GLOBALS['title']) ? '' : $GLOBALS['title'] );
@@ -1279,11 +1349,10 @@ function renderPage()
     }
 
     // -------- User wants to rename a tag or delete it
-    if (isset($_SERVER["QUERY_STRING"]) && startswith($_SERVER["QUERY_STRING"],'do=changetag'))
+    if ($targetPage == Router::$PAGE_CHANGETAG)
     {
         if (empty($_POST['fromtag']))
         {
-            $PAGE = new pageBuilder;
             $PAGE->assign('linkcount',count($LINKSDB));
             $PAGE->assign('token',getToken());
             $PAGE->assign('tags', $LINKSDB->allTags());
@@ -1328,9 +1397,8 @@ function renderPage()
     }
 
     // -------- User wants to add a link without using the bookmarklet: Show form.
-    if (isset($_SERVER["QUERY_STRING"]) && startswith($_SERVER["QUERY_STRING"],'do=addlink'))
+    if ($targetPage == Router::$PAGE_ADDLINK)
     {
-        $PAGE = new pageBuilder;
         $PAGE->assign('linkcount',count($LINKSDB));
         $PAGE->renderPage('addlink');
         exit;
@@ -1349,6 +1417,9 @@ function renderPage()
         $link = array('title'=>trim($_POST['lf_title']),'url'=>$url,'description'=>trim($_POST['lf_description']),'private'=>(isset($_POST['lf_private']) ? 1 : 0),
                       'linkdate'=>$linkdate,'tags'=>str_replace(',',' ',$tags));
         if ($link['title']=='') $link['title']=$link['url']; // If title is empty, use the URL as title.
+
+        $pluginManager->executeHooks('save_link', $link);
+
         $LINKSDB[$linkdate] = $link;
         $LINKSDB->savedb($GLOBALS['config']['PAGECACHE']); // Save to disk.
         pubsubhub();
@@ -1382,6 +1453,9 @@ function renderPage()
         // - confirmation is handled by JavaScript
         // - we are protected from XSRF by the token.
         $linkdate=$_POST['lf_linkdate'];
+
+        $pluginManager->executeHooks('delete_link', $LINKSDB[$linkdate]);
+
         unset($LINKSDB[$linkdate]);
         $LINKSDB->savedb($GLOBALS['config']['PAGECACHE']); // save to disk
 
@@ -1423,13 +1497,20 @@ function renderPage()
     {
         $link = $LINKSDB[$_GET['edit_link']];  // Read database
         if (!$link) { header('Location: ?'); exit; } // Link not found in database.
-        $PAGE = new pageBuilder;
-        $PAGE->assign('linkcount',count($LINKSDB));
-        $PAGE->assign('link',$link);
-        $PAGE->assign('link_is_new',false);
-        $PAGE->assign('token',getToken()); // XSRF protection.
-        $PAGE->assign('http_referer',(isset($_SERVER['HTTP_REFERER']) ? escape($_SERVER['HTTP_REFERER']) : ''));
-        $PAGE->assign('tags', $LINKSDB->allTags());
+        $data = array(
+            'linkcount' => count($LINKSDB),
+            'link' => $link,
+            'link_is_new' => false,
+            'token' => getToken(),
+            'http_referer' => (isset($_SERVER['HTTP_REFERER']) ? escape($_SERVER['HTTP_REFERER']) : ''),
+            'tags' => $LINKSDB->allTags(),
+        );
+        $pluginManager->executeHooks('render_editlink', $data);
+
+        foreach ($data as $key => $value) {
+            $PAGE->assign($key, $value);
+        }
+
         $PAGE->renderPage('editlink');
         exit;
     }
@@ -1493,24 +1574,30 @@ function renderPage()
             );
         }
 
-        $PAGE = new pageBuilder;
-        $PAGE->assign('linkcount',count($LINKSDB));
-        $PAGE->assign('link',$link);
-        $PAGE->assign('link_is_new',$link_is_new);
-        $PAGE->assign('token',getToken()); // XSRF protection.
-        $PAGE->assign('http_referer',(isset($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : ''));
-        $PAGE->assign('source',(isset($_GET['source']) ? $_GET['source'] : ''));
-        $PAGE->assign('tags', $LINKSDB->allTags());
+        $data = array(
+            'linkcount' => count($LINKSDB),
+            'link' => $link,
+            'link_is_new' => $link_is_new,
+            'token' => getToken(), // XSRF protection.
+            'http_referer' => (isset($_SERVER['HTTP_REFERER']) ? escape($_SERVER['HTTP_REFERER']) : ''),
+            'source' => (isset($_GET['source']) ? $_GET['source'] : ''),
+            'tags' => $LINKSDB->allTags(),
+        );
+        $pluginManager->executeHooks('render_editlink', $data);
+
+        foreach ($data as $key => $value) {
+            $PAGE->assign($key, $value);
+        }
+
         $PAGE->renderPage('editlink');
         exit;
     }
 
     // -------- Export as Netscape Bookmarks HTML file.
-    if (isset($_SERVER["QUERY_STRING"]) && startswith($_SERVER["QUERY_STRING"],'do=export'))
+    if ($targetPage == Router::$PAGE_EXPORT)
     {
         if (empty($_GET['what']))
         {
-            $PAGE = new pageBuilder;
             $PAGE->assign('linkcount',count($LINKSDB));
             $PAGE->renderPage('export');
             exit;
@@ -1562,9 +1649,8 @@ HTML;
     }
 
     // -------- Show upload/import dialog:
-    if (isset($_SERVER["QUERY_STRING"]) && startswith($_SERVER["QUERY_STRING"],'do=import'))
+    if ($targetPage == Router::$PAGE_IMPORT)
     {
-        $PAGE = new pageBuilder;
         $PAGE->assign('linkcount',count($LINKSDB));
         $PAGE->assign('token',getToken());
         $PAGE->assign('maxfilesize',getMaxFileSize());
@@ -1573,9 +1659,7 @@ HTML;
     }
 
     // -------- Otherwise, simply display search form and links:
-    $PAGE = new pageBuilder;
-    buildLinkList($PAGE,$LINKSDB); // Compute list of links to display
-    $PAGE->renderPage('linklist');
+    showLinkList($PAGE, $LINKSDB);
     exit;
 }
 
@@ -1746,7 +1830,7 @@ function buildLinkList($PAGE,$LINKSDB)
         $taglist = explode(' ',$link['tags']);
         uasort($taglist, 'strcasecmp');
         $link['taglist']=$taglist;
-
+        $link['shorturl'] = smallHash($link['linkdate']);
         if ($link["url"][0] === '?' && // Check for both signs of a note: starting with ? and 7 chars long. I doubt that you'll post any links that look like this.
             strlen($link["url"]) === 7) {
             $link["url"] = index_url($_SERVER) . $link["url"];
@@ -1766,18 +1850,28 @@ function buildLinkList($PAGE,$LINKSDB)
     $token = ''; if (isLoggedIn()) $token=getToken();
 
     // Fill all template fields.
-    $PAGE->assign('linkcount',count($LINKSDB));
-    $PAGE->assign('previous_page_url',$previous_page_url);
-    $PAGE->assign('next_page_url',$next_page_url);
-    $PAGE->assign('page_current',$page);
-    $PAGE->assign('page_max',$pagecount);
-    $PAGE->assign('result_count',count($linksToDisplay));
-    $PAGE->assign('search_type',$search_type);
-    $PAGE->assign('search_crits',$search_crits);
-    $PAGE->assign('redirector',empty($GLOBALS['redirector']) ? '' : $GLOBALS['redirector']); // Optional redirector URL.
-    $PAGE->assign('token',$token);
-    $PAGE->assign('links',$linkDisp);
-    $PAGE->assign('tags', $LINKSDB->allTags());
+    $data = array(
+        'linkcount' => count($LINKSDB),
+        'previous_page_url' => $previous_page_url,
+        'next_page_url' => $next_page_url,
+        'page_current' => $page,
+        'page_max' => $pagecount,
+        'result_count' => count($linksToDisplay),
+        'search_type' => $search_type,
+        'search_crits' => $search_crits,
+        'redirector' => empty($GLOBALS['redirector']) ? '' : $GLOBALS['redirector'],  // Optional redirector URL.
+        'token' => $token,
+        'links' => $linkDisp,
+        'tags' => $LINKSDB->allTags(),
+    );
+
+    $pluginManager = PluginManager::getInstance();
+    $pluginManager->executeHooks('render_linklist', $data, array('loggedin' => isLoggedIn()));
+
+    foreach ($data as $key => $value) {
+        $PAGE->assign($key, $value);
+    }
+
     return;
 }
 
diff --git a/tests/PluginManagerTest.php b/tests/PluginManagerTest.php
new file mode 100755 (executable)
index 0000000..749ce2b
--- /dev/null
@@ -0,0 +1,66 @@
+<?php
+
+/**
+ * Plugin Manager tests
+ */
+
+require_once 'application/PluginManager.php';
+
+/**
+ * Unit tests for Plugins
+ */
+class PluginManagerTest extends PHPUnit_Framework_TestCase
+{
+    /**
+     * Path to tests plugin.
+     * @var string $_PLUGIN_PATH
+     */
+    private static $_PLUGIN_PATH = 'tests/plugins';
+
+    /**
+     * Test plugin.
+     * @var string $_PLUGIN_NAME
+     */
+    private static $_PLUGIN_NAME = 'test';
+
+    /**
+     * Test plugin loading and hook execution.
+     *
+     * @return void
+     */
+    public function testPlugin()
+    {
+        $pluginManager = PluginManager::getInstance();
+
+        PluginManager::$PLUGINS_PATH = self::$_PLUGIN_PATH;
+        $pluginManager->load(array(self::$_PLUGIN_NAME));
+
+        $this->assertTrue(function_exists('hook_test_random'));
+
+        $data = array(0 => 'woot');
+        $pluginManager->executeHooks('random', $data);
+        $this->assertEquals('woot', $data[1]);
+
+        $data = array(0 => 'woot');
+        $pluginManager->executeHooks('random', $data, array('target' => 'test'));
+        $this->assertEquals('page test', $data[1]);
+
+        $data = array(0 => 'woot');
+        $pluginManager->executeHooks('random', $data, array('loggedin' => true));
+        $this->assertEquals('loggedin', $data[1]);
+    }
+
+    /**
+     * Test missing plugin loading.
+     *
+     * @return void
+     */
+    public function testPluginNotFound()
+    {
+        $pluginManager = PluginManager::getInstance();
+
+        $pluginManager->load(array());
+
+        $pluginManager->load(array('nope', 'renope'));
+    }
+}
\ No newline at end of file
diff --git a/tests/RouterTest.php b/tests/RouterTest.php
new file mode 100755 (executable)
index 0000000..8838bc8
--- /dev/null
@@ -0,0 +1,515 @@
+<?php
+
+/**
+ * Router tests
+ */
+
+require_once 'application/Router.php';
+
+/**
+ * Unit tests for Router
+ */
+class RouterTest extends PHPUnit_Framework_TestCase
+{
+    /**
+     * Test findPage: login page output.
+     * Valid: page should be return.
+     *
+     * @return void
+     */
+    public function testFindPageLoginValid()
+    {
+        $this->assertEquals(
+            Router::$PAGE_LOGIN,
+            Router::findPage('do=login', array(), false)
+        );
+
+        $this->assertEquals(
+            Router::$PAGE_LOGIN,
+            Router::findPage('do=login', array(), 1)
+        );
+
+        $this->assertEquals(
+            Router::$PAGE_LOGIN,
+            Router::findPage('do=login&stuff', array(), false)
+        );
+    }
+
+    /**
+     * Test findPage: login page output.
+     * Invalid: page shouldn't be return.
+     *
+     * @return void
+     */
+    public function testFindPageLoginInvalid()
+    {
+        $this->assertNotEquals(
+            Router::$PAGE_LOGIN,
+            Router::findPage('do=login', array(), true)
+        );
+
+        $this->assertNotEquals(
+            Router::$PAGE_LOGIN,
+            Router::findPage('do=other', array(), false)
+        );
+    }
+
+    /**
+     * Test findPage: picwall page output.
+     * Valid: page should be return.
+     *
+     * @return void
+     */
+    public function testFindPagePicwallValid()
+    {
+        $this->assertEquals(
+            Router::$PAGE_PICWALL,
+            Router::findPage('do=picwall', array(), false)
+        );
+
+        $this->assertEquals(
+            Router::$PAGE_PICWALL,
+            Router::findPage('do=picwall', array(), true)
+        );
+    }
+
+    /**
+     * Test findPage: picwall page output.
+     * Invalid: page shouldn't be return.
+     *
+     * @return void
+     */
+    public function testFindPagePicwallInvalid()
+    {
+        $this->assertEquals(
+            Router::$PAGE_PICWALL,
+            Router::findPage('do=picwall&stuff', array(), false)
+        );
+
+        $this->assertNotEquals(
+            Router::$PAGE_PICWALL,
+            Router::findPage('do=other', array(), false)
+        );
+    }
+
+    /**
+     * Test findPage: tagcloud page output.
+     * Valid: page should be return.
+     *
+     * @return void
+     */
+    public function testFindPageTagcloudValid()
+    {
+        $this->assertEquals(
+            Router::$PAGE_TAGCLOUD,
+            Router::findPage('do=tagcloud', array(), false)
+        );
+
+        $this->assertEquals(
+            Router::$PAGE_TAGCLOUD,
+            Router::findPage('do=tagcloud', array(), true)
+        );
+
+        $this->assertEquals(
+            Router::$PAGE_TAGCLOUD,
+            Router::findPage('do=tagcloud&stuff', array(), false)
+        );
+    }
+
+    /**
+     * Test findPage: tagcloud page output.
+     * Invalid: page shouldn't be return.
+     *
+     * @return void
+     */
+    public function testFindPageTagcloudInvalid()
+    {
+        $this->assertNotEquals(
+            Router::$PAGE_TAGCLOUD,
+            Router::findPage('do=other', array(), false)
+        );
+    }
+
+    /**
+     * Test findPage: linklist page output.
+     * Valid: page should be return.
+     *
+     * @return void
+     */
+    public function testFindPageLinklistValid()
+    {
+        $this->assertEquals(
+            Router::$PAGE_LINKLIST,
+            Router::findPage('', array(), true)
+        );
+
+        $this->assertEquals(
+            Router::$PAGE_LINKLIST,
+            Router::findPage('whatever', array(), true)
+        );
+
+        $this->assertEquals(
+            Router::$PAGE_LINKLIST,
+            Router::findPage('whatever', array(), false)
+        );
+
+        $this->assertEquals(
+            Router::$PAGE_LINKLIST,
+            Router::findPage('do=tools', array(), false)
+        );
+    }
+
+    /**
+     * Test findPage: tools page output.
+     * Valid: page should be return.
+     *
+     * @return void
+     */
+    public function testFindPageToolsValid()
+    {
+        $this->assertEquals(
+            Router::$PAGE_TOOLS,
+            Router::findPage('do=tools', array(), true)
+        );
+
+        $this->assertEquals(
+            Router::$PAGE_TOOLS,
+            Router::findPage('do=tools&stuff', array(), true)
+        );
+    }
+
+    /**
+     * Test findPage: tools page output.
+     * Invalid: page shouldn't be return.
+     *
+     * @return void
+     */
+    public function testFindPageToolsInvalid()
+    {
+        $this->assertNotEquals(
+            Router::$PAGE_TOOLS,
+            Router::findPage('do=tools', array(), 1)
+        );
+
+        $this->assertNotEquals(
+            Router::$PAGE_TOOLS,
+            Router::findPage('do=tools', array(), false)
+        );
+
+        $this->assertNotEquals(
+            Router::$PAGE_TOOLS,
+            Router::findPage('do=other', array(), true)
+        );
+    }
+
+    /**
+     * Test findPage: changepasswd page output.
+     * Valid: page should be return.
+     *
+     * @return void
+     */
+    public function testFindPageChangepasswdValid()
+    {
+        $this->assertEquals(
+            Router::$PAGE_CHANGEPASSWORD,
+            Router::findPage('do=changepasswd', array(), true)
+        );
+        $this->assertEquals(
+            Router::$PAGE_CHANGEPASSWORD,
+            Router::findPage('do=changepasswd&stuff', array(), true)
+        );
+
+    }
+
+    /**
+     * Test findPage: changepasswd page output.
+     * Invalid: page shouldn't be return.
+     *
+     * @return void
+     */
+    public function testFindPageChangepasswdInvalid()
+    {
+        $this->assertNotEquals(
+            Router::$PAGE_CHANGEPASSWORD,
+            Router::findPage('do=changepasswd', array(), 1)
+        );
+
+        $this->assertNotEquals(
+            Router::$PAGE_CHANGEPASSWORD,
+            Router::findPage('do=changepasswd', array(), false)
+        );
+
+        $this->assertNotEquals(
+            Router::$PAGE_CHANGEPASSWORD,
+            Router::findPage('do=other', array(), true)
+        );
+    }
+    /**
+     * Test findPage: configure page output.
+     * Valid: page should be return.
+     *
+     * @return void
+     */
+    public function testFindPageConfigureValid()
+    {
+        $this->assertEquals(
+            Router::$PAGE_CONFIGURE,
+            Router::findPage('do=configure', array(), true)
+        );
+
+        $this->assertEquals(
+            Router::$PAGE_CONFIGURE,
+            Router::findPage('do=configure&stuff', array(), true)
+        );
+    }
+
+    /**
+     * Test findPage: configure page output.
+     * Invalid: page shouldn't be return.
+     *
+     * @return void
+     */
+    public function testFindPageConfigureInvalid()
+    {
+        $this->assertNotEquals(
+            Router::$PAGE_CONFIGURE,
+            Router::findPage('do=configure', array(), 1)
+        );
+
+        $this->assertNotEquals(
+            Router::$PAGE_CONFIGURE,
+            Router::findPage('do=configure', array(), false)
+        );
+
+        $this->assertNotEquals(
+            Router::$PAGE_CONFIGURE,
+            Router::findPage('do=other', array(), true)
+        );
+    }
+
+    /**
+     * Test findPage: changetag page output.
+     * Valid: page should be return.
+     *
+     * @return void
+     */
+    public function testFindPageChangetagValid()
+    {
+        $this->assertEquals(
+            Router::$PAGE_CHANGETAG,
+            Router::findPage('do=changetag', array(), true)
+        );
+
+        $this->assertEquals(
+            Router::$PAGE_CHANGETAG,
+            Router::findPage('do=changetag&stuff', array(), true)
+        );
+    }
+
+    /**
+     * Test findPage: changetag page output.
+     * Invalid: page shouldn't be return.
+     *
+     * @return void
+     */
+    public function testFindPageChangetagInvalid()
+    {
+        $this->assertNotEquals(
+            Router::$PAGE_CHANGETAG,
+            Router::findPage('do=changetag', array(), 1)
+        );
+
+        $this->assertNotEquals(
+            Router::$PAGE_CHANGETAG,
+            Router::findPage('do=changetag', array(), false)
+        );
+
+        $this->assertNotEquals(
+            Router::$PAGE_CHANGETAG,
+            Router::findPage('do=other', array(), true)
+        );
+    }
+
+    /**
+     * Test findPage: addlink page output.
+     * Valid: page should be return.
+     *
+     * @return void
+     */
+    public function testFindPageAddlinkValid()
+    {
+        $this->assertEquals(
+            Router::$PAGE_ADDLINK,
+            Router::findPage('do=addlink', array(), true)
+        );
+
+        $this->assertEquals(
+            Router::$PAGE_ADDLINK,
+            Router::findPage('do=addlink&stuff', array(), true)
+        );
+    }
+
+    /**
+     * Test findPage: addlink page output.
+     * Invalid: page shouldn't be return.
+     *
+     * @return void
+     */
+    public function testFindPageAddlinkInvalid()
+    {
+        $this->assertNotEquals(
+            Router::$PAGE_ADDLINK,
+            Router::findPage('do=addlink', array(), 1)
+        );
+
+        $this->assertNotEquals(
+            Router::$PAGE_ADDLINK,
+            Router::findPage('do=addlink', array(), false)
+        );
+
+        $this->assertNotEquals(
+            Router::$PAGE_ADDLINK,
+            Router::findPage('do=other', array(), true)
+        );
+    }
+
+    /**
+     * Test findPage: export page output.
+     * Valid: page should be return.
+     *
+     * @return void
+     */
+    public function testFindPageExportValid()
+    {
+        $this->assertEquals(
+            Router::$PAGE_EXPORT,
+            Router::findPage('do=export', array(), true)
+        );
+
+        $this->assertEquals(
+            Router::$PAGE_EXPORT,
+            Router::findPage('do=export&stuff', array(), true)
+        );
+    }
+
+    /**
+     * Test findPage: export page output.
+     * Invalid: page shouldn't be return.
+     *
+     * @return void
+     */
+    public function testFindPageExportInvalid()
+    {
+        $this->assertNotEquals(
+            Router::$PAGE_EXPORT,
+            Router::findPage('do=export', array(), 1)
+        );
+
+        $this->assertNotEquals(
+            Router::$PAGE_EXPORT,
+            Router::findPage('do=export', array(), false)
+        );
+
+        $this->assertNotEquals(
+            Router::$PAGE_EXPORT,
+            Router::findPage('do=other', array(), true)
+        );
+    }
+
+    /**
+     * Test findPage: import page output.
+     * Valid: page should be return.
+     *
+     * @return void
+     */
+    public function testFindPageImportValid()
+    {
+        $this->assertEquals(
+            Router::$PAGE_IMPORT,
+            Router::findPage('do=import', array(), true)
+        );
+
+        $this->assertEquals(
+            Router::$PAGE_IMPORT,
+            Router::findPage('do=import&stuff', array(), true)
+        );
+    }
+
+    /**
+     * Test findPage: import page output.
+     * Invalid: page shouldn't be return.
+     *
+     * @return void
+     */
+    public function testFindPageImportInvalid()
+    {
+        $this->assertNotEquals(
+            Router::$PAGE_IMPORT,
+            Router::findPage('do=import', array(), 1)
+        );
+
+        $this->assertNotEquals(
+            Router::$PAGE_IMPORT,
+            Router::findPage('do=import', array(), false)
+        );
+
+        $this->assertNotEquals(
+            Router::$PAGE_IMPORT,
+            Router::findPage('do=other', array(), true)
+        );
+    }
+
+    /**
+     * Test findPage: editlink page output.
+     * Valid: page should be return.
+     *
+     * @return void
+     */
+    public function testFindPageEditlinkValid()
+    {
+        $this->assertEquals(
+            Router::$PAGE_EDITLINK,
+            Router::findPage('whatever', array('edit_link' => 1), true)
+        );
+
+        $this->assertEquals(
+            Router::$PAGE_EDITLINK,
+            Router::findPage('', array('edit_link' => 1), true)
+        );
+
+
+        $this->assertEquals(
+            Router::$PAGE_EDITLINK,
+            Router::findPage('whatever', array('post' => 1), true)
+        );
+
+        $this->assertEquals(
+            Router::$PAGE_EDITLINK,
+            Router::findPage('whatever', array('post' => 1, 'edit_link' => 1), true)
+        );
+    }
+
+    /**
+     * Test findPage: editlink page output.
+     * Invalid: page shouldn't be return.
+     *
+     * @return void
+     */
+    public function testFindPageEditlinkInvalid()
+    {
+        $this->assertNotEquals(
+            Router::$PAGE_EDITLINK,
+            Router::findPage('whatever', array('edit_link' => 1), false)
+        );
+
+        $this->assertNotEquals(
+            Router::$PAGE_EDITLINK,
+            Router::findPage('whatever', array('edit_link' => 1), 1)
+        );
+
+        $this->assertNotEquals(
+            Router::$PAGE_EDITLINK,
+            Router::findPage('whatever', array(), true)
+        );
+    }
+}
\ No newline at end of file
diff --git a/tests/plugins/test/test.php b/tests/plugins/test/test.php
new file mode 100755 (executable)
index 0000000..3d750c9
--- /dev/null
@@ -0,0 +1,21 @@
+<?php
+
+/**
+ * Hook for test.
+ *
+ * @param array $data - data passed to plugin.
+ *
+ * @return mixed altered data.
+ */
+function hook_test_random($data)
+{
+    if (isset($data['_PAGE_']) && $data['_PAGE_'] == 'test') {
+        $data[1] = 'page test';
+    } else if (isset($data['_LOGGEDIN_']) && $data['_LOGGEDIN_'] === true) {
+        $data[1] = 'loggedin';
+    } else {
+        $data[1] = $data[0];
+    }
+
+    return $data;
+}