List WordPress posts alphabetically beneath category headings

Now, this is going to be a niche blog post.

For the last ten years, I’ve been blogging metal, punk, hardcore and rock music reviews at 195metalcds.com.

Besides the main list of reviews, I also created three pages to list the reviews by artist, genre and score. I updated these manually because when I started the blog I hosted it at wordpress.com which had limitations about which themes and plugins I could use. But ever since moving the site to my own paid-hosting account at SiteGround, I’ve wanted to write a plugin or child theme function that could update these three pages automatically. Well, now I have and that’s what this blog post explains.

You can view the code on GitHub.

The problems to solve

There were four problems that I needed to solve:

  1. Give each review a score that can be displayed outside of the blog post.
  2. List all reviews by post title followed by the review score,
    e.g. Artist—Album (2022) — 80%.
  3. List all genres alphabetically, then beneath each genre heading list every review (alphabetically) that has been categorised with that genre. And include the review score.
  4. List all scores as headings, then list every review (alphabetically) that has been given that score.

Wait a minute, I’ve done this before!

While I was talking through the problem with my friend Aaron, I realised that I had solved this problem before when I created a WordPress website for the Andrew Marvell Society (AMS), when I was web architect at the University of St Andrews.

As I had done much of the work on this site at home, I took a look through my code archives and was delighted to find that I still had it.

When I created the solution for the AMS, I created WordPress plugins to handle the heavy lifting. This meant that they could easily switch WordPress themes and take the functionality with them. But for the 195 metal CDs blog, I’m happy with the ImageGridly theme that it uses so I decided to instead bake the functionality into a functions.php file within a child theme.

Solution 1: Give each review a reusable score

If you check out section 5 of the functions.php file in GitHub you can follow what I did.

i. Define a meta box

To solve this problem, I first defined a new meta box and added it to the post post-type. This is what the meta box looks like at the bottom of the right-hand sidebar in WordPress:

And here’s the code:

// 1. Define meta box

$prefix   = '195metalcds-';
$meta_box = array(
    'id'        => '195metalcds-meta-box', // HTML 'id' attribute of the edit screen section
    'title'     => 'Review score %',       // Title of the edit screen section, visible to user
    'posttype'  => 'post',                 // The type of write screen on which to show the edit screen section ('post', 'page', 'link', 'attachment' or 'custom_post_type' where custom_post_type is the custom post type slug)
    'context'   => 'side',                 // The part of the page where the edit screen section should be shown ('normal', 'advanced', or 'side')
    'priority'  => 'high',                 // The priority within the context where the boxes should show ('high', 'core', 'default' or 'low')
    'fields'    => array
    (
        array
        (
            'desc' => 'Min 0, Max 100, intervals of 5',
            'id'   => $prefix . 'score',
            'type' => 'text',
            'placeholder'  => '%'
        )
    )
);
add_action('admin_menu', 'mytheme_add_box');

function mytheme_add_box() {
    global $meta_box;
    add_meta_box($meta_box['id'], $meta_box['title'], 'mytheme_show_box', $meta_box['posttype'], $meta_box['context'], $meta_box['priority']);
}

ii. Create input fields and populate it with any saved data

The above code only defines the meta box and its title. It doesn’t handle the input fields in the meta box or saving any data or populating it with any saved data when you reload the page. That is split between the next two blocks of code.

First, we define the input fields in the meta box and populate it with any data that has been saved using the get_post_meta() method.

// 2. Callback function (called as a parameter in add_meta_box, above) 
//    to show input field in meta box and populate it with any data.

function mytheme_show_box() {
    global $meta_box, $post;

    // Use nonce for verification
    echo '<input type="hidden" name="mytheme_meta_box_nonce" value="', wp_create_nonce(basename(__FILE__)), '" />';
    
    // Loop through the fields array defined in 1. above.
    foreach ($meta_box['fields'] as $field) {

        // Get current post meta data
        $meta = get_post_meta($post->ID, $field['id'], true);
        ?>
        <p><input type="text" name="<?php echo($field['id']); ?>" id="<?php echo($field['id']); ?>" value="<?php echo($meta); ?>" placeholder="<?php echo($field['placeholder']); ?>" style="width: 50%;" /></p>
        <p><span class="howto" style="margin-left: 1em;"><?php echo($field['desc']); ?></span></p>
        <?php
    }
}

