Disable Comments Selectively

Header image for Disable Comments Selectively

Turn comments off with a bit more precision than just “every post”.

13 minute reading time of 2771 words (inc code) Code available at codeberg.org and the last commit was Plugin available at WordPress

Intro

There were already a few popular plugins that disabled all comments on the site (which is surely what the global checkbox in the Discussion settings is for…) but I wanted one that worked on a narrower focus, so I wrote this to disable comments on any chosen post type, taxonomy or term in a taxonomy.

This works on a blacklist principle, but you could obviously flip it around and whitelist particular terms to enable comments.

// check if what page we're looking at matches the combination of settings we've got
add_action('wp', array($this, 'check_if_have_match'));

// this works to hide existing comments, the form and the feed link
add_action('wp', array($this, 'zero_comments'), 99);

// need this for at least 2010, 2011, 2012 and 2013 archive pages (meta section)
add_filter('comments_open', array($this, 'close_comments_and_pings'), 99, 2);
add_filter('pings_open', array($this, 'close_comments_and_pings'), 99, 2);

// need this for themes that test like "( comments_open() || get_comments_number() )"
add_filter('get_comments_number', array($this, 'filter_number'), 99, 2);

// default WP recent comment widget calls this filter
add_filter('widget_comments_args', function($args) {
    if (! isset($this->closed_post_ids)) $this->closed_post_ids = $this->not_these_posts();
    if (sizeof($this->closed_post_ids)) {
        $args['post__not_in'] = $this->closed_post_ids;
    }
    return $args;
}, 99); 

// a bit of a hack, as befitting WPs feed "API"
add_filter('comment_feed_where', function($where) {
    if (! isset($this->closed_post_ids)) $this->closed_post_ids = $this->not_these_posts();
    if (sizeof($this->closed_post_ids)) {
        $where.= ' AND comment_post_ID NOT IN ('.implode(',', $this->closed_post_ids).')';
    }
    return $where;
}, 99);

// catch comments being submitted (from the frontend) to disabled posts
add_filter('pre_comment_approved', function($approved, $commentdata) {
    if (! isset($this->closed_post_ids)) $this->closed_post_ids = $this->not_these_posts();
    if (in_array($commentdata['comment_post_ID'], $this->closed_post_ids)) {
        $approved = new WP_Error('comment_closed', __('Comments for this post are closed.'), 410);
    }
    return $approved;
}, 99, 2);

Settings

If the plugin detects that we’re not in the admin area, then the actual actions/filters that do the work are attached to the relevant hooks. How much of these are subsequently needed greatly depends on the individual site choices regarding theme and widgets.

$this->taxonomies = get_taxonomies(array('public' => true, 'show_ui' => true), 'objects');

$this->types = array_filter(
    get_post_types(array('public' => true, 'show_ui' => true), 'objects'),
    function ($k) { return (post_type_supports($k, 'comments')); },
    ARRAY_FILTER_USE_KEY
);

add_action('admin_enqueue_scripts', function() {
    wp_enqueue_script('disable-comments-by-taxonomy-script', plugins_url('admin.js', __FILE__), array(), false, true);
});

If the current admin user can use the settings page, then the list of existing taxonomies and post types is retrieved. The post types are passed through the array_filter function to weed out the ones that haven’t registered support for comments in the first place.

Note that due to a WP “bug” regarding the “public” keyword, we might return less taxonomies or types than expected, depending on how they’ve been created.

We also load a little bit of javascript to make the UX clearer when selecting “all or not all” of the taxonomies/terms.

$sorted_terms = $this->get_terms_hierarchically_and_sorted();

foreach ($this->taxonomies as $key => $tax) {
    $checked = (in_array($key, $this->settings['taxonomies'])) ? ' checked="checked"' : '';

    $class = ($odd) ? 'alternate' : ''; $odd = (! $odd);
    $output.= '<div class="'.$class.' regular-text">'
            . '<label for="dcs_'.$key.'">'
                . '<input type="checkbox" value="true" '.$checked.'
                  \ id="'.esc_attr("dcs_{$key}").'"
                  \ name="'.esc_attr("DisableCommentsSelectivelyPlugin[{$key}]").'">'
                . wp_sprintf(' %s %s', __('All', 'DisableCommentsSelectively'), $tax->label)
            . '</label><br>';

    if (isset($sorted_terms[$tax->label])) {
        $output.= '<select class="widefat" multiple="true"
                      \ id="'.esc_attr("dcs_{$key}_terms").'"
                      \ name="'.esc_attr("DisableCommentsSelectivelyPlugin[{$key}_terms][]").'">'
                    . $this->display_options_recursively($sorted_terms[$tax->label])
                . '</select>';
    }

    $output.= '</div>';
}

private function get_terms_hierarchically_and_sorted() {
    $all_terms = get_terms(array(
        'taxonomy'                 => array_keys($this->taxonomies),
        'hide_empty'             => false,
        'update_term_meta_cache' => false,
    ));
    $return = array();
    foreach ($all_terms as $i => $term) {
        $tax = get_taxonomy($term->taxonomy);
        if (! isset($return[$tax->label])) $return[$tax->label] = array();
        $return[$tax->label][] = $term;
    }
    foreach ($return as $taxonomy => $terms) {
        $hier = array(); $this->sort_terms_hierarchically($terms, $hier);
        $return[$taxonomy] = $hier;
    }
    ksort($return);
    return $return;
}

Part of the settings display code, populating an associative array of Taxonomies with their respective Terms (sorted with an eye on category hierarchy if needed) and then displaying them so that we can toggle All Terms

The sort_terms_hierarchically and display_options_recursively functions are reused from my tag cloud widget.

