From 6aedd6a46e4a2c7f3f2b5172f0e09420668ea932 Mon Sep 17 00:00:00 2001 From: Nika Siradze Date: Tue, 30 Jun 2026 16:20:07 +0400 Subject: [PATCH 1/3] Settings: tabbed page absorbing Tools (General / Tools tabs) --- assets/css/admin.css | 4 ++ src/Admin/Admin.php | 89 +++++++++++++++++++++--------------- src/Admin/views/settings.php | 6 --- src/Admin/views/tools.php | 6 --- 4 files changed, 56 insertions(+), 49 deletions(-) diff --git a/assets/css/admin.css b/assets/css/admin.css index fbbc979..4abc8ff 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -314,6 +314,10 @@ text-decoration: none; } +.aggregate-it .ai-tabs { + margin: 12px 0 20px; +} + .aggregate-it .ai-preview { margin: 8px 0; max-width: 880px; diff --git a/src/Admin/Admin.php b/src/Admin/Admin.php index 1540466..2515486 100644 --- a/src/Admin/Admin.php +++ b/src/Admin/Admin.php @@ -136,15 +136,6 @@ public function menu(): void { self::SLUG . '-settings', [ $this, 'render_settings' ] ); - - $this->hooks[] = add_submenu_page( - self::SLUG, - __( 'Tools', 'aggregate-it' ), - __( 'Tools', 'aggregate-it' ), - 'manage_options', - self::SLUG . '-tools', - [ $this, 'render_tools' ] - ); } public function assets( string $hook ): void { @@ -765,8 +756,49 @@ private function is_entity_post( int $id ): bool { } public function render_settings(): void { + $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'general'; // phpcs:ignore WordPress.Security.NonceVerification + $tabs = [ + 'general' => __( 'General', 'aggregate-it' ), + 'tools' => __( 'Tools', 'aggregate-it' ), + ]; + if ( ! isset( $tabs[ $tab ] ) ) { + $tab = 'general'; + } + $settings = $this->plugin->settings(); - require AGGREGATE_IT_PATH . 'src/Admin/views/settings.php'; + + if ( $tab === 'tools' ) { + $flash = get_transient( 'aggregate_it_flash' ); + if ( $flash ) { + delete_transient( 'aggregate_it_flash' ); + } + $flash_type = 'success'; + if ( is_array( $flash ) ) { + $flash_type = sanitize_key( (string) ( $flash['type'] ?? 'success' ) ); + $flash = (string) ( $flash['message'] ?? '' ); + } + $blacklist = $settings->blacklist_raw(); + $events = ActivityLog::recent( 200 ); + $info = $this->system_info(); + } + + echo '
'; + echo '

' . esc_html__( 'Settings', 'aggregate-it' ) . '

'; + echo ''; + + $tab_embedded = true; + require AGGREGATE_IT_PATH . 'src/Admin/views/' . ( $tab === 'tools' ? 'tools.php' : 'settings.php' ); + + echo '
'; } public function handle_save_settings(): void { @@ -839,23 +871,6 @@ public function render_activity(): void { require AGGREGATE_IT_PATH . 'src/Admin/views/activity.php'; } - public function render_tools(): void { - $settings = $this->plugin->settings(); - $flash = get_transient( 'aggregate_it_flash' ); - if ( $flash ) { - delete_transient( 'aggregate_it_flash' ); - } - $flash_type = 'success'; - if ( is_array( $flash ) ) { - $flash_type = sanitize_key( (string) ( $flash['type'] ?? 'success' ) ); - $flash = (string) ( $flash['message'] ?? '' ); - } - $blacklist = $settings->blacklist_raw(); - $events = ActivityLog::recent( 200 ); - $info = $this->system_info(); - require AGGREGATE_IT_PATH . 'src/Admin/views/tools.php'; - } - public function handle_bulk_add_sources(): void { $this->guard( 'aggregate_it_bulk_add_sources' ); @@ -875,7 +890,7 @@ public function handle_bulk_add_sources(): void { /* translators: %d: number of feeds added */ $this->flash( sprintf( _n( '%d feed added.', '%d feeds added.', $added, 'aggregate-it' ), $added ) ); - $this->redirect( self::SLUG . '-tools', '' ); + $this->redirect( self::SLUG . '-settings', '', [ 'tab' => 'tools' ] ); } public function handle_save_blacklist(): void { @@ -883,7 +898,7 @@ public function handle_save_blacklist(): void { $this->plugin->settings()->set( 'blacklist', sanitize_textarea_field( wp_unslash( $_POST['blacklist'] ?? '' ) ) ); $this->flash( __( 'Blacklist saved.', 'aggregate-it' ) ); - $this->redirect( self::SLUG . '-tools', '' ); + $this->redirect( self::SLUG . '-settings', '', [ 'tab' => 'tools' ] ); } public function handle_export_config(): void { @@ -919,23 +934,23 @@ public function handle_import_config(): void { $upload = $_FILES['config'] ?? null; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput if ( ! is_array( $upload ) ) { $this->flash( __( 'Choose a JSON or XML file to import.', 'aggregate-it' ), 'error' ); - $this->redirect( self::SLUG . '-tools', '' ); + $this->redirect( self::SLUG . '-settings', '', [ 'tab' => 'tools' ] ); } $raw = $this->read_import_upload( $upload ); if ( $raw === null ) { - $this->redirect( self::SLUG . '-tools', '' ); + $this->redirect( self::SLUG . '-settings', '', [ 'tab' => 'tools' ] ); } if ( str_starts_with( ltrim( $raw ), '<' ) ) { $this->flash( $this->import_wxr_config( $raw ) ); - $this->redirect( self::SLUG . '-tools', '' ); + $this->redirect( self::SLUG . '-settings', '', [ 'tab' => 'tools' ] ); } $data = json_decode( $raw, true ); if ( ! is_array( $data ) ) { $this->flash( __( 'That file is not valid JSON or WordPress XML.', 'aggregate-it' ), 'error' ); - $this->redirect( self::SLUG . '-tools', '' ); + $this->redirect( self::SLUG . '-settings', '', [ 'tab' => 'tools' ] ); } if ( isset( $data['aggregate_it_export'] ) ) { @@ -952,14 +967,14 @@ public function handle_import_config(): void { } } - $this->redirect( self::SLUG . '-tools', '' ); + $this->redirect( self::SLUG . '-settings', '', [ 'tab' => 'tools' ] ); } public function handle_clear_logs(): void { $this->guard( 'aggregate_it_clear_logs' ); ActivityLog::clear(); $this->flash( __( 'Activity log cleared.', 'aggregate-it' ) ); - $this->redirect( self::SLUG . '-tools', '' ); + $this->redirect( self::SLUG . '-settings', '', [ 'tab' => 'tools' ] ); } public function handle_reset(): void { @@ -1000,7 +1015,7 @@ public function handle_reset(): void { $this->flash( __( 'Nothing was reset.', 'aggregate-it' ) ); } - $this->redirect( self::SLUG . '-tools', '' ); + $this->redirect( self::SLUG . '-settings', '', [ 'tab' => 'tools' ] ); } private function flash( string $message, string $type = 'success' ): void { @@ -1142,7 +1157,7 @@ private function import_wxr_config( string $raw ): string { if ( $xml === false || ! isset( $xml->channel->item ) ) { $this->flash( __( 'That file is not valid WordPress XML.', 'aggregate-it' ), 'error' ); - $this->redirect( self::SLUG . '-tools', '' ); + $this->redirect( self::SLUG . '-settings', '', [ 'tab' => 'tools' ] ); } $sources = []; diff --git a/src/Admin/views/settings.php b/src/Admin/views/settings.php index eb45e46..236fe7b 100644 --- a/src/Admin/views/settings.php +++ b/src/Admin/views/settings.php @@ -13,11 +13,6 @@ $keyword_list = implode( "\n", $settings->keyword_list() ); $has_key = $settings->api_key() !== ''; ?> -
-
-

-
-

@@ -272,4 +267,3 @@ function sync() { } } )(); -
diff --git a/src/Admin/views/tools.php b/src/Admin/views/tools.php index 3e84edf..db57fc6 100644 --- a/src/Admin/views/tools.php +++ b/src/Admin/views/tools.php @@ -22,11 +22,6 @@ $info_text .= $label . ': ' . $value . "\n"; } ?> -
-
-

