*/ public static function abilities(): array { return [ new Ability( 'sarcio/settings-list', __('Look at plugin settings', 'sarcio'), Ability::READ, 'The settings plugins and WordPress registered with the Settings API, grouped by plugin or screen, with their current values. Secrets (keys, tokens, passwords) are shown as hidden and cannot be changed from here. Use the exact name with settings-set.', ['properties' => ['search' => ['maxLength' => 100, 'type' => 'string']], 'required' => [], 'type' => 'object'], static fn (array $args): bool => current_user_can('manage_options'), static fn (array $args): array => self::list((string) ($args['search'] ?? '')), ), new Ability( 'sarcio/settings-set', __('Change a plugin setting', 'sarcio'), Ability::WRITE, 'Change one registered setting by its exact name from settings-list. The value goes through the plugin’s own checks. Booleans take true/false, numbers a number, text a string, and a setting shown as an object or list takes the whole object or list.', [ 'properties' => [ 'name' => ['maxLength' => 191, 'minLength' => 1, 'type' => 'string'], 'value' => ['type' => ['string', 'number', 'boolean', 'object', 'array', 'integer']], ], 'required' => ['name', 'value'], 'type' => 'object', ], static fn (array $args): bool => current_user_can('manage_options'), static fn (array $args): array => self::set($args), null, static fn (array $before, array $after): array => self::restore($before), false, static fn (array $args, Overlay $overlay): array => self::stage($args, $overlay), ), ]; } /** @return array> name => the register_setting args, minus Sarcio's own */ public static function registered(): array { if (!function_exists('get_registered_settings')) { return []; } $all = get_registered_settings(); if (function_exists('register_initial_settings') && !isset($all['blogdescription'])) { register_initial_settings(); $all = get_registered_settings(); } return array_filter(is_array($all) ? $all : [], static fn ($args, $name): bool => is_array($args) && !str_starts_with((string) $name, 'sarcio'), ARRAY_FILTER_USE_BOTH); } public static function isSecret(string $name): bool { return (bool) preg_match(self::SECRET_PATTERN, $name); } private static function list(string $search): array { $out = []; $needle = mb_strtolower(trim($search)); foreach (self::registered() as $name => $args) { $group = (string) ($args['group'] ?? ''); $description = (string) ($args['description'] ?? ''); if ($needle !== '' && !str_contains(mb_strtolower($name . ' ' . $group . ' ' . $description), $needle)) { continue; } $secret = self::isSecret((string) $name); $out[] = [ 'description' => $description, 'group' => $group, 'name' => (string) $name, 'secret' => $secret, 'type' => (string) ($args['type'] ?? 'string'), 'value' => $secret ? '(hidden)' : self::present(get_option((string) $name, $args['default'] ?? null)), ]; } return ['settings' => array_slice($out, 0, 150), 'total' => count($out)]; } /** A value as the model should see it: scalars as they are, structures as JSON, long ones cut. */ public static function present(mixed $value): mixed { if (is_scalar($value) || $value === null) { return is_string($value) ? mb_substr($value, 0, self::VALUE_LIMIT) : $value; } $json = (string) wp_json_encode($value); return mb_strlen($json) > self::VALUE_LIMIT ? mb_substr($json, 0, self::VALUE_LIMIT) . '…' : $value; } /** * @param array $args * @return array{name:string,type:string,current:mixed,value:mixed}|array{error:string} */ private static function resolve(array $args): array { $name = (string) ($args['name'] ?? ''); $registered = self::registered(); if (!isset($registered[$name])) { return ['error' => __('That is not a registered setting on this site. Read settings-list for the exact names.', 'sarcio')]; } if (self::isSecret($name)) { return ['error' => __('That setting holds a credential; change it on the plugin’s own settings page.', 'sarcio')]; } $type = (string) ($registered[$name]['type'] ?? 'string'); $value = self::coerce($args['value'] ?? null, $type); if (isset($value['error'])) { return ['error' => $value['error']]; } return ['current' => get_option($name, $registered[$name]['default'] ?? null), 'name' => $name, 'type' => $type, 'value' => $value['value']]; } /** @return array{value:mixed}|array{error:string} */ private static function coerce(mixed $value, string $type): array { switch ($type) { case 'boolean': if (is_bool($value)) { return ['value' => $value]; } if (in_array($value, ['true', 'false', '1', '0', 1, 0, 'yes', 'no', 'on', 'off'], true)) { return ['value' => in_array($value, ['true', '1', 1, 'yes', 'on'], true)]; } return ['error' => __('That setting takes true or false.', 'sarcio')]; case 'integer': return is_numeric($value) ? ['value' => (int) $value] : ['error' => __('That setting takes a whole number.', 'sarcio')]; case 'number': return is_numeric($value) ? ['value' => (float) $value] : ['error' => __('That setting takes a number.', 'sarcio')]; case 'array': case 'object': return is_array($value) ? ['value' => $value] : ['error' => __('That setting takes the whole list or object.', 'sarcio')]; default: return is_scalar($value) || $value === null ? ['value' => is_string($value) ? wp_kses_post((string) $value) : (string) $value] : ['error' => __('That setting takes text.', 'sarcio')]; } } /** @param array $args */ private static function set(array $args): array { $resolved = self::resolve($args); if (isset($resolved['error'])) { return $resolved; } if ($resolved['current'] === $resolved['value']) { return ['error' => __('Nothing would change.', 'sarcio')]; } $existed = get_option($resolved['name'], null) !== null; // Other plugins listen to the option's update hook and may throw there // (Elementor does, syncing its kit); by then the value is written, so // the change is journaled either way and the reaction is reported. $reaction = null; try { $written = update_option($resolved['name'], $resolved['value']); } catch (\Throwable $error) { $written = get_option($resolved['name'], null) === $resolved['value']; $reaction = sanitize_text_field($error->getMessage()); } if (!$written) { return ['error' => __('WordPress did not accept that value.', 'sarcio')]; } $stored = get_option($resolved['name']); return ($reaction !== null ? ['note' => sprintf( /* translators: %s: the message another plugin raised. */ __('The setting was changed, and another plugin reacted with an error: %s', 'sarcio'), $reaction, )] : []) + [ 'journal' => [ 'after' => ['existed' => true, 'name' => $resolved['name'], 'value' => $stored], 'before' => ['existed' => $existed, 'name' => $resolved['name'], 'value' => $resolved['current']], 'summary' => sprintf( /* translators: 1: the setting name, 2: the new value. */ __('Set “%1$s” to %2$s', 'sarcio'), $resolved['name'], self::short($stored), ), 'target' => 'setting:' . $resolved['name'], ], 'name' => $resolved['name'], 'value' => self::present($stored), ]; } /** @param array $args */ private static function stage(array $args, Overlay $overlay): array { $resolved = self::resolve($args); if (isset($resolved['error'])) { return $resolved; } $current = $overlay->option($resolved['name'], $resolved['current']); if ($current === $resolved['value']) { return ['error' => __('Nothing would change.', 'sarcio')]; } $overlay->options[$resolved['name']] = $resolved['value']; return [ 'after' => $resolved['name'] . ': ' . self::short($resolved['value']), 'before' => $resolved['name'] . ': ' . self::short($current), 'summary' => sprintf( /* translators: %s: the setting name. */ __('Change the setting “%s”', 'sarcio'), $resolved['name'], ), ]; } /** @param array $before */ private static function restore(array $before): array { $name = (string) ($before['name'] ?? ''); if ($name === '' || self::isSecret($name)) { return ['error' => __('That setting cannot be restored from here.', 'sarcio')]; } try { if (empty($before['existed'])) { delete_option($name); } else { update_option($name, $before['value'] ?? null); } } catch (\Throwable) { $back = get_option($name, null); if ((empty($before['existed']) && $back !== null) || (!empty($before['existed']) && $back !== ($before['value'] ?? null))) { return ['error' => __('WordPress could not restore that setting.', 'sarcio')]; } } return ['name' => $name, 'restored' => true]; } private static function short(mixed $value): string { $text = (string) wp_json_encode($value); return mb_strlen($text) > 120 ? mb_substr($text, 0, 120) . '…' : $text; } }