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..ff7485c 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' ] );
@@ -136,15 +137,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 +757,98 @@ 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' ),
+ 'rules' => __( 'Rules', '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 === '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 ( ! 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
+ $rule_ops = self::rule_op_labels();
+ }
+
+ 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;
+ $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'] ?? '' ) );
+ $allowed = get_post_types( [ 'public' => true ], 'names' );
+ unset( $allowed['attachment'] );
+ if ( $rtype === '' || ! in_array( $rtype, $allowed, true ) ) {
+ $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 {
@@ -839,23 +921,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 +940,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 +948,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 +984,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 +1017,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 +1065,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 +1207,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/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/Maintenance/GlobalRulesRefresher.php b/src/Maintenance/GlobalRulesRefresher.php
new file mode 100644
index 0000000..8c36183
--- /dev/null
+++ b/src/Maintenance/GlobalRulesRefresher.php
@@ -0,0 +1,139 @@
+> 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' ] );
+
+ if ( ! wp_next_scheduled( self::HOOK ) ) {
+ wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::HOOK );
+ }
+ }
+
+ public static function schedule_soon(): void {
+ wp_schedule_single_event( time() + 5, self::HOOK );
+ }
+
+ public function run(): void {
+ $now = time();
+ foreach ( $this->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 );
+ $owned = $this->source_owned_keys( (int) $id );
+ foreach ( Rules::apply( $values, $rules, $now ) as $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 );
+ }
+ }
+
+ $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 );
+ }
+
+ /** @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
+ */
+ 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..7076cff 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, $this->sources ) )->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 ) );