Merge pull request #388 from ArthurHoaro/pluginadmin

Fixes #378 - Plugin administration UI.
This commit is contained in:
Arthur 2016-01-31 19:00:13 +01:00
commit 53603f5823
20 changed files with 636 additions and 7 deletions

View file

@ -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[<plugin_name>]['parameters']['param_name'] = <value>.
* @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. * 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. * ==> if user is loggedIn, merge its content with config.php, then delete options.php.
@ -132,3 +232,17 @@ public function __construct()
$this->message = 'You are not authorized to alter config.'; $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.';
}
}

View file

@ -33,6 +33,12 @@ class PluginManager
*/ */
public static $PLUGINS_PATH = 'plugins'; public static $PLUGINS_PATH = 'plugins';
/**
* Plugins meta files extension.
* @var string $META_EXT
*/
public static $META_EXT = 'meta';
/** /**
* Private constructor: new instances not allowed. * Private constructor: new instances not allowed.
*/ */
@ -162,6 +168,51 @@ public function buildHookName($hook, $pluginName)
{ {
return 'hook_' . $pluginName . '_' . $hook; 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;
}
} }
/** /**

View file

@ -35,6 +35,10 @@ class Router
public static $PAGE_LINKLIST = 'linklist'; public static $PAGE_LINKLIST = 'linklist';
public static $PAGE_PLUGINSADMIN = 'pluginadmin';
public static $PAGE_SAVE_PLUGINSADMIN = 'save_pluginadmin';
/** /**
* Reproducing renderPage() if hell, to avoid regression. * Reproducing renderPage() if hell, to avoid regression.
* *
@ -112,6 +116,14 @@ public static function findPage($query, $get, $loggedIn)
return self::$PAGE_IMPORT; 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; return self::$PAGE_LINKLIST;
} }
} }

67
inc/plugin_admin.js Normal file
View file

@ -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;
}

View file

@ -1102,6 +1102,66 @@ ul.errors {
float: left; 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 */ /* 404 page */
.error-container { .error-container {

View file

@ -1770,6 +1770,54 @@ function renderPage()
exit; 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 '<script>alert("'. $e->getMessage() .'");document.location=\'?do=pluginsadmin\';</script>';
exit;
}
header('Location: ?do='. Router::$PAGE_PLUGINSADMIN);
exit;
}
// -------- Otherwise, simply display search form and links: // -------- Otherwise, simply display search form and links:
showLinkList($PAGE, $LINKSDB); showLinkList($PAGE, $LINKSDB);
exit; exit;

View file

@ -0,0 +1 @@
description="Adds the addlink input on the linklist page."

View file

@ -0,0 +1 @@
description="For each link, add an Archive.org icon."

View file

@ -0,0 +1 @@
description="A demo plugin covering all use cases for template designers and plugin developers."

View file

@ -0,0 +1 @@
description="Add a button in the toolbar allowing to watch all videos."

View file

@ -0,0 +1 @@
description="For each link, add a QRCode icon ."

View file

@ -0,0 +1,2 @@
description="For each link, add a ReadItYourself icon to save the shaared URL."
parameters=READITYOUSELF_URL;

View file

@ -13,7 +13,7 @@
include 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: '. $GLOBALS['plugin_errors'][] = 'Readityourself plugin error: '.
'Please define "$GLOBALS[\'plugins\'][\'READITYOUSELF_URL\']" '. 'Please define "$GLOBALS[\'plugins\'][\'READITYOUSELF_URL\']" '.
'in "plugins/readityourself/config.php" or in your Shaarli config.php file.'; 'in "plugins/readityourself/config.php" or in your Shaarli config.php file.';

View file

@ -0,0 +1,2 @@
description="For each link, add a Wallabag icon to save it in your instance."
parameters="WALLABAG_URL"

View file

@ -11,7 +11,7 @@
include 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: '. $GLOBALS['plugin_errors'][] = 'Wallabag plugin error: '.
'Please define "$GLOBALS[\'plugins\'][\'WALLABAG_URL\']" '. 'Please define "$GLOBALS[\'plugins\'][\'WALLABAG_URL\']" '.
'in "plugins/wallabag/config.php" or in your Shaarli config.php file.'; 'in "plugins/wallabag/config.php" or in your Shaarli config.php file.';

View file

@ -174,4 +174,113 @@ public function testMergeDeprecatedConfigNoFile()
include self::$configFields['config']['CONFIG_FILE']; include self::$configFields['config']['CONFIG_FILE'];
$this->assertEquals(self::$configFields['login'], $GLOBALS['login']); $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']);
}
} }

View file

@ -63,4 +63,23 @@ public function testPluginNotFound()
$pluginManager->load(array('nope', 'renope')); $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']);
}
} }

View file

@ -0,0 +1,2 @@
description="test plugin"
parameters="pop;hip"

131
tpl/pluginsadmin.html Normal file
View file

