From dea0ba28f950867532eae572e7bcda49e81bbcf0 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Wed, 18 Nov 2015 17:40:42 +0100 Subject: [PATCH] Fixes #378 - Plugin administration UI. --- application/Config.php | 114 ++++++++++++++++ application/PluginManager.php | 51 ++++++++ application/Router.php | 12 ++ inc/plugin_admin.js | 67 ++++++++++ inc/shaarli.css | 60 +++++++++ index.php | 48 +++++++ plugins/addlink_toolbar/addlink_toolbar.meta | 1 + plugins/archiveorg/archiveorg.meta | 1 + plugins/demo_plugin/demo_plugin.meta | 1 + plugins/playvideos/playvideos.meta | 1 + plugins/qrcode/qrcode.meta | 1 + plugins/readityourself/readityourself.meta | 2 + plugins/readityourself/readityourself.php | 2 +- plugins/wallabag/wallabag.meta | 2 + plugins/wallabag/wallabag.php | 2 +- tests/ConfigTest.php | 109 +++++++++++++++ tests/PluginManagerTest.php | 19 +++ tests/plugins/test/test.meta | 2 + tpl/pluginsadmin.html | 131 +++++++++++++++++++ tpl/tools.html | 17 ++- 20 files changed, 636 insertions(+), 7 deletions(-) create mode 100644 inc/plugin_admin.js create mode 100644 plugins/addlink_toolbar/addlink_toolbar.meta create mode 100644 plugins/archiveorg/archiveorg.meta create mode 100644 plugins/demo_plugin/demo_plugin.meta create mode 100644 plugins/playvideos/playvideos.meta create mode 100644 plugins/qrcode/qrcode.meta create mode 100644 plugins/readityourself/readityourself.meta create mode 100644 plugins/wallabag/wallabag.meta create mode 100644 tests/plugins/test/test.meta create mode 100644 tpl/pluginsadmin.html diff --git a/application/Config.php b/application/Config.php index c71ef68..9af5a53 100644 --- a/application/Config.php +++ b/application/Config.php @@ -73,6 +73,106 @@ function writeConfig($config, $isLoggedIn) } } +/** + * Process plugin administration form data and save it in an array. + * + * @param array $formData Data sent by the plugin admin form. + * + * @return array New list of enabled plugin, ordered. + * + * @throws PluginConfigOrderException Plugins can't be sorted because their order is invalid. + */ +function save_plugin_config($formData) +{ + // Make sure there are no duplicates in orders. + if (!validate_plugin_order($formData)) { + throw new PluginConfigOrderException(); + } + + $plugins = array(); + $newEnabledPlugins = array(); + foreach ($formData as $key => $data) { + if (startsWith($key, 'order')) { + continue; + } + + // If there is no order, it means a disabled plugin has been enabled. + if (isset($formData['order_' . $key])) { + $plugins[(int) $formData['order_' . $key]] = $key; + } + else { + $newEnabledPlugins[] = $key; + } + } + + // New enabled plugins will be added at the end of order. + $plugins = array_merge($plugins, $newEnabledPlugins); + + // Sort plugins by order. + if (!ksort($plugins)) { + throw new PluginConfigOrderException(); + } + + $finalPlugins = array(); + // Make plugins order continuous. + foreach ($plugins as $plugin) { + $finalPlugins[] = $plugin; + } + + return $finalPlugins; +} + +/** + * Validate plugin array submitted. + * Will fail if there is duplicate orders value. + * + * @param array $formData Data from submitted form. + * + * @return bool true if ok, false otherwise. + */ +function validate_plugin_order($formData) +{ + $orders = array(); + foreach ($formData as $key => $value) { + // No duplicate order allowed. + if (in_array($value, $orders)) { + return false; + } + + if (startsWith($key, 'order')) { + $orders[] = $value; + } + } + + return true; +} + +/** + * Affect plugin parameters values into plugins array. + * + * @param mixed $plugins Plugins array ($plugins[]['parameters']['param_name'] = . + * @param mixed $config Plugins configuration. + * + * @return mixed Updated $plugins array. + */ +function load_plugin_parameter_values($plugins, $config) +{ + $out = $plugins; + foreach ($plugins as $name => $plugin) { + if (empty($plugin['parameters'])) { + continue; + } + + foreach ($plugin['parameters'] as $key => $param) { + if (!empty($config[$key])) { + $out[$name]['parameters'][$key] = $config[$key]; + } + } + } + + return $out; +} + /** * 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. @@ -132,3 +232,17 @@ class UnauthorizedConfigException extends Exception $this->message = 'You are not authorized to alter config.'; } } + +/** + * Exception used if an error occur while saving plugin configuration. + */ +class PluginConfigOrderException extends Exception +{ + /** + * Construct exception. + */ + public function __construct() + { + $this->message = 'An error occurred while trying to save plugins loading order.'; + } +} diff --git a/application/PluginManager.php b/application/PluginManager.php index 803f11b..787ac6a 100644 --- a/application/PluginManager.php +++ b/application/PluginManager.php @@ -33,6 +33,12 @@ class PluginManager */ public static $PLUGINS_PATH = 'plugins'; + /** + * Plugins meta files extension. + * @var string $META_EXT + */ + public static $META_EXT = 'meta'; + /** * Private constructor: new instances not allowed. */ @@ -162,6 +168,51 @@ class PluginManager { return 'hook_' . $pluginName . '_' . $hook; } + + /** + * Retrieve plugins metadata from *.meta (INI) files into an array. + * Metadata contains: + * - plugin description [description] + * - parameters split with ';' [parameters] + * + * Respects plugins order from settings. + * + * @return array plugins metadata. + */ + public function getPluginsMeta() + { + $metaData = array(); + $dirs = glob(self::$PLUGINS_PATH . '/*', GLOB_ONLYDIR | GLOB_MARK); + + // Browse all plugin directories. + foreach ($dirs as $pluginDir) { + $plugin = basename($pluginDir); + $metaFile = $pluginDir . $plugin . '.' . self::$META_EXT; + if (!is_file($metaFile) || !is_readable($metaFile)) { + continue; + } + + $metaData[$plugin] = parse_ini_file($metaFile); + $metaData[$plugin]['order'] = array_search($plugin, $this->authorizedPlugins); + + // Read parameters and format them into an array. + if (isset($metaData[$plugin]['parameters'])) { + $params = explode(';', $metaData[$plugin]['parameters']); + } else { + $params = array(); + } + $metaData[$plugin]['parameters'] = array(); + foreach ($params as $param) { + if (empty($param)) { + continue; + } + + $metaData[$plugin]['parameters'][$param] = ''; + } + } + + return $metaData; + } } /** diff --git a/application/Router.php b/application/Router.php index 0c81384..6185f08 100644 --- a/application/Router.php +++ b/application/Router.php @@ -35,6 +35,10 @@ class Router public static $PAGE_LINKLIST = 'linklist'; + public static $PAGE_PLUGINSADMIN = 'pluginadmin'; + + public static $PAGE_SAVE_PLUGINSADMIN = 'save_pluginadmin'; + /** * Reproducing renderPage() if hell, to avoid regression. * @@ -112,6 +116,14 @@ class Router return self::$PAGE_IMPORT; } + if (startswith($query, 'do='. self::$PAGE_PLUGINSADMIN)) { + return self::$PAGE_PLUGINSADMIN; + } + + if (startswith($query, 'do='. self::$PAGE_SAVE_PLUGINSADMIN)) { + return self::$PAGE_SAVE_PLUGINSADMIN; + } + return self::$PAGE_LINKLIST; } } \ No newline at end of file diff --git a/inc/plugin_admin.js b/inc/plugin_admin.js new file mode 100644 index 0000000..134ffb3 --- /dev/null +++ b/inc/plugin_admin.js @@ -0,0 +1,67 @@ +/** + * Change the position counter of a row. + * + * @param elem Element Node to change. + * @param toPos int New position. + */ +function changePos(elem, toPos) +{ + var elemName = elem.getAttribute('data-line') + + elem.setAttribute('data-order', toPos); + var hiddenInput = document.querySelector('[name="order_'+ elemName +'"]'); + hiddenInput.setAttribute('value', toPos); +} + +/** + * Move a row up or down. + * + * @param pos Element Node to move. + * @param move int Move: +1 (down) or -1 (up) + */ +function changeOrder(pos, move) +{ + var newpos = parseInt(pos) + move; + var line = document.querySelector('[data-order="'+ pos +'"]'); + var changeline = document.querySelector('[data-order="'+ newpos +'"]'); + var parent = changeline.parentNode; + + changePos(line, newpos); + changePos(changeline, parseInt(pos)); + var changeItem = move < 0 ? changeline : changeline.nextSibling; + parent.insertBefore(line, changeItem); +} + +/** + * Move a row up in the table. + * + * @param pos int row counter. + * + * @returns false + */ +function orderUp(pos) +{ + if (pos == 0) { + return false; + } + changeOrder(pos, -1); + return false; +} + +/** + * Move a row down in the table. + * + * @param pos int row counter. + * + * @returns false + */ +function orderDown(pos) +{ + var lastpos = document.querySelector('[data-order]:last-child').getAttribute('data-order'); + if (pos == lastpos) { + return false; + } + + changeOrder(pos, +1); + return false; +} diff --git a/inc/shaarli.css b/inc/shaarli.css index 96e2cae..f137555 100644 --- a/inc/shaarli.css +++ b/inc/shaarli.css @@ -1103,6 +1103,66 @@ ul.errors { float: left; } +#pluginsadmin { + width: 80%; + padding: 20px 0 0 20px; +} + +#pluginsadmin section { + padding: 20px 0; +} + +#pluginsadmin .plugin_parameters { + margin: 10px 0; +} + +#pluginsadmin h1 { + font-style: normal; +} + +#pluginsadmin h2 { + font-size: 1.4em; + font-weight: bold; +} + +#pluginsadmin table { + width: 100%; +} + +#pluginsadmin table, #pluginsadmin th, #pluginsadmin td { + border-width: 1px 0; + border-style: solid; + border-color: #c0c0c0; +} + +#pluginsadmin table th { + font-weight: bold; + padding: 10px 0; +} + +#pluginsadmin table td { + padding: 5px 0; +} + +#pluginsadmin input[type=submit] { + margin: 10px 0; +} + +#pluginsadmin .plugin_parameter { + padding: 5px 0; + border-width: 1px 0; + border-style: solid; + border-color: #c0c0c0; +} + +#pluginsadmin .float_label { + float: left; + width: 20%; +} + +#pluginsadmin a { + color: black; +} /* 404 page */ .error-container { diff --git a/index.php b/index.php index beba9c3..51b7b39 100644 --- a/index.php +++ b/index.php @@ -1770,6 +1770,54 @@ HTML; exit; } + // Plugin administration page + if ($targetPage == Router::$PAGE_PLUGINSADMIN) { + $pluginMeta = $pluginManager->getPluginsMeta(); + + // Split plugins into 2 arrays: ordered enabled plugins and disabled. + $enabledPlugins = array_filter($pluginMeta, function($v) { return $v['order'] !== false; }); + // Load parameters. + $enabledPlugins = load_plugin_parameter_values($enabledPlugins, $GLOBALS['plugins']); + uasort( + $enabledPlugins, + function($a, $b) { return $a['order'] - $b['order']; } + ); + $disabledPlugins = array_filter($pluginMeta, function($v) { return $v['order'] === false; }); + + $PAGE->assign('enabledPlugins', $enabledPlugins); + $PAGE->assign('disabledPlugins', $disabledPlugins); + $PAGE->renderPage('pluginsadmin'); + exit; + } + + // Plugin administration form action + if ($targetPage == Router::$PAGE_SAVE_PLUGINSADMIN) { + try { + if (isset($_POST['parameters_form'])) { + unset($_POST['parameters_form']); + foreach ($_POST as $param => $value) { + $GLOBALS['plugins'][$param] = escape($value); + } + } + else { + $GLOBALS['config']['ENABLED_PLUGINS'] = save_plugin_config($_POST); + } + writeConfig($GLOBALS, isLoggedIn()); + } + catch (Exception $e) { + error_log( + 'ERROR while saving plugin configuration:.' . PHP_EOL . + $e->getMessage() + ); + + // TODO: do not handle exceptions/errors in JS. + echo ''; + exit; + } + header('Location: ?do='. Router::$PAGE_PLUGINSADMIN); + exit; + } + // -------- Otherwise, simply display search form and links: showLinkList($PAGE, $LINKSDB); exit; diff --git a/plugins/addlink_toolbar/addlink_toolbar.meta b/plugins/addlink_toolbar/addlink_toolbar.meta new file mode 100644 index 0000000..2f0b586 --- /dev/null +++ b/plugins/addlink_toolbar/addlink_toolbar.meta @@ -0,0 +1 @@ +description="Adds the addlink input on the linklist page." diff --git a/plugins/archiveorg/archiveorg.meta b/plugins/archiveorg/archiveorg.meta new file mode 100644 index 0000000..8b5703e --- /dev/null +++ b/plugins/archiveorg/archiveorg.meta @@ -0,0 +1 @@ +description="For each link, add an Archive.org icon." diff --git a/plugins/demo_plugin/demo_plugin.meta b/plugins/demo_plugin/demo_plugin.meta new file mode 100644 index 0000000..b063ecb --- /dev/null +++ b/plugins/demo_plugin/demo_plugin.meta @@ -0,0 +1 @@ +description="A demo plugin covering all use cases for template designers and plugin developers." diff --git a/plugins/playvideos/playvideos.meta b/plugins/playvideos/playvideos.meta new file mode 100644 index 0000000..c2b0908 --- /dev/null +++ b/plugins/playvideos/playvideos.meta @@ -0,0 +1 @@ +description="Add a button in the toolbar allowing to watch all videos." diff --git a/plugins/qrcode/qrcode.meta b/plugins/qrcode/qrcode.meta new file mode 100644 index 0000000..cbf371e --- /dev/null +++ b/plugins/qrcode/qrcode.meta @@ -0,0 +1 @@ +description="For each link, add a QRCode icon ." diff --git a/plugins/readityourself/readityourself.meta b/plugins/readityourself/readityourself.meta new file mode 100644 index 0000000..bd611dd --- /dev/null +++ b/plugins/readityourself/readityourself.meta @@ -0,0 +1,2 @@ +description="For each link, add a ReadItYourself icon to save the shaared URL." +parameters=READITYOUSELF_URL; \ No newline at end of file diff --git a/plugins/readityourself/readityourself.php b/plugins/readityourself/readityourself.php index 1b030bc..c8df4c4 100644 --- a/plugins/readityourself/readityourself.php +++ b/plugins/readityourself/readityourself.php @@ -13,7 +13,7 @@ if (is_file(PluginManager::$PLUGINS_PATH . '/readityourself/config.php')) { include PluginManager::$PLUGINS_PATH . '/readityourself/config.php'; } -if (!isset($GLOBALS['plugins']['READITYOUSELF_URL'])) { +if (empty($GLOBALS['plugins']['READITYOUSELF_URL'])) { $GLOBALS['plugin_errors'][] = 'Readityourself plugin error: '. 'Please define "$GLOBALS[\'plugins\'][\'READITYOUSELF_URL\']" '. 'in "plugins/readityourself/config.php" or in your Shaarli config.php file.'; diff --git a/plugins/wallabag/wallabag.meta b/plugins/wallabag/wallabag.meta new file mode 100644 index 0000000..8763c4a --- /dev/null +++ b/plugins/wallabag/wallabag.meta @@ -0,0 +1,2 @@ +description="For each link, add a Wallabag icon to save it in your instance." +parameters="WALLABAG_URL" \ No newline at end of file diff --git a/plugins/wallabag/wallabag.php b/plugins/wallabag/wallabag.php index e3c399a..0d6fc66 100644 --- a/plugins/wallabag/wallabag.php +++ b/plugins/wallabag/wallabag.php @@ -11,7 +11,7 @@ if (is_file(PluginManager::$PLUGINS_PATH . '/wallabag/config.php')) { include PluginManager::$PLUGINS_PATH . '/wallabag/config.php'; } -if (!isset($GLOBALS['plugins']['WALLABAG_URL'])) { +if (empty($GLOBALS['plugins']['WALLABAG_URL'])) { $GLOBALS['plugin_errors'][] = 'Wallabag plugin error: '. 'Please define "$GLOBALS[\'plugins\'][\'WALLABAG_URL\']" '. 'in "plugins/wallabag/config.php" or in your Shaarli config.php file.'; diff --git a/tests/ConfigTest.php b/tests/ConfigTest.php index adebfcc..492ddd3 100644 --- a/tests/ConfigTest.php +++ b/tests/ConfigTest.php @@ -174,4 +174,113 @@ class ConfigTest extends PHPUnit_Framework_TestCase include self::$configFields['config']['CONFIG_FILE']; $this->assertEquals(self::$configFields['login'], $GLOBALS['login']); } + + /** + * Test save_plugin_config with valid data. + * + * @throws PluginConfigOrderException + */ + public function testSavePluginConfigValid() + { + $data = array( + 'order_plugin1' => 2, // no plugin related + 'plugin2' => 0, // new - at the end + 'plugin3' => 0, // 2nd + 'order_plugin3' => 8, + 'plugin4' => 0, // 1st + 'order_plugin4' => 5, + ); + + $expected = array( + 'plugin3', + 'plugin4', + 'plugin2', + ); + + $out = save_plugin_config($data); + $this->assertEquals($expected, $out); + } + + /** + * Test save_plugin_config with invalid data. + * + * @expectedException PluginConfigOrderException + */ + public function testSavePluginConfigInvalid() + { + $data = array( + 'plugin2' => 0, + 'plugin3' => 0, + 'order_plugin3' => 0, + 'plugin4' => 0, + 'order_plugin4' => 0, + ); + + save_plugin_config($data); + } + + /** + * Test save_plugin_config without data. + */ + public function testSavePluginConfigEmpty() + { + $this->assertEquals(array(), save_plugin_config(array())); + } + + /** + * Test validate_plugin_order with valid data. + */ + public function testValidatePluginOrderValid() + { + $data = array( + 'order_plugin1' => 2, + 'plugin2' => 0, + 'plugin3' => 0, + 'order_plugin3' => 1, + 'plugin4' => 0, + 'order_plugin4' => 5, + ); + + $this->assertTrue(validate_plugin_order($data)); + } + + /** + * Test validate_plugin_order with invalid data. + */ + public function testValidatePluginOrderInvalid() + { + $data = array( + 'order_plugin1' => 2, + 'order_plugin3' => 1, + 'order_plugin4' => 1, + ); + + $this->assertFalse(validate_plugin_order($data)); + } + + /** + * Test load_plugin_parameter_values. + */ + public function testLoadPluginParameterValues() + { + $plugins = array( + 'plugin_name' => array( + 'parameters' => array( + 'param1' => true, + 'param2' => false, + 'param3' => '', + ) + ) + ); + + $parameters = array( + 'param1' => 'value1', + 'param2' => 'value2', + ); + + $result = load_plugin_parameter_values($plugins, $parameters); + $this->assertEquals('value1', $result['plugin_name']['parameters']['param1']); + $this->assertEquals('value2', $result['plugin_name']['parameters']['param2']); + $this->assertEquals('', $result['plugin_name']['parameters']['param3']); + } } diff --git a/tests/PluginManagerTest.php b/tests/PluginManagerTest.php index df2614b..348082c 100644 --- a/tests/PluginManagerTest.php +++ b/tests/PluginManagerTest.php @@ -63,4 +63,23 @@ class PluginManagerTest extends PHPUnit_Framework_TestCase $pluginManager->load(array('nope', 'renope')); } + + /** + * Test plugin metadata loading. + */ + public function testGetPluginsMeta() + { + $pluginManager = PluginManager::getInstance(); + + PluginManager::$PLUGINS_PATH = self::$pluginPath; + $pluginManager->load(array(self::$pluginName)); + + $expectedParameters = array( + 'pop' => '', + 'hip' => '', + ); + $meta = $pluginManager->getPluginsMeta(); + $this->assertEquals('test plugin', $meta[self::$pluginName]['description']); + $this->assertEquals($expectedParameters, $meta[self::$pluginName]['parameters']); + } } \ No newline at end of file diff --git a/tests/plugins/test/test.meta b/tests/plugins/test/test.meta new file mode 100644 index 0000000..ab999ed --- /dev/null +++ b/tests/plugins/test/test.meta @@ -0,0 +1,2 @@ +description="test plugin" +parameters="pop;hip" \ No newline at end of file diff --git a/tpl/pluginsadmin.html b/tpl/pluginsadmin.html new file mode 100644 index 0000000..4f7d091 --- /dev/null +++ b/tpl/pluginsadmin.html @@ -0,0 +1,131 @@ + + +{include="includes"} + + + + + +
+
+
+

