aboutsummaryrefslogtreecommitdiffhomepage
path: root/application/config
diff options
context:
space:
mode:
authorArthur <arthur@hoa.ro>2016-07-09 07:19:48 +0200
committerGitHub <noreply@github.com>2016-07-09 07:19:48 +0200
commit649af5b501d2a90448242f53764ff693e9854039 (patch)
tree23cde80a7ee2949e552c48939ae22fa462cfa0fc /application/config
parenta9cfa38df92bd2e1e2c00a67b6ac1516a2116ade (diff)
parent5ff23f02b80ec6ddee28dee869171ee8e3656b7c (diff)
downloadShaarli-649af5b501d2a90448242f53764ff693e9854039.tar.gz
Shaarli-649af5b501d2a90448242f53764ff693e9854039.tar.zst
Shaarli-649af5b501d2a90448242f53764ff693e9854039.zip
Merge pull request #570 from ArthurHoaro/config-manager
Introduce a configuration manager
Diffstat (limited to 'application/config')
-rw-r--r--application/config/ConfigIO.php33
-rw-r--r--application/config/ConfigJson.php78
-rw-r--r--application/config/ConfigManager.php392
-rw-r--r--application/config/ConfigPhp.php132
-rw-r--r--application/config/ConfigPlugin.php120
5 files changed, 755 insertions, 0 deletions
diff --git a/application/config/ConfigIO.php b/application/config/ConfigIO.php
new file mode 100644
index 00000000..2b68fe6a
--- /dev/null
+++ b/application/config/ConfigIO.php
@@ -0,0 +1,33 @@
1<?php
2
3/**
4 * Interface ConfigIO
5 *
6 * This describes how Config types should store their configuration.
7 */
8interface ConfigIO
9{
10 /**
11 * Read configuration.
12 *
13 * @param string $filepath Config file absolute path.
14 *
15 * @return array All configuration in an array.
16 */
17 function read($filepath);
18
19 /**
20 * Write configuration.
21 *
22 * @param string $filepath Config file absolute path.
23 * @param array $conf All configuration in an array.
24 */
25 function write($filepath, $conf);
26
27 /**
28 * Get config file extension according to config type.
29 *
30 * @return string Config file extension.
31 */
32 function getExtension();
33}
diff --git a/application/config/ConfigJson.php b/application/config/ConfigJson.php
new file mode 100644
index 00000000..d07fefee
--- /dev/null
+++ b/application/config/ConfigJson.php
@@ -0,0 +1,78 @@
1<?php
2
3/**
4 * Class ConfigJson (ConfigIO implementation)
5 *
6 * Handle Shaarli's JSON configuration file.
7 */
8class ConfigJson implements ConfigIO
9{
10 /**
11 * @inheritdoc
12 */
13 function read($filepath)
14 {
15 if (! is_readable($filepath)) {
16 return array();
17 }
18 $data = file_get_contents($filepath);
19 $data = str_replace(self::getPhpHeaders(), '', $data);
20 $data = str_replace(self::getPhpSuffix(), '', $data);
21 $data = json_decode($data, true);
22 if ($data === null) {
23 $error = json_last_error();
24 throw new Exception('An error occured while parsing JSON file: error code #'. $error);
25 }
26 return $data;
27 }
28
29 /**
30 * @inheritdoc
31 */
32 function write($filepath, $conf)
33 {
34 // JSON_PRETTY_PRINT is available from PHP 5.4.
35 $print = defined('JSON_PRETTY_PRINT') ? JSON_PRETTY_PRINT : 0;
36 $data = self::getPhpHeaders() . json_encode($conf, $print) . self::getPhpSuffix();
37 if (!file_put_contents($filepath, $data)) {
38 throw new IOException(
39 $filepath,
40 'Shaarli could not create the config file.
41 Please make sure Shaarli has the right to write in the folder is it installed in.'
42 );
43 }
44 }
45
46 /**
47 * @inheritdoc
48 */
49 function getExtension()
50 {
51 return '.json.php';
52 }
53
54 /**
55 * The JSON data is wrapped in a PHP file for security purpose.
56 * This way, even if the file is accessible, credentials and configuration won't be exposed.
57 *
58 * Note: this isn't a static field because concatenation isn't supported in field declaration before PHP 5.6.
59 *
60 * @return string PHP start tag and comment tag.
61 */
62 public static function getPhpHeaders()
63 {
64 return '<?php /*'. PHP_EOL;
65 }
66
67 /**
68 * Get PHP comment closing tags.
69 *
70 * Static method for consistency with getPhpHeaders.
71 *
72 * @return string PHP comment closing.
73 */
74 public static function getPhpSuffix()
75 {
76 return PHP_EOL . '*/ ?>';
77 }
78}
diff --git a/application/config/ConfigManager.php b/application/config/ConfigManager.php
new file mode 100644
index 00000000..ff41772a
--- /dev/null
+++ b/application/config/ConfigManager.php
@@ -0,0 +1,392 @@
1<?php
2
3// FIXME! Namespaces...
4require_once 'ConfigIO.php';
5require_once 'ConfigJson.php';
6require_once 'ConfigPhp.php';
7
8/**
9 * Class ConfigManager
10 *
11 * Manages all Shaarli's settings.
12 * See the documentation for more information on settings:
13 * - doc/Shaarli-configuration.html
14 * - https://github.com/shaarli/Shaarli/wiki/Shaarli-configuration
15 */
16class ConfigManager
17{
18 /**
19 * @var string Flag telling a setting is not found.
20 */
21 protected static $NOT_FOUND = 'NOT_FOUND';
22
23 /**
24 * @var string Config folder.
25 */
26 protected $configFile;
27
28 /**
29 * @var array Loaded config array.
30 */
31 protected $loadedConfig;
32
33 /**
34 * @var ConfigIO implementation instance.
35 */
36 protected $configIO;
37
38 /**
39 * Constructor.
40 */
41 public function __construct($configFile = 'data/config')
42 {
43 $this->configFile = $configFile;
44 $this->initialize();
45 }
46
47 /**
48 * Reset the ConfigManager instance.
49 */
50 public function reset()
51 {
52 $this->initialize();
53 }
54
55 /**
56 * Rebuild the loaded config array from config files.
57 */
58 public function reload()
59 {
60 $this->load();
61 }
62
63 /**
64 * Initialize the ConfigIO and loaded the conf.
65 */
66 protected function initialize()
67 {
68 if (file_exists($this->configFile . '.php')) {
69 $this->configIO = new ConfigPhp();
70 } else {
71 $this->configIO = new ConfigJson();
72 }
73 $this->load();
74 }
75
76 /**
77 * Load configuration in the ConfigurationManager.
78 */
79 protected function load()
80 {
81 $this->loadedConfig = $this->configIO->read($this->getConfigFileExt());
82 $this->setDefaultValues();
83 }
84
85 /**
86 * Get a setting.
87 *
88 * Supports nested settings with dot separated keys.
89 * Eg. 'config.stuff.option' will find $conf[config][stuff][option],
90 * or in JSON:
91 * { "config": { "stuff": {"option": "mysetting" } } } }
92 *
93 * @param string $setting Asked setting, keys separated with dots.
94 * @param string $default Default value if not found.
95 *
96 * @return mixed Found setting, or the default value.
97 */
98 public function get($setting, $default = '')
99 {
100 // During the ConfigIO transition, map legacy settings to the new ones.
101 if ($this->configIO instanceof ConfigPhp && isset(ConfigPhp::$LEGACY_KEYS_MAPPING[$setting])) {
102 $setting = ConfigPhp::$LEGACY_KEYS_MAPPING[$setting];
103 }
104
105 $settings = explode('.', $setting);
106 $value = self::getConfig($settings, $this->loadedConfig);
107 if ($value === self::$NOT_FOUND) {
108 return $default;
109 }
110 return $value;
111 }
112
113 /**
114 * Set a setting, and eventually write it.
115 *
116 * Supports nested settings with dot separated keys.
117 *
118 * @param string $setting Asked setting, keys separated with dots.
119 * @param string $value Value to set.
120 * @param bool $write Write the new setting in the config file, default false.
121 * @param bool $isLoggedIn User login state, default false.
122 *
123 * @throws Exception Invalid
124 */
125 public function set($setting, $value, $write = false, $isLoggedIn = false)
126 {
127 if (empty($setting) || ! is_string($setting)) {
128 throw new Exception('Invalid setting key parameter. String expected, got: '. gettype($setting));
129 }
130
131 // During the ConfigIO transition, map legacy settings to the new ones.
132 if ($this->configIO instanceof ConfigPhp && isset(ConfigPhp::$LEGACY_KEYS_MAPPING[$setting])) {
133 $setting = ConfigPhp::$LEGACY_KEYS_MAPPING[$setting];
134 }
135
136 $settings = explode('.', $setting);
137 self::setConfig($settings, $value, $this->loadedConfig);
138 if ($write) {
139 $this->write($isLoggedIn);
140 }
141 }
142
143 /**
144 * Check if a settings exists.
145 *
146 * Supports nested settings with dot separated keys.
147 *
148 * @param string $setting Asked setting, keys separated with dots.
149 *
150 * @return bool true if the setting exists, false otherwise.
151 */
152 public function exists($setting)
153 {
154 // During the ConfigIO transition, map legacy settings to the new ones.
155 if ($this->configIO instanceof ConfigPhp && isset(ConfigPhp::$LEGACY_KEYS_MAPPING[$setting])) {
156 $setting = ConfigPhp::$LEGACY_KEYS_MAPPING[$setting];
157 }
158
159 $settings = explode('.', $setting);
160 $value = self::getConfig($settings, $this->loadedConfig);
161 if ($value === self::$NOT_FOUND) {
162 return false;
163 }
164 return true;
165 }
166
167 /**
168 * Call the config writer.
169 *
170 * @param bool $isLoggedIn User login state.
171 *
172 * @return bool True if the configuration has been successfully written, false otherwise.
173 *
174 * @throws MissingFieldConfigException: a mandatory field has not been provided in $conf.
175 * @throws UnauthorizedConfigException: user is not authorize to change configuration.
176 * @throws IOException: an error occurred while writing the new config file.
177 */
178 public function write($isLoggedIn)
179 {
180 // These fields are required in configuration.
181 $mandatoryFields = array(
182 'credentials.login',
183 'credentials.hash',
184 'credentials.salt',
185 'security.session_protection_disabled',
186 'general.timezone',
187 'general.title',
188 'general.header_link',
189 'privacy.default_private_links',
190 'redirector.url',
191 );
192
193 // Only logged in user can alter config.
194 if (is_file($this->getConfigFileExt()) && !$isLoggedIn) {
195 throw new UnauthorizedConfigException();
196 }
197
198 // Check that all mandatory fields are provided in $conf.
199 foreach ($mandatoryFields as $field) {
200 if (! $this->exists($field)) {
201 throw new MissingFieldConfigException($field);
202 }
203 }
204
205 return $this->configIO->write($this->getConfigFileExt(), $this->loadedConfig);
206 }
207
208 /**
209 * Set the config file path (without extension).
210 *
211 * @param string $configFile File path.
212 */
213 public function setConfigFile($configFile)
214 {
215 $this->configFile = $configFile;
216 }
217
218 /**
219 * Return the configuration file path (without extension).
220 *
221 * @return string Config path.
222 */
223 public function getConfigFile()
224 {
225 return $this->configFile;
226 }
227
228 /**
229 * Get the configuration file path with its extension.
230 *
231 * @return string Config file path.
232 */
233 public function getConfigFileExt()
234 {
235 return $this->configFile . $this->configIO->getExtension();
236 }
237
238 /**
239 * Recursive function which find asked setting in the loaded config.
240 *
241 * @param array $settings Ordered array which contains keys to find.
242 * @param array $conf Loaded settings, then sub-array.
243 *
244 * @return mixed Found setting or NOT_FOUND flag.
245 */
246 protected static function getConfig($settings, $conf)
247 {
248 if (!is_array($settings) || count($settings) == 0) {
249 return self::$NOT_FOUND;
250 }
251
252 $setting = array_shift($settings);
253 if (!isset($conf[$setting])) {
254 return self::$NOT_FOUND;
255 }
256
257 if (count($settings) > 0) {
258 return self::getConfig($settings, $conf[$setting]);
259 }
260 return $conf[$setting];
261 }
262
263 /**
264 * Recursive function which find asked setting in the loaded config.
265 *
266 * @param array $settings Ordered array which contains keys to find.
267 * @param mixed $value
268 * @param array $conf Loaded settings, then sub-array.
269 *
270 * @return mixed Found setting or NOT_FOUND flag.
271 */
272 protected static function setConfig($settings, $value, &$conf)
273 {
274 if (!is_array($settings) || count($settings) == 0) {
275 return self::$NOT_FOUND;
276 }
277
278 $setting = array_shift($settings);
279 if (count($settings) > 0) {
280 return self::setConfig($settings, $value, $conf[$setting]);
281 }
282 $conf[$setting] = $value;
283 }
284
285 /**
286 * Set a bunch of default values allowing Shaarli to start without a config file.
287 */
288 protected function setDefaultValues()
289 {
290 $this->setEmpty('resource.data_dir', 'data');
291 $this->setEmpty('resource.config', 'data/config.php');
292 $this->setEmpty('resource.datastore', 'data/datastore.php');
293 $this->setEmpty('resource.ban_file', 'data/ipbans.php');
294 $this->setEmpty('resource.updates', 'data/updates.txt');
295 $this->setEmpty('resource.log', 'data/log.txt');
296 $this->setEmpty('resource.update_check', 'data/lastupdatecheck.txt');
297 $this->setEmpty('resource.raintpl_tpl', 'tpl/');
298 $this->setEmpty('resource.raintpl_tmp', 'tmp/');
299 $this->setEmpty('resource.thumbnails_cache', 'cache');
300 $this->setEmpty('resource.page_cache', 'pagecache');
301
302 $this->setEmpty('security.ban_after', 4);
303 $this->setEmpty('security.ban_duration', 1800);
304 $this->setEmpty('security.session_protection_disabled', false);
305 $this->setEmpty('security.open_shaarli', false);
306
307 $this->setEmpty('general.header_link', '?');
308 $this->setEmpty('general.links_per_page', 20);
309 $this->setEmpty('general.enabled_plugins', array('qrcode'));
310
311 $this->setEmpty('updates.check_updates', false);
312 $this->setEmpty('updates.check_updates_branch', 'stable');
313 $this->setEmpty('updates.check_updates_interval', 86400);
314
315 $this->setEmpty('feed.rss_permalinks', true);
316 $this->setEmpty('feed.show_atom', false);
317
318 $this->setEmpty('privacy.default_private_links', false);
319 $this->setEmpty('privacy.hide_public_links', false);
320 $this->setEmpty('privacy.hide_timestamps', false);
321
322 $this->setEmpty('thumbnail.enable_thumbnails', true);
323 $this->setEmpty('thumbnail.enable_localcache', true);
324
325 $this->setEmpty('redirector.url', '');
326 $this->setEmpty('redirector.encode_url', true);
327
328 $this->setEmpty('plugins', array());
329 }
330
331 /**
332 * Set only if the setting does not exists.
333 *
334 * @param string $key Setting key.
335 * @param mixed $value Setting value.
336 */
337 public function setEmpty($key, $value)
338 {
339 if (! $this->exists($key)) {
340 $this->set($key, $value);
341 }
342 }
343
344 /**
345 * @return ConfigIO
346 */
347 public function getConfigIO()
348 {
349 return $this->configIO;
350 }
351
352 /**
353 * @param ConfigIO $configIO
354 */
355 public function setConfigIO($configIO)
356 {
357 $this->configIO = $configIO;
358 }
359}
360
361/**
362 * Exception used if a mandatory field is missing in given configuration.
363 */
364class MissingFieldConfigException extends Exception
365{
366 public $field;
367
368 /**
369 * Construct exception.
370 *
371 * @param string $field field name missing.
372 */
373 public function __construct($field)
374 {
375 $this->field = $field;
376 $this->message = 'Configuration value is required for '. $this->field;
377 }
378}
379
380/**
381 * Exception used if an unauthorized attempt to edit configuration has been made.
382 */
383class UnauthorizedConfigException extends Exception
384{
385 /**
386 * Construct exception.
387 */
388 public function __construct()
389 {
390 $this->message = 'You are not authorized to alter config.';
391 }
392}
diff --git a/application/config/ConfigPhp.php b/application/config/ConfigPhp.php
new file mode 100644
index 00000000..27187b66
--- /dev/null
+++ b/application/config/ConfigPhp.php
@@ -0,0 +1,132 @@
1<?php
2
3/**
4 * Class ConfigPhp (ConfigIO implementation)
5 *
6 * Handle Shaarli's legacy PHP configuration file.
7 * Note: this is only designed to support the transition to JSON configuration.
8 */
9class ConfigPhp implements ConfigIO
10{
11 /**
12 * @var array List of config key without group.
13 */
14 public static $ROOT_KEYS = array(
15 'login',
16 'hash',
17 'salt',
18 'timezone',
19 'title',
20 'titleLink',
21 'redirector',
22 'disablesessionprotection',
23 'privateLinkByDefault',
24 );
25
26 /**
27 * Map legacy config keys with the new ones.
28 * If ConfigPhp is used, getting <newkey> will actually look for <legacykey>.
29 * The Updater will use this array to transform keys when switching to JSON.
30 *
31 * @var array current key => legacy key.
32 */
33 public static $LEGACY_KEYS_MAPPING = array(
34 'credentials.login' => 'login',
35 'credentials.hash' => 'hash',
36 'credentials.salt' => 'salt',
37 'resource.data_dir' => 'config.DATADIR',
38 'resource.config' => 'config.CONFIG_FILE',
39 'resource.datastore' => 'config.DATASTORE',
40 'resource.updates' => 'config.UPDATES_FILE',
41 'resource.log' => 'config.LOG_FILE',
42 'resource.update_check' => 'config.UPDATECHECK_FILENAME',
43 'resource.raintpl_tpl' => 'config.RAINTPL_TPL',
44 'resource.raintpl_tmp' => 'config.RAINTPL_TMP',
45 'resource.thumbnails_cache' => 'config.CACHEDIR',
46 'resource.page_cache' => 'config.PAGECACHE',
47 'resource.ban_file' => 'config.IPBANS_FILENAME',
48 'security.session_protection_disabled' => 'disablesessionprotection',
49 'security.ban_after' => 'config.BAN_AFTER',
50 'security.ban_duration' => 'config.BAN_DURATION',
51 'general.title' => 'title',
52 'general.timezone' => 'timezone',
53 'general.header_link' => 'titleLink',
54 'updates.check_updates' => 'config.ENABLE_UPDATECHECK',
55 'updates.check_updates_branch' => 'config.UPDATECHECK_BRANCH',
56 'updates.check_updates_interval' => 'config.UPDATECHECK_INTERVAL',
57 'privacy.default_private_links' => 'privateLinkByDefault',
58 'feed.rss_permalinks' => 'config.ENABLE_RSS_PERMALINKS',
59 'general.links_per_page' => 'config.LINKS_PER_PAGE',
60 'thumbnail.enable_thumbnails' => 'config.ENABLE_THUMBNAILS',
61 'thumbnail.enable_localcache' => 'config.ENABLE_LOCALCACHE',
62 'general.enabled_plugins' => 'config.ENABLED_PLUGINS',
63 'redirector.url' => 'redirector',
64 'redirector.encode_url' => 'config.REDIRECTOR_URLENCODE',
65 'feed.show_atom' => 'config.SHOW_ATOM',
66 'privacy.hide_public_links' => 'config.HIDE_PUBLIC_LINKS',
67 'privacy.hide_timestamps' => 'config.HIDE_TIMESTAMPS',
68 'security.open_shaarli' => 'config.OPEN_SHAARLI',
69 );
70
71 /**
72 * @inheritdoc
73 */
74 function read($filepath)
75 {
76 if (! file_exists($filepath) || ! is_readable($filepath)) {
77 return array();
78 }
79
80 include $filepath;
81
82 $out = array();
83 foreach (self::$ROOT_KEYS as $key) {
84 $out[$key] = $GLOBALS[$key];
85 }
86 $out['config'] = $GLOBALS['config'];
87 $out['plugins'] = !empty($GLOBALS['plugins']) ? $GLOBALS['plugins'] : array();
88 return $out;
89 }
90
91 /**
92 * @inheritdoc
93 */
94 function write($filepath, $conf)
95 {
96 $configStr = '<?php '. PHP_EOL;
97 foreach (self::$ROOT_KEYS as $key) {
98 if (isset($conf[$key])) {
99 $configStr .= '$GLOBALS[\'' . $key . '\'] = ' . var_export($conf[$key], true) . ';' . PHP_EOL;
100 }
101 }
102
103 // Store all $conf['config']
104 foreach ($conf['config'] as $key => $value) {
105 $configStr .= '$GLOBALS[\'config\'][\''. $key .'\'] = '.var_export($conf['config'][$key], true).';'. PHP_EOL;
106 }
107
108 if (isset($conf['plugins'])) {
109 foreach ($conf['plugins'] as $key => $value) {
110 $configStr .= '$GLOBALS[\'plugins\'][\''. $key .'\'] = '.var_export($conf['plugins'][$key], true).';'. PHP_EOL;
111 }
112 }
113
114 if (!file_put_contents($filepath, $configStr)
115 || strcmp(file_get_contents($filepath), $configStr) != 0
116 ) {
117 throw new IOException(
118 $filepath,
119 'Shaarli could not create the config file.
120 Please make sure Shaarli has the right to write in the folder is it installed in.'
121 );
122 }
123 }
124
125 /**
126 * @inheritdoc
127 */
128 function getExtension()
129 {
130 return '.php';
131 }
132}
diff --git a/application/config/ConfigPlugin.php b/application/config/ConfigPlugin.php
new file mode 100644
index 00000000..047d2b03
--- /dev/null
+++ b/application/config/ConfigPlugin.php
@@ -0,0 +1,120 @@
1<?php
2/**
3 * Plugin configuration helper functions.
4 *
5 * Note: no access to configuration files here.
6 */
7
8/**
9 * Process plugin administration form data and save it in an array.
10 *
11 * @param array $formData Data sent by the plugin admin form.
12 *
13 * @return array New list of enabled plugin, ordered.
14 *
15 * @throws PluginConfigOrderException Plugins can't be sorted because their order is invalid.
16 */
17function save_plugin_config($formData)
18{
19 // Make sure there are no duplicates in orders.
20 if (!validate_plugin_order($formData)) {
21 throw new PluginConfigOrderException();
22 }
23
24 $plugins = array();
25 $newEnabledPlugins = array();
26 foreach ($formData as $key => $data) {
27 if (startsWith($key, 'order')) {
28 continue;
29 }
30
31 // If there is no order, it means a disabled plugin has been enabled.
32 if (isset($formData['order_' . $key])) {
33 $plugins[(int) $formData['order_' . $key]] = $key;
34 }
35 else {
36 $newEnabledPlugins[] = $key;
37 }
38 }
39
40 // New enabled plugins will be added at the end of order.
41 $plugins = array_merge($plugins, $newEnabledPlugins);
42
43 // Sort plugins by order.
44 if (!ksort($plugins)) {
45 throw new PluginConfigOrderException();
46 }
47
48 $finalPlugins = array();
49 // Make plugins order continuous.
50 foreach ($plugins as $plugin) {
51 $finalPlugins[] = $plugin;
52 }
53
54 return $finalPlugins;
55}
56
57/**
58 * Validate plugin array submitted.
59 * Will fail if there is duplicate orders value.
60 *
61 * @param array $formData Data from submitted form.
62 *
63 * @return bool true if ok, false otherwise.
64 */
65function validate_plugin_order($formData)
66{
67 $orders = array();
68 foreach ($formData as $key => $value) {
69 // No duplicate order allowed.
70 if (in_array($value, $orders)) {
71 return false;
72 }
73
74 if (startsWith($key, 'order')) {
75 $orders[] = $value;
76 }
77 }
78
79 return true;
80}
81
82/**
83 * Affect plugin parameters values into plugins array.
84 *
85 * @param mixed $plugins Plugins array ($plugins[<plugin_name>]['parameters']['param_name'] = <value>.
86 * @param mixed $conf Plugins configuration.
87 *
88 * @return mixed Updated $plugins array.
89 */
90function load_plugin_parameter_values($plugins, $conf)
91{
92 $out = $plugins;
93 foreach ($plugins as $name => $plugin) {
94 if (empty($plugin['parameters'])) {
95 continue;
96 }
97
98 foreach ($plugin['parameters'] as $key => $param) {
99 if (!empty($conf[$key])) {
100 $out[$name]['parameters'][$key] = $conf[$key];
101 }
102 }
103 }
104
105 return $out;
106}
107
108/**
109 * Exception used if an error occur while saving plugin configuration.
110 */
111class PluginConfigOrderException extends Exception
112{
113 /**
114 * Construct exception.
115 */
116 public function __construct()
117 {
118 $this->message = 'An error occurred while trying to save plugins loading order.';
119 }
120}