diff --git a/abilities/class-ability-update-settings.php b/abilities/class-ability-update-settings.php index 96d0d4dd0..564daa03d 100644 --- a/abilities/class-ability-update-settings.php +++ b/abilities/class-ability-update-settings.php @@ -103,13 +103,14 @@ public function execute( $input = null ) { // Build allowlist of {section}_{field} keys from registered settings, // and a parallel list of which keys correspond to checkbox fields. - // We need the latter because Settings::sanitize_setting_by_field_type() + // We need the latter because Settings_Sanitizer::sanitize_setting_by_field_type() // only accepts numeric values for checkboxes (is_numeric() rejects PHP // booleans), so true/false from a JSON client would otherwise be // silently coerced to '' instead of 0/1. $valid_keys = array(); $checkbox_keys = array(); - foreach ( $this->plugin->settings->get_fields() as $section => $section_data ) { + $fields = $this->plugin->settings->registry->get_fields(); + foreach ( $fields as $section => $section_data ) { if ( empty( $section_data['fields'] ) || ! is_array( $section_data['fields'] ) ) { continue; } @@ -147,7 +148,7 @@ public function execute( $input = null ) { // Run only the incoming keys through Stream's sanitize pipeline so // values are normalized to their declared field type, then merge over // the existing options so unrelated keys are preserved. - $sanitized = $this->plugin->settings->sanitize_settings( $filtered ); + $sanitized = $this->plugin->settings->sanitizer->sanitize_settings( $filtered, $fields ); $merged = array_merge( $current, $sanitized ); // update_all_setting_values() persists to the correct store and diff --git a/classes/class-admin-purge.php b/classes/class-admin-purge.php index d6d6485e5..f3b7056a9 100644 --- a/classes/class-admin-purge.php +++ b/classes/class-admin-purge.php @@ -495,14 +495,14 @@ public function purge_scheduled_action() { return; } - $defaults = $this->admin->plugin->settings->get_defaults(); + $defaults = $this->admin->plugin->settings->registry->get_defaults(); if ( $this->admin->plugin->is_multisite_network_activated() ) { $options = wp_parse_args( (array) get_site_option( 'wp_stream_network', array() ), $defaults ); } else { $options = wp_parse_args( (array) get_option( 'wp_stream', array() ), $defaults ); } - // TTL fallback. Settings::get_defaults() runs every settings field + // TTL fallback. Settings_Registry::get_defaults() runs every settings field // through the `wp_stream_settings_option_fields` filter, which // Network::get_network_admin_fields() uses to strip the `records_ttl` // field from the per-site option's defaults set. When this callback runs diff --git a/classes/class-admin-screen-settings.php b/classes/class-admin-screen-settings.php index 39202dcb1..e0e4a2510 100644 --- a/classes/class-admin-screen-settings.php +++ b/classes/class-admin-screen-settings.php @@ -59,7 +59,7 @@ public function render_settings_page() { $page_description = apply_filters( 'wp_stream_settings_form_description', '' ); - $sections = $this->admin->plugin->settings->get_fields(); + $sections = $this->admin->plugin->settings->registry->get_fields(); $active_tab = wp_stream_filter_input( INPUT_GET, 'tab' ); $this->admin->plugin->enqueue_asset( diff --git a/classes/class-network.php b/classes/class-network.php index 8e7d0f3bb..4aab616f0 100644 --- a/classes/class-network.php +++ b/classes/class-network.php @@ -355,7 +355,7 @@ public function get_settings_translations( $labels ) { $labels[ $network_key ] = array(); } - foreach ( $this->plugin->settings->get_fields() as $section_slug => $section ) { + foreach ( $this->plugin->settings->registry->get_fields() as $section_slug => $section ) { foreach ( $section['fields'] as $field ) { $labels[ $network_key ][ sprintf( '%s_%s', $section_slug, $field['name'] ) ] = $field['title']; } @@ -395,14 +395,14 @@ public function network_options_action() { $value = array(); $posted_options = isset( $_POST[ $option ] ) && is_array( $_POST[ $option ] ) ? wp_unslash( $_POST[ $option ] ) : array(); - $sections = $this->plugin->settings->get_fields(); + $sections = $this->plugin->settings->registry->get_fields(); foreach ( $sections as $section_name => $section ) { foreach ( $section['fields'] as $field_idx => $field ) { $option_key = $section_name . '_' . $field['name']; if ( isset( $posted_options[ $option_key ] ) ) { - $value[ $option_key ] = $this->plugin->settings->sanitize_setting_by_field_type( $posted_options[ $option_key ], $field['type'] ); + $value[ $option_key ] = $this->plugin->settings->sanitizer->sanitize_setting_by_field_type( $posted_options[ $option_key ], $field['type'] ); } else { $value[ $option_key ] = false; } diff --git a/classes/class-settings-registry.php b/classes/class-settings-registry.php new file mode 100644 index 000000000..61a3ea42f --- /dev/null +++ b/classes/class-settings-registry.php @@ -0,0 +1,387 @@ + array( + 'title' => esc_html__( 'General', 'stream' ), + 'fields' => array( + array( + 'name' => 'role_access', + 'title' => esc_html__( 'Role Access', 'stream' ), + 'type' => 'multi_checkbox', + 'desc' => esc_html__( 'Users from the selected roles above will have permission to view Stream Records. However, only site Administrators can access Stream Settings.', 'stream' ), + 'choices' => self::get_roles(), + 'default' => array( 'administrator' ), + ), + array( + 'name' => 'records_ttl', + 'title' => esc_html__( 'Keep Records for', 'stream' ), + 'type' => 'number', + 'class' => 'small-text', + 'desc' => esc_html__( 'Maximum number of days to keep activity records.', 'stream' ), + 'default' => 30, + 'min' => 1, + 'max' => 999, + 'step' => 1, + 'after_field' => esc_html__( 'days', 'stream' ), + ), + array( + 'name' => 'keep_records_indefinitely', + 'title' => esc_html__( 'Keep Records Indefinitely', 'stream' ), + 'type' => 'checkbox', + 'desc' => sprintf( '%s %s', esc_html__( 'Not recommended.', 'stream' ), esc_html__( 'Purging old records helps to keep your WordPress installation running optimally.', 'stream' ) ), + 'after_field' => esc_html__( 'Enabled', 'stream' ), + 'default' => 0, + ), + ), + ), + 'exclude' => array( + 'title' => esc_html__( 'Exclude', 'stream' ), + 'fields' => array( + array( + 'name' => 'rules', + 'title' => esc_html__( 'Exclude Rules', 'stream' ), + 'type' => 'rule_list', + 'desc' => esc_html__( 'Create rules to exclude certain kinds of activity from being recorded by Stream.', 'stream' ), + 'default' => array(), + 'nonce' => 'stream_get_ips', + ), + ), + ), + 'advanced' => array( + 'title' => esc_html__( 'Advanced', 'stream' ), + 'fields' => array( + array( + 'name' => 'comment_flood_tracking', + 'title' => esc_html__( 'Comment Flood Tracking', 'stream' ), + 'type' => 'checkbox', + 'desc' => esc_html__( 'WordPress will automatically prevent duplicate comments from flooding the database. By default, Stream does not track these attempts unless you opt-in here. Enabling this is not necessary or recommended for most sites.', 'stream' ), + 'after_field' => esc_html__( 'Enabled', 'stream' ), + 'default' => 0, + ), + $this->build_delete_all_records_field(), + $this->build_clean_orphan_meta_field(), + ), + ), + ); + + // If Akismet is active, allow Admins to opt-in to Akismet tracking. + if ( class_exists( 'Akismet' ) ) { + $akismet_tracking = array( + 'name' => 'akismet_tracking', + 'title' => esc_html__( 'Akismet Tracking', 'stream' ), + 'type' => 'checkbox', + 'desc' => esc_html__( 'Akismet already keeps statistics for comment attempts that it blocks as SPAM. By default, Stream does not track these attempts unless you opt-in here. Enabling this is not necessary or recommended for most sites.', 'stream' ), + 'after_field' => esc_html__( 'Enabled', 'stream' ), + 'default' => 0, + ); + + array_push( $fields['advanced']['fields'], $akismet_tracking ); + } + + $wp_cron_tracking = array( + 'name' => 'wp_cron_tracking', + 'title' => esc_html__( 'WP Cron Tracking', 'stream' ), + 'type' => 'checkbox', + 'desc' => esc_html__( 'By default, Stream does not track activity performed by WordPress cron events unless you opt-in here. Enabling this is not necessary or recommended for most sites.', 'stream' ), + 'after_field' => esc_html__( 'Enabled', 'stream' ), + 'default' => 0, + ); + + array_push( $fields['advanced']['fields'], $wp_cron_tracking ); + + // Abilities API toggle is only meaningful on WordPress 6.9+. On + // network-activated multisite, Abilities::is_enabled() reads the + // network option (wp_stream_network), so a per-site checkbox on the + // site's own settings screen would be a no-op and misleading. Hide + // the field from per-site settings pages, but keep it available in + // network admin and in REST/CLI contexts where update_all_setting_values() + // routes writes to the network option correctly. + $hide_per_site = $this->plugin->is_network_activated() && is_admin() && ! is_network_admin(); + + if ( + class_exists( '\WP_Ability' ) + && ! $hide_per_site + ) { + $enable_abilities_api = array( + 'name' => 'enable_abilities_api', + 'title' => esc_html__( 'Enable Abilities API and MCP', 'stream' ), + 'type' => 'checkbox', + 'desc' => esc_html__( 'Expose Stream operations to AI agents via the WordPress Abilities API (and MCP when the MCP Adapter plugin is installed). Requires WordPress 6.9.', 'stream' ), + 'after_field' => esc_html__( 'Enabled', 'stream' ), + 'default' => 0, + ); + + array_push( $fields['advanced']['fields'], $enable_abilities_api ); + } + + /** + * Filter allows for modification of options fields + * + * @param array $fields Option fields. + * + * @return array Array of option fields + */ + $filtered_fields = apply_filters( 'wp_stream_settings_option_fields', $fields ); + + // Guard against filters returning a non-array (XWPENG-47). + $fields = is_array( $filtered_fields ) ? $filtered_fields : $fields; + + // Sort option fields in each tab by title ASC. + foreach ( $fields as $tab => $options ) { + $titles = array(); + + foreach ( $options['fields'] as $field ) { + $prefix = null; + + if ( ! empty( $field['sticky'] ) ) { + $prefix = ( 'bottom' === $field['sticky'] ) ? 'ZZZ' : 'AAA'; + } + + $titles[] = $prefix . $field['title']; + } + + array_multisort( $titles, SORT_ASC, $fields[ $tab ]['fields'] ); + } + + return $fields; + } + + /** + * Return a single field definition by option key. + * + * @param string $key Option key in `{section}_{name}` form (e.g. `general_records_ttl`). + * @return array|null Field definition, or null when unknown. + */ + public function get_field( $key ) { + foreach ( $this->get_fields() as $section_name => $section ) { + if ( empty( $section['fields'] ) || ! is_array( $section['fields'] ) ) { + continue; + } + + foreach ( $section['fields'] as $field ) { + if ( empty( $field['name'] ) ) { + continue; + } + + if ( $section_name . '_' . $field['name'] === $key ) { + return $field; + } + } + } + + return null; + } + + /** + * Whether a field exists for the given option key. + * + * @param string $key Option key in `{section}_{name}` form (e.g. `general_records_ttl`). + * @return bool + */ + public function has_field( $key ) { + return null !== $this->get_field( $key ); + } + + /** + * Build the "Reset Stream Database" settings field definition. + * + * Extracted so the async-deletion running-state check + * ({@see Admin_Purge::is_running_async_deletion()}) is evaluated once per render + * instead of once per field property, and only in admin context. + * + * `Settings::__construct` populates `$this->options = $this->get_options()` + * on the `init` hook for every pageload, which walks `get_fields()`. The + * field is only ever rendered in admin, so outside admin the dynamic state + * is irrelevant and the Action Scheduler query is skipped entirely. + * + * @return array + */ + private function build_delete_all_records_field() { + $is_running_deletion = is_admin() ? $this->plugin->admin->purge->is_running_async_deletion() : false; + + return array( + 'name' => 'delete_all_records', + 'title' => esc_html__( 'Reset Stream Database', 'stream' ), + 'type' => $is_running_deletion ? 'none' : 'link', + 'href' => add_query_arg( + array( + 'action' => 'wp_stream_reset', + 'wp_stream_nonce_reset' => wp_create_nonce( 'stream_nonce_reset' ), + ), + admin_url( 'admin-ajax.php' ) + ), + 'class' => 'warning', + 'desc' => esc_html( $this->get_deletion_warning( $is_running_deletion ) ), + 'default' => 0, + 'sticky' => 'bottom', + ); + } + + /** + * Build the "Clean Orphaned Meta" settings field definition. + * + * Extracted so the auto-purge running-state check + * ({@see Admin_Purge::is_running_auto_purge()}) is evaluated once per render + * instead of once per field property, and only in admin context — the + * field is never rendered outside admin, so the Action Scheduler query + * is skipped on front-end pageloads. + * + * @return array + */ + private function build_clean_orphan_meta_field() { + $is_running = is_admin() ? $this->plugin->admin->purge->is_running_auto_purge() : false; + + return array( + 'name' => 'clean_orphan_meta', + 'title' => esc_html__( 'Clean Orphaned Meta', 'stream' ), + 'type' => $is_running ? 'none' : 'link', + 'href' => add_query_arg( + array( + 'action' => 'wp_stream_clean_orphan_meta', + 'wp_stream_nonce_clean_orphan_meta' => wp_create_nonce( 'stream_nonce_clean_orphan_meta' ), + ), + admin_url( 'admin-ajax.php' ) + ), + 'desc' => $is_running + ? esc_html__( 'Auto-purge is currently running. The orphan reaper will execute as part of that cycle; the manual cleanup link is hidden to avoid duplicating the work.', 'stream' ) + : esc_html__( 'Schedules an immediate background cleanup of stream_meta rows whose parent record is missing. Safe to run while Stream is in use; runs once via Action Scheduler.', 'stream' ), + 'default' => 0, + 'sticky' => 'bottom', + ); + } + + /** + * Iterate through registered fields and extract default values. + * + * @return array + */ + public function get_defaults() { + $fields = $this->get_fields(); + $defaults = array(); + + foreach ( $fields as $section_name => $section ) { + foreach ( $section['fields'] as $field ) { + $defaults[ $section_name . '_' . $field['name'] ] = isset( $field['default'] ) ? $field['default'] : null; + } + } + + return (array) $defaults; + } + + /** + * Retrieves the deletion warning message based on the site type + * and whether or not there is currently a process running to delete the tables. + * + * @param bool|null $is_running_deletion Optional pre-computed deletion state. + * Pass to avoid a duplicate Action Scheduler + * query when the caller has already checked. + * Defaults to checking only in admin context. + * Untyped parameter to remain compatible with + * phpcs.xml.dist testVersion=7.0- (nullable + * type declarations require PHP 7.1+). + * @return string The deletion warning message. + */ + public function get_deletion_warning( $is_running_deletion = null ): string { + if ( null === $is_running_deletion ) { + $is_running_deletion = is_admin() ? $this->plugin->admin->purge->is_running_async_deletion() : false; + } + + if ( $is_running_deletion ) { + $warning = __( 'Currently deleting records. Please be patient, this can take a while.', 'stream' ); + } elseif ( $this->plugin->is_multisite_network_activated() ) { + $warning = __( 'Warning: This will delete all activity records from the database for all sites.', 'stream' ); + } elseif ( $this->plugin->is_multisite_not_network_activated() ) { + $warning = __( 'Warning: This will delete all activity records from the database for this site.', 'stream' ); + } else { + $warning = __( 'Warning: This will delete all activity records from the database.', 'stream' ); + } + + return $warning; + } + + /** + * Get an array of user roles. + * + * Static so the renderer can share this list without a second WP_Roles loop. + * + * @return array + */ + public static function get_roles() { + $wp_roles = \wp_roles(); + + if ( ! $wp_roles instanceof WP_Roles ) { + return array(); + } + + $roles = array(); + + foreach ( $wp_roles->get_names() as $role => $label ) { + $roles[ $role ] = translate_user_role( $label ); + } + + return $roles; + } + + /** + * Filter callback for site-level settings labels. + * + * @filter wp_stream_serialized_labels + * + * @param array $labels Setting labels. + * @return array Multidimensional array of fields + */ + public function filter_serialized_labels( $labels ) { + return $this->get_settings_translations( $labels, $this->plugin->settings->option_key ); + } + + /** + * Get translations of serialized Stream settings. + * + * @param array $labels Setting labels. + * @param string $option_key Settings option key. + * @return array Multidimensional array of fields + */ + public function get_settings_translations( $labels, $option_key ) { + if ( ! isset( $labels[ $option_key ] ) ) { + $labels[ $option_key ] = array(); + } + + foreach ( $this->get_fields() as $section_slug => $section ) { + foreach ( $section['fields'] as $field ) { + $labels[ $option_key ][ sprintf( '%s_%s', $section_slug, $field['name'] ) ] = $field['title']; + } + } + + return $labels; + } +} diff --git a/classes/class-settings-renderer.php b/classes/class-settings-renderer.php new file mode 100644 index 000000000..36769a9a5 --- /dev/null +++ b/classes/class-settings-renderer.php @@ -0,0 +1,624 @@ + %11$s', + esc_attr( $type ), + esc_attr( $option_key ), + esc_attr( $section ), + esc_attr( $name ), + esc_attr( $class ), + esc_attr( $placeholder ), + esc_attr( $min ), + esc_attr( $max ), + esc_attr( $step ), + esc_attr( $current_value ), + wp_kses_post( $after_field ) + ); + break; + case 'textarea': + $output = sprintf( + ' %9$s', + esc_attr( $option_key ), + esc_attr( $section ), + esc_attr( $name ), + esc_attr( $class ), + esc_attr( $placeholder ), + absint( $rows ), + absint( $cols ), + esc_textarea( $current_value ), + wp_kses_post( $after_field ) + ); + break; + case 'checkbox': + if ( isset( $current_value ) ) { + $value = $current_value; + } elseif ( isset( $default ) ) { + $value = $default; + } else { + $value = 0; + } + + $output = sprintf( + '', + esc_attr( $option_key ), + esc_attr( $section ), + esc_attr( $name ), + checked( $value, 1, false ), + wp_kses_post( $after_field ) + ); + break; + case 'multi_checkbox': + $output = sprintf( + '
', + esc_attr( $option_key ), + esc_attr( $section ), + esc_attr( $name ) + ); + // Fallback if nothing is selected. + $output .= sprintf( + '', + esc_attr( $option_key ), + esc_attr( $section ), + esc_attr( $name ) + ); + $current_value = (array) $current_value; + $choices = $field['choices']; + if ( is_callable( $choices ) ) { + $choices = call_user_func( $choices ); + } + foreach ( $choices as $value => $label ) { + $output .= sprintf( + '
', + sprintf( + '', + esc_attr( $option_key ), + esc_attr( $section ), + esc_attr( $name ), + esc_attr( $value ), + checked( in_array( $value, $current_value, true ), true, false ) + ), + esc_html( $label ) + ); + } + $output .= '
'; + break; + case 'select': + $current_value = $options[ $section . '_' . $name ]; + $default_value = isset( $default['value'] ) ? $default['value'] : '-1'; + $default_name = isset( $default['name'] ) ? $default['name'] : 'Choose Setting'; + + $output = sprintf( + ''; + break; + case 'file': + $output = sprintf( + '', + esc_attr( $option_key ), + esc_attr( $section ), + esc_attr( $name ), + esc_attr( $class ) + ); + break; + case 'link': + $output = sprintf( + '%6$s', + esc_attr( $option_key ), + esc_attr( $section ), + esc_attr( $name ), + esc_attr( $class ), + esc_attr( $href ), + esc_attr( $title ) + ); + break; + case 'none': + // Intentional no-op: callers set 'none' to hide a control's value + // column while still letting the row label + description render + // (e.g. Reset Stream Database while a deletion is running, or + // Clean Orphaned Meta while the auto-purge chain is active). + // The description string carries the running-state message. + $output = ''; + break; + case 'select2': + if ( ! isset( $current_value ) ) { + $current_value = ''; + } + + $data_values = array(); + + if ( isset( $field['choices'] ) ) { + $choices = $field['choices']; + if ( is_callable( $choices ) ) { + $param = ( isset( $field['param'] ) ) ? $field['param'] : null; + $choices = call_user_func( $choices, $param ); + } + foreach ( $choices as $key => $value ) { + if ( is_array( $value ) ) { + $child_values = array(); + if ( isset( $value['children'] ) ) { + $child_values = array(); + foreach ( $value['children'] as $child_key => $child_value ) { + $child_values[] = array( + 'id' => $child_key, + 'text' => $child_value, + ); + } + } + if ( isset( $value['label'] ) ) { + $data_values[] = array( + 'id' => $key, + 'text' => $value['label'], + 'children' => $child_values, + ); + } + } else { + $data_values[] = array( + 'id' => $key, + 'text' => $value, + ); + } + } + $class .= ' with-source'; + } + + $input_html = sprintf( + '', + esc_attr( $option_key ), + esc_attr( $section ), + esc_attr( $name ), + esc_attr( wp_json_encode( $data_values ) ), + esc_attr( $current_value ), + esc_attr( $class ), + /* translators: %s: the title of the dropdown menu (e.g. "users") */ + sprintf( esc_html__( 'Any %s', 'stream' ), $title ) + ); + + $output = sprintf( + '
%4$s
', + esc_attr( $option_key ), + esc_attr( $section ), + esc_attr( $name ), + $input_html + ); + + break; + case 'rule_list': + $output = $this->render_rule_list( $field, $current_value, $option_key, $section, $name, $description ); + $description = null; + break; + } + $output .= ! empty( $description ) ? wp_kses_post( sprintf( '

%s

', $description ) ) : null; + + return $output; + } + + /** + * Render the exclude-rules table control. + * + * @param array $field Field settings. + * @param mixed $current_value Stored rule list value. + * @param string $option_key Settings option key. + * @param string $section Field section slug. + * @param string $name Field name. + * @param string|null $description Field description (consumed here; caller must not reprint). + * @return string + */ + private function render_rule_list( $field, $current_value, $option_key, $section, $name, $description ) { + unset( $field ); + + $users = count_users(); + $form = new Form_Generator(); + $output = '

' . esc_html( $description ) . '

'; + + $actions_top = sprintf( '', esc_attr( $section . '_' . $name ), esc_html__( 'Add New Rule', 'stream' ) ); + $actions_bottom = sprintf( '', esc_attr( $section . '_' . $name ), esc_html__( 'Delete Selected Rules', 'stream' ) ); + + $output .= sprintf( '
%1$s
', $actions_top ); + $output .= ''; + + $description = null; + + $heading_row = sprintf( + ' + + + + + + + ', + '', + esc_html__( 'Author or Role', 'stream' ), + esc_html__( 'Context', 'stream' ), + esc_html__( 'Action', 'stream' ), + esc_html__( 'IP Address', 'stream' ), + esc_html__( 'Filters', 'stream' ) + ); + + $exclude_rows = array(); + + // Account for when no rules have been added yet. + if ( ! is_array( $current_value ) ) { + $current_value = array(); + } + + // Prepend an empty row. + $current_value['exclude_row'] = ( isset( $current_value['exclude_row'] ) ? $current_value['exclude_row'] : array() ) + array( 'helper' => '' ); + + foreach ( $current_value['exclude_row'] as $key => $value ) { + $exclude_rows[] = $this->render_rule_list_row( + $form, + $users, + $current_value, + $key, + $option_key, + $section, + $name + ); + } + + $no_rules_found_row = sprintf( + '', + esc_html__( 'No rules found.', 'stream' ) + ); + + $output .= '' . $heading_row . ''; + $output .= '' . $heading_row . ''; + $output .= '' . $no_rules_found_row . implode( '', $exclude_rows ) . ''; + + $output .= '
%1$s%2$s%3$s%4$s%5$s
'; + + $output .= sprintf( '
%1$s
', $actions_bottom ); + + return $output; + } + + /** + * Render a single exclude-rule table row. + * + * @param Form_Generator $form Form helper. + * @param array $users count_users() payload. + * @param array $current_value Stored rule list value. + * @param string|int $key Row key. + * @param string $option_key Settings option key. + * @param string $section Field section slug. + * @param string $name Field name. + * @return string + */ + private function render_rule_list_row( $form, $users, $current_value, $key, $option_key, $section, $name ) { + $author_or_role = isset( $current_value['author_or_role'][ $key ] ) ? $current_value['author_or_role'][ $key ] : ''; + $connector = isset( $current_value['connector'][ $key ] ) ? $current_value['connector'][ $key ] : ''; + $context = isset( $current_value['context'][ $key ] ) ? $current_value['context'][ $key ] : ''; + $action = isset( $current_value['action'][ $key ] ) ? $current_value['action'][ $key ] : ''; + $ip_address = isset( $current_value['ip_address'][ $key ] ) ? $current_value['ip_address'][ $key ] : ''; + + $author_or_role_values = array(); + $author_or_role_selected = array(); + + foreach ( Settings_Registry::get_roles() as $role_id => $role ) { + $args = array( + 'value' => $role_id, + 'text' => $role, + ); + $count = isset( $users['avail_roles'][ $role_id ] ) ? $users['avail_roles'][ $role_id ] : 0; + + if ( ! empty( $count ) ) { + /* translators: %d: a number of users (e.g. "42") */ + $args['user_count'] = sprintf( _n( '%d user', '%d users', absint( $count ), 'stream' ), absint( $count ) ); + } + + if ( $role_id === $author_or_role ) { + $author_or_role_selected['value'] = $role_id; + $author_or_role_selected['text'] = $role; + } + + $author_or_role_values[] = $args; + } + + if ( empty( $author_or_role_selected ) && is_numeric( $author_or_role ) ) { + $user = new WP_User( $author_or_role ); + $display_name = ( 0 === $user->ID ) ? esc_html__( 'N/A', 'stream' ) : $user->display_name; + $author_or_role_selected = array( + 'value' => $user->ID, + 'text' => $display_name, + ); + $author_or_role_values[] = $author_or_role_selected; + } + + $author_or_role_input = $form->render_field( + 'select2', + array( + 'name' => esc_attr( sprintf( '%1$s[%2$s_%3$s][%4$s][]', $option_key, $section, $name, 'author_or_role' ) ), + 'options' => $author_or_role_values, + 'classes' => 'author_or_role', + // Data attributes are escaped in Form_Generator::prepare_data_attributes_string(). + 'data' => array( + 'placeholder' => __( 'Any Author or Role', 'stream' ), + 'nonce' => wp_create_nonce( 'stream_get_users' ), + 'selected-id' => isset( $author_or_role_selected['value'] ) ? $author_or_role_selected['value'] : '', + 'selected-text' => isset( $author_or_role_selected['text'] ) ? $author_or_role_selected['text'] : '', + ), + ), + false + ); + + $context_values = array(); + + foreach ( $this->get_terms_labels( 'context' ) as $context_id => $context_data ) { + if ( is_array( $context_data ) ) { + $child_values = array(); + if ( isset( $context_data['children'] ) ) { + $child_values = array(); + foreach ( $context_data['children'] as $child_id => $child_value ) { + $child_values[] = array( + 'value' => $context_id . '-' . $child_id, + 'text' => $child_value, + 'parent' => $context_id, + ); + } + } + if ( isset( $context_data['label'] ) ) { + $context_values[] = array( + 'value' => $context_id, + 'text' => $context_data['label'], + 'children' => $child_values, + ); + } + } else { + $context_values[] = array( + 'value' => $context_id, + 'text' => $context_data, + ); + } + } + + $connector_or_context_input = $form->render_field( + 'select2', + array( + 'name' => esc_attr( sprintf( '%1$s[%2$s_%3$s][%4$s][]', $option_key, $section, $name, 'connector_or_context' ) ), + 'options' => $context_values, + 'classes' => 'connector_or_context', + // Data attributes are escaped in Form_Generator::prepare_data_attributes_string(). + 'data' => array( + 'group' => 'connector', + 'placeholder' => __( 'Any Context', 'stream' ), + ), + ), + false + ); + + $connector_input = $form->render_field( + 'hidden', + array( + 'name' => esc_attr( sprintf( '%1$s[%2$s_%3$s][%4$s][]', $option_key, $section, $name, 'connector' ) ), + 'value' => $connector, + 'classes' => 'connector', + ), + false + ); + + $context_input = $form->render_field( + 'hidden', + array( + 'name' => esc_attr( sprintf( '%1$s[%2$s_%3$s][%4$s][]', $option_key, $section, $name, 'context' ) ), + 'value' => $context, + 'classes' => 'context', + ), + false + ); + + $action_values = array(); + + foreach ( $this->get_terms_labels( 'action' ) as $action_id => $action_data ) { + $action_values[] = array( + 'value' => $action_id, + 'text' => $action_data, + ); + } + + $action_input = $form->render_field( + 'select2', + array( + 'name' => esc_attr( sprintf( '%1$s[%2$s_%3$s][%4$s][]', $option_key, $section, $name, 'action' ) ), + 'value' => $action, + 'options' => $action_values, + 'classes' => 'action', + // Data attributes are escaped in Form_Generator::prepare_data_attributes_string(). + 'data' => array( + 'placeholder' => __( 'Any Action', 'stream' ), + ), + ), + false + ); + + $ip_address_input = $form->render_field( + 'select2', + array( + 'name' => esc_attr( sprintf( '%1$s[%2$s_%3$s][%4$s][]', $option_key, $section, $name, 'ip_address' ) ), + 'value' => $ip_address, + 'classes' => 'ip_address', + // Data attributes are escaped in Form_Generator::prepare_data_attributes_string(). + 'data' => array( + 'placeholder' => __( 'Any IP Address', 'stream' ), + 'nonce' => wp_create_nonce( 'stream_get_ips' ), + ), + 'multiple' => true, + ), + false + ); + + $helper_input = sprintf( + '', + esc_attr( $option_key ), + esc_attr( $section ), + esc_attr( $name ), + 'exclude_row' + ); + + return sprintf( + ' + %3$s %4$s + %5$s + %6$s %7$s %8$s + %9$s + %10$s + + %11$s + + ', + ( 0 !== (int) $key % 2 ) ? 'alternate' : '', + ( 'helper' === (string) $key ) ? 'hidden helper' : '', + '', + $helper_input, + $author_or_role_input, + $connector_or_context_input, + $connector_input, + $context_input, + $action_input, + $ip_address_input, + esc_html__( 'Delete', 'stream' ) + ); + } + + /** + * Function will return all terms labels of given column. + * + * @param string $column Name of the column. + * @return array + */ + public function get_terms_labels( $column ) { + $return_labels = array(); + + if ( isset( $this->plugin->connectors->term_labels[ 'stream_' . $column ] ) ) { + if ( 'context' === $column && isset( $this->plugin->connectors->term_labels['stream_connector'] ) ) { + $connectors = $this->plugin->connectors->term_labels['stream_connector']; + $contexts = $this->plugin->connectors->term_labels['stream_context']; + + foreach ( $connectors as $connector => $connector_label ) { + $return_labels[ $connector ]['label'] = $connector_label; + foreach ( $contexts as $context => $context_label ) { + if ( isset( $this->plugin->connectors->contexts[ $connector ] ) && array_key_exists( $context, $this->plugin->connectors->contexts[ $connector ] ) ) { + $return_labels[ $connector ]['children'][ $context ] = $context_label; + } + } + } + } else { + $return_labels = $this->plugin->connectors->term_labels[ 'stream_' . $column ]; + } + + ksort( $return_labels ); + } + + return $return_labels; + } + + /** + * Settings API field callback: render and echo field HTML. + * + * @param array $field Field to be rendered. + * @return void + */ + public function output_field( $field ) { + $settings = $this->plugin->settings; + $method = 'output_' . $field['name']; + + if ( method_exists( $settings, $method ) ) { + call_user_func( array( $settings, $method ), $field ); + return; + } + + echo $this->render_field( $field, $settings->options, $settings->option_key ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } +} diff --git a/classes/class-settings-sanitizer.php b/classes/class-settings-sanitizer.php new file mode 100644 index 000000000..bdefab46b --- /dev/null +++ b/classes/class-settings-sanitizer.php @@ -0,0 +1,114 @@ +plugin = $plugin; + } + + /** + * Sanitize callback for register_setting(). + * + * @param array $input Raw posted values keyed by `{section}_{name}`. + * @return array + */ + public function sanitize_settings_for_save( $input ) { + return $this->sanitize_settings( $input, $this->plugin->settings->registry->get_fields() ); + } + + /** + * Sanitize posted settings using field definitions. + * + * Empty or missing keys are skipped. Output keys use `{section}_{name}`. + * + * @param array $input Raw posted values keyed by `{section}_{name}`. + * @param array $fields Section/field definitions from the registry. + * @return array + */ + public function sanitize_settings( $input, $fields ) { + $output = array(); + + foreach ( $fields as $section => $data ) { + if ( empty( $data['fields'] ) || ! is_array( $data['fields'] ) ) { + continue; + } + + foreach ( $data['fields'] as $field ) { + $type = ! empty( $field['type'] ) ? $field['type'] : null; + $name = ! empty( $field['name'] ) ? sprintf( '%s_%s', $section, $field['name'] ) : null; + + if ( empty( $type ) || ! isset( $input[ $name ] ) || '' === $input[ $name ] ) { + continue; + } + + $output[ $name ] = $this->sanitize_setting_by_field_type( $input[ $name ], $type ); + } + } + + return $output; + } + + /** + * Sanitize a setting value based on the field type. + * + * @param mixed $value The value to be sanitized. + * @param string $field_type The type of field. + * @return mixed The sanitized value. + */ + public function sanitize_setting_by_field_type( $value, $field_type ) { + switch ( $field_type ) { + case 'number': + $sanitized_value = is_numeric( $value ) ? intval( trim( $value ) ) : ''; + break; + case 'checkbox': + $sanitized_value = is_numeric( $value ) ? absint( trim( $value ) ) : ''; + break; + default: + if ( is_array( $value ) ) { + $sanitized_value = $value; + + array_walk_recursive( + $sanitized_value, + array( self::class, 'sanitize_walk_value' ) + ); + } else { + $sanitized_value = sanitize_text_field( trim( $value ) ); + } + } + + return $sanitized_value; + } + + /** + * Sanitize a single nested value during array_walk_recursive. + * + * @param mixed $value Value passed by reference from array_walk_recursive. + * @param mixed $key Array key (unused; provided by array_walk_recursive). + */ + public static function sanitize_walk_value( &$value, $key = null ) { + unset( $key ); + $value = sanitize_text_field( trim( $value ) ); + } +} diff --git a/classes/class-settings.php b/classes/class-settings.php index 1f044b702..e29735dca 100644 --- a/classes/class-settings.php +++ b/classes/class-settings.php @@ -7,8 +7,6 @@ namespace WP_Stream; -use WP_Roles; -use WP_User; use WP_User_Query; /** @@ -25,6 +23,8 @@ class Settings { /** * Network settings key/identifier + * + * @var string */ public string $network_options_key = 'wp_stream_network'; @@ -36,11 +36,31 @@ class Settings { public $options = array(); /** - * Settings fields + * Field definition registry. * - * @var array + * Public so in-plugin callers can query schema without a Settings façade. + * + * @var Settings_Registry + */ + public Settings_Registry $registry; + + /** + * Field HTML renderer. + * + * Public so callers can render a field without a Settings façade. + * + * @var Settings_Renderer + */ + public Settings_Renderer $renderer; + + /** + * Posted-value sanitizer. + * + * Public so callers can sanitize without a Settings façade. + * + * @var Settings_Sanitizer */ - public $fields = array(); + public Settings_Sanitizer $sanitizer; /** * Class constructor. @@ -48,6 +68,10 @@ class Settings { * @param Plugin $plugin Instance of plugin object. */ public function __construct( public $plugin ) { + $this->registry = new Settings_Registry( $this->plugin ); + $this->renderer = new Settings_Renderer( $this->plugin ); + $this->sanitizer = new Settings_Sanitizer( $this->plugin ); + $this->option_key = $this->get_option_key(); $this->options = $this->get_options(); @@ -69,8 +93,8 @@ public function __construct( public $plugin ) { add_filter( 'wp_stream_serialized_labels', array( - $this, - 'get_settings_translations', + $this->registry, + 'filter_serialized_labels', ) ); @@ -157,7 +181,7 @@ public function get_users() { $response->status = true; $response->message = ''; - $response->roles = $this->get_roles(); + $response->roles = $this->registry->get_roles(); $response->users = array(); $users_added_to_response = array(); @@ -285,228 +309,6 @@ public function get_option_key() { return is_string( $filtered_key ) ? $filtered_key : $option_key; } - /** - * Return settings fields - * - * @return array - */ - public function get_fields() { - $fields = array( - 'general' => array( - 'title' => esc_html__( 'General', 'stream' ), - 'fields' => array( - array( - 'name' => 'role_access', - 'title' => esc_html__( 'Role Access', 'stream' ), - 'type' => 'multi_checkbox', - 'desc' => esc_html__( 'Users from the selected roles above will have permission to view Stream Records. However, only site Administrators can access Stream Settings.', 'stream' ), - 'choices' => $this->get_roles(), - 'default' => array( 'administrator' ), - ), - array( - 'name' => 'records_ttl', - 'title' => esc_html__( 'Keep Records for', 'stream' ), - 'type' => 'number', - 'class' => 'small-text', - 'desc' => esc_html__( 'Maximum number of days to keep activity records.', 'stream' ), - 'default' => 30, - 'min' => 1, - 'max' => 999, - 'step' => 1, - 'after_field' => esc_html__( 'days', 'stream' ), - ), - array( - 'name' => 'keep_records_indefinitely', - 'title' => esc_html__( 'Keep Records Indefinitely', 'stream' ), - 'type' => 'checkbox', - 'desc' => sprintf( '%s %s', esc_html__( 'Not recommended.', 'stream' ), esc_html__( 'Purging old records helps to keep your WordPress installation running optimally.', 'stream' ) ), - 'after_field' => esc_html__( 'Enabled', 'stream' ), - 'default' => 0, - ), - ), - ), - 'exclude' => array( - 'title' => esc_html__( 'Exclude', 'stream' ), - 'fields' => array( - array( - 'name' => 'rules', - 'title' => esc_html__( 'Exclude Rules', 'stream' ), - 'type' => 'rule_list', - 'desc' => esc_html__( 'Create rules to exclude certain kinds of activity from being recorded by Stream.', 'stream' ), - 'default' => array(), - 'nonce' => 'stream_get_ips', - ), - ), - ), - 'advanced' => array( - 'title' => esc_html__( 'Advanced', 'stream' ), - 'fields' => array( - array( - 'name' => 'comment_flood_tracking', - 'title' => esc_html__( 'Comment Flood Tracking', 'stream' ), - 'type' => 'checkbox', - 'desc' => esc_html__( 'WordPress will automatically prevent duplicate comments from flooding the database. By default, Stream does not track these attempts unless you opt-in here. Enabling this is not necessary or recommended for most sites.', 'stream' ), - 'after_field' => esc_html__( 'Enabled', 'stream' ), - 'default' => 0, - ), - $this->build_delete_all_records_field(), - $this->build_clean_orphan_meta_field(), - ), - ), - ); - - // If Akismet is active, allow Admins to opt-in to Akismet tracking. - if ( class_exists( 'Akismet' ) ) { - $akismet_tracking = array( - 'name' => 'akismet_tracking', - 'title' => esc_html__( 'Akismet Tracking', 'stream' ), - 'type' => 'checkbox', - 'desc' => esc_html__( 'Akismet already keeps statistics for comment attempts that it blocks as SPAM. By default, Stream does not track these attempts unless you opt-in here. Enabling this is not necessary or recommended for most sites.', 'stream' ), - 'after_field' => esc_html__( 'Enabled', 'stream' ), - 'default' => 0, - ); - - array_push( $fields['advanced']['fields'], $akismet_tracking ); - } - - $wp_cron_tracking = array( - 'name' => 'wp_cron_tracking', - 'title' => esc_html__( 'WP Cron Tracking', 'stream' ), - 'type' => 'checkbox', - 'desc' => esc_html__( 'By default, Stream does not track activity performed by WordPress cron events unless you opt-in here. Enabling this is not necessary or recommended for most sites.', 'stream' ), - 'after_field' => esc_html__( 'Enabled', 'stream' ), - 'default' => 0, - ); - - array_push( $fields['advanced']['fields'], $wp_cron_tracking ); - - // Abilities API toggle is only meaningful on WordPress 6.9+. On - // network-activated multisite, Abilities::is_enabled() reads the - // network option (wp_stream_network), so a per-site checkbox on the - // site's own settings screen would be a no-op and misleading. Hide - // the field from per-site settings pages, but keep it available in - // network admin and in REST/CLI contexts where update_all_setting_values() - // routes writes to the network option correctly. - $hide_per_site = $this->plugin->is_network_activated() && is_admin() && ! is_network_admin(); - - if ( - class_exists( '\WP_Ability' ) - && ! $hide_per_site - ) { - $enable_abilities_api = array( - 'name' => 'enable_abilities_api', - 'title' => esc_html__( 'Enable Abilities API and MCP', 'stream' ), - 'type' => 'checkbox', - 'desc' => esc_html__( 'Expose Stream operations to AI agents via the WordPress Abilities API (and MCP when the MCP Adapter plugin is installed). Requires WordPress 6.9.', 'stream' ), - 'after_field' => esc_html__( 'Enabled', 'stream' ), - 'default' => 0, - ); - - array_push( $fields['advanced']['fields'], $enable_abilities_api ); - } - - /** - * Filter allows for modification of options fields - * - * @param array $fields Option fields. - * - * @return array Array of option fields - */ - $filtered_fields = apply_filters( 'wp_stream_settings_option_fields', $fields ); - - // Guard against filters returning a non-array: the value feeds the - // Settings::$fields property, which becomes array-typed in XWPENG-47 — - // a non-array would throw a TypeError once typed. - $this->fields = is_array( $filtered_fields ) ? $filtered_fields : $fields; - - // Sort option fields in each tab by title ASC. - foreach ( $this->fields as $tab => $options ) { - $titles = array(); - - foreach ( $options['fields'] as $field ) { - $prefix = null; - - if ( ! empty( $field['sticky'] ) ) { - $prefix = ( 'bottom' === $field['sticky'] ) ? 'ZZZ' : 'AAA'; - } - - $titles[] = $prefix . $field['title']; - } - - array_multisort( $titles, SORT_ASC, $this->fields[ $tab ]['fields'] ); - } - - return $this->fields; - } - - /** - * Build the "Reset Stream Database" settings field definition. - * - * Extracted so the async-deletion running-state check - * ({@see Admin_Purge::is_running_async_deletion()}) is evaluated once per render - * instead of once per field property, and only in admin context. - * - * `Settings::__construct` populates `$this->options = $this->get_options()` - * on the `init` hook for every pageload, which walks `get_fields()`. The - * field is only ever rendered in admin, so outside admin the dynamic state - * is irrelevant and the Action Scheduler query is skipped entirely. - * - * @return array - */ - private function build_delete_all_records_field() { - $is_running_deletion = is_admin() ? $this->plugin->admin->purge->is_running_async_deletion() : false; - - return array( - 'name' => 'delete_all_records', - 'title' => esc_html__( 'Reset Stream Database', 'stream' ), - 'type' => $is_running_deletion ? 'none' : 'link', - 'href' => add_query_arg( - array( - 'action' => 'wp_stream_reset', - 'wp_stream_nonce_reset' => wp_create_nonce( 'stream_nonce_reset' ), - ), - admin_url( 'admin-ajax.php' ) - ), - 'class' => 'warning', - 'desc' => esc_html( $this->get_deletion_warning( $is_running_deletion ) ), - 'default' => 0, - 'sticky' => 'bottom', - ); - } - - /** - * Build the "Clean Orphaned Meta" settings field definition. - * - * Extracted so the auto-purge running-state check - * ({@see Admin_Purge::is_running_auto_purge()}) is evaluated once per render - * instead of once per field property, and only in admin context — the - * field is never rendered outside admin, so the Action Scheduler query - * is skipped on front-end pageloads. - * - * @return array - */ - private function build_clean_orphan_meta_field() { - $is_running = is_admin() ? $this->plugin->admin->purge->is_running_auto_purge() : false; - - return array( - 'name' => 'clean_orphan_meta', - 'title' => esc_html__( 'Clean Orphaned Meta', 'stream' ), - 'type' => $is_running ? 'none' : 'link', - 'href' => add_query_arg( - array( - 'action' => 'wp_stream_clean_orphan_meta', - 'wp_stream_nonce_clean_orphan_meta' => wp_create_nonce( 'stream_nonce_clean_orphan_meta' ), - ), - admin_url( 'admin-ajax.php' ) - ), - 'desc' => $is_running - ? esc_html__( 'Auto-purge is currently running. The orphan reaper will execute as part of that cycle; the manual cleanup link is hidden to avoid duplicating the work.', 'stream' ) - : esc_html__( 'Schedules an immediate background cleanup of stream_meta rows whose parent record is missing. Safe to run while Stream is in use; runs once via Action Scheduler.', 'stream' ), - 'default' => 0, - 'sticky' => 'bottom', - ); - } - /** * Returns a single setting value, reading the network-level option when * Stream is network-activated on multisite. @@ -589,7 +391,7 @@ public function update_all_setting_values( array $options ) { // reading $plugin->settings->options keep seeing a fully-populated // array (matches get_options()'s historical contract). if ( $is_network ) { - $defaults = $this->get_defaults( $this->option_key ); + $defaults = $this->registry->get_defaults(); $this->options = wp_parse_args( (array) get_site_option( $this->network_options_key, array() ), $defaults @@ -608,7 +410,7 @@ public function update_all_setting_values( array $options ) { */ public function get_options() { $option_key = $this->option_key; - $defaults = $this->get_defaults( $option_key ); + $defaults = $this->registry->get_defaults(); $options = wp_parse_args( is_network_admin() ? (array) get_site_option( $option_key, array() ) : (array) get_option( $option_key, array() ), @@ -631,77 +433,20 @@ public function get_options() { return is_array( $filtered ) ? $filtered : $options; } - /** - * Iterate through registered fields and extract default values - * - * @return array - */ - public function get_defaults() { - $fields = $this->get_fields(); - $defaults = array(); - - foreach ( $fields as $section_name => $section ) { - foreach ( $section['fields'] as $field ) { - $defaults[ $section_name . '_' . $field['name'] ] = isset( $field['default'] ) ? $field['default'] : null; - } - } - - return (array) $defaults; - } - - /** - * Retrieves the deletion warning message based on the site type - * and whether or not there is currently a process running to delete the tables. - * - * @param bool|null $is_running_deletion Optional pre-computed deletion state. - * Pass to avoid a duplicate Action Scheduler - * query when the caller has already checked. - * Defaults to checking only in admin context. - * Untyped parameter to remain compatible with - * phpcs.xml.dist testVersion=7.0- (nullable - * type declarations require PHP 7.1+). - * @return string The deletion warning message. - */ - public function get_deletion_warning( $is_running_deletion = null ): string { - - if ( null === $is_running_deletion ) { - $is_running_deletion = is_admin() ? $this->plugin->admin->purge->is_running_async_deletion() : false; - } - - if ( $is_running_deletion ) { - - $warning = __( 'Currently deleting records. Please be patient, this can take a while.', 'stream' ); - - } elseif ( $this->plugin->is_multisite_network_activated() ) { - - $warning = __( 'Warning: This will delete all activity records from the database for all sites.', 'stream' ); - - } elseif ( $this->plugin->is_multisite_not_network_activated() ) { - - $warning = __( 'Warning: This will delete all activity records from the database for this site.', 'stream' ); - - } else { - - $warning = __( 'Warning: This will delete all activity records from the database.', 'stream' ); - } - - return $warning; - } - /** * Registers settings fields and sections * * @return void */ public function register_settings() { - $sections = $this->get_fields(); + $sections = $this->registry->get_fields(); register_setting( $this->option_key, $this->option_key, array( - $this, - 'sanitize_settings', + $this->sanitizer, + 'sanitize_settings_for_save', ) ); @@ -723,7 +468,7 @@ public function register_settings() { $field['name'], $field['title'], ( isset( $field['callback'] ) ? $field['callback'] : array( - $this, + $this->renderer, 'output_field', ) ), $this->option_key, @@ -737,654 +482,6 @@ public function register_settings() { } } - /** - * Sanitization callback for settings field values before save - * - * @param array $input Raw input. - * - * @return array - */ - public function sanitize_settings( $input ) { - $output = array(); - $sections = $this->get_fields(); - - foreach ( $sections as $section => $data ) { - if ( empty( $data['fields'] ) || ! is_array( $data['fields'] ) ) { - continue; - } - - foreach ( $data['fields'] as $field ) { - $type = ! empty( $field['type'] ) ? $field['type'] : null; - $name = ! empty( $field['name'] ) ? sprintf( '%s_%s', $section, $field['name'] ) : null; - - if ( empty( $type ) || ! isset( $input[ $name ] ) || '' === $input[ $name ] ) { - continue; - } - - $output[ $name ] = $this->sanitize_setting_by_field_type( $input[ $name ], $type ); - } - } - - return $output; - } - - /** - * Sanitizes a setting value based on the field type. - * - * @param mixed $value The value to be sanitized. - * @param string $field_type The type of field. - * - * @return mixed The sanitized value. - */ - public function sanitize_setting_by_field_type( $value, $field_type ) { - - // Sanitize depending on the type of field. - switch ( $field_type ) { - case 'number': - $sanitized_value = is_numeric( $value ) ? intval( trim( $value ) ) : ''; - break; - case 'checkbox': - $sanitized_value = is_numeric( $value ) ? absint( trim( $value ) ) : ''; - break; - default: - if ( is_array( $value ) ) { - $sanitized_value = $value; - - // Support all values in multidimentional arrays too. - array_walk_recursive( - $sanitized_value, - function ( &$v ) { - $v = sanitize_text_field( trim( $v ) ); - } - ); - } else { - $sanitized_value = sanitize_text_field( trim( $value ) ); - } - } - - return $sanitized_value; - } - - /** - * Compile HTML needed for displaying the field - * - * @param array $field Field settings. - * - * @return string HTML to be displayed - */ - public function render_field( $field ) { - $output = null; - $type = isset( $field['type'] ) ? $field['type'] : null; - $section = isset( $field['section'] ) ? $field['section'] : null; - $name = isset( $field['name'] ) ? $field['name'] : null; - $class = isset( $field['class'] ) ? $field['class'] : null; - $placeholder = isset( $field['placeholder'] ) ? $field['placeholder'] : null; - $description = isset( $field['desc'] ) ? $field['desc'] : null; - $href = isset( $field['href'] ) ? $field['href'] : null; - $rows = isset( $field['rows'] ) ? $field['rows'] : 10; - $cols = isset( $field['cols'] ) ? $field['cols'] : 50; - $after_field = isset( $field['after_field'] ) ? $field['after_field'] : null; - $default = isset( $field['default'] ) ? $field['default'] : null; - $min = isset( $field['min'] ) ? $field['min'] : 0; - $max = isset( $field['max'] ) ? $field['max'] : 999; - $step = isset( $field['step'] ) ? $field['step'] : 1; - $title = isset( $field['title'] ) ? $field['title'] : null; - $nonce = isset( $field['nonce'] ) ? $field['nonce'] : null; - - if ( isset( $field['value'] ) ) { - $current_value = $field['value']; - } elseif ( isset( $this->options[ $section . '_' . $name ] ) ) { - $current_value = $this->options[ $section . '_' . $name ]; - } else { - $current_value = null; - } - - $option_key = $this->option_key; - - if ( is_callable( $current_value ) ) { - $current_value = call_user_func( $current_value ); - } - - if ( ! $type || ! $section || ! $name ) { - return ''; - } - - if ( 'multi_checkbox' === $type && ( empty( $field['choices'] ) || ! is_array( $field['choices'] ) ) ) { - return ''; - } - - switch ( $type ) { - case 'text': - case 'number': - $output = sprintf( - ' %11$s', - esc_attr( $type ), - esc_attr( $option_key ), - esc_attr( $section ), - esc_attr( $name ), - esc_attr( $class ), - esc_attr( $placeholder ), - esc_attr( $min ), - esc_attr( $max ), - esc_attr( $step ), - esc_attr( $current_value ), - wp_kses_post( $after_field ) - ); - break; - case 'textarea': - $output = sprintf( - ' %9$s', - esc_attr( $option_key ), - esc_attr( $section ), - esc_attr( $name ), - esc_attr( $class ), - esc_attr( $placeholder ), - absint( $rows ), - absint( $cols ), - esc_textarea( $current_value ), - wp_kses_post( $after_field ) - ); - break; - case 'checkbox': - if ( isset( $current_value ) ) { - $value = $current_value; - } elseif ( isset( $default ) ) { - $value = $default; - } else { - $value = 0; - } - - $output = sprintf( - '', - esc_attr( $option_key ), - esc_attr( $section ), - esc_attr( $name ), - checked( $value, 1, false ), - wp_kses_post( $after_field ) - ); - break; - case 'multi_checkbox': - $output = sprintf( - '
', - esc_attr( $option_key ), - esc_attr( $section ), - esc_attr( $name ) - ); - // Fallback if nothing is selected. - $output .= sprintf( - '', - esc_attr( $option_key ), - esc_attr( $section ), - esc_attr( $name ) - ); - $current_value = (array) $current_value; - $choices = $field['choices']; - if ( is_callable( $choices ) ) { - $choices = call_user_func( $choices ); - } - foreach ( $choices as $value => $label ) { - $output .= sprintf( - '
', - sprintf( - '', - esc_attr( $option_key ), - esc_attr( $section ), - esc_attr( $name ), - esc_attr( $value ), - checked( in_array( $value, $current_value, true ), true, false ) - ), - esc_html( $label ) - ); - } - $output .= '
'; - break; - case 'select': - $current_value = $this->options[ $section . '_' . $name ]; - $default_value = isset( $default['value'] ) ? $default['value'] : '-1'; - $default_name = isset( $default['name'] ) ? $default['name'] : 'Choose Setting'; - - $output = sprintf( - ''; - break; - case 'file': - $output = sprintf( - '', - esc_attr( $option_key ), - esc_attr( $section ), - esc_attr( $name ), - esc_attr( $class ) - ); - break; - case 'link': - $output = sprintf( - '%6$s', - esc_attr( $option_key ), - esc_attr( $section ), - esc_attr( $name ), - esc_attr( $class ), - esc_attr( $href ), - esc_attr( $title ) - ); - break; - case 'none': - // Intentional no-op: callers set 'none' to hide a control's value - // column while still letting the row label + description render - // (e.g. Reset Stream Database while a deletion is running, or - // Clean Orphaned Meta while the auto-purge chain is active). - // The description string carries the running-state message. - $output = ''; - break; - case 'select2': - if ( ! isset( $current_value ) ) { - $current_value = ''; - } - - $data_values = array(); - - if ( isset( $field['choices'] ) ) { - $choices = $field['choices']; - if ( is_callable( $choices ) ) { - $param = ( isset( $field['param'] ) ) ? $field['param'] : null; - $choices = call_user_func( $choices, $param ); - } - foreach ( $choices as $key => $value ) { - if ( is_array( $value ) ) { - $child_values = array(); - if ( isset( $value['children'] ) ) { - $child_values = array(); - foreach ( $value['children'] as $child_key => $child_value ) { - $child_values[] = array( - 'id' => $child_key, - 'text' => $child_value, - ); - } - } - if ( isset( $value['label'] ) ) { - $data_values[] = array( - 'id' => $key, - 'text' => $value['label'], - 'children' => $child_values, - ); - } - } else { - $data_values[] = array( - 'id' => $key, - 'text' => $value, - ); - } - } - $class .= ' with-source'; - } - - $input_html = sprintf( - '', - esc_attr( $option_key ), - esc_attr( $section ), - esc_attr( $name ), - esc_attr( wp_json_encode( $data_values ) ), - esc_attr( $current_value ), - esc_attr( $class ), - /* translators: %s: the title of the dropdown menu (e.g. "users") */ - sprintf( esc_html__( 'Any %s', 'stream' ), $title ) - ); - - $output = sprintf( - '
%4$s
', - esc_attr( $option_key ), - esc_attr( $section ), - esc_attr( $name ), - $input_html - ); - - break; - case 'rule_list': - $users = count_users(); - $form = new Form_Generator(); - $output = '

' . esc_html( $description ) . '

'; - - $actions_top = sprintf( '', esc_attr( $section . '_' . $name ), esc_html__( 'Add New Rule', 'stream' ) ); - $actions_bottom = sprintf( '', esc_attr( $section . '_' . $name ), esc_html__( 'Delete Selected Rules', 'stream' ) ); - - $output .= sprintf( '
%1$s
', $actions_top ); - $output .= ''; - - unset( $description ); - - $heading_row = sprintf( - ' - - - - - - - ', - '', - esc_html__( 'Author or Role', 'stream' ), - esc_html__( 'Context', 'stream' ), - esc_html__( 'Action', 'stream' ), - esc_html__( 'IP Address', 'stream' ), - esc_html__( 'Filters', 'stream' ) - ); - - $exclude_rows = array(); - - // Account for when no rules have been added yet. - if ( ! is_array( $current_value ) ) { - $current_value = array(); - } - - // Prepend an empty row. - $current_value['exclude_row'] = ( isset( $current_value['exclude_row'] ) ? $current_value['exclude_row'] : array() ) + array( 'helper' => '' ); - - foreach ( $current_value['exclude_row'] as $key => $value ) { - // Prepare values. - $author_or_role = isset( $current_value['author_or_role'][ $key ] ) ? $current_value['author_or_role'][ $key ] : ''; - $connector = isset( $current_value['connector'][ $key ] ) ? $current_value['connector'][ $key ] : ''; - $context = isset( $current_value['context'][ $key ] ) ? $current_value['context'][ $key ] : ''; - $action = isset( $current_value['action'][ $key ] ) ? $current_value['action'][ $key ] : ''; - $ip_address = isset( $current_value['ip_address'][ $key ] ) ? $current_value['ip_address'][ $key ] : ''; - - // Author or Role dropdown menu. - $author_or_role_values = array(); - $author_or_role_selected = array(); - - foreach ( $this->get_roles() as $role_id => $role ) { - $args = array( - 'value' => $role_id, - 'text' => $role, - ); - $count = isset( $users['avail_roles'][ $role_id ] ) ? $users['avail_roles'][ $role_id ] : 0; - - if ( ! empty( $count ) ) { - /* translators: %d: a number of users (e.g. "42") */ - $args['user_count'] = sprintf( _n( '%d user', '%d users', absint( $count ), 'stream' ), absint( $count ) ); - } - - if ( $role_id === $author_or_role ) { - $author_or_role_selected['value'] = $role_id; - $author_or_role_selected['text'] = $role; - } - - $author_or_role_values[] = $args; - } - - if ( empty( $author_or_role_selected ) && is_numeric( $author_or_role ) ) { - $user = new WP_User( $author_or_role ); - $display_name = ( 0 === $user->ID ) ? esc_html__( 'N/A', 'stream' ) : $user->display_name; - $author_or_role_selected = array( - 'value' => $user->ID, - 'text' => $display_name, - ); - $author_or_role_values[] = $author_or_role_selected; - } - - $author_or_role_input = $form->render_field( - 'select2', - array( - 'name' => esc_attr( sprintf( '%1$s[%2$s_%3$s][%4$s][]', $option_key, $section, $name, 'author_or_role' ) ), - 'options' => $author_or_role_values, - 'classes' => 'author_or_role', - // Data attributes are escaped in Form_Generator::prepare_data_attributes_string(). - 'data' => array( - 'placeholder' => __( 'Any Author or Role', 'stream' ), - 'nonce' => wp_create_nonce( 'stream_get_users' ), - 'selected-id' => isset( $author_or_role_selected['value'] ) ? $author_or_role_selected['value'] : '', - 'selected-text' => isset( $author_or_role_selected['text'] ) ? $author_or_role_selected['text'] : '', - ), - ), - false - ); - - // Context dropdown menu. - $context_values = array(); - - foreach ( $this->get_terms_labels( 'context' ) as $context_id => $context_data ) { - if ( is_array( $context_data ) ) { - $child_values = array(); - if ( isset( $context_data['children'] ) ) { - $child_values = array(); - foreach ( $context_data['children'] as $child_id => $child_value ) { - $child_values[] = array( - 'value' => $context_id . '-' . $child_id, - 'text' => $child_value, - 'parent' => $context_id, - ); - } - } - if ( isset( $context_data['label'] ) ) { - $context_values[] = array( - 'value' => $context_id, - 'text' => $context_data['label'], - 'children' => $child_values, - ); - } - } else { - $context_values[] = array( - 'value' => $context_id, - 'text' => $context_data, - ); - } - } - - $connector_or_context_input = $form->render_field( - 'select2', - array( - 'name' => esc_attr( sprintf( '%1$s[%2$s_%3$s][%4$s][]', $option_key, $section, $name, 'connector_or_context' ) ), - 'options' => $context_values, - 'classes' => 'connector_or_context', - // Data attributes are escaped in Form_Generator::prepare_data_attributes_string(). - 'data' => array( - 'group' => 'connector', - 'placeholder' => __( 'Any Context', 'stream' ), - ), - ), - false - ); - - $connector_input = $form->render_field( - 'hidden', - array( - 'name' => esc_attr( sprintf( '%1$s[%2$s_%3$s][%4$s][]', $option_key, $section, $name, 'connector' ) ), - 'value' => $connector, - 'classes' => 'connector', - ), - false - ); - - $context_input = $form->render_field( - 'hidden', - array( - 'name' => esc_attr( sprintf( '%1$s[%2$s_%3$s][%4$s][]', $option_key, $section, $name, 'context' ) ), - 'value' => $context, - 'classes' => 'context', - ), - false - ); - - // Action dropdown menu. - $action_values = array(); - - foreach ( $this->get_terms_labels( 'action' ) as $action_id => $action_data ) { - $action_values[] = array( - 'value' => $action_id, - 'text' => $action_data, - ); - } - - $action_input = $form->render_field( - 'select2', - array( - 'name' => esc_attr( sprintf( '%1$s[%2$s_%3$s][%4$s][]', $option_key, $section, $name, 'action' ) ), - 'value' => $action, - 'options' => $action_values, - 'classes' => 'action', - // Data attributes are escaped in Form_Generator::prepare_data_attributes_string(). - 'data' => array( - 'placeholder' => __( 'Any Action', 'stream' ), - ), - ), - false - ); - - // IP Address input. - $ip_address_input = $form->render_field( - 'select2', - array( - 'name' => esc_attr( sprintf( '%1$s[%2$s_%3$s][%4$s][]', $option_key, $section, $name, 'ip_address' ) ), - 'value' => $ip_address, - 'classes' => 'ip_address', - // Data attributes are escaped in Form_Generator::prepare_data_attributes_string(). - 'data' => array( - 'placeholder' => __( 'Any IP Address', 'stream' ), - 'nonce' => wp_create_nonce( 'stream_get_ips' ), - ), - 'multiple' => true, - ), - false - ); - - // Hidden helper input. - $helper_input = sprintf( - '', - esc_attr( $option_key ), - esc_attr( $section ), - esc_attr( $name ), - 'exclude_row' - ); - - $exclude_rows[] = sprintf( - ' - - - - - - - ', - ( 0 !== (int) $key % 2 ) ? 'alternate' : '', - ( 'helper' === (string) $key ) ? 'hidden helper' : '', - '', - $helper_input, - $author_or_role_input, - $connector_or_context_input, - $connector_input, - $context_input, - $action_input, - $ip_address_input, - esc_html__( 'Delete', 'stream' ) - ); - } - - $no_rules_found_row = sprintf( - '', - esc_html__( 'No rules found.', 'stream' ) - ); - - $output .= '' . $heading_row . ''; - $output .= '' . $heading_row . ''; - $output .= '' . $no_rules_found_row . implode( '', $exclude_rows ) . ''; - - $output .= '
%1$s%2$s%3$s%4$s%5$s
%3$s %4$s%5$s%6$s %7$s %8$s%9$s%10$s - %11$s -
'; - - $output .= sprintf( '
%1$s
', $actions_bottom ); - - break; - } - $output .= ! empty( $description ) ? wp_kses_post( sprintf( '

%s

', $description ) ) : null; - - return $output; - } - - /** - * Render Callback for post_types field - * - * @param array $field Field to be rendered. - * - * @return string - */ - public function output_field( $field ) { - $method = 'output_' . $field['name']; - - if ( method_exists( $this, $method ) ) { - return call_user_func( array( $this, $method ), $field ); - } - - $output = $this->render_field( $field ); - - echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped - } - - /** - * Get an array of user roles - * - * @return array - */ - public function get_roles() { - $wp_roles = new WP_Roles(); - $roles = array(); - - foreach ( $wp_roles->get_names() as $role => $label ) { - $roles[ $role ] = translate_user_role( $label ); - } - - return $roles; - } - - /** - * Function will return all terms labels of given column - * - * @param string $column Name of the column. - * - * @return array - */ - public function get_terms_labels( $column ) { - $return_labels = array(); - - if ( isset( $this->plugin->connectors->term_labels[ 'stream_' . $column ] ) ) { - if ( 'context' === $column && isset( $this->plugin->connectors->term_labels['stream_connector'] ) ) { - $connectors = $this->plugin->connectors->term_labels['stream_connector']; - $contexts = $this->plugin->connectors->term_labels['stream_context']; - - foreach ( $connectors as $connector => $connector_label ) { - $return_labels[ $connector ]['label'] = $connector_label; - foreach ( $contexts as $context => $context_label ) { - if ( isset( $this->plugin->connectors->contexts[ $connector ] ) && array_key_exists( $context, $this->plugin->connectors->contexts[ $connector ] ) ) { - $return_labels[ $connector ]['children'][ $context ] = $context_label; - } - } - } - } else { - $return_labels = $this->plugin->connectors->term_labels[ 'stream_' . $column ]; - } - - ksort( $return_labels ); - } - - return $return_labels; - } - /** * Remove records when records TTL is shortened * @@ -1436,27 +533,4 @@ public function updated_option_ttl_remove_records( $old_value, $new_value ) { } } } - - /** - * Get translations of serialized Stream settings - * - * @filter wp_stream_serialized_labels - * - * @param array $labels Setting labels. - * - * @return array Multidimensional array of fields - */ - public function get_settings_translations( $labels ) { - if ( ! isset( $labels[ $this->option_key ] ) ) { - $labels[ $this->option_key ] = array(); - } - - foreach ( $this->get_fields() as $section_slug => $section ) { - foreach ( $section['fields'] as $field ) { - $labels[ $this->option_key ][ sprintf( '%s_%s', $section_slug, $field['name'] ) ] = $field['title']; - } - } - - return $labels; - } } diff --git a/connectors/class-connector-settings.php b/connectors/class-connector-settings.php index 8b0796259..5cdb9da7c 100644 --- a/connectors/class-connector-settings.php +++ b/connectors/class-connector-settings.php @@ -482,7 +482,7 @@ public function action_links( $links, $record ) { $url_tab = null; if ( '' !== $option_key ) { - foreach ( $plugin->settings->get_fields() as $tab_name => $tab_properties ) { + foreach ( $plugin->settings->registry->get_fields() as $tab_name => $tab_properties ) { foreach ( $tab_properties['fields'] as $field ) { $field_key = sprintf( '%s_%s', $tab_name, $field['name'] ); if ( $field_key === $option_key ) { diff --git a/tests/phpunit/Abilities_Test.php b/tests/phpunit/Abilities_Test.php index 0e652ea62..6fab79af9 100644 --- a/tests/phpunit/Abilities_Test.php +++ b/tests/phpunit/Abilities_Test.php @@ -239,7 +239,7 @@ public function test_settings_field_visible_when_not_network_activated() { $this->markTestSkipped( 'Test asserts the non-network-activated branch.' ); } - $fields = $this->plugin->settings->get_fields(); + $fields = $this->plugin->settings->registry->get_fields(); $advanced_field_names = wp_list_pluck( $fields['advanced']['fields'], 'name' ); $this->assertContains( diff --git a/tests/phpunit/Admin_Test.php b/tests/phpunit/Admin_Test.php index 83738dc8c..61ec9b722 100644 --- a/tests/phpunit/Admin_Test.php +++ b/tests/phpunit/Admin_Test.php @@ -236,7 +236,7 @@ public function test_filter_role_caps() { /** * Integration test for the running-state UI swap. Asserts that the - * "Clean Orphaned Meta" field in Settings::get_fields() flips from + * "Clean Orphaned Meta" field in Settings_Registry::get_fields() flips from * type=link to type=none and swaps its description when an auto-purge * chain is active. Admin_Purge::is_running_auto_purge() is covered in * isolation in Admin_Purge_Test; this test closes the loop on the @@ -252,7 +252,7 @@ public function test_clean_orphan_meta_field_reflects_running_state() { } $find_field = function () { - $fields = $this->plugin->settings->get_fields(); + $fields = $this->plugin->settings->registry->get_fields(); foreach ( $fields['advanced']['fields'] as $field ) { if ( isset( $field['name'] ) && 'clean_orphan_meta' === $field['name'] ) { return $field; @@ -310,7 +310,7 @@ public function test_delete_all_records_field_reflects_running_state() { } $find_field = function () { - $fields = $this->plugin->settings->get_fields(); + $fields = $this->plugin->settings->registry->get_fields(); foreach ( $fields['advanced']['fields'] as $field ) { if ( isset( $field['name'] ) && 'delete_all_records' === $field['name'] ) { return $field; @@ -357,9 +357,9 @@ public function test_delete_all_records_field_reflects_running_state() { } /** - * Verifies that get_deletion_warning() honours the pre-computed deletion - * state passed by build_delete_all_records_field(), avoiding a duplicate - * Action Scheduler query inside a single render. + * Verifies that Settings_Registry::get_deletion_warning() honours the + * pre-computed deletion state passed by build_delete_all_records_field(), + * avoiding a duplicate Action Scheduler query inside a single render. */ public function test_get_deletion_warning_respects_precomputed_state() { if ( function_exists( 'as_unschedule_all_actions' ) ) { @@ -369,7 +369,7 @@ public function test_get_deletion_warning_respects_precomputed_state() { // No deletion scheduled, but caller asserts "running" — message must reflect the argument. $this->assertStringContainsString( 'Currently deleting records', - $this->plugin->settings->get_deletion_warning( true ), + $this->plugin->settings->registry->get_deletion_warning( true ), 'When caller passes true, message must reflect running deletion regardless of AS state' ); @@ -385,7 +385,7 @@ public function test_get_deletion_warning_respects_precomputed_state() { ); $this->assertStringNotContainsString( 'Currently deleting records', - $this->plugin->settings->get_deletion_warning( false ), + $this->plugin->settings->registry->get_deletion_warning( false ), 'When caller passes false, message must reflect idle state regardless of AS state' ); diff --git a/tests/phpunit/abilities/Ability_Update_Settings_Test.php b/tests/phpunit/abilities/Ability_Update_Settings_Test.php index fbd4aab2c..6dd0fe2ba 100644 --- a/tests/phpunit/abilities/Ability_Update_Settings_Test.php +++ b/tests/phpunit/abilities/Ability_Update_Settings_Test.php @@ -188,7 +188,7 @@ public function test_boolean_values_for_checkbox_keys_are_normalized_to_one_zero $option_key = $this->plugin->settings->option_key; // JSON-native boolean true must round-trip to 1, not '' (which is what - // Settings::sanitize_setting_by_field_type() would produce for a + // Settings_Sanitizer::sanitize_setting_by_field_type() would produce for a // raw bool because it gates on is_numeric()). $result = $this->ability->execute( array( diff --git a/tests/phpunit/unit/Settings_Registry_Unit_Test.php b/tests/phpunit/unit/Settings_Registry_Unit_Test.php new file mode 100644 index 000000000..d728d5c40 --- /dev/null +++ b/tests/phpunit/unit/Settings_Registry_Unit_Test.php @@ -0,0 +1,318 @@ +stubTranslationFunctions(); + $this->stubEscapeFunctions(); + + Functions\when( 'is_admin' )->justReturn( false ); + Functions\when( 'is_network_admin' )->justReturn( false ); + Functions\when( 'translate_user_role' )->returnArg(); + Functions\when( 'wp_create_nonce' )->justReturn( 'test-nonce' ); + Functions\when( 'admin_url' )->justReturn( 'http://example.com/wp-admin/admin-ajax.php' ); + Functions\when( 'add_query_arg' )->alias( array( self::class, 'add_query_arg_stub' ) ); + self::stub_wp_roles(); + + $this->plugin = Mockery::mock( Plugin::class ); + $this->plugin->admin = Mockery::mock( Admin::class ); + $this->plugin->admin->purge = Mockery::mock( Admin_Purge::class ); + + $this->plugin->shouldReceive( 'is_network_activated' )->andReturn( false )->byDefault(); + $this->plugin->shouldReceive( 'is_multisite_network_activated' )->andReturn( false )->byDefault(); + $this->plugin->shouldReceive( 'is_multisite_not_network_activated' )->andReturn( false )->byDefault(); + $this->plugin->admin->purge->shouldReceive( 'is_running_async_deletion' )->andReturn( false )->byDefault(); + $this->plugin->admin->purge->shouldReceive( 'is_running_auto_purge' )->andReturn( false )->byDefault(); + + $this->registry = new Settings_Registry( $this->plugin ); + } + + /** + * Alias a test-local role source as WP_Roles and stub wp_roles(). + * + * Host unit tests do not load WordPress; Settings_Registry::get_roles() + * calls wp_roles() and requires a WP_Roles instance. + * + * @return void + */ + private static function stub_wp_roles() { + if ( ! class_exists( \WP_Roles::class, false ) ) { + class_alias( Settings_Registry_Wp_Roles_Stub::class, 'WP_Roles' ); + } + + Functions\when( 'wp_roles' )->justReturn( new Settings_Registry_Wp_Roles_Stub() ); + } + + /** + * Minimal add_query_arg stand-in. + * + * @param mixed ...$args add_query_arg argument list. + * @return string + */ + public static function add_query_arg_stub( ...$args ) { + if ( 2 === count( $args ) && is_array( $args[0] ) ) { + $url = (string) $args[1]; + $query = http_build_query( $args[0] ); + return false === strpos( $url, '?' ) ? $url . '?' . $query : $url . '&' . $query; + } + + return 'http://example.com/wp-admin/admin-ajax.php'; + } + + /** + * Inject a network-style field, matching Network::get_network_admin_fields(). + * + * @param array $fields Existing option fields. + * @return array + */ + public static function inject_site_access_field( $fields ) { + $fields['general']['fields'][] = array( + 'name' => 'site_access', + 'title' => 'Site Access', + 'type' => 'checkbox', + 'default' => 1, + ); + + return $fields; + } + + public function test_get_fields_contains_core_sections() { + $fields = $this->registry->get_fields(); + + $this->assertArrayHasKey( 'general', $fields ); + $this->assertArrayHasKey( 'exclude', $fields ); + $this->assertArrayHasKey( 'advanced', $fields ); + } + + /** + * Registered fields match the expected section, type, and extra keys. + * + * @param string $section Section slug. + * @param string $name Field name. + * @param string $type Expected field type. + * @param array $extra Optional extra key => expected value. + */ + #[DataProvider( 'data_field_schema' )] + public function test_registered_field_matches_schema( $section, $name, $type, $extra ) { + $field = $this->find_field( $this->registry->get_fields(), $section, $name ); + + $this->assertNotNull( $field ); + $this->assertSame( $type, $field['type'] ); + + foreach ( $extra as $key => $value ) { + $this->assertSame( $value, $field[ $key ], "Failed asserting field[{$key}]" ); + } + } + + /** + * Schema cases for core fields that share find + type assertions. + * + * @return array}> + */ + public static function data_field_schema() { + return array( + 'records_ttl' => array( 'general', 'records_ttl', 'number', array( 'default' => 30 ) ), + 'exclude_rules' => array( 'exclude', 'rules', 'rule_list', array() ), + 'delete_all_records' => array( 'advanced', 'delete_all_records', 'link', array() ), + 'clean_orphan_meta' => array( 'advanced', 'clean_orphan_meta', 'link', array() ), + ); + } + + /** + * has_field() / get_field() accept `{section}_{name}` and reject bare names. + * + * @param string $key Option key under test. + * @param bool $exists Expected has_field(). + * @param string|null $expected_name Expected field name when present. + * @param string|null $expected_type Expected field type when present. + */ + #[DataProvider( 'data_field_lookup' )] + public function test_has_field_and_get_field_use_section_name_option_key( $key, $exists, $expected_name, $expected_type ) { + $this->assertSame( $exists, $this->registry->has_field( $key ) ); + + if ( $exists ) { + $field = $this->registry->get_field( $key ); + $this->assertIsArray( $field ); + $this->assertSame( $expected_name, $field['name'] ); + $this->assertSame( $expected_type, $field['type'] ); + return; + } + + $this->assertNull( $this->registry->get_field( $key ) ); + } + + /** + * Lookup cases for option keys vs bare names and stale aliases. + * + * @return array + */ + public static function data_field_lookup() { + return array( + 'option_key' => array( 'general_records_ttl', true, 'records_ttl', 'number' ), + 'bare_name' => array( 'records_ttl', false, null, null ), + 'stale_jira_alias' => array( 'keep_records_for', false, null, null ), + 'unknown_section_key' => array( 'general_unknown', false, null, null ), + ); + } + + /** + * Defaults are keyed by `{section}_{name}`. + * + * @param string $key Defaults key (`{section}_{name}`). + * @param mixed $expected Expected default. + */ + #[DataProvider( 'data_defaults' )] + public function test_get_defaults_uses_section_name_keys( $key, $expected ) { + $defaults = $this->registry->get_defaults(); + + $this->assertSame( $expected, $defaults[ $key ] ); + } + + /** + * Default values for representative option keys. + * + * @return array + */ + public static function data_defaults() { + return array( + 'records_ttl' => array( 'general_records_ttl', 30 ), + 'keep_indefinitely' => array( 'general_keep_records_indefinitely', 0 ), + 'exclude_rules' => array( 'exclude_rules', array() ), + ); + } + + public function test_network_filter_injects_fields_into_get_fields() { + Filters\expectApplied( 'wp_stream_settings_option_fields' ) + ->andReturnUsing( array( self::class, 'inject_site_access_field' ) ); + + $this->assertTrue( $this->registry->has_field( 'general_site_access' ) ); + $field = $this->registry->get_field( 'general_site_access' ); + $this->assertSame( 'checkbox', $field['type'] ); + } + + /** + * Settings translations label `{section}_{name}` keys under the option group. + * + * @param string $option_key Settings option key. + * @param string $field_key `{section}_{name}` label key. + * @param string $expected Expected title. + */ + #[DataProvider( 'data_settings_translations' )] + public function test_get_settings_translations_labels_option_keys( $option_key, $field_key, $expected ) { + $labels = $this->registry->get_settings_translations( array(), $option_key ); + + $this->assertSame( $expected, $labels[ $option_key ][ $field_key ] ); + } + + /** + * Translation labels for representative option keys. + * + * @return array + */ + public static function data_settings_translations() { + return array( + 'records_ttl' => array( 'wp_stream', 'general_records_ttl', 'Keep Records for' ), + 'exclude_rules' => array( 'wp_stream', 'exclude_rules', 'Exclude Rules' ), + ); + } + + /** + * Deletion warning text depends on the precomputed running flag. + * + * @param bool $is_running Whether deletion is running. + * @param string $needle Expected substring. + */ + #[DataProvider( 'data_deletion_warning' )] + public function test_get_deletion_warning_uses_precomputed_running_state( $is_running, $needle ) { + $this->assertStringContainsString( $needle, $this->registry->get_deletion_warning( $is_running ) ); + } + + /** + * Running vs idle single-site deletion warning needles. + * + * @return array + */ + public static function data_deletion_warning() { + return array( + 'running' => array( true, 'Currently deleting records' ), + 'idle_single_site' => array( false, 'Warning: This will delete all activity records from the database.' ), + ); + } + + public function test_get_roles_returns_translated_wp_role_names() { + $roles = Settings_Registry::get_roles(); + + $this->assertArrayHasKey( 'administrator', $roles ); + $this->assertSame( 'Administrator', $roles['administrator'] ); + } + + public function test_get_roles_returns_empty_array_when_wp_roles_unavailable() { + Functions\when( 'wp_roles' )->justReturn( null ); + + $this->assertSame( array(), Settings_Registry::get_roles() ); + } + + /** + * Find a field definition by section and name. + * + * @param array $fields get_fields() output. + * @param string $section Section slug. + * @param string $name Field name. + * @return array|null + */ + private function find_field( $fields, $section, $name ) { + if ( empty( $fields[ $section ]['fields'] ) ) { + return null; + } + + foreach ( $fields[ $section ]['fields'] as $field ) { + if ( isset( $field['name'] ) && $name === $field['name'] ) { + return $field; + } + } + + return null; + } +} + +/** + * Test-local role list for Settings_Registry::get_roles(). + */ +class Settings_Registry_Wp_Roles_Stub { + + /** + * Return role slug => label pairs. + * + * @return array + */ + public function get_names() { + return array( + 'administrator' => 'Administrator', + 'editor' => 'Editor', + 'author' => 'Author', + 'contributor' => 'Contributor', + 'subscriber' => 'Subscriber', + ); + } +} diff --git a/tests/phpunit/unit/Settings_Renderer_Unit_Test.php b/tests/phpunit/unit/Settings_Renderer_Unit_Test.php new file mode 100644 index 000000000..72975b9f8 --- /dev/null +++ b/tests/phpunit/unit/Settings_Renderer_Unit_Test.php @@ -0,0 +1,283 @@ +stubTranslationFunctions(); + $this->stubEscapeFunctions(); + + Functions\when( 'wp_kses_post' )->returnArg(); + Functions\when( 'absint' )->alias( array( self::class, 'absint_stub' ) ); + Functions\when( 'checked' )->alias( array( self::class, 'checked_stub' ) ); + Functions\when( 'selected' )->alias( array( self::class, 'selected_stub' ) ); + Functions\when( 'translate_user_role' )->returnArg(); + Functions\when( 'wp_create_nonce' )->justReturn( 'test-nonce' ); + Functions\when( 'wp_parse_args' )->alias( array( self::class, 'wp_parse_args_stub' ) ); + Functions\when( 'wp_roles' )->justReturn( null ); + Functions\when( 'count_users' )->justReturn( + array( + 'avail_roles' => array( + 'administrator' => 1, + ), + ) + ); + + $this->plugin = Mockery::mock( Plugin::class ); + $this->plugin->connectors = Mockery::mock( Connectors::class ); + $this->plugin->connectors->term_labels = array( + 'stream_action' => array( + 'updated' => 'Updated', + ), + 'stream_connector' => array( + 'posts' => 'Posts', + ), + 'stream_context' => array( + 'post' => 'Posts', + ), + ); + $this->plugin->connectors->contexts = array( + 'posts' => array( + 'post' => 'Posts', + ), + ); + + $this->renderer = new Settings_Renderer( $this->plugin ); + } + + /** + * WordPress absint stand-in. + * + * @param mixed $value Raw value. + * @return int + */ + public static function absint_stub( $value ) { + return abs( (int) $value ); + } + + /** + * WordPress checked() stand-in. + * + * @param mixed $checked Value to check. + * @param mixed $current Comparison value. + * @param bool $echo Unused. + * @return string + */ + public static function checked_stub( $checked, $current = true, $echo = true ) { + unset( $echo ); + return ( (string) $checked === (string) $current ) ? ' checked="checked"' : ''; + } + + /** + * WordPress selected() stand-in. + * + * @param mixed $selected Value to check. + * @param mixed $current Comparison value. + * @param bool $echo Unused. + * @return string + */ + public static function selected_stub( $selected, $current = true, $echo = true ) { + unset( $echo ); + return ( (string) $selected === (string) $current ) ? ' selected="selected"' : ''; + } + + /** + * WordPress wp_parse_args stand-in. + * + * @param array $args Incoming args. + * @param array $defaults Defaults. + * @return array + */ + public static function wp_parse_args_stub( $args, $defaults = array() ) { + return array_merge( $defaults, (array) $args ); + } + + /** + * Build a field array for render_field(), with general/records_ttl defaults. + * + * @param array $overrides Field keys merged over the defaults. + * @return array + */ + private function make_field( array $overrides = array() ) { + return array_merge( + array( + 'type' => 'text', + 'section' => 'general', + 'name' => 'records_ttl', + ), + $overrides + ); + } + + /** + * Rendered control HTML matches type-specific name, value, and description needles. + * + * @param array $overrides Merged into make_field() defaults. + * @param string[] $contains Substrings that must appear. + * @param string[] $not_contains Substrings that must not appear. + * @param string[] $count_once Substrings that must appear exactly once. + */ + #[DataProvider( 'data_render_field_html' )] + public function test_render_field_html( $overrides, $contains, $not_contains, $count_once ) { + $html = $this->renderer->render_field( + $this->make_field( $overrides ), + array(), + 'wp_stream' + ); + + foreach ( $contains as $needle ) { + $this->assertStringContainsString( $needle, $html, "Expected HTML to contain: {$needle}" ); + } + + foreach ( $not_contains as $needle ) { + $this->assertStringNotContainsString( $needle, $html, "Expected HTML not to contain: {$needle}" ); + } + + foreach ( $count_once as $needle ) { + $this->assertSame( 1, substr_count( $html, $needle ), "Expected HTML to contain exactly once: {$needle}" ); + } + } + + /** + * Render-field HTML cases keyed by control type. + * + * @return array, 2: array, 3: array}> + */ + public static function data_render_field_html() { + return array( + 'number' => array( + array( + 'type' => 'number', + 'name' => 'records_ttl', + 'class' => 'small-text', + 'min' => 1, + 'max' => 999, + 'step' => 1, + 'after_field' => 'days', + 'value' => 30, + ), + array( + 'type="number"', + 'name="wp_stream[general_records_ttl]"', + 'value="30"', + 'days', + ), + array(), + array(), + ), + 'checkbox' => array( + array( + 'type' => 'checkbox', + 'name' => 'keep_records_indefinitely', + 'after_field' => 'Enabled', + 'value' => 1, + ), + array( + 'type="checkbox"', + 'name="wp_stream[general_keep_records_indefinitely]"', + 'checked="checked"', + 'Enabled', + ), + array(), + array(), + ), + 'multi_checkbox' => array( + array( + 'type' => 'multi_checkbox', + 'name' => 'role_access', + 'choices' => array( + 'administrator' => 'Administrator', + 'editor' => 'Editor', + ), + 'value' => array( 'administrator' ), + ), + array( + 'name="wp_stream[general_role_access][]"', + 'value="administrator"', + 'value="editor"', + '__placeholder__', + 'Administrator', + ), + array(), + array(), + ), + 'link' => array( + array( + 'type' => 'link', + 'section' => 'advanced', + 'name' => 'delete_all_records', + 'class' => 'warning', + 'href' => 'http://example.com/reset', + 'title' => 'Reset Stream Database', + 'desc' => 'Warning: This will delete all activity records from the database.', + ), + array( + ' array( + array( + 'type' => 'none', + 'section' => 'advanced', + 'name' => 'delete_all_records', + 'desc' => 'Currently deleting records. Please be patient, this can take a while.', + ), + array( + 'class="description"', + 'Currently deleting records', + ), + array( + ' array( + array( + 'type' => 'rule_list', + 'section' => 'exclude', + 'name' => 'rules', + 'desc' => 'Create rules to exclude certain kinds of activity from being recorded by Stream.', + ), + array( + 'stream-exclude-list', + 'Add New Rule', + 'Create rules to exclude certain kinds of activity', + ), + array(), + array( + 'Create rules to exclude certain kinds of activity from being recorded by Stream.', + ), + ), + ); + } + + public function test_render_field_returns_empty_when_required_keys_missing() { + $this->assertSame( '', $this->renderer->render_field( array( 'type' => 'text' ), array(), 'wp_stream' ) ); + } +} diff --git a/tests/phpunit/unit/Settings_Sanitizer_Unit_Test.php b/tests/phpunit/unit/Settings_Sanitizer_Unit_Test.php new file mode 100644 index 000000000..fe706e19d --- /dev/null +++ b/tests/phpunit/unit/Settings_Sanitizer_Unit_Test.php @@ -0,0 +1,197 @@ +alias( array( self::class, 'sanitize_text_field_stub' ) ); + Functions\when( 'absint' )->alias( array( self::class, 'absint_stub' ) ); + $registry = \Mockery::mock( Settings_Registry::class ); + $settings = \Mockery::mock( Settings::class ); + $settings->registry = $registry; + $plugin = \Mockery::mock( Plugin::class ); + $plugin->settings = $settings; + $this->sanitizer = new Settings_Sanitizer( $plugin ); + } + + /** + * Trim stand-in for sanitize_text_field. + * + * @param mixed $value Raw value. + * @return mixed + */ + public static function sanitize_text_field_stub( $value ) { + return is_string( $value ) ? trim( $value ) : $value; + } + + /** + * WordPress absint stand-in. + * + * @param mixed $value Raw value. + * @return int + */ + public static function absint_stub( $value ) { + return abs( (int) $value ); + } + + /** + * Number fields coerce numeric strings to int and reject non-numeric input. + * + * @param mixed $value Raw posted value. + * @param mixed $expected Expected sanitized value. + */ + #[DataProvider( 'data_number_values' )] + public function test_sanitize_setting_by_field_type_number( $value, $expected ) { + $this->assertSame( + $expected, + $this->sanitizer->sanitize_setting_by_field_type( $value, 'number' ) + ); + } + + /** + * Number sanitizer cases. + * + * @return array + */ + public static function data_number_values() { + return array( + 'integer string' => array( '30', 30 ), + 'padded string' => array( ' 7 ', 7 ), + 'integer' => array( 42, 42 ), + 'zero' => array( '0', 0 ), + 'non-numeric' => array( 'abc', '' ), + 'empty string' => array( '', '' ), + ); + } + + /** + * Checkbox fields coerce numeric values via absint and reject non-numeric input. + * + * @param mixed $value Raw posted value. + * @param mixed $expected Expected sanitized value. + */ + #[DataProvider( 'data_checkbox_values' )] + public function test_sanitize_setting_by_field_type_checkbox( $value, $expected ) { + $this->assertSame( + $expected, + $this->sanitizer->sanitize_setting_by_field_type( $value, 'checkbox' ) + ); + } + + /** + * Checkbox sanitizer cases. + * + * @return array + */ + public static function data_checkbox_values() { + return array( + 'one' => array( '1', 1 ), + 'zero' => array( '0', 0 ), + 'negative' => array( '-3', 3 ), + 'non-numeric' => array( 'yes', '' ), + 'empty' => array( '', '' ), + ); + } + + public function test_sanitize_setting_by_field_type_default_scalar() { + $this->assertSame( + 'hello world', + $this->sanitizer->sanitize_setting_by_field_type( ' hello world ', 'text' ) + ); + } + + public function test_sanitize_setting_by_field_type_default_nested_array() { + $input = array( + 'outer' => array( + 'inner' => ' nested ', + ), + 'flat' => ' value ', + ); + + $expected = array( + 'outer' => array( + 'inner' => 'nested', + ), + 'flat' => 'value', + ); + + $this->assertSame( + $expected, + $this->sanitizer->sanitize_setting_by_field_type( $input, 'rule_list' ) + ); + } + + public function test_sanitize_settings_emits_section_name_keys_and_skips_empty() { + $fields = array( + 'general' => array( + 'fields' => array( + array( + 'name' => 'records_ttl', + 'type' => 'number', + ), + array( + 'name' => 'keep_records_indefinitely', + 'type' => 'checkbox', + ), + array( + 'name' => 'role_access', + 'type' => 'multi_checkbox', + ), + ), + ), + 'advanced' => array( + 'fields' => array(), + ), + ); + + $input = array( + 'general_records_ttl' => '30', + 'general_keep_records_indefinitely' => '', + 'general_role_access' => array( ' administrator ', 'editor' ), + 'general_missing_from_input' => 'ignored-because-not-a-field', + ); + + $output = $this->sanitizer->sanitize_settings( $input, $fields ); + + $this->assertSame( 30, $output['general_records_ttl'] ); + $this->assertArrayNotHasKey( 'general_keep_records_indefinitely', $output ); + $this->assertSame( array( 'administrator', 'editor' ), $output['general_role_access'] ); + $this->assertArrayNotHasKey( 'general_missing_from_input', $output ); + } + + public function test_sanitize_settings_skips_fields_without_type_or_name() { + $fields = array( + 'general' => array( + 'fields' => array( + array( + 'name' => 'no_type', + ), + array( + 'type' => 'text', + ), + ), + ), + ); + + $output = $this->sanitizer->sanitize_settings( + array( + 'general_no_type' => 'x', + 'general_' => 'y', + ), + $fields + ); + + $this->assertSame( array(), $output ); + } +}