Enabled Plugins

+ +
+ {if="count($enabledPlugins)==0"} +

No plugin enabled.

+ {else} + + + + + + + + + + + {loop="$enabledPlugins"} + + + + + + + {/loop} + +
DisableOrderNameDescription
+ + ▲ + + + ▼ + + + {$key}{$value.description}
+ {/if} +
+
+ +
+

Disabled Plugins

+ +
+ {if="count($disabledPlugins)==0"} +

No plugin disabled.

+ {else} + + + + + + + {loop="$disabledPlugins"} + + + + + + {/loop} +
EnableNameDescription
{$key}{$value.description}
+ {/if} +
+ +
+ +
+
+
+ +
+
+

Enabled Plugin Parameters

+ +
+ {if="count($enabledPlugins)==0"} +

No plugin enabled.

+ {else} + {loop="$enabledPlugins"} + {if="count($value.parameters) > 0"} +
+

{$key}

+ {loop="$value.parameters"} +
+
+ +
+
+ +
+
+ {/loop} +
+ {/if} + {/loop} + {/if} +
+ +
+
+
+
+ +
+{include="page.footer"} + + + + \ No newline at end of file diff --git a/tpl/tools.html b/tpl/tools.html index c13f4f1..78b8166 100644 --- a/tpl/tools.html +++ b/tpl/tools.html @@ -5,11 +5,18 @@