Half-Elf on Tech

Thoughts From a Professional Lesbian

Tag: taxonomies

  • Organization

    Organization

    In September 2005, Lorelle wrote what I consider to be the definitive piece on tags vs categories. In 12 years, my opinions have not changed and I still feel her explanation is correct. That said, there is room for improvement at scale.

    The Gist

    Her advice boils down to this:

    • Categories are a table of contents
    • Tags are index words

    By this we mean that categories are the high-level, big ticket items, and tags are the smaller, more precise terms. This is, I feel, the heart of understanding the two.

    Further down, Lorelle states that at around 25 posts, a tag is ‘big enough’ to be a category, and that if a category dominates a blog, it should perhaps be a separate blog. And that’s where I disagree.

    On Beyond Zebra

    When she wrote her post, the concept of custom taxonomies was barely a gleam in someone’s eyes. Multisite was still WPMU, and a separate installation. Today we have the ability to add our own taxonomies (either in category or tag styles) and we can create a network of related sites on our own. All we need is a little more technical know-how.

    When we add on custom taxonomies, we afford ourselves a new way to classify posts, so to the above I would add this:

    • Custom Taxonomies are critical but exceptionally unique index words that must be grouped together

    Okay that was long, I know, but a Custom Taxonomy is in essence a new subdivision of your site. You can either make it a new table of contents or a new index … or a combination of the two. It’s a little wild, especially when you factor in custom post types.

    Overwhelming Category? Custom Post Type!

    Instead of making a new blog when your category gets too large and unwieldy, I would recommend making a new custom post type. If I use my helpful example of LezWatchTV, we currently have three custom post types: Shows, Actors, and Characters.

    While we could have made them into posts, and used categories to index them, having them be their own post type means instead of a table of contents, I’ve made an appendix. This gives me access to all the cool WordPress features, like archives and sorting and organization, but it does so outside the realm of posts which restricts crossovers. Unless you’re really clever with cross-related content.

    A custom post type keeps it all on one blog, but separates them like your laundry.

    Too Many Tags? Custom Taxonomy!

    If you find yourself having too many tags, it’s time to consider a custom taxonomy. Again, pointing to LezWatchTV, actors have two custom taxonomies: gender identity and sexuality. While those are the same as we use for characters, by having them separate and only applicable to the actor post type, we are able to give a list of all trans female actors with a click. In other words, we’re using WordPress’s native features.

    But if we look at the custom post type for TV shows, we have a lot more taxonomies, including two that are constantly being added on to: nations and stations. Every time a new station airs a show, we have to add it in. And there, as of April 1, we end up having 29 nations and 168 TV stations.

    Which brings up the next problem, and one that Lorelle does indeed address, but not the way I would.

    When Tags Go Rogue

    Can tags still go too large? Yes. Oh my lordy, yes.

    Recently I saw a site that used unique tags on every single post. I physically flinched when I realized that.

    You see, they had around 30,000 posts and 48,000 tags, and for the life of me I couldn’t understand why until I read the site and looked. For every single post there was a commensurate tag for the post title and the date. After 365 dates they thankfully started to repeat, so you might have 10 posts for the march-25 tag. Except they weren’t consistent and someone else used 25-march and now you can see the rabbit hole fall into infinity and beyond.

    Now that said, I have 168 tags for TV stations, each TV show has one, maybe two if they’re lucky or weird, and some tags only have 1 show listed. Others, like ABC, NBC, and CBS, have around 60. Do I think any of those are ‘too large’?

    I don’t. Because the number of 25 posts to a tag only holds up at a smaller scale. With 100 to 200 posts, yes, that starts to make sense. At 600 to 3000 posts, suddenly having 198 posts tagged with “Bury Your Queers” doesn’t sound so out of place. It’s about the percentages, somewhat, and also the use-case.

    If I know people are looking for a smaller tag (say they really want to see the 10 shows that have the ‘Fake Relationship’ tag), then for the purpose of this site, it’s important. On the other hand, if only one character was tagged cougar, I might not keep the tag as it’s too small to make the data useful.

    Optimal Organization

    There is no magic number of tags to categories to custom post types to taxonomies. It all comes down to understanding the goal of your site, the way users look for data, and what is maintainable to you.

    In the case of the site with 48k tags, I would have them delete all the date ones, as well as the ones with the same names as posts, and stick to using topical tags. After all, if a tag is only used once, or duplicates some feature already found in WordPress, it’s perhaps not the best idea.

  • Stacked Charts Part 3: The Javascript

    Stacked Charts Part 3: The Javascript

    Finally!

    We have our data in a properly consumable array. It’s formatted the way we need. Now we just need to script the java.

    Take a deep breath.

    What We Want

    What we want is simple. A stacked bar chart that shows the values of all possible permutations. It looks like this:

    A stacked chart that shows how many characters per gender orientation there are per country
    A stacked chart

    That shows how many characters there are per gender orientation, and stacks it for a total count (which is why we needed that count you see).

    Send In The Clowns

    Since I’m already using Chart.js, I just need to have a function to output the javascript. But. Since I also have to loop through the arrays to get the collective data, I need a bit of PHP:

    /*
     * Statistics Display Barcharts
     *
     * Output the list of data usually from functions like self::meta_array
     * It loops through the arrays and outputs data as needed
     *
     * This relies on ChartJS existing
     *
     * @param string $subject The content subject (shows, characters)
     * @param string $data The data - used to generate the URLs
     * @param array $array The array of data
     *
     * @return Content
     */
    static function stacked_barcharts( $subject, $data, $array ) {
    
    	// Defaults
    	$data       = ( $data == 'nations' )? 'nations' : substr( $data, 8 );
    	$title      = ucfirst( substr($subject, 0, -1) ) . ' ' . ucfirst( $data );
    	$height     = '550';
    
    	// Define our settings
    	switch ( $data ) {
    		case 'gender':
    		case 'sexuality':
    		case 'romantic':
    			$title    = 'Character per Nation by ' . ucfirst( $data );
    			$datasets = array();
    			$terms    = get_terms( 'lez_' . $data, array( 'orderby' => 'count', 'order' => 'DESC', 'hide_empty' => 0 ) );
    			if ( ! empty( $terms ) && ! is_wp_error( $terms ) ) {
    				foreach ( $terms as $term ) $datasets[] = $term->slug;
    			}
    			$counter  = 'characters';
    			$height   = '400';
    			break;
    	}
    	?>
    	<h3><?php echo $title; ?></h3>
    	<div id="container" style="width: 100%;">
    		<canvas id="barStacked<?php echo ucfirst( $subject ) . ucfirst( $data ); ?>" width="700" height="<?php echo $height; ?>"></canvas>
    	</div>
    
    	<script>
    	// Defaults
    	Chart.defaults.global.responsive = true;
    	Chart.defaults.global.legend.display = false;
    
    	// Bar Chart
    	var barStacked<?php echo ucfirst( $subject ) . ucfirst( $data ); ?>Data = {
    		labels : [
    		<?php
    			foreach ( $array as $item ) {
    				if ( $item[$counter] !== 0 ) {
    					$name = esc_html( $item['name'] );
    				}
    				echo '"'. $name .' ('.$item[$counter].')", ';
    			}
    		?>
    		],
    		datasets: [
    		<?php
    		foreach ( $datasets as $label ) {
    			$color = ( $label == 'undefined' )? 'nundefined' : str_replace( ["-", "–","-"], "", $label );
    			?>
    			{
    				borderWidth: 1,
    				backgroundColor: window.chartColors.<?php echo $color; ?>,
    				label: '<?php echo ucfirst( $label ); ?>',
    				stack: 'Stack',
    				data : [<?php
    					foreach ( $array as $item ) {
    						echo $item[ 'dataset' ][ $label ] . ',';
    					}
    				?>],
    			},
    			<?php
    		}
    		?>
    		]
    	};
    	var ctx = document.getElementById("barStacked<?php echo ucfirst( $subject ) . ucfirst( $data ); ?>").getContext("2d");
    	var barStacked<?php echo ucfirst( $subject ) . ucfirst( $data ); ?> = new Chart(ctx, {
    		type: 'horizontalBar',
    		data: barStacked<?php echo ucfirst( $subject ) . ucfirst( $data ); ?>Data,
    		options: {
    			scales: {
    				xAxes: [{ stacked: true }],
    				yAxes: [{ stacked: true }]
    			},
    			tooltips: {
    				mode: 'index',
    				intersect: false
    			},
    		}
    	});
    
    	</script>
    	<?php
    }
    

    The Color

    You may have noticed a strange variable:

    $color = ( $label == 'undefined' )? 'nundefined' : str_replace( ["-", "–","-"], "", $label );
    

    Which was then called in the javascript here:

    backgroundColor: window.chartColors.<?php echo $color; ?>,
    

    I have this in a javascript file that is loaded on that page:

    // Color Defines
    window.chartColors = {
    	
    	// Gender
    	agender: 'rgba(255, 99, 132, 0.6)', // 'red'
    	cisgender: 'rgba(75, 192, 192, 0.6)', // 'aqua'
    	demigender: 'rgba(255, 205, 86, 0.6)', // 'goldenrod'
    	genderfluid: 'rgba(54, 162, 235, 0.6)', // 'light blue'
    	genderqueer: 'rgba(255, 159, 64, 0.6)', // 'orange'
    	nonbinary: 'rgba(201, 203, 207, 0.6)', // 'grey'
    	transman: 'rgba(0, 169, 80, 0.6)', // 'green'
    	transwoman: 'rgba(153, 102, 255, 0.6)', // 'purple'
    
    	// Sexuality
    	asexual: 'rgba(255, 99, 132, 0.6)', // 'red'
    	bisexual: 'rgba(75, 192, 192, 0.6)', // 'aqua'
    	heterosexual: 'rgba(255, 205, 86, 0.6)', // 'goldenrod'
    	homosexual: 'rgba(54, 162, 235, 0.6)', // 'light blue'
    	pansexual: 'rgba(255, 159, 64, 0.6)', // 'orange'
    	nundefined: 'rgba(201, 203, 207, 0.6)', // 'grey'
    	queer: 'rgba(0, 169, 80, 0.6)', // 'green'
    	demisexual: 'rgba(153, 102, 255, 0.6)', // 'purple'
    }
    

    The reason it’s ‘undefined’ is that things got weird when I had a variable with a name of undefined.

  • Stacked Charts Part 2: Rebuilding the Array

    Stacked Charts Part 2: Rebuilding the Array

    I’ve talked about this before in category statistics, but in order to get the data from a simple array into a Chart.js consumable one, we have to rebuild the array.

    All Arrays are not Equal

    In order to save the data in a way I could use and reuse, I had to aim at the lowest common denominator. But also I had to save the arrays at a per show basis, which is not the same as what I was going to need to output.

    Instead of just outputting the averages for the show, I needed to combine all this into a ‘by nation’ statistic. That is, I needed to get a list of all shows that were associated with a taxonomy value for that country (easy) and combine all their arrays (not quite easy) and order the data in a way that would make sense (not easy).

    So again we start with understanding the array. Here’s a show that happens to air in Argentina:

    Array
    (
        [cisgender]    => 2
        [trans-woman]  => 0
        [trans-man]    => 0
        [non-binary]   => 0
        [gender-fluid] => 0
        [gender-queer] => 0
        [agender]      => 0
    )
    

    This is the data for one show. Argentina has 2, oddly both with the same stats breakdown by gender identity. What I need to do is loop through both those shows and add the arrays to be this:

    Array
    (
        [cisgender]    => 4
        [trans-woman]  => 0
        [trans-man]    => 0
        [non-binary]   => 0
        [gender-fluid] => 0
        [gender-queer] => 0
        [agender]      => 0
    )
    

    Get the Base Arrays

    Just like before, we make an array of the base data as we have it in the gender, sexuality, and romantic orientations. In this case, we’re adding in a query to change the order to be largest to smallest overall from the taxonomy. While this may not be true for all nations in the future, it is today:

    $taxonomy = get_terms( 'lez_nations' );
    foreach ( $taxonomy as $the_tax ) {
    }
    

    I need to pause here. Everything from here out goes in that foreach. We’re going to be looping for each nation in the list of nations. Now… I re-use this code for multiple taxonomies, so lez_nations is actually lez_' . $data and it dynamically changes based on how I call this function.

    On we go!

    	$characters = 0;
    	$shows      = 0;
    	
    	// Create a massive array of all the character terms we care about...
    	$valid_char_data = array( 
    		'gender'    => 'lez_gender',
    		'sexuality' => 'lez_sexuality',
    		'romantic'  => 'lez_romantic',
    	);
    
    	if ( isset( $subdata ) && !empty( $subdata ) ) {
    		$char_data = array();
    		$terms     = get_terms( $valid_char_data[ $subdata ], array( 'orderby' => 'count', 'order' => 'DESC' ) );
    
    		if ( ! empty( $terms ) && ! is_wp_error( $terms ) ) {
    			foreach ( $terms as $term ) {
    				$char_data[ $term->slug ] = 0;
    			}
    		}
    	}
    

    Now that we have those base arrays, again set to zero,

    By the way, $subdata and $data are parameters sent to the function that runs this. $subdata is for the taxonomy we’re calculating (sexuality etc) and $data is for the overall taxonomy (Nations or perhaps Stations or genres – we use a lot of those).

    This gets us started.

    Queery the Posts

    Next we need a WP_Query of all the posts in the taxonomy.

    	$count = wp_count_posts( 'post_type_shows' )->publish;
    	$queery = new WP_Query ( array(
    		'post_type'              => 'post_type_shows',
    		'posts_per_page'         => $count,
    		'post_status'            => array( 'publish' ),
    		'tax_query'              => array( array(
    			'taxonomy' => 'lez_' . $data,
    			'field'    => 'slug',
    			'terms'    => $the_tax->slug,
    			'operator' => '=',
    		),),
    	) );
    	wp_reset_query();
    

    Remember, this is still within that foreach above. And once we have the posts, let’s query all the shows:

    	if ( $queery->have_posts() ) {
    		foreach( $queery->posts as $show ) {
    
    			$shows++;
    			// Get all the crazy arrays
    			$gender = get_post_meta( $show->ID, 'lezshows_char_gender' );
    			if ( isset( $subdata ) ) { 
    				$dataset = get_post_meta( $show->ID, 'lezshows_char_' . $subdata );
    			}
    
    			// Add the character counts
    			foreach( array_shift( $gender ) as $this_gender => $count ) {
    				$characters += $count;
    			}
    
    			if ( !empty( $dataset ) ) {
    				foreach( array_shift( $dataset ) as $this_data => $count ) {
    					$char_data[ $this_data ] += $count;
    				}
    			}
    
    		}
    	}
    

    The weird section you see, // Add the character counts is there because every character has a gender, but not everyone has a sexuality or romantic orientation. Because of that, I decided it was safest to use that as my baseline count.

    The second section that checks if ( !empty( $dataset ) ) {...} is what adds things up for the array.

    Speaking of…

    Output the New Array

    Once I have those counts, I generate different arrays depending on what I’m outputting. The basic barchart is different from a percentage, which is different from the stacked bar.

    	// Determine what kind of array we need to show...
    	switch( $format ) {
    		case 'barchart':
    			$array[] = array (
    				'name'  => $the_tax->name,
    				'count' => $shows,
    			);
    			break;
    		case 'percentage':
    			$array = self::taxonomy( 'post_type_shows', 'lez_' . $data );
    			break;
    		case 'count':
    			$array = count( $taxonomy );
    			break;
    		case 'stackedbar':
    			$array[$the_tax->slug] = array(
    				'name'       => $the_tax->name,
    				'count'      => $shows,
    				'characters' => $characters,
    				'dataset'    => $char_data,
    			);
    	}
    

    And all of this is so I could get that silly stacked bar, which will have the count of total characters, shows, and the data.

    Whew.

  • Stacked Charts Part 1: Understanding Your Data

    Stacked Charts Part 1: Understanding Your Data

    There are a few different type of charts. Actually there are a lot. I find a nice bar chart fairly easy to read and understand. So when Tracy said we should generate some nice stats about nations, like how many shows there were per nation, I was able to do that pretty easily:

    An excerpt of shows by nation - USA has the most. Yaaaay.
    An excerpt of shows by nation

    And as far as that goes, it’s pretty cool. It’s really just the same code I use to generate category statistics already. This is, by the way, why using WordPress to generate your data is useful. It’s easy to replicate code you’ve already got.

    But then Tracy, who I think derives some perverse joy out of doing this to me, says “Can we find out how many trans characters there are per nation?”

    Use WordPress First

    If you heard my talks about Sara Lance, you’ve heard me tout that data based sites should always use WordPress functions first. By which I mean they should use taxonomies and custom post types when possible, because accessing the data will be consistent, regular, and repeatable.

    Ironically, it’s because I chose to use WordPress than I was in a bit of a bind.

    You see, we have three post types on the site right now: shows, characters, and actors. The shows have the taxonomy of ‘nation’ so getting that simple data was straightforward. The characters store the taxonomies of gender identity and sexual preference. That sounds pretty logical, right?

    So how, you may wonder, do we get a list of characters on a show? A query. Basically we search wp_post_meta for all characters with the array of lezchars_show_group and, within that multidimensional, have a show of the post ID of the show saved. Which means the characters are dynamically generated every single time a page is loaded. And yes, that is why I use The L Word as my benchmark for page speed.

    However by doing all this dynamically, generating the stats for characters per nation would look like this:

    1. Use get_terms to get a list of all shows in a nation to …
    2. Loop through all those shows and …
    3. Loop through all the characters on each show to extract the data to …
    4. Store the data per nation

    Ouch. Talk about slow.

    Solution? Use WordPress!

    Thankfully there was a workaround. One of the other odd things we do with shows is generate a show ‘score’ – a value calculated by the shows relative awesomeness, our subjective enjoyment of it, and the number of characters, alive or dead, it has.

    In order to make that generation run faster, every time a show or character is saved, I trigger the following post_meta values to be saved:

    • lezshows_characters – An array of character counts alive and dead
    • lezshows_the_score – The insane math of the score

    So I added three more:

    • lezshows_sexuality
    • lezshows_gender
    • lezshows_romantic

    All of those are generated when the post is saved, as it loops through all the characters and extracts data.

    Generate The Base

    In order to get the basics, we start by generating an array of everything we’re going to care about. I do this by listing all the taxonomies I want to use and then loop through them, adding each slug to a new array with a value of 0:

    $valid_taxes = array( 
    	'gender'    => 'lez_gender',
    	'sexuality' => 'lez_sexuality',
    	'romantic'  => 'lez_romantic',
    );
    $tax_data = array();
    
    foreach ( $valid_taxes as $title => $taxonomy ) {
    	$terms = get_terms( $taxonomy );
    	if ( ! empty( $terms ) && ! is_wp_error( $terms ) ) {
    		$tax_data[ $title ] = array();
    		foreach ( $terms as $term ) {
    			$tax_data[ $title ][ $term->slug ] = 0;
    		}
    	}
    }
    

    That gives me a multidimensional array which, I admit, is pretty epic and huge. But it lets move on to step two, of getting all the characters:

    $count          = wp_count_posts( 'post_type_characters' )->publish;
    $charactersloop = new WP_Query( array(
    	'post_type'              => 'post_type_characters',
    	'post_status'            => array( 'publish' ),
    	'orderby'                => 'title',
    	'order'                  => 'ASC',
    	'posts_per_page'         => $count,
    	'no_found_rows'          => true,
    	'meta_query'             => array( array(
    		'key'     => 'lezchars_show_group',
    		'value'   => $post_id,
    		'compare' => 'LIKE',
    	),),
    ) );
    

    Next I stop everything as a new array. Which is where we get into some serious fun. See, I have to actually double check the character is in the show, since the ‘like’ search has a few quirks when you’re searching arrays. The tl;dr explanation here is that if I look for shows with a post ID of “23” then I get “23” and “123” and “223” and so on.

    Yeah. It’s about as fun as you’d think. If I wasn’t doing arrays, this would be easier, but I have Sara Lance to worry about.

    if ($charactersloop->have_posts() ) {
    	while ( $charactersloop->have_posts() ) {
    		$charactersloop->the_post();
    		$char_id     = get_the_ID();
    		$shows_array = get_post_meta( $char_id, 'lezchars_show_group', true );
    
    		if ( $shows_array !== '' && get_post_status ( $char_id ) == 'publish' ) {
    			foreach( $shows_array as $char_show ) {
    				if ( $char_show['show'] == $post_id ) {
    					foreach ( $valid_taxes as $title => $taxonomy ) {
    						$this_term = get_the_terms( $char_id, $taxonomy, true );
    						if ( $this_term && ! is_wp_error( $this_term ) ) {
    							foreach( $this_term as $term ) {
    								$tax_data[ $title ][ $term->slug ]++;
    							}
    						}
    					}
    				}
    			}
    		}
    	}
    	wp_reset_query();
    }
    

    You’ll notice there’s a quick $tax_data[ $title ][ $term->slug ]++; in there to increment the count. That’s the magic that gets processed all over. It tells me things like “this show has 7 cisgender characters” which is the first half of everything I wanted.

    Because in the end I save this as an array for the show:

    foreach ( $valid_taxes as $title => $taxonomy ) { 
    	update_post_meta( $post_id, 'lezshows_char_' . $title , $tax_data[ $title ] );
    }
    

    How Well Does This Run?

    It’s okay. It’s not super awesome, since it has to loop so many times, this can get pretty chunky. See The L Word and it’s 60+ characters. However. It only updates when the show is saved, or a character is added to the show, which means the expensive process is limited. And by saving this data in an easily retrievable format, I’m able to do the next phase. Generate the stats.

  • Taxonomy Icons

    Taxonomy Icons

    Last year I talked about how I made icons for my taxonomy terms. When you have a limited number of terms, that makes sense. When you have a taxonomy with the potential for a high volume of terms, like nations (192), or worse an unlimited number of terms, this approach looses its value.

    Instead, I realized what I needed for a particular project was a custom icon for each taxonomy. Not the term.

    I split this up into two files because I used a slightly different setup for my settings API, but the tl;dr of all this is I made a settings page under themes called “Taxonomy Icons” which loads all the public, non-default taxonomies and associates them with an icon.

    For this to work for you, you will need to have your images in a folder and define that as your IMAGE_PATH in the code below. Also mine is using .svg files, so change that if you’re not.

    File 1: taxonomy-icons.php

    The one gotcha with this is I usually set my default values with $this->plugin_vars = array(); in the __construct function. You can’t do that with custom taxonomies, as they don’t exist yet.

    class TaxonomyIcons {
    
    	private $settings;
    	const SETTINGS_KEY = 'taxicons_settings';
    	const IMAGE_PATH   = '/path/to/your/images/';
    
    	/*
    	 * Construct
    	 *
    	 * Actions to happen immediately
    	 */
        public function __construct() {
    
    		add_action( 'admin_menu', array( $this, 'admin_menu' ) );
    		add_action( 'admin_init', array( $this, 'admin_init' ) );
    		add_action( 'init', array( $this, 'init' ) );
    
    		// Normally this array is all the default values
    		// Since we can't set it here, it's a placeholder
    		$this->plugin_vars = array();
    
    		// Create the list of imagess
    		$this->images_array = array();
    		foreach ( glob( static::IMAGE_PATH . '*.svg' ) as $file) {
    			$this->images_array[ basename($file, '.svg') ] = basename($file);
    		}
    
    		// Permissions needed to use this plugin
    		$this->plugin_permission = 'edit_posts';
    
    		// Menus and their titles
    		$this->plugin_menus = array(
    			'taxicons'    => array(
    				'slug'         => 'taxicons',
    				'submenu'      => 'themes.php',
    				'display_name' => __ ( 'Taxonomy Icons', 'taxonomy-icons' ),
    			),
    		);
        }
    
    	/**
    	 * admin_init function.
    	 *
    	 * @access public
    	 * @return void
    	 * @since 0.1.0
    	 */
    	function admin_init() {
    		// Since we couldn't set it in _construct, we do it here
    		// Create a default (false) for all current taxonomies
    		$taxonomies = get_taxonomies( array( 'public' => true, '_builtin' => false ), 'names', 'and' );
    		if ( $taxonomies && empty( $this->plugin_vars ) ) {
    			foreach ( $taxonomies as $taxonomy ) {
    				$this->plugin_vars[$taxonomy] = false;
    			}
    		}
    	}
    
    	/**
    	 * init function.
    	 *
    	 * @access public
    	 * @return void
    	 * @since 0.1.0
    	 */
    	function init() {
    		add_shortcode( 'taxonomy-icon', array( $this, 'shortcode' ) );
    	}
    
    	/**
    	 * Get Settings
    	 *
    	 * @access public
    	 * @param bool $force (default: false)
    	 * @return settings array
    	 * @since 0.1.0
    	 */
    	function get_settings( $force = false) {
    		if ( is_null( $this->settings ) || $force ) {
    			$this->settings = get_option( static::SETTINGS_KEY, $this->plugin_vars );
    		}
    		return $this->settings;
    	}
    
    	/**
    	 * Get individual setting
    	 *
    	 * @access public
    	 * @param mixed $key
    	 * @return key value (if available)
    	 * @since 0.1.0
    	 */
    	function get_setting( $key ) {
    		$this->get_settings();
    		if ( isset( $this->settings[$key] ) ) {
    			return $this->settings[$key];
    		} else {
    			return false;
    		}
    	}
    
    	/**
    	 * Set setting from array
    	 *
    	 * @access public
    	 * @param mixed $key
    	 * @param mixed $value
    	 * @return void
    	 * @since 0.1.0
    	 */
    	function set_setting( $key, $value ) {
    		$this->settings[$key] = $value;
    	}
    
    	/**
    	 * Save individual setting
    	 *
    	 * @access public
    	 * @return void
    	 * @since 0.1.0
    	 */
    	function save_settings() {
    		update_option( static::SETTINGS_KEY, $this->settings );
    	}
    
    	/**
    	 * admin_menu function.
    	 *
    	 * @access public
    	 * @return void
    	 * @since 0.1.0
    	 */
    	function admin_menu() {
    
    		foreach ( $this->plugin_menus as $menu ) {
    			$hook_suffixes[ $menu['slug'] ] = add_submenu_page(
    				$menu['submenu'],
    				$menu['display_name'],
    				$menu['display_name'],
    				$this->plugin_permission,
    				$menu['slug'],
    				array( $this, 'render_page' )
    			);
    		}
    
    		foreach ( $hook_suffixes as $hook_suffix ) {
    			add_action( 'load-' . $hook_suffix , array( $this, 'plugin_load' ) );
    		}
    	}
    
    	/**
    	 * Plugin Load
    	 * Tells plugin to handle post requests
    	 *
    	 * @access public
    	 * @return void
    	 * @since 0.1.0
    	 */
    	function plugin_load() {
    		$this->handle_post_request();
    	}
    
    	/**
    	 * Handle Post Requests
    	 *
    	 * This saves our settings
    	 *
    	 * @access public
    	 * @return void
    	 * @since 0.1.0
    	 */
    	function handle_post_request() {
    		if ( empty( $_POST['action'] ) || 'save' != $_POST['action'] || !current_user_can( 'edit_posts' ) ) return;
    
    		if ( !wp_verify_nonce( $_POST['_wpnonce'], 'taxicons-save-settings' ) ) die( 'Cheating, eh?' );
    
    		$this->get_settings();
    
    		$post_vars = $this->plugin_vars;
    		foreach ( $post_vars as $var => $default ) {
    			if ( !isset( $_POST[$var] ) ) continue;
    			$this->set_setting( $var, sanitize_text_field( $_POST[$var] ) );
    		}
    
    		$this->save_settings();
    	}
    
    	/**
    	 * Render admin settings page
    	 * If setup is not complete, display setup instead.
    	 *
    	 * @access public
    	 * @return void
    	 * @since 0.1.0
    	 */
    	function render_page() {
    		// Not sure why we'd ever end up here, but just in case
    		if ( empty( $_GET['page'] ) ) wp_die( 'Error, page cannot render.' );
    
    		$screen = get_current_screen();
    		$view  = $screen->id;
    
    		$this->render_view( $view );
    	}
    
    	/**
    	 * Render page view
    	 *
    	 * @access public
    	 * @param mixed $view
    	 * @param array $args ( default: array() )
    	 * @return content based on the $view param
    	 * @since 0.1.0
    	 */
    	function render_view( $view, $args = array() ) {
    		extract( $args );
    		include 'view-' . $view . '.php';
    	}
    
    	/**
    	 * Render Taxicon
    	 *
    	 * This outputs the taxonomy icon associated with a specific taxonomy
    	 *
    	 * @access public
    	 * @param mixed $taxonomy
    	 * @return void
    	 */
    	public function render_taxicon ( $taxonomy ) {
    
    		// BAIL: If it's empty, or the taxonomy doesn't exist
    		if ( !$taxonomy || taxonomy_exists( $taxonomy ) == false ) return;
    
    		$filename = $this->get_setting( $taxonomy );
    
    		// BAIL: If the setting is false or otherwise empty
    		if ( $filename == false || !$filename || empty( $filename ) ) return;
    
    		$icon     = file_get_contents( static::IMAGE_PATH . $filename  . '.svg' );
    		$taxicon  = '<span role="img" class="taxonomy-icon ' . $filename . '">' . $icon . '</span>';
    
    		echo $taxicon;
    	}
    
    	/*
    	 * Shortcode
    	 *
    	 * Generate the Taxicon via shortcode
    	 *
    	 * @param array $atts Attributes for the shortcode
    	 *        - tax: The taxonomy
    	 * @return SVG icon of awesomeness
    	 */
    	function shortcode( $atts ) {
    		return $this->render_taxicon( $atts[ 'tax' ] );
    	}
    
    }
    
    new TaxonomyIcons();
    

    File 2: view-appearance_page_taxicons.php

    Why that name? If you look at my render_view function, I pass the ID from get_current_screen() to it, and that means the ID is appearance_page_taxicons and that’s the page name.

    <div class="wrap">
    
    	<h1><?php _e( 'Taxonomy Icons', 'taxonomy-icons' ); ?></h1>
    
    	<div>
    
    		<p><?php __( 'Taxonomy Icons allows you to assign an icon to a non-default taxonomy in order to make it look damn awesome.', 'taxonomy-icons' ); ?></p>
    
    		<p><?php __( 'By default, Taxonomy Icons don\'t display in your theme. In order to use them, you can use a shortcode or a function:' , 'taxonomy-icons' ); ?></p>
    
    		<ul>
    			<li>Shortcode: <code>[taxonomy-icon tax=TAXONOMY]</code></li>
    		</ul>
    
    		<form method="post">
    
    		<?php
    		if ( isset( $_GET['updated'] ) ) {
    			?>
    			<div class="notice notice-success is-dismissible"><p><?php _e( 'Settings saved.', 'taxonomy-icons' ); ?></p></div>
    			<?php
    		}
    		?>
    
    		<input type="hidden" name="action" value="save" />
    		<?php wp_nonce_field( 'taxicons-save-settings' ) ?>
    
    		<table class="form-table">
    
    			<tr>
    				<th scope="row"><?php _e( 'Category', 'taxonomy-icons' ); ?></th>
    				<th scope="row"><?php _e( 'Current Icon', 'taxonomy-icons' ); ?></th>
    				<th scope="row"><?php _e( 'Select Icon', 'taxonomy-icons' ); ?></th>
    			</tr>
    
    			<?php
    
    			foreach ( $this->plugin_vars as $taxonomy => $value ) {
    				?>
    				<tr>
    					<td>
    						<strong><?php echo get_taxonomy( $taxonomy )->label; ?></strong>
    						<br /><em><?php echo get_taxonomy( $taxonomy )->name; ?></em>
    					</td>
    
    					<td>
    						<?php
    						if ( $this->get_setting( $taxonomy ) && $this->get_setting( $taxonomy ) !== false ) {
    							echo $this->render_taxicon( $taxonomy );
    						}
    						?>
    
    					</td>
    
    					<td>
    						<select name="<?php echo $taxonomy; ?>" class="taxonomy-icon">
    							<option value="">-- <?php _e( 'Select an Icon', 'taxonomy-icons' ); ?> --</option>
    							<?php
    							foreach ( $this->images_array as $file => $name ) {
    								?><option value="<?php echo esc_attr( $file ); ?>" <?php echo $file == $this->get_setting( $taxonomy ) ? 'selected="selected"' : ''; ?>><?php echo esc_html( $name ); ?></option><?php
    								};
    							?>
    						</select>
    					</td>
    
    				</tr><?php
    			}
    
    			?>
    
    			<tr valign="top">
    				<td colspan="3">
    					<button type="submit" class="button button-primary"><?php _e( 'Save', 'taxonomy-icons' ); ?></button>
    				</td>
    			</tr>
    		</table>
    		</form>
    	</div>
    </div>
    

    End Result

    And in the end?

    An example of Taxonomy Icons

    By the way, the image has some wrong text, but that is what it looks like.

  • Customizing Taxonomies as Dropdowns in Quick Edit

    Customizing Taxonomies as Dropdowns in Quick Edit

    When you go to quick edit for a post, you will automatically see your custom taxonomies as editable fields:

    Custom Taxonomies showing in quick edit!

    But there’s a problem … I don’t want these to be text fields nor do I want them to be checkboxes. When it’s just you running a site, there’s less to worry about with regards to these things. You know what you’re adding, and if you typo, you blame yourself. At the same time, WordPress only has two options for these sorts of things: tags (freeform text) and categories (checkboxes).

    Technically they’re settings for hierarchical, where false is text and true is checkboxes, and I’ve hidden the parent field (a topic for another post). But doing that makes them all check boxes, and I want to restrict people to one and only one checkbox. Except I don’t. The UX of having checkboxes vanish is pretty shitty, and it isn’t logical that you would only check one box.

    Let me explain. In this example, which is practical, I have a taxonomy for human sexuality. I chose taxonomies and not post-meta because that would allow me to sort much more easily on data by groups. Simply, I can grab a list of all people who are flagged as ‘pansexual’ with one function and not have to reinvent the wheel.

    That means I can use radio buttons or a dropdown. I feel a dropdown is a better, cleaner, UX, so that’s what I’m going to do. You can use the same logic here, though, so don’t worry.

    After reading ShibaShake’s documentation on adding in quick edit values I winced. Adding it with quick_edit_custom_box is super easy. The problem is that you have to use an incredibly weird amount of javascript to get the post ID and pass data back and forth. As weird and annoying as that was, it actually works. The example, of course, is comparing a CPT, where as I am using a custom taxonomy, so my own code was a little crazier.

    Remove the taxonomy from Quick Edit

    To do this starts out sounding like the stupidest idea ever, but you want to set show_in_quick_edit to false in your taxonomy. That does what it sounds like, removing the item from the quick edit page. Obviously now I have to add it back.

    It’s important to note that if you don’t have any custom columns in your post list view, the rest of this won’t work. I do, and since part two of all this will be about being able to edit those custom columns, I don’t have to worry. If you do, here’s how you add a fake column… Ready? You add and remove the column. I know, I know, WP can be weird:

    add_filter('manage_posts_columns', 'lezchars_add_fake_column', 10, 2);
    function lezchars_add_fake_column( $posts_columns, $post_type ) {
        $posts_columns['lez_fakeit'] = 'Fake Column (Invisible)';
        return $posts_columns;
    }
    add_filter('manage_edit-post_columns', 'lezchars_remove_fake_column');
    function remove_dummy_column( $posts_columns ) {
        unset($posts_columns['lez_fakeit']);
        return $posts_columns;
    }
    

    Like I said, I know it’s weird.

    Add the Quick Edit Box

    Since I know I’m going to be building out a few more of these, and one is a cross-relational CPT to CPT drama, I’ve broken mine out into a switch/case basis.

    // Add quick Edit boxes
    add_action('quick_edit_custom_box',  'lezchars_quick_edit_add', 10, 2);
    function lezchars_quick_edit_add($column_name, $post_type) {
    	switch ( $column_name ) {
    		case 'shows':
    			// Multiselect - CPT where characters may have multiple
    			break;
    		case 'roletype':
    			// Single Select - Custom Taxonomy
    			break;
    		case 'taxonomy-lez_sexuality':
    			?>
    			<fieldset class="inline-edit-col-left">
    			<div class="inline-edit-col">
    			<span class="title">Sexual Orientation</span>
    				<input type="hidden" name="lez_sexuality_noncename" id="lez_sexuality_noncename" value="" />
    				<?php 
    				$terms = get_terms( array( 'taxonomy' => 'lez_sexuality','hide_empty' => false ) );
    				?>
    				<select name='terms_lez_sexuality' id='terms_lez_sexuality'>
    					<option class='lez_sexuality-option' value='0'>(Undefined)</option>
    					<?php
    					foreach ($terms as $term) {
    						echo "<option class='lez_sexuality-option' value='{$term->name}'>{$term->name}</option>\n";
    					}
    						?>
    				</select>
    			</div>
    			</fieldset>
    			<?php
    		break;
    	}
    }
    

    This is all pretty basic stuff. I’m simply making a form for a drop-down based on the contents of my taxonomy. If you want to make it radio buttons, do that.

    Saving The Changes

    Now we’re getting a little weirder. We need to save our changes, but only if it’s not an auto-save, and if it’s the correct post type (post_type_characters for this page), and if the user can edit the page:

    add_action('save_post', 'lezchars_quick_edit_save');
    function lezchars_quick_edit_save($post_id) {
        // Criteria for not saving: Auto-saves, not post_type_characters, can't edit
        if ( ( defined('DOING_AUTOSAVE') && DOING_AUTOSAVE ) || ( 'post_type_characters' != $_POST['post_type'] ) || !current_user_can( 'edit_page', $post_id ) ) {
    		return $post_id;
    	}
    
    	$post = get_post($post_id);
    
    	// Lez Sexuality
    	if ( isset($_POST['terms_lez_sexuality']) && ($post->post_type != 'revision') ) {
    		$lez_sexuality_term = esc_attr($_POST['terms_lez_sexuality']);
    		$term = term_exists( $lez_sexuality_term, 'lez_sexuality');
    		if ( $term !== 0 && $term !== null) {
    			wp_set_object_terms( $post_id, $lez_sexuality_term, 'lez_sexuality' );
    		}
    	}
    }
    

    The two checks going on here are first to be sure it’s the right POST action to save on, and then to only update if the term already exists. If you wanted to append terms instead of replace, you could add a ‘true’ param to wp_set_object_terms, however I want only set one sexuality per person in this case.

    At this point, the code actually works!

    The new dropdown

    Changing the current selection for the dropdown

    There’s one problem though. If you’re working along with me this far, you’ll have noticed that the default selection is always ‘(Undefined)’ and that’s not what we want. The extension of this problem is we have to use bloody javascript to edit it. Damn it.

    // Javascript to change 'defaults'
    add_action('admin_footer', 'lezchars_quick_edit_js');
    function lezchars_quick_edit_js() {
    	global $current_screen;
    	if ( ($current_screen->id !== 'edit-post_type_characters') || ($current_screen->post_type !== 'post_type_characters') ) return;
    	?>
    	<script type="text/javascript">
    	<!--
    	function set_inline_lez_sexuality( widgetSet, nonce ) {
    		// revert Quick Edit menu so that it refreshes properly
    		inlineEditPost.revert();
    		var widgetInput = document.getElementById('terms_lez_sexuality');
    		var nonceInput = document.getElementById('lez_sexuality_noncename');
    		nonceInput.value = nonce;
    
    		// check option manually
    		for (i = 0; i < widgetInput.options.length; i++) {
    			if (widgetInput.options[i].value == widgetSet) {
    				widgetInput.options[i].setAttribute("selected", "selected");
    			} else { widgetInput.options[i].removeAttribute("selected"); }
    		}
    	}
    	//-->
    	</script>
    	<?php
    }
    
    // Calls the JS in the previous function
    add_filter('post_row_actions', 'lezchars_quick_edit_link', 10, 2);
    
    function lezchars_quick_edit_link($actions, $post) {
    	global $current_screen;
    	if (($current_screen->id != 'edit-post_type_characters') || ($current_screen->post_type != 'post_type_characters')) return $actions;
    
    	$lez_nonce = wp_create_nonce( 'lez_sexuality_'.$post->ID);
    	$lez_sex   = wp_get_post_terms( $post->ID, 'lez_sexuality', array( 'fields' => 'all' ) );
    
    	$actions['inline hide-if-no-js'] = '<a href="#" class="editinline" title="';
    	$actions['inline hide-if-no-js'] .= esc_attr( __( 'Edit this item inline' ) ) . '" ';
    	$actions['inline hide-if-no-js'] .= " onclick=\"set_inline_lez_sexuality('{$lez_sex[0]->name}', '{$lez_nonce}')\">";
    	$actions['inline hide-if-no-js'] .= __( 'Quick&nbsp;Edit' );
    	$actions['inline hide-if-no-js'] .= '</a>';
    	return $actions;
    }
    

    Please don’t ask me to explain that. And yes, I know we should be using Unobtrusive Javascript and hooking into the DOM instead, but I don’t yet know how to do that.

    I do know that if I wanted to add in multiple checks, one way is to duplicate the function set_inline_lez_sexuality( widgetSet, nonce ) and rename it to set_inline_lez_gender and then extend the actions like this:

    $actions['inline hide-if-no-js'] .= " onclick=\"set_inline_lez_sexuality('{$sex_terms[0]->name}', '{$sex_nonce}');set_inline_lez_gender('{$gender_terms[0]->name}', '{$gender_nonce}')\">";
    

    There are more combinations and concatenations one can do here. Knock yourself out. It’s enough javascript for me today!