public function validate_settings($input) {
    // reset our settings to no options chosen
    $settings = array('types' => array(), 'taxonomies' => array(), 'terms' => array());

    if (! isset($input) OR ! isset($_POST['DisableCommentsSelectivelyPlugin']) OR
        ! is_array($input) OR ! is_array($_POST['DisableCommentsSelectivelyPlugin'])
    ) return $settings;

    // assuming there's no overlap because WP permalinks should complain first
    $type_keys = array_keys($this->types);
    $tax_keys = array_keys($this->taxonomies);

    foreach ($input as $key => $value) {
        if (in_array($key, $type_keys) AND $value == true) {
            $settings['types'][] = $key;

        // selecting whole taxonomies overrides selecting by individual terms
        } elseif (in_array($key, $tax_keys) AND $value == true) {
            $settings['taxonomies'][] = $key;

        } else {
            $tax = substr_replace($key, '', -6); // strip _terms
            if (! in_array($tax, $settings['taxonomies']) AND in_array($tax, $tax_keys) AND is_array($value)) {
                foreach ($value as $term_id) {
                    $settings['terms'][] = (int) $term_id;
                }
            }
        }
    }

    return $settings;
}

I’ve not tested that assumption (that type keys and taxonomy keys are separate) in a fair few years, but I do remember fighting WP pretty permalinks when having a custom post type called the same as an existing taxonomy, so I’m reasonably sure that the keys won’t clash unless you go out of your way to register a post type/taxonomy manually.

check_if_have_match()

The main check of this plugin; testing whether the user is looking at just one thing - and then we can simply check if that thing overlaps with our settings - or if the user is looking at multiple posts at once; in which case, the simplest way to make the plugin work is to retrieve all the posts that are disabled and remember their IDs.

(That list can then be used in not just checking against the current posts loop, but also the Recent Comments widget and the comment feed as well.)

public function check_if_have_match() {
    if (is_singular()) {

        if (in_array(get_post_type(), $this->settings['types'])) {
            $this->show_comments = false;
            return; // skip the following tax/term checks
        }

        global $post;
        $post_terms = wp_get_post_terms($post->ID, get_object_taxonomies($post, 'names')); // really keys

        // have we got a match on a whole taxonomy?
        $post_taxes = array_unique(array_column($post_terms, 'taxonomy'));
        if (sizeof(array_intersect($post_taxes, $this->settings['taxonomies'])) > 0) {
            $this->show_comments = false;
            return; // save a milli micro second by skipping the following term check...
        }

        // or a specific term?
        $post_terms_ids = array_column($post_terms, 'term_id');
        if (sizeof(array_intersect($post_terms_ids, $this->settings['terms'])) > 0) {
            $this->show_comments = false;
        }

    } elseif (is_home() OR is_archive() OR is_search()) {
        // we're much more likely (esp with default themes) to need the list of disabled posts here
        $this->closed_post_ids = $this->not_these_posts();
    }
}
public function zero_comments() {
    if ($this->show_comments) return;
    global $post, $wp_query;
    $wp_query->current_comment = $wp_query->comment_count = $post->comment_count = 0;
    $post->comment_status = $post->ping_status = 'closed';
}

Altering the $post and $wp_query comment variables is almost, but not quite, enough to disable comments across the multiple default WP themes.

public function filter_number($count, $post_id) {
    if (is_singular() AND ! $this->show_comments) {
        $count = 0;
    } elseif (isset($this->closed_post_ids) AND in_array($post_id, $this->closed_post_ids)) {
        $count = 0;
    }
    return $count;
}

public function close_comments_and_pings($open, $post_id) {
    if (is_singular() AND ! $this->show_comments) {
        $open = false;
    } elseif (isset($this->closed_post_ids) AND in_array($post_id, $this->closed_post_ids)) {
        $open = false;
    }
    return $open;
}

If I need a third function of ($thing, $post_id) that returns $thing, then I’ll combine the practically identical functions.

not_these_posts()

If we’re looking at multiple posts at once (an index page as the simplest example), then getting a list of the posts that should be excluded from allowing comments was far simpler than checking each visible one individually.

private function not_these_posts() {
    if (empty($this->settings['types']) AND empty($this->settings['taxonomies'])
        AND empty($this->settings['terms'])) return array();

    // can't use $wpdb->prepare because of the IN (...) statement
    $types = '0=1'; if (sizeof($this->settings['types'])) {
        $types = 'p.post_type IN ('.implode(',', array_map(function($v) { return "'".esc_sql($v)."'"; }, $this->settings['types'])).')';
    }

    $taxes = '0=1'; if (sizeof($this->settings['taxonomies'])) {
        $taxes = 'tt.taxonomy IN ('.implode(',', array_map(function($v) { return "'".esc_sql($v)."'"; }, $this->settings['taxonomies'])).')';
    }

    // can't use esc_sql for numeric values either...
    $terms = '0=1'; if (sizeof($this->settings['terms'])) {
        $terms = 'tt.term_id IN ('.implode(',', array_map(function($v) { return (int) $v; }, $this->settings['terms'])).')';
    }

    global $wpdb;
    $sql = "SELECT p.ID
            FROM {$wpdb->prefix}posts AS p
            LEFT OUTER JOIN {$wpdb->prefix}term_relationships AS tr ON p.ID = tr.object_id
            LEFT OUTER JOIN {$wpdb->prefix}term_taxonomy AS tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
            WHERE {$types} OR {$taxes} OR {$terms}";
            // btw no GROUP or ORDER to avoid using temporary/filesort

    $post_ids = array_unique($wpdb->get_col($sql));
    return $post_ids;
}

I was marginally concerned with the resulting front end queries now looking at an IN clause with potentially thousands of items (post IDs), but while it’s not particularly elegant, it’s also unlikely to be a problem, and certainly not the first problem you’ll encounter with large datasets in WP.


Reply via email