Skip to content

Commit fd89cda

Browse files
feat: keyword-based filtering for post sharing
Add the ability to filter posts for automatic sharing by keywords in the post title or content, in addition to taxonomies. A global field is available under General Settings (Free/Starter); in Pro the filter is configured per account in the post-format panel, mirroring how taxonomy filtering works. An Exclude toggle switches between skipping matching posts and sharing only matching posts. The match is applied as a scoped posts_where clause and is inactive when empty. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a473b56 commit fd89cda

7 files changed

Lines changed: 258 additions & 4 deletions

File tree

assets/js/build/dashboard.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

includes/admin/class-rop-global-settings.php

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,8 @@ class Rop_Global_Settings {
179179
'available_taxonomies' => array(),
180180
'selected_taxonomies' => array(),
181181
'exclude_taxonomies' => false,
182+
'keyword_filter' => '',
183+
'exclude_keywords' => true,
182184
'available_posts' => array(), // get_posts(),
183185
'selected_posts' => array(),
184186
'exclude_posts' => true,
@@ -666,11 +668,24 @@ public function license_type() {
666668
* @return array|mixed
667669
*/
668670
public function get_default_post_format( $service_name = false ) {
671+
// Per-account keyword filter defaults, added to every service post format.
672+
$keyword_defaults = array(
673+
'keyword_filter' => '',
674+
'exclude_keywords' => true,
675+
);
676+
669677
if ( isset( $service_name ) && $service_name != false && isset( self::instance()->post_format[ $service_name ] ) ) {
670-
return self::instance()->post_format[ $service_name ];
678+
return array_merge( $keyword_defaults, self::instance()->post_format[ $service_name ] );
679+
}
680+
681+
$formats = self::instance()->post_format;
682+
foreach ( $formats as $service => $format ) {
683+
if ( is_array( $format ) ) {
684+
$formats[ $service ] = array_merge( $keyword_defaults, $format );
685+
}
671686
}
672687

673-
return self::instance()->post_format;
688+
return $formats;
674689
}
675690

676691
/**

includes/admin/models/class-rop-posts-selector-model.php

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,18 @@ class Rop_Posts_Selector_Model extends Rop_Model_Abstract {
5050
*/
5151
private $settings = array();
5252

53+
/**
54+
* Per-account keyword filter override taken from the account post format.
55+
*
56+
* Null means "no per-account override" — the global keyword filter applies.
57+
* Set per call to select() so it never leaks between accounts.
58+
*
59+
* @since 9.3.7
60+
* @access private
61+
* @var array|null
62+
*/
63+
private $post_format_keyword_override = null;
64+
5365
/**
5466
* Rop_Posts_Selector_Model constructor.
5567
*
@@ -344,6 +356,9 @@ public function select( $account_id = false ) {
344356
$post_types = $this->build_post_types();
345357
$global_settings = new Rop_Global_Settings();
346358

359+
// Reset per-account overrides so they never leak between accounts.
360+
$this->post_format_keyword_override = null;
361+
347362
// Taxonomy: Post Format new option
348363
if ( $global_settings->license_type() > 0 && $global_settings->license_type() !== 7 && ! empty( $account_id ) ) {
349364
$parts = explode( '_', $account_id );
@@ -369,6 +384,17 @@ public function select( $account_id = false ) {
369384
}
370385

371386
$tax_queries = $this->build_tax_query( $custom_data );
387+
388+
// Per-account keyword filter (overrides the global one when set).
389+
if ( isset( $post_format['keyword_filter'] ) && '' !== trim( (string) $post_format['keyword_filter'] ) ) {
390+
$keywords = Rop_Settings_Model::parse_keyword_string( $post_format['keyword_filter'] );
391+
if ( ! empty( $keywords ) ) {
392+
$this->post_format_keyword_override = array(
393+
'keywords' => $keywords,
394+
'exclude' => isset( $post_format['exclude_keywords'] ) ? filter_var( $post_format['exclude_keywords'], FILTER_VALIDATE_BOOLEAN ) : true,
395+
);
396+
}
397+
}
372398
} else {
373399
$tax_queries = $this->build_tax_query();
374400
}
@@ -459,8 +485,35 @@ private function query_results( $account_id, $post_types, $tax_queries, $exclude
459485
$exclude = array();
460486
}
461487

462-
$args = $this->build_query_args( $post_types, $tax_queries, $exclude );
488+
$args = $this->build_query_args( $post_types, $tax_queries, $exclude );
489+
490+
/**
491+
* Optional keyword filtering on post title/content. Only active when the
492+
* user has entered keywords; otherwise the query is untouched. A per-account
493+
* (post format) filter takes precedence over the global one. The filter is
494+
* added only around this query and guarded by a custom query var so no other
495+
* query is ever affected.
496+
*/
497+
if ( is_array( $this->post_format_keyword_override ) ) {
498+
$keywords = $this->post_format_keyword_override['keywords'];
499+
$exclude_keyword = ! empty( $this->post_format_keyword_override['exclude'] );
500+
} else {
501+
$keywords = $this->settings->get_keyword_filter();
502+
$exclude_keyword = $this->settings->get_exclude_keywords();
503+
}
504+
if ( ! empty( $keywords ) ) {
505+
$args['rop_keyword_filter'] = array(
506+
'keywords' => $keywords,
507+
'exclude' => $exclude_keyword,
508+
);
509+
add_filter( 'posts_where', array( $this, 'filter_keyword_where' ), 10, 2 );
510+
}
511+
463512
$query = new WP_Query( $args );
513+
514+
if ( ! empty( $keywords ) ) {
515+
remove_filter( 'posts_where', array( $this, 'filter_keyword_where' ), 10 );
516+
}
464517
// echo $query->request;
465518
$posts = $query->posts;
466519

@@ -594,6 +647,46 @@ private function build_query_args( $post_types, $tax_queries, $exclude ) {
594647
return $args;
595648
}
596649

650+
/**
651+
* Append a keyword condition to the posts query WHERE clause.
652+
*
653+
* Hooked on `posts_where` only while the selector query runs. It acts solely
654+
* on queries carrying the `rop_keyword_filter` query var, matching the
655+
* keywords against the post title or content. In exclude mode posts matching
656+
* any keyword are removed; in include mode only matching posts are kept.
657+
*
658+
* @since 9.3.7
659+
* @access public
660+
*
661+
* @param string $where The WHERE clause of the query.
662+
* @param WP_Query $query The current WP_Query instance.
663+
*
664+
* @return string
665+
*/
666+
public function filter_keyword_where( $where, $query ) {
667+
global $wpdb;
668+
669+
$config = $query->get( 'rop_keyword_filter' );
670+
if ( empty( $config ) || empty( $config['keywords'] ) || ! is_array( $config['keywords'] ) ) {
671+
return $where;
672+
}
673+
674+
$clauses = array();
675+
foreach ( $config['keywords'] as $keyword ) {
676+
$like = '%' . $wpdb->esc_like( $keyword ) . '%';
677+
$clauses[] = $wpdb->prepare( "({$wpdb->posts}.post_title LIKE %s OR {$wpdb->posts}.post_content LIKE %s)", $like, $like );
678+
}
679+
680+
if ( empty( $clauses ) ) {
681+
return $where;
682+
}
683+
684+
$group = '( ' . implode( ' OR ', $clauses ) . ' )';
685+
$where .= ! empty( $config['exclude'] ) ? " AND NOT {$group}" : " AND {$group}";
686+
687+
return $where;
688+
}
689+
597690
/**
598691
* Method to determine if the buffer is empty or not.
599692
*

includes/admin/models/class-rop-settings-model.php

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,62 @@ public function get_exclude_taxonomies() {
306306
return $this->settings['exclude_taxonomies'];
307307
}
308308

309+
/**
310+
* Getter for the keyword filter list.
311+
*
312+
* Returns the user supplied keywords as a clean, de-duplicated array.
313+
* An empty array means the feature is inactive.
314+
*
315+
* @since 9.3.7
316+
* @access public
317+
* @return array
318+
*/
319+
public function get_keyword_filter() {
320+
$raw = isset( $this->settings['keyword_filter'] ) ? $this->settings['keyword_filter'] : '';
321+
322+
return self::parse_keyword_string( $raw );
323+
}
324+
325+
/**
326+
* Parse a comma separated keyword string into a clean, de-duplicated array.
327+
*
328+
* Shared by the global keyword filter and the per-account (post format) one.
329+
*
330+
* @since 9.3.7
331+
* @access public
332+
*
333+
* @param string $raw The raw comma separated keywords.
334+
* @return array
335+
*/
336+
public static function parse_keyword_string( $raw ) {
337+
if ( ! is_string( $raw ) || '' === trim( $raw ) ) {
338+
return array();
339+
}
340+
$keywords = array_map( 'trim', explode( ',', $raw ) );
341+
$keywords = array_filter(
342+
$keywords,
343+
function ( $keyword ) {
344+
return '' !== $keyword;
345+
}
346+
);
347+
348+
return array_values( array_unique( $keywords ) );
349+
}
350+
351+
/**
352+
* Getter for the keyword filter mode.
353+
*
354+
* When true, posts matching the keywords are excluded from sharing;
355+
* when false, only posts matching the keywords are shared.
356+
*
357+
* @since 9.3.7
358+
* @access public
359+
* @return bool
360+
*/
361+
public function get_exclude_keywords() {
362+
return ! empty( $this->settings['exclude_keywords'] );
363+
}
364+
309365
/**
310366
* Add one post or a list of posts to the excluded posts list.
311367
*
@@ -469,6 +525,14 @@ private function validate_settings( $data ) {
469525
}
470526
}
471527

528+
if ( isset( $data['keyword_filter'] ) ) {
529+
$data['keyword_filter'] = sanitize_text_field( $data['keyword_filter'] );
530+
}
531+
532+
if ( isset( $data['exclude_keywords'] ) ) {
533+
$data['exclude_keywords'] = (bool) $data['exclude_keywords'];
534+
}
535+
472536
return $data;
473537
}
474538

includes/class-rop-i18n.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,13 @@ public static function get_labels( $key = '' ) {
179179
),
180180
'filter_by_taxonomies_desc' => __( 'Filter posts list by Taxonomy', 'tweet-old-post' ),
181181
'taxonomies_exclude' => __( 'Exclude?', 'tweet-old-post' ),
182+
'keyword_filter_title' => __( 'Keyword Filtering', 'tweet-old-post' ),
183+
'keyword_filter_desc' => __(
184+
'Comma-separated words or phrases matched against the post title and content. Use the toggle to either exclude matching posts from sharing, or share only matching posts.',
185+
'tweet-old-post'
186+
),
187+
'keyword_filter_placeholder' => __( 'e.g. sponsored, internal, draft review', 'tweet-old-post' ),
188+
'keyword_filter_exclude' => __( 'Exclude?', 'tweet-old-post' ),
182189
'posts_title' => __( 'Posts', 'tweet-old-post' ),
183190
'posts_desc' => __( 'Posts excluded from sharing, filtered based on previous selections.', 'tweet-old-post' ),
184191
'ga_title' => __( 'Enable Google Analytics Tracking', 'tweet-old-post' ),

vue/src/vue-elements/post-format.vue

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,39 @@
445445
</div>
446446
</div>
447447
</div>
448+
<!-- Per-account keyword filtering. Disabled (upsell) outside Pro, like taxonomies above. -->
449+
<div
450+
class="columns py-2"
451+
:class="'rop-control-container-'+(isPro && (license_price_id !== 7))"
452+
>
453+
<div class="column col-6 col-sm-12 vertical-align rop-control">
454+
<b>{{ labels_settings.keyword_filter_title }}</b>
455+
<p class="text-gray">
456+
<span v-html="labels_settings.keyword_filter_desc" />
457+
</p>
458+
</div>
459+
<div class="column col-6 col-sm-12 vertical-align">
460+
<div class="input-group">
461+
<input
462+
v-model="post_format.keyword_filter"
463+
:disabled="!isPro || (license_price_id === 7)"
464+
type="text"
465+
class="form-input"
466+
:placeholder="labels_settings.keyword_filter_placeholder"
467+
>
468+
<span class="input-group-addon vertical-align">
469+
<label class="form-checkbox">
470+
<input
471+
v-model="post_format.exclude_keywords"
472+
:disabled="!isPro || (license_price_id === 7)"
473+
type="checkbox"
474+
>
475+
<i class="form-icon" />{{ labels_settings.keyword_filter_exclude }}
476+
</label>
477+
</span>
478+
</div>
479+
</div>
480+
</div>
448481
<div
449482
v-if="!isPro || (license_price_id === 7)"
450483
class="columns "

vue/src/vue-elements/settings-tab-panel.vue

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,46 @@
275275

276276
<span class="divider" />
277277

278+
<!-- Keyword Filtering (global). In Pro this moves to per-account settings. -->
279+
<div
280+
v-if="!isPro || license_price_id === 7"
281+
class="columns py-2"
282+
>
283+
<div class="column col-6 col-sm-12 vertical-align">
284+
<b>{{ labels.keyword_filter_title }}</b>
285+
<p class="text-gray">
286+
<span v-html="labels.keyword_filter_desc" />
287+
</p>
288+
</div>
289+
<div
290+
id="rop_keyword_filter"
291+
class="column col-6 col-sm-12 vertical-align text-left"
292+
>
293+
<div class="input-group">
294+
<input
295+
v-model="generalSettings.keyword_filter"
296+
type="text"
297+
class="form-input"
298+
:placeholder="labels.keyword_filter_placeholder"
299+
>
300+
<span class="input-group-addon vertical-align">
301+
<label class="form-checkbox">
302+
<input
303+
v-model="generalSettings.exclude_keywords"
304+
type="checkbox"
305+
>
306+
<i class="form-icon" />{{ labels.keyword_filter_exclude }}
307+
</label>
308+
</span>
309+
</div>
310+
</div>
311+
</div>
312+
313+
<span
314+
v-if="!isPro || license_price_id === 7"
315+
class="divider"
316+
/>
317+
278318
<!-- Update publish date -->
279319
<div
280320
class="columns py-2"
@@ -746,6 +786,8 @@
746786
selected_post_types: postTypesSelected,
747787
selected_taxonomies: taxonomiesSelected,
748788
exclude_taxonomies: excludeTaxonomies,
789+
keyword_filter: this.generalSettings.keyword_filter,
790+
exclude_keywords: this.generalSettings.exclude_keywords,
749791
update_post_published_date: this.generalSettings.update_post_published_date,
750792
ga_tracking: this.generalSettings.ga_tracking,
751793
custom_messages: this.generalSettings.custom_messages,

0 commit comments

Comments
 (0)