*/ private array $defaults = [ 'version' => self::CURRENT_VERSION, 'ui' => [ 'show_admin_bar_button' => true, ], 'access' => [ 'roles_allowed' => ['administrator'], ], 'logging' => [ 'enabled' => false, 'levels' => ['error'], ], 'rules' => [], ]; /** * Options loaded during this request. * * @var array|null */ private ?array $cache = null; /** * Get all options. * * @since 1.0.0 * @return array All plugin options. */ public function get_all(): array { if ($this->cache === null) { $options = get_option(self::OPTION_NAME, []); $this->cache = wp_parse_args(is_array($options) ? $options : [], $this->defaults); } return $this->cache; } /** * Save all options. * * @since 1.0.0 * @param array $options Options to save. * @return bool True on success, false on failure. */ public function save_all(array $options): bool { $options['version'] = self::CURRENT_VERSION; $this->cache = wp_parse_args($options, $this->defaults); return update_option(self::OPTION_NAME, $options); } /** * Get a specific option value. * * @since 1.0.0 * @param string $key Option key (supports dot notation). * @param mixed $default Default value if option doesn't exist. * @return mixed Option value or default. */ public function get_option(string $key, $default = null) { $options = $this->get_all(); foreach (explode('.', $key) as $k) { if (!is_array($options) || !array_key_exists($k, $options)) { return $default; } $options = $options[$k]; } return $options; } /** * Set a specific option value. * * @since 1.0.0 * @param string $key Option key (supports dot notation). * @param mixed $value Value to set. * @return bool True on success, false on failure. */ public function set_option(string $key, $value): bool { $options = $this->get_all(); $current = &$options; foreach (explode('.', $key) as $k) { if (!isset($current[$k]) || !is_array($current[$k])) { $current[$k] = []; } $current = &$current[$k]; } $current = $value; return $this->save_all($options); } /** * Get all rules, keyed by rule ID. * * @since 1.0.0 * @return array> Array of rules. */ public function get_rules(): array { $stored = $this->get_option('rules', []); if (!is_array($stored)) { return []; } $rules = []; foreach ($stored as $key => $rule) { if (!is_array($rule) || empty($rule['xpath'])) { continue; } $id = $this->resolve_rule_id($rule, $key); $rules[$id] = $this->normalize_rule($rule, $id); } return $rules; } /** * Get a specific rule by ID. * * @since 1.0.0 * @param string $rule_id Rule identifier. * @return array|null Rule data or null if not found. */ public function get_rule(string $rule_id): ?array { return $this->get_rules()[$rule_id] ?? null; } /** * Determine the ID of a stored rule. * * Records written by releases before 1.0.29 are stored under a numeric key * and carry no ID. Such records get an ID derived from their XPath, so the * value stays the same until the migration rewrites the option. * * @since 1.0.29 * @param array $rule Rule data. * @param int|string $key Key the rule is stored under. * @return string Rule ID. */ private function resolve_rule_id(array $rule, $key): string { $id = (string) ($rule['id'] ?? ''); if ($id !== '') { return $id; } if (is_string($key) && $key !== '') { return $key; } return 'rule_' . md5((string) $rule['xpath']); } /** * Bring a stored rule to the current schema. * * @since 1.0.0 * @param array $rule Rule data. * @param string $rule_id Rule ID. * @return array Complete rule data. */ private function normalize_rule(array $rule, string $rule_id): array { $label = $rule['label'] ?? $rule['description'] ?? $rule['page_info'] ?? ''; $active = $rule['active'] ?? $rule['enabled'] ?? true; // Rules stored before 1.0.31 carry no scope and keep applying everywhere. $scope = (string) ($rule['scope'] ?? self::SCOPE_ANY); if (!in_array($scope, [self::SCOPE_ANY, self::SCOPE_PAGE], true)) { $scope = self::SCOPE_ANY; } return [ 'id' => $rule_id, 'xpath' => (string) ($rule['xpath'] ?? ''), 'label' => (string) $label, 'active' => (bool) $active, 'expires_at' => (int) ($rule['expires_at'] ?? 0), 'created_at' => (string) ($rule['created_at'] ?? ''), 'created_by' => (string) ($rule['created_by'] ?? ''), 'updated_at' => (int) ($rule['updated_at'] ?? 0), 'page_title' => (string) ($rule['page_title'] ?? ''), 'page_url' => (string) ($rule['page_url'] ?? ''), 'css_selector' => (string) ($rule['css_selector'] ?? ''), 'strategy' => (string) ($rule['strategy'] ?? ''), 'scope' => $scope, ]; } /** * Add a new rule. * * @since 1.0.0 * @param array $data Rule data. * @return array The added rule with ID. */ public function add_rule(array $data): array { $rules = $this->get_rules(); $rule_id = 'rule_' . wp_generate_uuid4(); $rule = $this->normalize_rule( array_merge($data, [ 'created_at' => current_time('mysql'), 'created_by' => wp_get_current_user()->user_login ?: 'system', ]), $rule_id ); $rules[$rule_id] = $rule; $this->set_option('rules', $rules); return $rule; } /** * Update an existing rule. * * @since 1.0.0 * @param string $rule_id Rule identifier. * @param array $rule_data Rule data. * @return bool True on success, false on failure. */ public function update_rule(string $rule_id, array $rule_data): bool { $rules = $this->get_rules(); if (!isset($rules[$rule_id])) { return false; } $rules[$rule_id] = $this->normalize_rule( array_merge($rules[$rule_id], $rule_data, ['updated_at' => time()]), $rule_id ); return $this->set_option('rules', $rules); } /** * Delete a rule. * * @since 1.0.0 * @param string $rule_id Rule identifier. * @return bool True on success, false on failure. */ public function delete_rule(string $rule_id): bool { $rules = $this->get_rules(); if (!isset($rules[$rule_id])) { return false; } unset($rules[$rule_id]); return $this->set_option('rules', $rules); } /** * Get active rules (not expired and enabled). * * @since 1.0.0 * @return array> Array of active rules. */ public function get_active_rules(): array { $active_rules = []; $now = time(); foreach ($this->get_rules() as $rule_id => $rule) { if (!$rule['active']) { continue; } if ($rule['expires_at'] > 0 && $rule['expires_at'] < $now) { continue; } $active_rules[$rule_id] = $rule; } return $active_rules; } /** * Clean up expired rules. * * @since 1.0.0 * @return int Number of cleaned up rules. */ public function cleanup_expired_rules(): int { $rules = $this->get_rules(); $now = time(); $cleaned_count = 0; foreach ($rules as $rule_id => $rule) { if ($rule['expires_at'] > 0 && $rule['expires_at'] < $now) { unset($rules[$rule_id]); $cleaned_count++; } } if ($cleaned_count > 0) { $this->set_option('rules', $rules); } return $cleaned_count; } /** * Ensure default options exist. * * @since 1.0.0 * @return bool True if defaults were set, false if they already existed. */ public function ensure_defaults(): bool { if (get_option(self::OPTION_NAME, false) === false) { return $this->save_all($this->defaults); } return false; } /** * Migrate stored data to the current schema when needed. * * @since 1.0.0 */ public function maybe_migrate(): void { $options = $this->get_all(); $stored_version = (string) ($options['version'] ?? '0.0.0'); if (version_compare($stored_version, self::CURRENT_VERSION, '>=')) { return; } // Rewrite rules with ID keys and current field names. $options['rules'] = $this->get_rules(); $this->save_all($options); // Log files used to live in a directory that was readable over HTTP. Logger::remove_legacy_log_directory(); } }