iii. Save data from meta box on post save

Lastly, we can handle how to save any data entered using the update_post_meta() method, and delete_post_meta() method if the data has been removed.

// 3. Save data from meta box on post save

function mytheme_save_data($post_id) {
    global $meta_box;

    // Verify nonce
    if (!wp_verify_nonce($_POST['mytheme_meta_box_nonce'], basename(__FILE__))) {
        return $post_id;
    }

    // Check autosave
    if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
        return $post_id;
    }

    // Check permissions
    if ('page' == $_POST['post_type']) {
        if (!current_user_can('edit_page', $post_id)) {
            return $post_id;
        }
    } elseif (!current_user_can('edit_post', $post_id)) {
        return $post_id;
    }

    foreach ($meta_box['fields'] as $field) {
        $old = get_post_meta($post_id, $field['id'], true);
        $new = $_POST[$field['id']];
        if ($new && $new != $old) {
            update_post_meta($post_id, $field['id'], $new);
        } elseif ('' == $new && $old) {
            delete_post_meta($post_id, $field['id'], $old);
        }
    }
}
add_action('save_post', 'mytheme_save_data');

iv. Display meta box data on the All posts page

The next piece of the puzzle was to display this data in its own column on All posts page, like this:

This uses the meta data ID that was defined in section i. above: 'id' => $prefix . 'score'.

function add_reviewscore_admin_columns($columns) {
    $new_columns = array(
        '195metalcds-score' => __('Review score', '195-metal-cds')
    );
    return array_merge($columns, $new_columns);
}

// First parameter must be: manage_{post-type}_columns
// Second parameter names the function to be called
add_filter('manage_posts_columns','add_reviewscore_admin_columns');

// Show data within the column
function show_review_score_column($name){
    global $post;
    switch ($name) {
        case '195metalcds-score':
            $review_score = get_post_meta(get_the_ID(), '195metalcds-score', true);
            echo ($review_score);
            break;
        default:
            break;
    }
}
add_action('manage_posts_custom_column','show_review_score_column');

Notice in this block of code, the first parameter for the add_filter() method includes the name of the post-type: manage_posts_columns. Change this if you are using this for a custom post-type. For example, if you were using a custom post-type called property then you would use: manage_property_columns. This caught me out for a moment before I realised this.

Solution 2: List all reviews by post title followed by the review score

So, I wanted a shortcode [fulllist] that I could drop into any page to output a full list of reviews followed by the review score. Like this:

Artist—Album (2022) — 80%.

This turned out to be easier than I expected. Here’s the code:

// shortcode_name, function
add_shortcode( 'fulllist', 'custom_shortcode_fulllist' );

function custom_shortcode_fulllist() {
ob_start();
// CODE


// Exclude category 2 which is 'About the project'
$arguments = array(
    'numberposts'      => -1,
    'orderby'          => 'title',
    'post_status'      => 'publish',
    'order'            => 'ASC',
    'category__not_in' => [2,764],
);

$posts = get_posts($arguments);
echo('<ol>');

foreach($posts as $post) {
    $postid = $post->ID;
    $link = get_permalink($postid);
    $review_score = get_post_meta( $postid, '195metalcds-score', true );

    if ($review_score) {
        echo("<li><a href='$link'>" . $post->post_title . "</a> — $review_score%</li>");
    } else {
        echo("<li><a href='$link'>" . $post->post_title . "</a></li>");
    }
}
echo('</ol>');



// END CODE
$return_string = ob_get_clean();
return $return_string;
}

A few things to note:

  • numberposts is set to -1. This means all posts.
  • post_status is set to publish. I only want to list posts that have been published.
  • category_not_in has been set to the category IDs of two posts that I want to exclude. The first is an ‘About the project’ category, the second is the ‘metal’ category which would simply duplicate all posts categorised under a metal sub-genre.
  • This is the code that grabs the review score meta data for each post by $postid: $review_score = get_post_meta( $postid, '195metalcds-score', true );

To output the full list of reviews, all I need to do now is drop the following shortcode into any post: [fulllist].

This allows me to add whatever text or images I want before or after it in the WordPress page editor without needing to edit any theme files.

Solution 3: List reviews by genre

The next problem was how to output all the genres as headings with an alphabetical list of posts beneath them. This also turned out to be easier than I had feared.