@ -0,0 +1,131 @@
<!DOCTYPE html>
<html>
<head>{include="includes"}</head>
<body>
<div id="pageheader">
{include="page.header"}
</div>
<noscript>
<div>
<ul class="errors">
<li>You need to enable Javascript to change plugin loading order.</li>
</ul>
</div>
<div class="clear"></div>
</noscript>
<div id="pluginsadmin">
<form action="?do=save_pluginadmin" method="POST">
<section id="enabled_plugins">
<h1>Enabled Plugins</h1>
<div>
{if="count($enabledPlugins)==0"}
<p>No plugin enabled.</p>
{else}
<table id="plugin_table">
<thead>
<tr>
<th class="center">Disable</th>
<th class="center">Order</th>
<th>Name</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{loop="$enabledPlugins"}
<tr data-line="{$key}" data-order="{$counter}">
<td class="center"><input type="checkbox" name="{$key}" checked="checked"></td>
<td class="center">
<a href="#"
onclick="return orderUp(this.parentNode.parentNode.getAttribute('data-order'));">
</a>
<a href="#"
onclick="return orderDown(this.parentNode.parentNode.getAttribute('data-order'));">
</a>
<input type="hidden" name="order_{$key}" value="{$counter}">
</td>
<td>{$key}</td>
<td>{$value.description}</td>
</tr>
{/loop}
</tbody>
</table>
{/if}
</div>
</section>
<section id="disabled_plugins">
<h1>Disabled Plugins</h1>
<div>
{if="count($disabledPlugins)==0"}
<p>No plugin disabled.</p>
{else}
<table>
<tr>
<th class="center">Enable</th>
<th>Name</th>
<th>Description</th>
</tr>
{loop="$disabledPlugins"}
<tr>
<td class="center"><input type="checkbox" name="{$key}"></td>
<td>{$key}</td>
<td>{$value.description}</td>
</tr>
{/loop}
</table>
{/if}
</div>
<div class="center">
<input type="submit" value="Save"/>
</div>
</section>
</form>
<form action="?do=save_pluginadmin" method="POST">
<section id="plugin_parameters">
<h1>Enabled Plugin Parameters</h1>
<div>
{if="count($enabledPlugins)==0"}
<p>No plugin enabled.</p>
{else}
{loop="$enabledPlugins"}
{if="count($value.parameters) > 0"}
<div class="plugin_parameters">
<h2>{$key}</h2>
{loop="$value.parameters"}
<div class="plugin_parameter">
<div class="float_label">
<label for="{$key}">
<code>{$key}</code>
</label>
</div>
<div class="float_input">
<input name="{$key}" value="{$value}" id="{$key}"/>
</div>
</div>
{/loop}
</div>
{/if}
{/loop}
{/if}
<div class="center">
<input type="submit" name="parameters_form" value="Save"/>
</div>
</div>
</section>
</form>
</div>
{include="page.footer"}
<script src="inc/plugin_admin.js#"></script>
</body>
</html>

View file

@ -5,11 +5,18 @@
<div id="pageheader"> <div id="pageheader">
{include="page.header"} {include="page.header"}
<div id="toolsdiv"> <div id="toolsdiv">
{if="!$GLOBALS['config']['OPEN_SHAARLI']"}<a href="?do=changepasswd"><b>Change password</b> <span>: Change your password.</span></a><br><br>{/if} <a href="?do=configure"><b>Configure your Shaarli</b><span>: Change Title, timezone...</span></a>
<a href="?do=configure"><b>Configure your Shaarli</b> <span>: Change Title, timezone...</span></a><br><br> <br><br>
<a href="?do=changetag"><b>Rename/delete tags</b> <span>: Rename or delete a tag in all links</span></a><br><br> <a href="?do=pluginadmin"><b>Plugin administration</b><span>: Enable, disable and configure plugins.</span></a>
<a href="?do=import"><b>Import</b> <span>: Import Netscape html bookmarks (as exported from Firefox, Chrome, Opera, delicious...)</span></a> <br><br> <br><br>
<a href="?do=export"><b>Export</b> <span>: Export Netscape html bookmarks (which can be imported in Firefox, Chrome, Opera, delicious...)</span></a><br><br> {if="!$GLOBALS['config']['OPEN_SHAARLI']"}<a href="?do=changepasswd"><b>Change password</b><span>: Change your password.</span></a>
<br><br>{/if}
<a href="?do=changetag"><b>Rename/delete tags</b><span>: Rename or delete a tag in all links</span></a>
<br><br>
<a href="?do=import"><b>Import</b><span>: Import Netscape html bookmarks (as exported from Firefox, Chrome, Opera, delicious...)</span></a>
<br><br>
<a href="?do=export"><b>Export</b><span>: Export Netscape html bookmarks (which can be imported in Firefox, Chrome, Opera, delicious...)</span></a>
<br><br>
<a class="smallbutton" <a class="smallbutton"
onclick="return alertBookmarklet();" onclick="return alertBookmarklet();"
href="javascript:( href="javascript:(