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(
+ '
';
+ 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(
+ '
+ | %1$s |
+ %2$s |
+ %3$s |
+ %4$s |
+ %5$s |
+ %6$s |
+
',
+ '',
+ 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(
+ '| %1$s |
',
+ esc_html__( 'No rules found.', 'stream' )
+ );
+
+ $output .= '' . $heading_row . '';
+ $output .= '' . $heading_row . '';
+ $output .= '' . $no_rules_found_row . implode( '', $exclude_rows ) . '';
+
+ $output .= '
';
+
+ $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(
- '';
- 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(
- '
- | %1$s |
- %2$s |
- %3$s |
- %4$s |
- %5$s |
- %6$s |
-
',
- '',
- 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(
- '
- | %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' )
- );
- }
-
- $no_rules_found_row = sprintf(
- '| %1$s |
',
- esc_html__( 'No rules found.', 'stream' )
- );
-
- $output .= '' . $heading_row . '';
- $output .= '' . $heading_row . '';
- $output .= '' . $no_rules_found_row . implode( '', $exclude_rows ) . '';
-
- $output .= '
';
-
- $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 );
+ }
+}