Basically, grab an array of all the categories — $terms = get_terms($taxonomy,$tax_terms) where $tax_terms defines the order and any categories to exclude — then loops through those, outputting all posts that have that category.

Thus…

// shortcode_name, function
add_shortcode( 'genres', 'custom_shortcode_genres' );

function custom_shortcode_genres() {

    ob_start();

    $post_type = 'post';
    $taxonomy = 'category';

    // exclude category 3 = 'metal'
    $tax_args = array(
        'order' => 'ASC',
        'exclude' => '3',
    );
    $terms = get_terms( $taxonomy, $tax_args );

    foreach( $terms as $term ) :

        $post_args = array(
            'taxonomy'       => $taxonomy,
            'term'           => $term->slug,
            'order'          => 'ASC',
            'orderby'        => 'title',
            'posts_per_page' => '-1',
            'post_type'      => 'post',
            'post_status'    => 'publish',
            );
        $posts = new WP_Query( $post_args );
    ?>
        <h2 class="genre-heading"><?php echo($term->name); ?></h2>
        <ul>
            <?php
            if( $posts->have_posts() ):
                while( $posts->have_posts() ) : $posts->the_post(); ?>
                    <li><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a>
                            <?php
                                $review_score = get_post_meta( get_the_ID(), '195metalcds-score', true );

                                if ($review_score) {
                                    echo('<span class="review-score"> — ' . $review_score . '%</span>');
                                } else {
                                    echo('<span class="review-score"></span>');
                                }
                            ?>
                    </li>
                <?php endwhile;
            endif; ?>
        </ul>
    <?php endforeach;

        $return_string = ob_get_clean();
        return $return_string;
}

You’ll see that it also grabs the $review_score meta data and throws that into the mix too.

Solution 4: List reviews by score

Lastly, we need to do the same but instead of ouputing music genre categories, we want to output review scores (e.g. 0%, 10%, 15%, 20%, … 45%, … 90%, 95%, 100%) and list all reviews that have been given that score beneath.

After puzzling over how I might read in all the $review_score meta data into an array to loop through, I decided to instead create a new taxonomy called score and use the solution above for genres, but do it for scores.

The downside was that for each review I would now need to enter the review score twice, but it was a bit of technical debt that I was happy to live with. If I ever revisit this code, I can easily remove the meta box and use just the new taxonomy.

i. Create a new score taxonomy

Here’s how I created the new score taxonomy:

function metalcds_custom_taxonomy()  {
    $labels = array(
        'name'                       => _x( 'Scores', 'Taxonomy General Name', 'text_domain' ),
        'singular_name'              => _x( 'Score', 'Taxonomy Singular Name', 'text_domain' ),
        'menu_name'                  => __( 'Scores', 'text_domain' ),
        'all_items'                  => __( 'All Scores', 'text_domain' ),
        'parent_item'                => __( 'Parent Score', 'text_domain' ),
        'parent_item_colon'          => __( 'Parent Score:', 'text_domain' ),
        'new_item_name'              => __( 'New Score Name', 'text_domain' ),
        'add_new_item'               => __( 'Add New Score', 'text_domain' ),
        'edit_item'                  => __( 'Edit Score', 'text_domain' ),
        'update_item'                => __( 'Update Score', 'text_domain' ),
        'separate_items_with_commas' => __( 'Separate scores with commas', 'text_domain' ),
        'search_items'               => __( 'Search scores', 'text_domain' ),
        'add_or_remove_items'        => __( 'Add or remove scores', 'text_domain' ),
        'choose_from_most_used'      => __( 'Choose from the most used scores', 'text_domain' ),
    );
    $args = array(
        'labels'                     => $labels,
        'hierarchical'               => true, // true = like categories; false = like tags
        'public'                     => true,
        'show_admin_column'          => true, // View on the All Posts admin screen
        'show_in_menu'               => true, 
        'show_in_nav_menus'          => true,
        'show_in_rest'               => true, // View in the new blocks editor
        'show_tagcloud'              => true,
        'show_ui'                    => true
    );
    // Register the new 'score' taxonomy for the 'post' post_type.
    register_taxonomy( 'score', 'post', $args );
}
add_action( 'init', 'metalcds_custom_taxonomy', 0 );

The two arrays define labels and taxonomy arguments. The register_taxonomy() method then defines the score taxonomy for post post-types.

