JezK
Edit File: OptionManager.php
<?php namespace GeminiLabs\SiteReviews\Database; use GeminiLabs\SiteReviews\Addons\Addon; use GeminiLabs\SiteReviews\Helper; use GeminiLabs\SiteReviews\Helpers\Arr; use GeminiLabs\SiteReviews\Helpers\Cast; use GeminiLabs\SiteReviews\Helpers\Str; use GeminiLabs\SiteReviews\Modules\Migrate; /** * @method array getArray(string $path = '', $fallback = []) * @method bool getBool(string $path = '', $fallback = false) * @method float getFloat(string $path = '', $fallback = 0.0) * @method int getInt(string $path = '', $fallback = 0) * @method string getString(string $path = '', $fallback = '') */ class OptionManager { protected static bool $persisting = false; /** * @return mixed */ public function __call(string $method, array $args = []) { if (!str_starts_with($method, 'get')) { throw new \BadMethodCallException("Method [$method] does not exist."); } $cast = strtolower((string) substr($method, 3)); if (!in_array($cast, ['array', 'bool', 'float', 'int', 'string'])) { throw new \BadMethodCallException("Method [$method] does not exist."); } $path = Arr::getAs('string', $args, 0); $fallback = Arr::get($args, 1); return call_user_func([$this, 'get'], $path, $fallback, $cast); } /** * The WP option key of an addon's own settings option. */ public static function addonKey(string $addonId): string { return Str::snakeCase($addonId); } /** * The registered addon instances, keyed by slug. * * @return Addon[] */ public static function addons(): array { $addons = []; foreach (array_keys(glsr()->addons) as $addonId) { $addon = glsr($addonId); if ($addon instanceof Addon) { $addons[$addon->slug] = $addon; } } return $addons; } public function all(): array { $settings = Arr::consolidate(glsr()->retrieve('settings')); if (empty($settings)) { $settings = $this->reset(); } return $settings; } public function clean(array $data = []): array { $settings = $this->kses($data); if (!empty(glsr()->settings)) { // access the property directly to prevent an infinite loop $savedSettings = $settings; $defaults = glsr()->defaults(); $defaults = Arr::flatten($defaults); $settings = Arr::flatten($settings); $settings = shortcode_atts($defaults, $settings); $settings = Arr::unflatten($settings); $settings = $this->restoreOrphanedSettings($settings, $savedSettings); } return $settings; } public static function databaseKey(?int $version = null): string { $versions = static::databaseKeys(); if (null === $version) { $version = glsr()->version('major'); } if (array_key_exists($version, $versions)) { return $versions[$version]; } return ''; } public static function databaseKeys(): array { $keys = []; $slug = Str::snakeCase(glsr()->id); $version = intval(glsr()->version('major')) + 1; while (--$version) { if ($version >= 7) { $keys[$version] = $slug; // remove version from settings key in versions >= 7.0 } elseif (1 === $version) { $keys[$version] = sprintf('geminilabs_%s_settings', $slug); } elseif (2 === $version) { $keys[$version] = sprintf('geminilabs_%s-v%s', $slug, $version); } else { $keys[$version] = sprintf('%s_v%s', $slug, $version); } } return $keys; } public static function flushSettingsCache(): void { $options = static::databaseKeys(); foreach (static::addons() as $addon) { $options[] = $addon->storageKey(); } $alloptions = wp_load_alloptions(true); $flushed = false; foreach (array_unique($options) as $option) { wp_cache_delete($option, 'options'); // the single-option cache can hold a stale copy too if (isset($alloptions[$option])) { unset($alloptions[$option]); $flushed = true; } } if ($flushed) { wp_cache_set('alloptions', $alloptions, 'options'); } } /** * @param mixed $fallback * * @return mixed */ public function get(string $path = '', $fallback = '', string $cast = '') { $settings = $this->all(); $option = Arr::get($settings, $path, $fallback); $path = ltrim(Str::removePrefix($path, 'settings'), '.'); if (!empty($path)) { $hook = 'option/'.str_replace('.', '/', $path); $option = glsr()->filter($hook, $option, $settings, $path); } return Cast::to($cast, $option); } /** * Whether a stored option value is an addon settings row this class wrote. * Every row split() or the addon migration writes has a top-level * "settings" key. * * @param mixed $stored */ public static function isAddonRow($stored): bool { return is_array($stored) && array_key_exists('settings', $stored); } /** * True while persist() is writing its already-split settings. The * settings-form sanitize callback (registered with register_setting, so * WP fires it on EVERY update_option of the core key) must stand down * for these writes. */ public static function isPersisting(): bool { return static::$persisting; } /** * Runs the callback with the persist guard raised: core-option writes * inside it skip the settings-form sanitize callback (migrations write * half-migrated trees that must not be resanitized). * * @return mixed */ public static function whilePersisting(callable $callback) { $wasPersisting = static::$persisting; static::$persisting = true; try { return $callback(); } finally { static::$persisting = $wasPersisting; } } /** * This is used when exporting the settings. */ public function json(): string { $all = $this->all(); $all['extra'] = glsr()->filterArray('export/settings/extra', []); // allow addons to export additional data return (string) wp_json_encode($all, \JSON_HEX_APOS | \JSON_HEX_QUOT | \JSON_HEX_TAG | \JSON_NUMERIC_CHECK | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE); } public function kses(array $data): array { $data = Arr::flatten($data); array_walk($data, function (&$value) { if (is_string($value)) { $value = wp_kses($value, wp_kses_allowed_html('post')); } }); $data = Arr::unflatten($data); return $data; } public function mergeDefaults(array $defaults): void { $saved = Arr::consolidate($this->wp(static::databaseKey(), [])); $saved = $this->compose($saved); // registered addon settings live in their own options $defaults = Arr::flatten(Arr::getAs('array', $defaults, 'settings')); $settings = Arr::flatten(Arr::getAs('array', $saved, 'settings')); if (empty($defaults) || empty(array_diff_key($defaults, $settings))) { return; } $settings = shortcode_atts($defaults, $settings); $settings = Arr::unflatten($settings); $settings['strings'] = Arr::consolidate(Arr::get($saved, 'settings.strings')); $saved['settings'] = $settings; $this->replace($saved); } public function normalize(array $data = []): array { $settings = $this->kses($data); if (!empty(glsr()->settings)) { // access the property directly to prevent an infinite loop $defaults = glsr()->defaults(); $defaults = Arr::flatten($defaults); $settings = Arr::flatten($settings); $settings = wp_parse_args($settings, $defaults); $settings = Arr::unflatten($settings); } return $settings; } public function previous(): array { static::flushSettingsCache(); foreach (static::databaseKeys() as $version => $databaseKey) { if ($version === intval(glsr()->version('major'))) { continue; } $settings = Arr::consolidate(get_option($databaseKey)); if (!empty(array_filter(Arr::getAs('array', $settings, 'settings')))) { return $settings; } } return []; } public function replace(array $settings): bool { if (empty($settings)) { return false; } $settings = $this->normalize($settings); if (!$this->persist($settings)) { return false; } $this->reset(); return true; } public function reset(): array { $settings = Arr::consolidate($this->wp(static::databaseKey(), [])); if (empty($settings)) { delete_option(static::databaseKey()); } $settings = $this->compose($settings); $settings = $this->normalize($settings); glsr()->store('settings', $settings); return $settings; } /** * @param mixed $value */ public function set(string $path, $value = ''): bool { $settings = $this->all(); $settings = Arr::set($settings, $path, $value); $settings = $this->normalize($settings); if (!$this->persist($settings)) { return false; } glsr()->store('settings', $settings); return true; } /** * Splits the addon subtrees out of a composed settings array and writes each * to its addon's own option. Returns the remaining (core-only) settings. * This is the single write authority for addon settings; it is also used * directly by the Settings API sanitize callback, where WP itself persists * the returned remainder to the core plugin option. */ public function split(array $settings, bool &$changed = false): array { $writes = []; $versions = []; foreach (static::addons() as $slug => $addon) { if ($addon->hostedBy()) { continue; // the host claims its whole subtree, hosted values included } $path = $addon->settingsPath(); $values = Arr::get($settings, "settings.{$path}", null); if (!is_array($values)) { continue; } $key = $addon->storageKey(); if (!array_key_exists($key, $writes)) { $stored = get_option($key); if (is_array($stored) && !static::isAddonRow($stored)) { continue; // the addon's own data: the subtree stays in the parent's row } $writes[$key] = Arr::consolidate($stored); } $writes[$key] = Arr::set($writes[$key], $addon->storagePath(), $values); $versions[$key] = $addon->version; $settings = Arr::remove($settings, "settings.{$path}"); } foreach ($writes as $key => $value) { $storedVersion = Arr::getAs('string', $value, 'version', '0.0.0') ?: '0.0.0'; if ($versions[$key] !== $storedVersion) { // a write can see the upgrade before updateVersion() does; record it $value['version_upgraded_from'] = $storedVersion; } $value['version'] = $versions[$key]; if (update_option($key, $value, true)) { $changed = true; } } return $settings; } public function updateVersion(): void { // An absent version composes to '': the fallback fires only on a // missing key. An empty history means a fresh install. $version = $this->get('version', '0.0.0') ?: '0.0.0'; if (glsr()->version !== $version) { $this->set('version', glsr()->version); $this->set('version_upgraded_from', $version); } foreach (static::addons() as $addon) { if ($addon->hostedBy()) { continue; // hosted settings live in the host's row, which tracks them } $stored = get_option($addon->storageKey()); if (!static::isAddonRow($stored)) { continue; // no row until the first settings write, or a row the addon keeps for its own data } $storedVersion = Arr::getAs('string', $stored, 'version', '0.0.0') ?: '0.0.0'; if ($addon->version !== $storedVersion) { $stored['version'] = $addon->version; $stored['version_upgraded_from'] = $storedVersion; update_option($addon->storageKey(), $stored, true); } } } /** * @param mixed $fallback * * @return mixed */ public function wp(string $path, $fallback = '', string $cast = '') { $option = get_option($path, $fallback); return Cast::to($cast, Helper::ifEmpty($option, $fallback, $strict = true)); } /** * Mounts each registered addon's stored settings into the composed view. * Addons without their own row — not migrated yet, or the key holds the * addon's own data — are skipped so any legacy subtree in the core option * remains visible. */ protected function compose(array $settings): array { foreach (static::addons() as $slug => $addon) { if ($addon->hostedBy()) { continue; // mounted by its host's whole subtree } $stored = get_option($addon->storageKey()); if (!static::isAddonRow($stored)) { continue; } $values = Arr::getAs('array', Arr::consolidate($stored), $addon->storagePath()); $settings = Arr::set($settings, "settings.{$addon->settingsPath()}", $values); } return $settings; } protected function persist(array $settings): bool { $changed = false; // save/restore, not toggle: persist() can run inside whilePersisting() $wasPersisting = static::$persisting; static::$persisting = true; try { $settings = $this->split($settings, $changed); // A write that only touches addon options legitimately leaves the core // option unchanged — update_option() returning false there is not a // failure, so a successful addon write counts as a persisted change. return update_option(static::databaseKey(), $settings, true) || $changed; } finally { static::$persisting = $wasPersisting; } } /** * This restores orphaned settings in cases where addons have been deactivated, etc. */ protected function restoreOrphanedSettings(array $settings, array $saved): array { $defaults = glsr()->defaults(); $settings = Arr::set($settings, 'settings.strings', Arr::get($saved, 'settings.strings', [])); foreach (Arr::get($saved, 'settings.addons', []) as $addon => $values) { if (!isset($defaults['settings']['addons'][$addon])) { $settings['settings']['addons'][$addon] = $values; } } foreach (static::addons() as $slug => $addon) { if (!$addon->isHost()) { continue; } // A disabled feature never registers, so its settings config is // not loaded and its keys are absent from the defaults; preserve // its stored values key-by-key $savedTree = Arr::flatten(Arr::getAs('array', $saved, "settings.{$slug}")); $defaultTree = Arr::flatten(Arr::getAs('array', $defaults, "settings.{$slug}")); foreach (array_diff_key($savedTree, $defaultTree) as $path => $value) { $settings = Arr::set($settings, "settings.{$slug}.{$path}", $value); } } foreach (Arr::get($saved, 'settings.integrations', []) as $integration => $values) { if (!isset($defaults['settings']['integrations'][$integration])) { $settings['settings']['integrations'][$integration] = $values; } } foreach (Arr::get($saved, 'settings.licenses', []) as $addon => $value) { if (!isset($defaults['settings']['licenses'][$addon])) { $settings['settings']['licenses'][$addon] = $value; } } return $settings; } }