include/lcp-category.php
<?php
/**
* This Singleton class has the code for defining which category to
* use according to the shortcode.
* @author fernando@picandocodigo.net
*/
class LcpCategory{
// Singleton implementation
private static $instance = null;
public static function get_instance(){
if( !isset( self::$instance ) ){
self::$instance = new self;
}
return self::$instance;
}
/**
* Used to store the single main category to filter by.
*
* @var int
*/
private $main_cat;
/**
* Parses category related shortcode parameters and returns
* WP_Query compatible $args array. Also sets $lcp_category_id.
*
* This method is the main interface of the LcpCategory class. It
* is currently only used by the CatList class and servers as its helper.
* $params expects all category related shortcode parameters.
* $lcp_category_id is **passed by reference** so that it can be
* changed here. CatList::$lcp_category_id relies on this value heavily.
*
* @param array $params {
* Category related shortcode parameter values.
*
* @type string $id
* @type string $name
* @type string $categorypage
* @type string $child_categories
* @type string $main_cat_only
* }
* @param mixed &$lcp_category_id Optional. Updated by this method if necessary.
* @return array WP_Query $args array, @see lcp_categories.
*/
public function get_lcp_category($params, &$lcp_category_id=0) {
// Only used when excluded categories are combined with 'and' relationship.
$exclude = [];
// This will be the value of lcp_category_id which is passed by reference.
$categories = $lcp_category_id;
// In a category page:
if ($params['categorypage'] &&
in_array($params['categorypage'], ['yes', 'all', 'other'])) {
// Use current category
$categories = $this->current_category($params['categorypage'], $params['id']);
} elseif ($params['name']) {
// Using the category name:
$categories = $this->with_name($params['name']);
} elseif ($params['id']) {
// Using the id:
$categories = $this->with_id($params['id']);
}
// If the 'exclude' array was added, extract it.
if (is_array($categories) && array_key_exists('exclude', $categories)) {
$exclude = $categories['exclude'];
unset($categories['exclude']);
}
// This is where the lcp_category_id property of CatList is changed.
$lcp_category_id = $categories;
// Check if only the main category should be used.
$this->check_main_cat_only( $params[ 'main_cat_only' ], $categories );
return $this->lcp_categories(
$categories, $params['child_categories'], $exclude);
}
/**
* Formats the $args array in compliance with WP_Query.
*
* This method assigns input category IDs to proper WP_Query $args array.
* $categories expects an int, string or an array following the logic:
* - int -> single category
* - array -> 'and' relationship
* - string -> 'or' relationship (or single cateogry as a string)
*
* $child_categories is the value of `child_categories` shortcode param.
* $exclude is only used when combining 'and' relationship with excluded IDs.
*
* @param int|string|array $categories Category IDs.
* @param string $child_categories 'no' or 'false' disables child cats.
* @param array $exclude Accepts an array of IDs.
* @return array WP_Query $args array.
*/
private function lcp_categories($categories, $child_categories, $exclude) {
$args = array();
if (is_array($categories)) {
// Handle excluded categories for the 'and' relationship.
if ($exclude) {
$args['category__not_in'] = $exclude;
}
$args['category__and'] = $categories;
} else if (in_array($child_categories, ['no', 'false'])) {
$args['category__in']= $categories;
} else {
$args['cat'] = $categories;
}
return $args;
}
/**
* Used when the category is set using the `name` shortcode parameter.
*
* This method returns a category ID when a single category is specified,
* a string containing comma separated category IDs when using the 'or'
* relationship, an array of category IDs when using the 'and' relationship.
*
* If $name does not resolve to an existing name or slug, `0` will be returned.
* Similarly, the returned comma separated string or an array will have `0`
* for any name/slug that could not be found.
*
* @param string $name Accepts valid `name` shortcode parameter values.
* @return int|string|array Int for single category, string for 'or' relationsip,
* array for 'and' relationship.
*/
public function with_name($name) {
if (false !== strpos($name, '+')) { // AND relationship
return $this->and_relationship($name);
} elseif (false !== strpos($name, ',')) { // OR relationship
return $this->or_relationship($name);
}
return $this->get_category_id_by_name($name);
}
/**
* Used when the category is set using the `id` shortcode parameter.
*
* Accepts all valid `id` parameter values. If $cat_id is a single category
* ID or comma separated IDs (for the 'or' relationship), this method does not
* perfom any parsing and returns the string as is. If the 'and' relationship
* is used (eg. `id=1+2+3`), returns an array of IDs. If the 'and' relationship is
* used together with excluded categories (`id=1+2-3-4`), returns an array of
* included IDs that also contains the 'exclude' key that is an array of excluded
* IDs.
*
* @param string $name Accepts valid `id` shortcode parameter values.
* @return string|array Array of IDs for 'and' relationship, string otherwise.
*/
public function with_id($cat_id) {
if (false !== strpos($cat_id, '+')) {
if (false !== strpos($cat_id, '-')) {
/*
* If the 'and' relationship is used together with excluded
* categories (eg. 1+2+3-4-5) we parse it with regex and append an
* array of excluded IDs to the returned array.
*/
preg_match('/(?P<in>(\+?[0-9]+)+)(?P<ex>(-[0-9]+)+)/', $cat_id, $matches);
$cat_id = array_map('intval', explode("+", $matches['in']));
$cat_id['exclude'] = explode('-', ltrim($matches['ex'], '-'));
} else {
// Simple 'and' relationship, just convert input into an array.
$cat_id = array_map('intval', explode("+", $cat_id));
}
}
// In all other cases leave user input as is.
return $cat_id;
}
/**
* Handles the `categorypage` shortcode parameter with all its modes.
*
* This method accepts all valid `categorypage` shortcode parameters.
* Returns a single category ID when used on category archive page,
* a comma separated string of IDs for the 'or' relationship,
* an array of IDs for the 'and' relationship. Also accepts optional
* $ids which is used to handle excluded category IDs for 'yes' and 'all'
* modes. When no posts should be displayed it returns `[0]`.
*
* @param string $mode Accepts 'all', 'yes', 'other'.
* @param string $ids IDs of excluded categories as in the shortcode.
* @return int|string|array Category ID(s).
*/
public function current_category($mode, $ids='') {
// Only single post pages with assigned category and
// category archives have a 'current category',
// in all other cases no posts should be returned. (#69)
// Should be overriden if any categories are found.
$cats = [0]; // workaround to display no posts
$category = get_category( get_query_var( 'cat' ) );
if( ! ( isset( $category->errors ) && $category->errors["invalid_term"][0] == __("Empty Term.") ) ) {
$cats = $category->cat_ID;
} else if ( is_singular() || in_the_loop() ) {
/* Since WP 4.9 global $post is nullified in text widgets
* when is_singular() is false.
*
* Added in_the_loop check to make the shortcode work
* in posts listed in archives and home page (#358).
*/
global $post;
$categories = get_the_category($post->ID);
if ( !empty($categories) ) {
$cats = array_map(function($cat) {
return $cat->cat_ID;
}, $categories);
// Parse excluded categories if ids are used.
if (in_array($mode, ['all', 'yes']) && !empty($ids)) {
$ids = $this->validate_excluded_ids(explode(',', $ids));
// Remove excluded ids from $cats.
$cats = array_diff($cats, array_map('abs', $ids));
}
// AND relationship
if ('all' === $mode) {
// Handle excluded categories
if (!empty($ids)) $cats['exclude'] = array_map('abs', $ids);
}
// OR relationship, default
if ('yes' === $mode) {
// Handle excluded categories
if (!empty($ids)) $cats = array_merge($cats, $ids);
$cats = implode(',', $cats);
}
// Exclude current categories
if ('other' === $mode) {
$cats = implode(',', array_map(function($cat) {
return "-$cat";
}, $cats));
}
}
}
return $cats;
}
/**
* Gets the category id from its name.
*
* @author Eric Celeste / http://eric.clst.org
*
* @param string $category_name Accepts category name or slug.
* @return int Category ID or 0 if none found.
*/
private function get_category_id_by_name($category_name) {
//We check if the slug gets the category id, if not, we check the name.
$term = get_term_by('slug', $category_name, 'category');
if (!$term) {
$term = get_term_by('name', $category_name, 'category');
}
return ($term) ? $term->term_id : 0;
}
/**
* Handles 'and' relationship when categories are specified by name.
*
* Parses the input string and returns an array of corresponding
* category IDs.
*
* @param string $name Accepts category names or slugs separated by the '+' sign.
* @return array Category IDs.
*/
private function and_relationship($name) {
$categories = array();
$cat_array = explode("+", $name);
foreach ($cat_array as $category) {
$categories[] = $this->get_category_id_by_name($category);
}
return $categories;
}
/**
* Handles 'or' relationship when categories are specified by name.
*
* Parses the input string and returns comma separated
* category IDs.
*
* @param string $name Accepts category names or slugs separated by the ',' sign.
* @return string Comma separated category IDs.
*/
private function or_relationship($name) {
$categories = array();
$catArray = explode(",", $name);
foreach ($catArray as $category) {
$categories[] = $this->get_category_id_by_name($category);
}
return implode(',',$categories);
}
/**
* Handles the 'main_cat_only' shortcode parameter.
*
* When filtering by main category is enabled, adds
* a proper filter function to the 'posts_results' hook.
*
* @param string $main_cat_only Shortcode parameter value, 'yes' to enable.
* @param mixed $categories Category ID of the main category to filter by.
*/
private function check_main_cat_only( $main_cat_only, $categories ) {
if ( 'yes' === $main_cat_only ) {
$this->main_cat = intval( $categories );
add_filter( 'posts_results', [ $this, 'filter_by_main_category' ] );
}
}
/**
* Filter method intended for the 'posts_results' hook.
*
* Filters the posts array and returns only those that
* have their main/primary category matching the one saved
* in the $main_cat private property.
*
* @param array $posts WP_Post objects.
* @return array Filtered WP_Post objects.
*/
public function filter_by_main_category( $posts ) {
/* array_values is necessary to fix indexes, WordPress expects posts
array to have proper numerical indexing but array_filter retains
original array's keys.
*/
return array_values( array_filter( $posts, function( $post ) {
return $this->get_post_primary_category( $post )->term_id === $this->main_cat;
}));
}
/**
* Gets the main category of a post.
*
* This method accepts a post ID and first tries to get the
* primary category (a Yoast SEO feature) of the post. If none is found
* it falls back to the first assigned category on the post's category list.
*
* @link https://www.lab21.gr/blog/wordpress-get-primary-category-post/
*
* @param int $post_id ID of the post to check.
* @return mixed Category ID (int) of the post's main category or null if none found.
*/
private function get_post_primary_category( $post_id ) {
$return = null;
if ( class_exists( 'WPSEO_Primary_Term' ) ) {
// Show Primary category by Yoast if it is enabled & set
$wpseo_primary_term = new WPSEO_Primary_Term( 'category', $post_id );
$primary_term = get_term( $wpseo_primary_term->get_primary_term() ) ;
if ( !is_wp_error( $primary_term ) ) {
$return = $primary_term;
}
}
if ( empty( $return ) ) {
$categories_list = get_the_terms( $post_id, 'category' );
if ( !empty( $categories_list ) ) {
$return = $categories_list[0]; //get the first category
}
}
return $return;
}
/**
* Validates excluded category ids for current categories.
*
* This method takes an array of category IDs which users
* might use together with `categorypage`. Each id should be prefixed with
* a minus sign.
*
* @param array $ids Array of strings or ints.
* @return array Only valid negative integers.
*/
private function validate_excluded_ids($ids) {
$ids = array_map('intval', $ids);
$ids = array_filter($ids, function($id) {
if (is_int($id) && $id < 0) {
return true;
} else {
return false;
}
});
return $ids;
}
}