The show_admin_column option automatically displays the new score category on the All posts page. No need for a separate function like for meta data.

ii. Scores shortcode

The last piece of the puzzle was to create the shortcode to loop through the new score category and output the relevant posts as before.

However, I quickly realised that PHP can’t sort numbers naturally.

Instead of a reverse list like:

  • 100
  • 95
  • 90
  • 85
  • 80
  • 10
  • 5
  • 0

When PHP sorts numbers as strings, it does it like this:

  • 95
  • 90
  • 85
  • 80
  • 50
  • 5
  • 100
  • 10
  • 5
  • 0

So, I decided to drop 5% as an option (none of the CDs had been given 5% anyway), and rather than faffing around creating some kind of function to manipulate the sort, I just looped through the posts twice: once for 100%, and then from 95% to 0%.

I wasn’t entirely happy with that but a) it worked , b) we’re not talking about a lot of data so the performance impact isn’t huge, and c) I needed to make dinner for my children.

// shortcode_name, function
add_shortcode( 'scores', 'custom_shortcode_scores' );

function custom_shortcode_scores() {
ob_start();

// 100
// Update 'include' to reflect the live category ID for 100
// localhost include 767
// live      include 770

    $post_type = 'post';
    $taxonomy = 'score';
    $tax_args = array(
        'order' => 'DESC',
        'include' => '770',
    );
    $terms = get_terms( $taxonomy, $tax_args );

    foreach( $terms as $term ) :

        $post_args = array(
            'taxonomy'       => $taxonomy,
            'term'           => $term->slug,
            'order'          => 'ASC',
            'orderby'        => 'title',
            'posts_per_page' => '-1',
            'post_type'      => 'post',
            'post_status'    => 'publish',
            );
        $posts = new WP_Query( $post_args );
    ?>
        <h2 class="scores-heading"><?php echo($term->name); ?></h2>
        <ul>
            <?php
            if( $posts->have_posts() ):
                while( $posts->have_posts() ) : $posts->the_post(); ?>
                    <li><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></li>
                <?php endwhile;
            endif; ?>
        </ul>
    <?php endforeach;

// 95 to 0
// Update 'exclude' to reflect the live category ID for 100

    $post_type = 'post';
    $taxonomy = 'score';
    $tax_args = array(
        'order' => 'DESC',
        'exclude' => '770',
    );
    $terms = get_terms( $taxonomy, $tax_args );

    foreach( $terms as $term ) :

        $post_args = array(
            'taxonomy'       => $taxonomy,
            'term'           => $term->slug,
            'order'          => 'ASC',
            'orderby'        => 'title',
            'posts_per_page' => '-1',
            'post_type'      => 'post'
            );
        $posts = new WP_Query( $post_args );
    ?>
        <h2 class="scores-heading"><?php echo($term->name); ?></h2>
        <ul>
            <?php
            if( $posts->have_posts() ):
                while( $posts->have_posts() ) : $posts->the_post(); ?>
                    <li><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></li>
                <?php endwhile;
            endif; ?>
        </ul>
    <?php endforeach;

// END
$return_string = ob_get_clean();
return $return_string;
}

Conclusion

I was rather delighted with the end result. I now have a blog that automatically lists posts by artist, genre and score. No longer do I need to manually update three pages after I write a review (which would always appear hours after I had scheduled the post to be published), but it is also now more accurate.

I’m sure there were some things that I could have done better, and if I’d considered the new score taxonomy before I had begun then I wouldn’t have spent all that time coding that up. But that’s a project for another day to iterate on it and improve it.

Many thanks ot the many blog posts and support forums that I got answers and snippets of code from.

Like I said above, you can view the full code on GitHub.

Published by

Gareth Saunders

I’m Gareth J M Saunders, 50 years old, 6′ 4″, father of 3 boys (including twins). Enneagram type FOUR and introvert (INFP), I am a non-stipendiary priest in the Scottish Episcopal Church, I sing with the NYCGB alumni choir, play guitar, play mahjong, write, draw and laugh… Scrum master at Safeguard Global; latterly at Sky and Vision/Cegedim. Former web architect and agile project manager at the University of St Andrews and previously warden at Agnes Blackadder Hall.

Leave a Reply

Your email address will not be published.

This site uses Akismet to reduce spam. Learn how your comment data is processed.