-
-

@@ -152,4 +147,3 @@
- From 8c90eccef326bf13b5805466c69e86b52113a7a2 Mon Sep 17 00:00:00 2001 From: Nika Siradze Date: Tue, 30 Jun 2026 16:29:26 +0400 Subject: [PATCH 2/3] Settings: add global Rules tab (per post type) + consistent Activity header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Global rules: per-post-type field->meta rules in Settings → Rules, applied to ALL posts of the type from their own meta; GlobalRulesRefresher runs daily and on save. Complements per-source scrape rules. - Activity page header wrapped in .ai-head to match the other pages. --- src/Admin/Admin.php | 49 ++++++++- src/Admin/views/activity.php | 4 +- src/Admin/views/settings-rules.php | 124 +++++++++++++++++++++++ src/Maintenance/GlobalRulesRefresher.php | 106 +++++++++++++++++++ src/Plugin.php | 2 + src/Settings.php | 17 ++++ 6 files changed, 300 insertions(+), 2 deletions(-) create mode 100644 src/Admin/views/settings-rules.php create mode 100644 src/Maintenance/GlobalRulesRefresher.php diff --git a/src/Admin/Admin.php b/src/Admin/Admin.php index 2515486..37e4706 100644 --- a/src/Admin/Admin.php +++ b/src/Admin/Admin.php @@ -34,6 +34,7 @@ public function register(): void { add_action( 'admin_post_aggregate_it_approve_hub', [ $this, 'handle_approve_hub' ] ); add_action( 'admin_post_aggregate_it_trash_hub', [ $this, 'handle_trash_hub' ] ); add_action( 'admin_post_aggregate_it_save_settings', [ $this, 'handle_save_settings' ] ); + add_action( 'admin_post_aggregate_it_save_rules', [ $this, 'handle_save_rules' ] ); add_action( 'admin_post_aggregate_it_dismiss_setup', [ $this, 'handle_dismiss_setup' ] ); add_action( 'admin_post_aggregate_it_retry_article', [ $this, 'handle_retry_article' ] ); add_action( 'admin_post_aggregate_it_retry_failed', [ $this, 'handle_retry_failed' ] ); @@ -759,6 +760,7 @@ public function render_settings(): void { $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'general'; // phpcs:ignore WordPress.Security.NonceVerification $tabs = [ 'general' => __( 'General', 'aggregate-it' ), + 'rules' => __( 'Rules', 'aggregate-it' ), 'tools' => __( 'Tools', 'aggregate-it' ), ]; if ( ! isset( $tabs[ $tab ] ) ) { @@ -767,6 +769,19 @@ public function render_settings(): void { $settings = $this->plugin->settings(); + if ( $tab === 'rules' ) { + $public_types = get_post_types( [ 'public' => true ], 'objects' ); + unset( $public_types['attachment'] ); + $global = $settings->global_rules(); + $rtype = isset( $_GET['rtype'] ) ? sanitize_key( wp_unslash( $_GET['rtype'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification + if ( ! $rtype || ! isset( $public_types[ $rtype ] ) ) { + $rtype = (string) ( array_key_first( $global ) ?: 'post' ); + } + $rules = (array) ( $global[ $rtype ] ?? [] ); + $saved = isset( $_GET['ai_notice'] ) && sanitize_key( wp_unslash( $_GET['ai_notice'] ) ) === 'saved'; // phpcs:ignore WordPress.Security.NonceVerification + $rule_ops = self::rule_op_labels(); + } + if ( $tab === 'tools' ) { $flash = get_transient( 'aggregate_it_flash' ); if ( $flash ) { @@ -796,11 +811,43 @@ public function render_settings(): void { echo ''; $tab_embedded = true; - require AGGREGATE_IT_PATH . 'src/Admin/views/' . ( $tab === 'tools' ? 'tools.php' : 'settings.php' ); + $view = [ 'general' => 'settings.php', 'rules' => 'settings-rules.php', 'tools' => 'tools.php' ][ $tab ]; + require AGGREGATE_IT_PATH . 'src/Admin/views/' . $view; echo ''; } + /** @return array rule operator => label */ + public static function rule_op_labels(): array { + return [ + 'always' => __( 'always', 'aggregate-it' ), + 'equals' => __( 'equals', 'aggregate-it' ), + 'not_equals' => __( 'does not equal', 'aggregate-it' ), + 'contains' => __( 'contains', 'aggregate-it' ), + 'not_contains' => __( 'does not contain', 'aggregate-it' ), + 'empty' => __( 'is empty', 'aggregate-it' ), + 'not_empty' => __( 'is not empty', 'aggregate-it' ), + 'date_past' => __( 'date is in the past', 'aggregate-it' ), + 'date_future' => __( 'date is in the future', 'aggregate-it' ), + 'gt' => __( 'greater than', 'aggregate-it' ), + 'lt' => __( 'less than', 'aggregate-it' ), + ]; + } + + public function handle_save_rules(): void { + $this->guard( 'aggregate_it_save_rules' ); + + $rtype = sanitize_key( wp_unslash( $_POST['rtype'] ?? '' ) ); + if ( $rtype === '' ) { + $this->redirect( self::SLUG . '-settings', '', [ 'tab' => 'rules' ] ); + } + + $this->plugin->settings()->set_global_rules( $rtype, $this->rules_from_post() ); + \AggregateIt\Maintenance\GlobalRulesRefresher::schedule_soon(); + + $this->redirect( self::SLUG . '-settings', 'saved', [ 'tab' => 'rules', 'rtype' => $rtype ] ); + } + public function handle_save_settings(): void { $this->guard( 'aggregate_it_save_settings' ); diff --git a/src/Admin/views/activity.php b/src/Admin/views/activity.php index a3acd8f..af35a3b 100644 --- a/src/Admin/views/activity.php +++ b/src/Admin/views/activity.php @@ -18,7 +18,9 @@ }; ?>
-

+
+

+
diff --git a/src/Admin/views/settings-rules.php b/src/Admin/views/settings-rules.php new file mode 100644 index 0000000..34d6fa8 --- /dev/null +++ b/src/Admin/views/settings-rules.php @@ -0,0 +1,124 @@ +> $rules + * @var array $rule_ops + * @var bool $saved + */ + +if ( ! $rules ) { + $rules = [ [ 'field' => '', 'op' => 'always', 'value' => '', 'set_key' => '', 'set_value' => '' ] ]; +} + +$render_rule_row = static function ( array $rule, array $rule_ops ): void { + ?> + + ⠿ + + + + + + + + + + + +

+ + +

+ + +

+ + + + + + + + + + + + + + + + + + + + + +
+

+ + +

+ + + + + + diff --git a/src/Maintenance/GlobalRulesRefresher.php b/src/Maintenance/GlobalRulesRefresher.php new file mode 100644 index 0000000..0362ddf --- /dev/null +++ b/src/Maintenance/GlobalRulesRefresher.php @@ -0,0 +1,106 @@ +settings->global_rules() as $type => $rules ) { + if ( is_array( $rules ) && $rules ) { + $this->apply_type( (string) $type, $rules, $now ); + } + } + } + + /** @param array> $rules */ + public function apply_type( string $type, array $rules, int $now ): void { + $needed = $this->referenced_fields( $rules ); + $offset = 0; + + do { + $ids = get_posts( + [ + 'post_type' => $type, + 'post_status' => 'any', + 'posts_per_page' => self::BATCH, + 'offset' => $offset, + 'fields' => 'ids', + 'no_found_rows' => true, + ] + ); + + foreach ( (array) $ids as $id ) { + $values = $this->values_for( (int) $id, $needed ); + foreach ( Rules::apply( $values, $rules, $now ) as $key => $value ) { + update_post_meta( (int) $id, sanitize_key( (string) $key ), $value ); + } + } + + $offset += self::BATCH; + } while ( count( (array) $ids ) === self::BATCH ); + } + + /** + * @param array> $rules + * @return string[] + */ + private function referenced_fields( array $rules ): array { + $fields = []; + foreach ( $rules as $rule ) { + if ( ! empty( $rule['field'] ) ) { + $fields[ (string) $rule['field'] ] = true; + } + if ( preg_match_all( '/\{([a-z0-9_]+)/i', (string) ( $rule['set_value'] ?? '' ), $m ) ) { + foreach ( $m[1] as $f ) { + $fields[ strtolower( $f ) ] = true; + } + } + } + return array_keys( $fields ); + } + + /** + * @param string[] $needed + * @return array + */ + private function values_for( int $id, array $needed ): array { + $values = [ + 'post_title' => (string) get_the_title( $id ), + 'post_date' => (string) get_post_field( 'post_date', $id ), + ]; + foreach ( $needed as $field ) { + if ( ! isset( $values[ $field ] ) ) { + $values[ $field ] = (string) get_post_meta( $id, $field, true ); + } + } + return $values; + } +} diff --git a/src/Plugin.php b/src/Plugin.php index 412381d..a476662 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -138,6 +138,7 @@ public function boot(): void { ( new QueueWorker( $this->items, $this->pipeline, $this->cost, $this->cap, $this->settings ) )->register(); ( new Maintenance\Retention( $this->items, $this->settings ) )->register(); ( new Maintenance\RulesRefresher( $this->sources ) )->register(); + ( new Maintenance\GlobalRulesRefresher( $this->settings ) )->register(); ( new RestController( $this ) )->register(); if ( is_admin() ) { @@ -153,6 +154,7 @@ public static function deactivate(): void { wp_clear_scheduled_hook( 'aggregate_it_import' ); wp_clear_scheduled_hook( 'aggregate_it_retention' ); wp_clear_scheduled_hook( 'aggregate_it_rules_refresh' ); + wp_clear_scheduled_hook( 'aggregate_it_global_rules' ); } public function settings(): Settings { diff --git a/src/Settings.php b/src/Settings.php index 1178708..af273c8 100644 --- a/src/Settings.php +++ b/src/Settings.php @@ -179,6 +179,23 @@ public function hub_review(): bool { return (bool) $this->get( 'hub_review', false ); } + /** @return array>> post type => rules */ + public function global_rules(): array { + $rules = $this->get( 'global_rules', [] ); + return is_array( $rules ) ? $rules : []; + } + + /** @param array> $rules */ + public function set_global_rules( string $post_type, array $rules ): void { + $all = $this->global_rules(); + if ( $rules ) { + $all[ $post_type ] = array_values( $rules ); + } else { + unset( $all[ $post_type ] ); + } + $this->set( 'global_rules', $all ); + } + /** Skip feed items older than this many hours. 0 = no age limit. */ public function import_max_age_hours(): int { return max( 0, (int) $this->get( 'import_max_age_hours', 48 ) ); From d122be95c69fcd0f93627e57768e60d405102ab4 Mon Sep 17 00:00:00 2001 From: Nika Siradze Date: Tue, 30 Jun 2026 16:46:11 +0400 Subject: [PATCH 3/3] Address review: validate rtype against public types; global refresh respects per-source rule overrides --- src/Admin/Admin.php | 11 ++++--- src/Maintenance/GlobalRulesRefresher.php | 37 ++++++++++++++++++++++-- src/Plugin.php | 2 +- 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/Admin/Admin.php b/src/Admin/Admin.php index 37e4706..ff7485c 100644 --- a/src/Admin/Admin.php +++ b/src/Admin/Admin.php @@ -774,8 +774,9 @@ public function render_settings(): void { unset( $public_types['attachment'] ); $global = $settings->global_rules(); $rtype = isset( $_GET['rtype'] ) ? sanitize_key( wp_unslash( $_GET['rtype'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification - if ( ! $rtype || ! isset( $public_types[ $rtype ] ) ) { - $rtype = (string) ( array_key_first( $global ) ?: 'post' ); + if ( ! isset( $public_types[ $rtype ] ) ) { + $saved_types = array_values( array_filter( array_keys( $global ), static fn ( $type ): bool => isset( $public_types[ $type ] ) ) ); + $rtype = (string) ( $saved_types[0] ?? ( isset( $public_types['post'] ) ? 'post' : (string) array_key_first( $public_types ) ) ); } $rules = (array) ( $global[ $rtype ] ?? [] ); $saved = isset( $_GET['ai_notice'] ) && sanitize_key( wp_unslash( $_GET['ai_notice'] ) ) === 'saved'; // phpcs:ignore WordPress.Security.NonceVerification @@ -837,8 +838,10 @@ public static function rule_op_labels(): array { public function handle_save_rules(): void { $this->guard( 'aggregate_it_save_rules' ); - $rtype = sanitize_key( wp_unslash( $_POST['rtype'] ?? '' ) ); - if ( $rtype === '' ) { + $rtype = sanitize_key( wp_unslash( $_POST['rtype'] ?? '' ) ); + $allowed = get_post_types( [ 'public' => true ], 'names' ); + unset( $allowed['attachment'] ); + if ( $rtype === '' || ! in_array( $rtype, $allowed, true ) ) { $this->redirect( self::SLUG . '-settings', '', [ 'tab' => 'rules' ] ); } diff --git a/src/Maintenance/GlobalRulesRefresher.php b/src/Maintenance/GlobalRulesRefresher.php index 0362ddf..8c36183 100644 --- a/src/Maintenance/GlobalRulesRefresher.php +++ b/src/Maintenance/GlobalRulesRefresher.php @@ -4,6 +4,7 @@ use AggregateIt\Publish\Rules; use AggregateIt\Settings; +use AggregateIt\Source\SourceRepository; defined( 'ABSPATH' ) || exit; @@ -17,7 +18,13 @@ final class GlobalRulesRefresher { private const HOOK = 'aggregate_it_global_rules'; private const BATCH = 200; - public function __construct( private Settings $settings ) {} + /** @var array> source id => meta keys it governs */ + private array $owned_cache = []; + + public function __construct( + private Settings $settings, + private SourceRepository $sources + ) {} public function register(): void { add_action( self::HOOK, [ $this, 'run' ] ); @@ -59,8 +66,14 @@ public function apply_type( string $type, array $rules, int $now ): void { foreach ( (array) $ids as $id ) { $values = $this->values_for( (int) $id, $needed ); + $owned = $this->source_owned_keys( (int) $id ); foreach ( Rules::apply( $values, $rules, $now ) as $key => $value ) { - update_post_meta( (int) $id, sanitize_key( (string) $key ), $value ); + $key = sanitize_key( (string) $key ); + // A scraped post's per-source rules own these keys; leave them to win. + if ( isset( $owned[ $key ] ) ) { + continue; + } + update_post_meta( (int) $id, $key, $value ); } } @@ -87,6 +100,26 @@ private function referenced_fields( array $rules ): array { return array_keys( $fields ); } + /** @return array meta keys governed by the post's per-source scrape rules */ + private function source_owned_keys( int $id ): array { + $sid = (int) get_post_meta( $id, '_ai_source_id', true ); + if ( ! $sid ) { + return []; + } + if ( ! isset( $this->owned_cache[ $sid ] ) ) { + $source = $this->sources->get( $sid ); + $keys = []; + foreach ( $source ? $source->rules() : [] as $rule ) { + $key = sanitize_key( (string) ( $rule['set_key'] ?? '' ) ); + if ( $key !== '' ) { + $keys[ $key ] = true; + } + } + $this->owned_cache[ $sid ] = $keys; + } + return $this->owned_cache[ $sid ]; + } + /** * @param string[] $needed * @return array diff --git a/src/Plugin.php b/src/Plugin.php index a476662..7076cff 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -138,7 +138,7 @@ public function boot(): void { ( new QueueWorker( $this->items, $this->pipeline, $this->cost, $this->cap, $this->settings ) )->register(); ( new Maintenance\Retention( $this->items, $this->settings ) )->register(); ( new Maintenance\RulesRefresher( $this->sources ) )->register(); - ( new Maintenance\GlobalRulesRefresher( $this->settings ) )->register(); + ( new Maintenance\GlobalRulesRefresher( $this->settings, $this->sources ) )->register(); ( new RestController( $this ) )->register(); if ( is_admin() ) {