CSS siblings and :target

A cool thing I just discovered will working on the WPMovieLibrary tabbed metabox: use CSS siblings and :target pseudo-class to build a full-CSS tab menu.

Siblings works with the ~ combinator to target elements from the same container if the second element is preceded by the first; The :target pseudo-class can be used to style an element targeted by an identifier in the URI. Combine the two and you can style a target link — the tab menu link — and the targeted panel.

Two things to take into account: the menu has to be located after the panels, and both menu and panels have to be children of the same parent element. In my case it meant I had to modify a bit the way the tabs were built, which involving some floating and margins (vertical menu). If you’re using an horizontal menu you’ll have to play with positions to have your menu showing on top of your tabs.

I’m not going to develop the coding part, CSS-Tricks and Nathan Faubion did that for you already. As mention on the former, this is far from an ideal technique to build tabs, the biggest inconvenient IMHO is that relying on URI identifier makes the user jump through the page, which is pretty dirty is you’re dealing with massive tabs.

That said, it’s a very useful trick to provide minimal tabbing as a fall-back for JS-disabled pages. In my case the WordPress Dashboard is very limited if you don’t have JS enabled, but it happens, either by the user’s will or a bug causing JS errors and preventing your nice JS-powered tab to work, so it’s always nice to provide some alternative to make sure a tabbed metabox somehow works in every situation.

A way to Implement AJAX in WP_List_Table

I’ve been playing with WordPress’ WP_List_Table for a while now, but since the first day the truly missing feature was an AJAX loading. And I finally managed to find time to put some work on it, I couldn’t find any real documentation about we actually do that, apart from a two-years old StackExchange question that doesn’t provide a full solution. So, here’s what I came up with.

Digging through the core

If you’re not the kind of archaeology guy, you may skip to the next part.

When you don’t find any worthy doc about something you’re trying to build on WordPress, a good thing is to search through the source code to find any clue: undocumented functions you may have missed, hooks you never heard about… I came across both. Looking for classes extending WP_List_Table and using a 'ajax' => true parameter, I quickly found /wp-admin/includes/class-wp-themes-list-table.php, and a encouraging line mentioning a _ajax_fetch_list_nonce Nonce field. This nonce lead me to the /wp-admin/includes/class-wp-themes-list-table.php file, and then again a line mentioning a _get_list_table() function that fetches an instance of a WP_List_Table class. That’s great, except that it’s a private function, meaning we can’t use it. Too bad. But, we have a full example of how to use AJAX: we need to use an AJAX action filter to create an instance of our custom WP_List_Table class, and then call the WP_List_Table->ajax_response() method. Yeah, it looks easy now that we know what to do!

Custom AJAX List Table Example

Beware, from here we’ll be using a plugin; all that follows suppose you know at least what you’re doing with WP_List_Table. We’ll implement a custom post type and add a new admin page for testing purpose; I will not detail these parts, I used Matt Van Andel‘s very well documented Custom List Table Example as a starting base and updated it to implement AJAX. You can see the full plugin file on GitHub.

Activate AJAX

The first thing to do is to add a parameter to the WP_List_Table __construct() method, indicating we’re using AJAX on this class:

function __construct() {

	global $status, $page;

	//Set parent defaults
	parent::__construct(
		array(
			//singular name of the listed records
			'singular'	=> 'movie',
			//plural name of the listed records
			'plural'	=> 'movies',
			//does this table support ajax?
			'ajax'		=> true
		)
	);

}

Now you can just forget about it, we will not be using it today. Truth to tell, I’m not even sure about what it really does; I guess it can become handy if you need to call specific actions whether using AJAX or not. I’m interested in any better explanation!

Edit: As Matt detailed in the comments, setting 'ajax' => true makes WP_List_Table automatically call the private _js_vars() method, which adds to the footer a set of useful Javascript variables.

Set order in pagination arguments

If we want to be able to sort tables through AJAX, will need a way to access the ordering values, order and orderby, at any time; fine, we’ll just add them to the class’ _pagination_args:

$this->set_pagination_args(
	array(
		//WE have to calculate the total number of items
		'total_items'	=> $total_items,
		//WE have to determine how many items to show on a page
		'per_page'	=> $per_page,
		//WE have to calculate the total number of pages
		'total_pages'	=> ceil( $total_items / $per_page ),
		// Set ordering values if needed (useful for AJAX)
		'orderby'	=> ! empty( $_REQUEST['orderby'] ) && '' != $_REQUEST['orderby'] ? $_REQUEST['orderby'] : 'title',
		'order'		=> ! empty( $_REQUEST['order'] ) && '' != $_REQUEST['order'] ? $_REQUEST['order'] : 'asc'
	)
);

This action should be put at the end of your WP_List_Table prepare_items() method.

Nonce and order inputs

This time we will add some more code to your class’ display() method; as its name let it figure it is the method that will render the final table. We want to add a Nonce field for security, and a couple of hidden input that will store the order and orderby values, so that we can use them later with jQuery.

function display() {

	wp_nonce_field( 'ajax-custom-list-nonce', '_ajax_custom_list_nonce' );

	echo '<input id="order" type="hidden" name="order" value="' . $this->_pagination_args['order'] . '" />';
	echo '<input id="orderby" type="hidden" name="orderby" value="' . $this->_pagination_args['orderby'] . '" />';

	parent::display();
}

The AJAX Response

This would be the most important part: the ajax_response() method, the very method that will gather and display the update table rows and elements.

The first thing we do is check the nonce, and only then, we prepare the items. The method will return an array in the form of a JSON string containing:

  • column_headers: string containing the table header’s HTML content
  • pagination: array containing top and bottom table navs’ HTML content
  • rows: string containing the requested table rows
  • total_items_i18n: string containing the translated number of items (unused here)
  • total_pages: int containing the number of pages (unused here)
  • total_pages_i18n: string containing the translated number of pages (unused here)

We use output buffering a few times; it may not always be a wise choice to do so, the point here is to avoid rewriting a couple of big methods. We need to call WP_List_Table->pagination() (two times, top and bottom) and WP_List_Table->print_column_headers() (one time), methods that both echo the HTML result instead of return it; for the sake of simplicity I prefer to use output buffering rather than copying the two methods in each class I have only to add a return option.

function ajax_response() {

	check_ajax_referer( 'ajax-custom-list-nonce', '_ajax_custom_list_nonce' );

	$this->prepare_items();

	extract( $this->_args );
	extract( $this->_pagination_args, EXTR_SKIP );

	ob_start();
	if ( ! empty( $_REQUEST['no_placeholder'] ) )
		$this->display_rows();
	else
		$this->display_rows_or_placeholder();
	$rows = ob_get_clean();

	ob_start();
	$this->print_column_headers();
	$headers = ob_get_clean();

	ob_start();
	$this->pagination('top');
	$pagination_top = ob_get_clean();

	ob_start();
	$this->pagination('bottom');
	$pagination_bottom = ob_get_clean();

	$response = array( 'rows' => $rows );
	$response['pagination']['top'] = $pagination_top;
	$response['pagination']['bottom'] = $pagination_bottom;
	$response['column_headers'] = $headers;

	if ( isset( $total_items ) )
		$response['total_items_i18n'] = sprintf( _n( '1 item', '%s items', $total_items ), number_format_i18n( $total_items ) );

	if ( isset( $total_pages ) ) {
		$response['total_pages'] = $total_pages;
		$response['total_pages_i18n'] = number_format_i18n( $total_pages );
	}

	die( json_encode( $response ) );
}

AJAX Callback

Almost done with PHP! All we need to do is handle our hook.

function _ajax_fetch_custom_list_callback() {

	$wp_list_table = new TT_Example_List_Table();
	$wp_list_table->ajax_response();
}
add_action('wp_ajax__ajax_fetch_custom_list', '_ajax_fetch_custom_list_callback');

The jQuery part

That’s the part that will capture links click events, send AJAX calls and handle the HTML update. It’s quite simple if not short:

(function($) {

list = {

	/**
	 * Register our triggers
	 * 
	 * We want to capture clicks on specific links, but also value change in
	 * the pagination input field. The links contain all the information we
	 * need concerning the wanted page number or ordering, so we'll just
	 * parse the URL to extract these variables.
	 * 
	 * The page number input is trickier: it has no URL so we have to find a
	 * way around. We'll use the hidden inputs added in TT_Example_List_Table::display()
	 * to recover the ordering variables, and the default paged input added
	 * automatically by WordPress.
	 */
	init: function() {

		// This will have its utility when dealing with the page number input
		var timer;
		var delay = 500;

		// Pagination links, sortable link
		$('.tablenav-pages a, .manage-column.sortable a, .manage-column.sorted a').on('click', function(e) {
			// We don't want to actually follow these links
			e.preventDefault();
			// Simple way: use the URL to extract our needed variables
			var query = this.search.substring( 1 );
			
			var data = {
				paged: list.__query( query, 'paged' ) || '1',
				order: list.__query( query, 'order' ) || 'asc',
				orderby: list.__query( query, 'orderby' ) || 'title'
			};
			list.update( data );
		});

		// Page number input
		$('input[name=paged]').on('keyup', function(e) {

			// If user hit enter, we don't want to submit the form
			// We don't preventDefault() for all keys because it would
			// also prevent to get the page number!
			if ( 13 == e.which )
				e.preventDefault();

			// This time we fetch the variables in inputs
			var data = {
				paged: parseInt( $('input[name=paged]').val() ) || '1',
				order: $('input[name=order]').val() || 'asc',
				orderby: $('input[name=orderby]').val() || 'title'
			};

			// Now the timer comes to use: we wait half a second after
			// the user stopped typing to actually send the call. If
			// we don't, the keyup event will trigger instantly and
			// thus may cause duplicate calls before sending the intended
			// value
			window.clearTimeout( timer );
			timer = window.setTimeout(function() {
				list.update( data );
			}, delay);
		});
	},

	/** AJAX call
	 * 
	 * Send the call and replace table parts with updated version!
	 * 
	 * @param    object    data The data to pass through AJAX
	 */
	update: function( data ) {
		$.ajax({
			// /wp-admin/admin-ajax.php
			url: ajaxurl,
			// Add action and nonce to our collected data
			data: $.extend(
				{
					_ajax_custom_list_nonce: $('#_ajax_custom_list_nonce').val(),
					action: '_ajax_fetch_custom_list',
				},
				data
			),
			// Handle the successful result
			success: function( response ) {

				// WP_List_Table::ajax_response() returns json
				var response = $.parseJSON( response );

				// Add the requested rows
				if ( response.rows.length )
					$('#the-list').html( response.rows );
				// Update column headers for sorting
				if ( response.column_headers.length )
					$('thead tr, tfoot tr').html( response.column_headers );
				// Update pagination for navigation
				if ( response.pagination.bottom.length )
					$('.tablenav.top .tablenav-pages').html( $(response.pagination.top).html() );
				if ( response.pagination.top.length )
					$('.tablenav.bottom .tablenav-pages').html( $(response.pagination.bottom).html() );

				// Init back our event handlers
				list.init();
			}
		});
	},

	/**
	 * Filter the URL Query to extract variables
	 * 
	 * @see http://css-tricks.com/snippets/javascript/get-url-variables/
	 * 
	 * @param    string    query The URL query part containing the variables
	 * @param    string    variable Name of the variable we want to get
	 * 
	 * @return   string|boolean The variable value if available, false else.
	 */
	__query: function( query, variable ) {

		var vars = query.split("&");
		for ( var i = 0; i <vars.length; i++ ) {
			var pair = vars[ i ].split("=");
			if ( pair[0] == variable )
				return pair[1];
		}
		return false;
	},
}

// Show time!
list.init();

})(jQuery);

And that’s about it

Voilà! You should now have nice AJAX loading List Table in your plugins and themes. This is a very basic example and should serve as a base to build extended List Tables, suggestions and advice are welcomed! A coming-soon feature on this will be an History update to change URL and keep track of the visited page. Until then, you can download the latest dev version on GitHub, fork on GitHub, leave a comment, buy me a beer… Your call!

Photo: WordPress Day by Andrew Abogado

WordPress Network: Cookie error when using different domains

This is a recurring I have with my website: I can’t get my WordPress Network to work properly when using different, unrelated domains.

Both my personal and professional blog (blog.charliemerland.me and blog.caercam.org) from the same WordPress installation, as well as my Themes Demo site (themes.caercam.org). I read a lot about WordPress MU Domain Mapping, spend quite a lot of time reading StackExchange threads; I found a lot of answers, too, but nothing that would work for me. Here’s what I figured out.

Set up

I have three sites in my Network:

  • Blog | CaerCam.org
  • Blog | Charlie MERLAND
  • WordPress Themes

My Domain Mapping options are:

  1. ☑ Remote Login
  2. ☑ Permanent redirect (better for your blogger’s pagerank)
  3. ☐ User domain mapping page
  4. ☑Redirect administration pages to site’s original domain (remote login disabled if this redirect is disabled)
  5. ☑Disable primary domain check. Sites will not redirect to one domain name. May cause duplicate content issues.

And in wp-config.php:

define('WP_ALLOW_MULTISITE', true);
define('MULTISITE', true);
define('SUNRISE', 'on');
define('SUBDOMAIN_INSTALL', true);
define('DOMAIN_CURRENT_SITE', 'blog.caercam.org');
define('PATH_CURRENT_SITE', '/');
define('SITE_ID_CURRENT_SITE', 1);
define('BLOG_ID_CURRENT_SITE', 1);
define('NOBLOGREDIRECT', true);

No matter what changes I try to do, I either:

  • Lose control over at least one site, possibly all of them: « ERROR: Cookies are blocked or not supported » error
  • Cripple at least one site, possibly all of them: all permalinks redirect to the main site

The only I found to make things work is to alter mu /wp-content/sunrise.php file, commenting the Cookie related line:

if ( !defined( 'SUNRISE_LOADED' ) )
	define( 'SUNRISE_LOADED', 1 );

// Comment this
//if ( defined( 'COOKIE_DOMAIN' ) ) {
//	die( 'The constant "COOKIE_DOMAIN" is defined (probably in wp-config.php). Please remove or comment out that define() line.' );
//}

// let the site admin page catch the VHOST == 'no'
$wpdb->dmtable = $wpdb->base_prefix . 'domain_mapping';
$dm_domain = $wpdb->escape( $_SERVER[ 'HTTP_HOST' ] );

And add the following to wp-config.php:

define('WP_ALLOW_MULTISITE', true);
define('MULTISITE', true);
define('SUNRISE', 'on');
define('SUBDOMAIN_INSTALL', true);
define('DOMAIN_CURRENT_SITE', 'blog.caercam.org');
define('PATH_CURRENT_SITE', '/');
define('SITE_ID_CURRENT_SITE', 1);
define('BLOG_ID_CURRENT_SITE', 1);
define('NOBLOGREDIRECT', true);

$my_sites = array(
	'blog.caercam.org',
	'themes.caercam.org',
	'blog.charliemerland.me'
);

if ( isset( $_SERVER[ 'SERVER_NAME' ] ) && in_array( $_SERVER[ 'SERVER_NAME' ], $my_sites ) )
	define( 'COOKIE_DOMAIN', $_SERVER[ 'SERVER_NAME' ] );

Yes, that’s not a clean way to proceed, there might be a better solution (think of me when you find it!), but still, it works. This post is as much a reminder to myself in case I broke everything in a few months as it can be a piece of help for someone bumping into the same issue.

Photo: Cookies by Neil Conway

FluxBB: anti-spam alternative to CAPTCHAs and HoneyPots

Prior to anything this should be very clear from the start: CAPTCHAs and/or HoneyPots are commonly used, really efficient techniques to counter spam on forums. The point here is not to encourage not using these techniques, but to provide a quick and simple technique I’ve been using for years and that proved to be working on a lot of forums I own and/or use. If you have a FluxBB forum that is subject to heavy spamming attacks, you may want to take a look at better and stronger solutions like SpamBarrier or StopForumSpam.

The idea

The idea behind CAPTCHAs and similar antispam systems is that robots can’t guess every question or operation a human may have to proceed through to register on a forum/website. One thing you need to register is most generally an email address; that’s a good place to check whether you’re dealing with a human or not. If he’s a human, he will most likely notice the information right above the email fields stating that he must alter the first email field to include a magic keyword.

How we do it

Long story short, we store a short string in the database that users will need to add somewhere in one of the email address fields; when a registration is attempted, we check the email submitted for the magic word. If the mail contains it, then it should be human; if it doesn’t, most likely not human, throw an error. You may ask, what if users didn’t read carefully enough and didn’t notice they had to alter their email? Once they bump into an error they’ll should be more attentive to anything the error says, so we just inform them they may have missed something and that should be enough. For the record, I’ve never had any feedback about users unable to register because of this. Some take a few tries to register because they indeed didn’t read what they should have, but nothing more.

Note that all of this depends on thing: you need to activate the registration verification option in FluxBB, which is a good thing anyway. It sends a random password to the registering user, testing if the email address he provided is actually his and is working. The reg verify option also add the need to input the email address twice, which is exactly what we need.

register.php

The main modification is, unsurprisingly, done to register.php. Three different blocks are required.

Language

Around line 23, find:

// Load the register.php/profile.php language file
require PUN_ROOT.'lang/'.$pun_user['language'].'/prof_reg.php';

Right after, add:

// Antispam MailTrick
if ( file_exists( PUN_ROOT . 'lang/'.$admin_language.'/asmt.php' ) )
	require PUN_ROOT . 'lang/'.$admin_language.'/asmt.php';
else 
	require PUN_ROOT . 'lang/English/asmt.php';

Main part: mail checking

Right after the username verification around line 98:

	// Validate username and passwords
	check_username($username);

Add this block:

	// Antispam MailTrick
	$mail_field = $pun_config['o_asmt_mail_field'];
	$magick     = $pun_config['o_asmt_magic'];
	$position   = $pun_config['o_asmt_magic_position'];
	$separator  = $pun_config['o_asmt_magic_separator'];

	// Which mail to check
	$asmt_email = ( $mail_field == 1 ? $email1 : $email2 );

	// magic + separator
	if ( in_array( $position, array( 1, 3 ) ) )
		$magick = $magick . $separator;
	else if ( in_array( $position, array( 2, 4 ) ) )
		$magick = $separator . $magick;

	// total magic length
	$length = strlen( $magick );

	// where to look for the magic?
	// before email
	// CODE_john.doe@johndoe.com
	if ( $position == 1 )
		$valid = ( substr( $asmt_email, 0, $length ) === $magick );
	// after email
	// john.doe@johndoe.com_CODE
	else if ( $position == 2 )
		$valid = ( substr( $asmt_email, ( 0 - $length ) ) === $magick );
	// before @
	// john.doeCODE_@johndoe.com
	else if ( $position == 3 )
		$valid = ( substr( $asmt_email, strpos( $asmt_email, '@' ) - $length, $length ) === $magick );
	// after @
	// john.doe@_CODEjohndoe.com
	else if ( $position == 4 )
		$valid = ( substr( $asmt_email, strpos( $asmt_email, '@' ) + 1, $length ) === $magick );

	// Not valid? 404 error.
	if ( false === $valid )
		message( $lang_asmt['Invalid email'], false, '404 Not Found' );

	// Remove the magic from the email 
	$asmt_email = str_replace( $magick, '', $asmt_email );
	if ( $mail_field == 1 )
		$email1 = $asmt_email;
	else
		$email2 = $asmt_email;
	// /Antispam MailTrick

User notification

Around line 380, find the Email Info text:

<?php if ($pun_config['o_regs_verify'] == '1'): ?>						<p><?php echo $lang_register['Email info'] ?></p>

Right after, add:

<?php
// Antispam MailTrick
$position  = $pun_config['o_asmt_magic_position'];
$magick    = $pun_config['o_asmt_magic'];
$separator = $pun_config['o_asmt_magic_separator'];
$mail_field = ( $pun_config['o_asmt_mail_field'] == 1 ? $lang_asmt['First'] : $lang_asmt['Second'] );

// Magic word have to be before anything? Separator goes after magic word.
if ( in_array( $position, array( '1', '3' ) ) )
	$magick = $magick . $separator;
// Magic word have to be after anything? Separator goes before.
else if ( in_array( $position, array( '2', '4' ) ) )
	$magick = $separator . $magick;

// This is messy, but it works.
$pos = array( '1' => $lang_asmt['Before email'], '2' => $lang_asmt['After email'], '3' => $lang_asmt['Before @'], '4' => $lang_asmt['After @'] );
$position = $pos[ $position ];
?>
						<p><?php printf( $lang_asmt['Antispam info'], $mail_field, $position, strlen( $magick ), $magick ); ?></p>

Plugin Setting page

In your forum’s plugin directory create a AP_AntispamMailTrick.php file:

<?php
/**
 * 
 * 
 * @package AntispamMailTrick
 * @author Charlie MERLAND
 * @author_uri http://www.caercam.org
 * @version 1.0.0
 * @copyright Copyright (C)2014 Charlie MERLAND
 * @license: GPL version 2 or higher
 * @license_uri http://www.gnu.org/licenses/gpl.html
 */

// Make sure no one attempts to run this script "directly"
if ( ! defined( 'PUN' ) )
    exit;

// Tell admin_loader.php that this is indeed a plugin and that it is loaded
define( 'PUN_PLUGIN_LOADED', 1 );
define( 'PLUGIN_VERSION', '1.0.0' );
define( 'PLUGIN_URL', 'admin_loader.php?plugin=AP_AntispamMailTrick.php' );

if ( file_exists( PUN_ROOT . 'lang/'.$admin_language.'/asmt.php' ) )
	require PUN_ROOT . 'lang/'.$admin_language.'/asmt.php';
else 
	require PUN_ROOT . 'lang/English/asmt.php';

// Default settings
$default = array(
	// Magic word in the first email field
	'mail_field'         => 1,
	// Random 4 chars long magic word
	'magic'              => substr( str_shuffle( str_repeat( 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789', 4 ) ), 0, 4 ),
	// Underscore to separate magic word from email
	'magic_separator'    => '_',
	// Magic word before the email
	'magic_position'     => 1
);

if ( isset( $_POST['form_sent'] ) ) {

	confirm_referrer( 'admin_loader.php?plugin=AP_AntispamMailTrick.php', $lang_common['Bad referer'] );

	$o_asmt = array();

	// Parse submitted settings
	$o_asmt['mail_field']         = ( isset( $_POST['form']['mail_field'] ) && in_array( $_POST['form']['mail_field'], array( 1, 2 ) ) ? $_POST['form']['mail_field'] : $default['mail_field'] );
	$o_asmt['magic']              = ( isset( $_POST['form']['magic'] ) && $_POST['form']['magic'] != '' ? pun_htmlspecialchars( $_POST['form']['magic'] ) : $default['magic'] );
	$o_asmt['magic_separator']    = ( isset( $_POST['form']['magic_separator'] ) && $_POST['form']['magic_separator'] != '' ? pun_htmlspecialchars( $_POST['form']['magic_separator'] ) : $default['magic_separator'] );
	$o_asmt['magic_position']     = ( isset( $_POST['form']['magic_position'] ) && in_array( $_POST['form']['magic_position'], array( 1, 2, 3, 4 ) ) ? $_POST['form']['magic_position'] : $default['magic_position'] );

	// Update settings
	foreach ( $o_asmt as $name => $value ) {
		$db->query( 'UPDATE ' . $db->prefix . 'config SET conf_value="' . $value . '" WHERE conf_name="o_asmt_' . $name . '"' ) or error( 'Unable to update the configuration', __FILE__, __LINE__, $db->error() );
	}

	// Regenerate the config cache
	require_once PUN_ROOT . 'include/cache.php';
	generate_config_cache();

	redirect( PLUGIN_URL, $lang_asmt['Settings updated redirect'] );
}
else {

	// Check if settings exist
	$sql = 'SELECT * FROM ' . $db->prefix . 'config WHERE conf_name IN ("o_asmt_mail_field", "o_asmt_magic", "o_asmt_magic_separator", "o_asmt_magic_position")';
	$result = $db->query( $sql ) or error( 'Unable to get config', __FILE__, __LINE__, $db->error() );

	// Settings not found, set default
	if ( ! $db->fetch_assoc( $result ) ) {
		foreach ( $default as $name => $value ) {
			$db->query( 'INSERT INTO '.$db->prefix.'config (conf_name, conf_value) VALUES ("o_asmt_'.pun_htmlspecialchars( $name ).'", "'.pun_htmlspecialchars( $value ).'")' ) or error( 'Unable to set default config', __FILE__, __LINE__, $db->error() );
		}

		// Regenerate the config cache
		require_once PUN_ROOT . 'include/cache.php';
		generate_config_cache();
		
	}

	// Display the admin navigation menu
	generate_admin_menu( $plugin );
	
?>
	<div id="exampleplugin" class="plugin blockform">
		<h2><span>Spam MailTrick v<?php echo PLUGIN_VERSION ?></span></h2>
		<div class="box">
			<div class="inbox">
				<p><?php echo $lang_asmt['Antispam intro'] ?></p>
<?php if ( $pun_config['o_regs_verify'] == '0' ) : ?>
				<p><?php echo $lang_asmt['Antispam reg verify'] ?></p>
<?php endif; ?>
			</div>
		</div>
	</div>
	<div class="blockform">
		<h2 class="block2"><span><?php echo $lang_asmt['Settings'] ?></span></h2>
		<div class="box">
			<form method="post" action="<?php echo PLUGIN_URL; ?>">
				<div class="inform">
					<input type="hidden" name="form_sent" value="1" />
					<fieldset>
						<legend><?php echo $lang_asmt['Options'] ?></span></legend>
						<div class="infldset">
							<table class="aligntop">
								<tr>
									<th scope="row"><?php echo $lang_asmt['Email field'] ?></th>
									<td>
										<label><input type="radio" name="form[mail_field]" value="1"<?php if ( $pun_config['o_asmt_mail_field'] == '1' ) echo ' checked="checked"' ?> />&nbsp;Email #1</label>
										<label><input type="radio" name="form[mail_field]" value="2"<?php if ( $pun_config['o_asmt_mail_field'] == '2' ) echo ' checked="checked"' ?> />&nbsp;Email #2</label>
									</td>
								</tr>
								<tr>
									<th scope="row"><?php echo $lang_asmt['Magic word'] ?></th>
									<td>
										<label><input type="text" name="form[magic]" value="<?php if ( $pun_config['o_asmt_magic'] != '' ) echo $pun_config['o_asmt_magic'] ?>" /><br /><?php echo $lang_asmt['Magic word info'] ?></label>
									</td>
								</tr>
								<tr>
									<th scope="row"><?php echo $lang_asmt['Separator'] ?></th>
									<td>
										<label><input type="text" name="form[magic_separator]" value="<?php if ( $pun_config['o_asmt_magic_separator'] != '' ) echo $pun_config['o_asmt_magic_separator'] ?>" /><br /><?php echo $lang_asmt['Separator info'] ?></label>
									</td>
								</tr>
								<tr>
									<th scope="row"><?php echo $lang_asmt['Position'] ?></th>
									<td>
										<label><input type="radio" name="form[magic_position]" value="1"<?php if ( $pun_config['o_asmt_magic_position'] == '1' ) echo ' checked="checked"' ?> />&nbsp;<?php echo $lang_asmt['Before email'] ?></label>
										<label><input type="radio" name="form[magic_position]" value="2"<?php if ( $pun_config['o_asmt_magic_position'] == '2' ) echo ' checked="checked"' ?> />&nbsp;<?php echo $lang_asmt['After email'] ?></label>
										<label><input type="radio" name="form[magic_position]" value="3"<?php if ( $pun_config['o_asmt_magic_position'] == '3' ) echo ' checked="checked"' ?> />&nbsp;<?php echo $lang_asmt['Before @'] ?></label>
										<label><input type="radio" name="form[magic_position]" value="4"<?php if ( $pun_config['o_asmt_magic_position'] == '4' ) echo ' checked="checked"' ?> />&nbsp;<?php echo $lang_asmt['After @'] ?></label>
									</td>
								</tr>
							</table>
						</div>
					</fieldset>
				</div>
			<p class="submitend"><input type="submit" name="save" value="<?php echo $lang_admin_common['Update'] ?>" /></p>
			</form>
		</div>
	</div>

<?php
}
?>

i18n

Last step, in your lang/English directory, create a asmt.php file:

<?php

// Language definitions used in Antispam MailTrick
$lang_asmt = array(

'Settings updated redirect'		=>  'Settings updated. Redirecting …',

'Antispam intro'			=>  '<strong>AntiSpam Mail Trick</strong> is an antispam alternative to CAPTCHAs and other HoneyPots solution. It uses a simple trick to detect non-human registration by check the submitted email for a specific keyword the user need to enter.',
'Antispam reg verify'			=>  'You don\'t have the registration verification option activated, you need it for the antispam to work properly. Activate it in the <a href="/admin_options.php">Admin Options registration section</a>.',
'Settings'				=>  'Settings',
'Options'				=>  'Options',

'Email field'				=>  'Email field to trick',
'Magic word'				=>  'Magic word',
'Magic word info'			=>  'Find something short, like four or five random letters and numbers.',
'Separator'				=>  'Separator',
'Separator info'			=>  'Optional: use a character to separate the magic from the email, like "_" or "+".',
'Position'				=>  'Position',

'Antispam info'				=>  'To prevent robots from registering automatically, <strong>your registration will not be valid unless the <u>%s</u> email contains, %s, the following %d characters code</strong>: "%s".',
'First'					=>  'first',
'Second'				=>  'second',
'Before email'				=>  'before the email',
'After email'				=>  'after the email',
'Before @'				=>  'before the "@"',
'After @'				=>  'after the "@"',
'Invalid email'				=>  'The email address you entered is invalid. It is most likely that you failed the anti-spam test: you need to add a magic word in your email address… Read the comments carefully ;-)',

);

Conclusion

And that’s about it. The great advantage of this technique is that it can be done in only two lines of code if you strip it to the minimal: if on of the email doesn’t contain a special code, throw an error. This version is more elaborate and comes with a bunch of settings to change easily the magic code, chose where it should be placed, chose a separator, which email field to check… It doesn’t bother users with unreadable images like CAPTCHAs, and doesn’t come with the heavy artillery a honeypot represents; it isn’t bullet-proof either, but have proved to be very efficient on forums I know that were literally under constant assault from spambots and seldom see one anymore.

What it looks like in action:

I plan to put all this in a clean and well tested package to be released as a FluxBB Mod; will keep track of the updates here.

Photo: Funny Internet Spam for eMail and Websites is Spicy by epSos.de

Export Storify Tweets to plain text

Storify is a nice archiving service for social medias; create a new story and you can use it to embed Tweets, Facebook posts, YouTube videos, Instagram pictures… It becomes really handy on Twitter if you want to archive a long series of Tweets you want to be able to find later without browsing manually the author Timeline for ages. You can find more on the service’s website: http://storify.com/tour.

Yesterday a client of mine asked me if and how he could save all his Storify Stories as text files. The point is he wanted to be able to print them as text for his wife to read them, and thinking about an easy solution for him to do that I actually thought it may not be that bad an idea to save contents from social networks that can get easily lost. If one posts a great story on Twitter, Storify it and six months later delete his Twitter and/or Storify, the great story is ad patres for good. Too bad.

Anyway, rather than cleaner but complicated solutions like coding a small bash script to fetch and parse all the Stories in rough HTML or using the Storify’s developer API, I simply used the Chrome Console. I can’t think of anything simpler:

  1. Use Chrome browser to display all pages of the Story you want to save. All pages, meaning that before doing anything you have to load each and every Tweets in the page; once done loading the first page, scroll to the end of it and click Next Page until you have the complete Story in a long, single page.*
  2. Load the Chrome Console with Ctrl+Shift+j. Refer to the Google doc if that doesn’t work for you. You may see some errors/warnings in the Console, nevermind.
  3. Paste the following code into the Console: $('iframe.twitter-tweet').each(function() { console.log( $(this).contents().find('p.e-entry-title').text() ); }); hit enter.
  4. You should this the plain text Tweets on top with a list of HTML <iframe> tags below. Copy the text and paste it to your Text Editor, replace/delete all occurrences of a weird code at the end of each line, and your done.

*: that’s not actually mandatory, I mean you can get the tweets without loading all of them in the first place, but the point is to get story as plain text so it’ll be easier to do it in one row.

Details: this trick uses jQuery to access all Tweets contents ($('iframe.twitter-tweet').each()), find the Tweets’ plain text message ($(this).contents().find('p.e-entry-title').text()) and display it in the Console (console.log()).

Example:

Export Storify Tweets to plain text using Chrome Console
Export Storify Tweets to plain text using Chrome Console

I didn’t test this on Firefox or any other browser, but it should work pretty much the same.

Hope that helps someone!

WordPress: Tweets as Posts with formatting and thumbnails

I’m currently working on some project that requires me to find a way to publish new posts out of specific Twitter accounts’ tweets. Chandesh Parekh already did most part of the work with his Tweets As Posts plugin, which work very well. But I want it to do a bit more, like automatically add featured images to new posts, or specific formatting, basically making the tweet a quote. Here’s the way.

Install Tweets As Posts

Yes, I’ll suppose you know how to do that.

Configure it

Let’s say I want to create a new post each time Pope Francis says something. Could have been the Barack Obama, it doesn’t matter actually, just pick up the Twitter(s) account(s) you want.

Capture-du-2013-03-30-145319

Now there’s something I don’t like: you have to enter at least one hashtag and one username to follow. But I don’t want to follow any hashtag, I just want to publish someones tweets so that my readers who aren’t on Twitter can read them.

Hack it

So the first thing we’re doing is getting rid of this: as long as we provide one username or one hashtag, we’re good. Nothing simpler, we just need to edit the tweets-as-posts-admin.php file, line 42:

if($hashtags !="" && $usernames !="") {

becomes

if($hashtags !="" || $usernames !="") {

Now for the rest, we will just wrap the tweets in a <blockquote> tag and add the username after it, along with the date. This time we edit tweets-as-posts-class.php file, starting at line 185:

$new_post["post_content"]    = $item["description_filtered"];

becomes

$new_post["post_content"]    = '<blockquote>'.$item["description_filtered"].'</blockquote><p style="text-align: right;">− <a href="'.$item["twitter_username_link"].'">'.$item["twitter_username"].'</a>, '.date( 'd F Y', strtotime( $item["date"] ) ).'</p>';

And right after line 198, we add:

add_post_meta($post_id, $meta_key = "_thumbnail_id", $meta_value = $featured_ID, $unique=TRUE);

where $featured_ID is your chosen featured pic’s ID. Just upload your image using WP media tool and spot the ID on the attachment page. If your attachment page’s URI is http://yout-blog.com/?attachment_id=19, then your ID is 19.

And we’re done, we now have some nice posts relaying Twitter messages.

WordPress : administration centralisée sur plusieurs installations

Un petit soucis d’optimisation qui s’est récemment imposé à moi : comment gérer efficacement plusieurs sites WordPress dont certains avec des installations dédiées sur différents serveurs ? En d’autres termes comment administrer tous mes sites à partir d’un seul back-office ? Une solution évidente serait de ne pas avoir plusieurs sites ; mais il est généralement préférable − pour le client autant que pour moi-même − de disposer d’une installation propre pour chaque site géré. Cela d’autant plus que CaerCam et OnEnAGros notamment me servent régulièrement de terrains d’essai pour diverses expérimentations.

La difficulté vient principalement du fait qu’il est question de gèrer plusieurs hébergements différemment, qui chacun accueillent plusieurs sites WordPress, sur une ou plusieurs installations. Après avoir un peu farfouillé sur le net et reçu plusieurs recommandations et idées, je me suis tourné vers une solution un peu bâtarde consistant à mélanger deux services. Idéalement j’aurais aimé pouvoir tout gérer à la manière d’un réseau WordPress, avec l’adminbar en haut de l’écran pour accéder aux différents dashboards ; mais avec plusieurs installations différentes, c’est le bordel pour les mises-à-jour qu’il faut tout de même faire séparément sur les différentes installations. L’option actuelle est donc la suivante :

  • Un réseau WordPress mis en place sur CaerCam
  • Les autres sites comme parties de ce réseau :
    • OnEnAGros! (serveur différent, donc installation différente)
    • Mordred (même serveur mais installation différente)
    • Deux autres sites (autre serveur mais installation partagée, sous-réseau en quelque sorte)
    • Trois autres sites encore (même serveur cette fois, installation partagée également)
  • Le plugin WordPress MU Domain Mapping pour ajouter des sites distants au réseau
  • Installation locale d’InfiniteWP et ajout des différents sites (installation d’un plugin nécessaire) pour l’administration à distance

InfiniteWP n’est pas le seul service de remote control pour WordPress, et je n’ai pas testé tous ses concurrents ; l’intérêt que je lui porte est donc totalement subjectif. Il est gratuit, ce qui en soi est déjà un bon point, et facile à prendre en main. La version payante semble de bonne qualité mais n’offre pas spécialement de fonctionnalités qui vaudraient − dans mon cas − le coup de mettre la main au porte-monnaie. L’ajout de sites se fait par clé, on peut accéder au panneau d’admin directement ou en nouvelle fenêtre, accéder à la rédaction de nouveaux posts, faire et accéder à des backups. Et évidemment faire à distance toutes les mises-à-jours que l’on veut : plugins, thèmes, core.

L’utilisation du réseau WordPress fait un peu doublon avec certaines fonctionnalités d’InfiniteWP, mais présente l’avantage de lier les différents sites entre eux ; par sécurité j’ai préféré installer InfiniteWP uniquement en local, le réseau WP me permet donc d’accéder tout de même à tous mes sites à partir d’un seul dashboard quand je ne suis pas sur ma machine de travail, ce qui est fort utile l’air de rien. Ne manque que la possibilité de mise-à-jour, mais en même temps, je préfère faire mes màj que je suis tranquille chez moi, ça évite de s’arracher les cheveux pendant la moitié du week-end chez les parents.

À voir ce que tout ça va donner en pratique sur le moyen et long terme, mais pour l’instant c’est un fonctionnement plutôt satisfaisant.

Édition : 8 août 2013

Après plusieurs d’utilisation très régulière − WordPress évolue sacrément vite, entre le core et les plugins il y a des mises-à-jours toutes les semaines − l’avis est confirmé : InfiniteWP est un excellent outil, que cela soit pour la gestion des mises à jours, des sauvegardes (fichiers & données) ou l’accès à l’administration des sites puisque InfiniteWP permet d’accéder à la création de nouveaux articles directement depuis son interface : un menu déroulant est lié à chaque site dans la barre latérale et donne accès direct au dashboard, à l’éditeur et autres. Vraiment pratique. Quelques captures d’écrans pour donner un aperçu de l’engin :

Photo: Fibrelight by Jeremy Atkinson

WordPress Login page complete redesign

The first time I went through the login process of WordPress, I told myself I would probably have to take some time to customize a bit that page. That was, well, four or five years ago.

I’m not telling that WordPress default login is useless, ulgy or badly crafted; I’m merely stating that it could get some improvement. If you’re working on a fully custom design like, like myself these days, you might find disturbing to have a login page totally ruining your graphical coherency. I have this nice dark blue and blood red design, my client wants to log himself in, boom, blank white page with orange links and blue buttons. Well, we’re gonna get rid of that.

Firsts statements

As usual, WordPress provides us with a built-in method to include a login form pretty much everywhere we want, called wp_login_form. But don’t rush out there to learn the Codex page by heart, we won’t be using this method here, and here’s why.

There’s a lot of ways to customize WordPress’ login page, but almost every page example I found are just basic CSS modifications passed through actions in functions.php. Problem is, this won’t allow us to change anything in the structure of the page, just its display. And that’s not what I want to do. I want to be able to choose where my elements will be placed, I want my inputs to have default values. There’s a fairly simple way to do this.

Fear the Core

We could just break out the chainsaw and hack through the wp-login.php file, but leaving WordPres’ Core is a good habit to keep. So we’re just going to add a tiny page to the Core, which we’ll put in WordPress’ root folder: wp-log-in.php. Now we need our visitors/clients to get to that page when they try to identify themselves; that we will do by adding the following code in our theme’s functions.php file:

function alter_login_link( $url )
{
    if ( strpos( $url, 'logout' ) === false )
        return preg_replace( "/(wp-login.php)/", "wp-log-in.php", $url );
    else
        return $url;
}
add_filter( 'site_url',  'alter_login_link' );

Every time the site_url method is called, except when logging out, « wp-login.php » is replaced by « wp-log-in.php », loading our custom page instead of the default WordPress page. We want to have a logout exception simply because wp-login.php doesn’t only handle the login form but is part of the whole loggin/out process. Better leave that as it is!

Designing our own login page

Now this up to you and to what you want or don’t want to be in your page. I’ll give my own current experiment as a working base. Looks like this:

wp-login1-1024x545
WordPress login page redesign

Changes made: get rid of the title link, « go back to blog » link, input labels, and added input default values. Added some jQuery to restore default values when the form is out of focus.

Page structure

<!DOCTYPE html>

<html xmlns="http://www.w3.org/1999/xhtml" dir="ltr" lang="fr-FR">

  <head>

    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    
    <title>WordPress &rsaquo; Log in</title>

    <meta name='robots' content='noindex,nofollow' />
    
  </head>

  <body class="login">

    <div id="nav">
      <a href="/wp-login.php?action=register">Register</a> |
      <a href="/wp-login.php?action=lostpassword" title="Get a new password">Lost password?</a>
    </div>
    
    <div id="login">

      <form name="loginform" id="loginform" action="/wp-login.php" method="post">

        <p><input type="text" name="log" id="user_login" class="input" value="username" size="20" tabindex="10" /></p>

        <p><input type="password" name="pwd" id="user_pass" class="input" value="password" size="20" tabindex="20" /></p>

        <p class="submit">
          <input type="submit" name="wp-submit" id="wp-submit" class="button-primary" value="Identify" tabindex="100" />
          <input type="hidden" name="redirect_to" value="/wp-admin/" />
          <input type="hidden" name="testcookie" value="1" />
        </p>

        <p class="forgetmenot">
          <label for="rememberme"><input name="rememberme" type="checkbox" id="rememberme" value="forever" tabindex="90" /> Remember me</label>
        </p>

      </form>

      <script type="text/javascript">
      function wp_attempt_focus() {
          setTimeout( function() {
              try {
                  d = document.getElementById('user_login');
                  d.focus();
                  d.select();
              }
              catch(e) {}
          }, 200);
      }

      wp_attempt_focus();
      if ( typeof wpOnload=='function' ) wpOnload();
      </script>

    </div>
    
    <script type='text/javascript' src='/wp-includes/js/jquery/jquery.js?ver=1.7.2'></script>

  </body>

</html>

Stylesheet

html, body {
	height : 100%;
	margin : 0;
	overflow : hidden;
	padding : 0;
}
body.login {
	background : url(/wp-content/themes/default/images/bg__blur.jpg) top center no-repeat #030712;
	background-size : 100% 100%;
	font-family : sans-serif;
	font-size : 12px;
	height : 100%;
	line-height : 1.4em;
}
#login {
	width : 320px;
	padding : 0;
	margin : 125px auto 0 auto;
}
body.login div#login h1 a {
	background-image : url(/wp-admin/images/wordpress-logo-rl.png);
	display : block;
	height : 125px;
	opacity : 0.75;
	overflow : hidden;
	padding-bottom : 0;
	text-indent : -9999px;
	width : 326px;
}
body.login div#login h1 a:hover {
	opacity : 1;
}
body.login form {
	background : transparent;
	border : 0;
	box-shadow : 0 0 0;
	padding : 0 25px;
}
body.login form p {
	margin : 0;
}
body.login form .input, body.login input[type="text"] {
	background : rgba(57, 72, 90, 0.15);
	border : 0;
	border-radius : 3px;
	box-shadow : 0 0 2px rgba(0, 0, 0, 0.5) inset, 0 1px 0 rgba(255, 255, 255, 0.15);
	color : #ccc;
	font-family : sans-serif;
	font-size : 12px;
	font-weight : normal;
	line-height : 1;
	margin : 6px 0;
	outline : 0;
	padding : 10px 16px;
	width : 245px;
}
body.login label {
	color : #ddd;
}
body.login label:hover {
	color : #fff;
}
body.login form .forgetmenot {
	float : none;
	margin : 5px 0 0 0;
	text-align : center;
}
body.login form .submit {
	margin-top : 15px;
	text-align : center;
}
body.login #nav {
	background : transparent;
	border-radius : 3px;
	color : rgba(255, 255, 255, 0.15);
	font-size : small;
	margin : 0;
	padding : 8px 16px;
	position : absolute;
	right : 5px;
	text-align : center;
	text-shadow : 0 0 0;
	top : 5px;
}
body.login #nav a {
	color : rgba(255, 255, 255, 0.15);
	text-shadow : 0 1px 0 rgba(0, 0, 0, 0.15);
	text-decoration : none;
}
body.login #nav a:hover {
	color : #fff;
}
body.login #nav a:active {
	color : #c30e0e;
}
body.login #wp-submit {
	background-image: linear-gradient(top, rgb(227,30,30) 50%, rgb(212,26,26) 50%);
	background-image: -o-linear-gradient(top, rgb(227,30,30) 50%, rgb(212,26,26) 50%);
	background-image: -moz-linear-gradient(top, rgb(227,30,30) 50%, rgb(212,26,26) 50%);
	background-image: -webkit-linear-gradient(top, rgb(227,30,30) 50%, rgb(212,26,26) 50%);
	background-image: -ms-linear-gradient(top, rgb(227,30,30) 50%, rgb(212,26,26) 50%);
	background-image: -webkit-gradient(linear,left bottom,left top,color-stop(0.5, rgb(212,26,26)),color-stop(0.5, rgb(227,30,30)));
	border : 0;
	border-radius : 3px;
	box-shadow : 0 0 2px rgba(0, 0, 0, 0.75), 0 1px 1px rgba(255, 255, 255, 0.5) inset, 0 -1px 1px rgba(255, 255, 255, 0.2) inset;
	color : #ddd;
	cursor : pointer;
	float : none;
	font-weight : bold;
	padding : 8px 36px;
	text-shadow : 0 1px 0 rgba(0, 0, 0, 0.35);
}
body.login #wp-submit:hover {
	box-shadow : 0 0 3px rgba(0, 0, 0, 0.85), 0 1px 1px rgba(255, 255, 255, 0.5) inset, 0 -1px 1px rgba(255, 255, 255, 0.2) inset;
	color : #fff;
	text-shadow : 0 1px 0 rgba(0, 0, 0, 0.75);
}
body.login #wp-submit:active {
	background : rgb(212, 26, 26);
	box-shadow : 0 0 5px rgba(0, 0, 0, 0.85) inset;
	text-shadow : 0 -1px 0 rgba(0, 0, 0, 0.75);
}

jQuery

jQuery(window).load(function() {
    jQuery('#user_login, #user_pass').each(function(){
        jQuery(this).prop('alt', jQuery(this).val());
    });

    jQuery('#user_login, #user_pass').focus(function() {
        if ( jQuery(this).val() == jQuery(this).prop('alt') )
            jQuery(this).val('');
    });

    jQuery('#user_login, #user_pass').blur(function() {
        if ( jQuery(this).val() == '' )
            jQuery(this).val(jQuery(this).prop('alt'));
    });
});

Stylesheet and jQuery code can be put in separate files or inside the page. This example should be fully W3C valid for HTML 5 and CSS 3 except the submit button due to the use of background-image gradient that needs multi browser variants. I usually don’t use these variants for box-shadow, border-radius and classic CSS 3 properties that most browsers support anyway.

Have fun with your login pages!

Bref, j’ai acheté un plugin WordPress.

Il est plutôt rare que j’achète quelque chose en matière d’informatique, et plus encore de logiciel, alors acheter un plugin, pour WordPress en plus, ça frôle l’exclusivité décennale… Je privilégie essentiellement l’utilisation des logiciels libres, que cela soit pour un usage personnel ou professionnel ; ça me permet de développer moi même mes modifications en toute légalité et de pouvoir le cas échéant les redistribuer pour qu’elles ne servent pas qu’à moi seul.

Alors pourquoi acheter un truc que j’aurais pu faire moi-même ? Parce que bien souvent, ce ne sont pas les capacités qui manquent, mais le temps. Pour la nouvelle mise-à-jour d’OnEnAGros! sur laquelle je travaille depuis quelques semaines, il me fallait un bon mega-menu ; vous savez, ces menus composés de sous-menus bien agencés que vous pouvez trouver sur des sites comme celui de la Fnac, CDiscount, etc. Des menus complexes, avec des liens, des boites, des sous-menus, des images, des widgets, etc. Ça peut rendre certains allergiques, mais il n’empêche que bien utilisés, c’est un gros plus pour l’ergonomie de certains types de sites.

03_MegaMenu_with_Descriptions

Or pour obtenir moi-même un résultat correct qui s’intègre parfaitement à WordPress, il m’aurait fallu des semaines de travail ; pour avoir un plugin complet qui puisse être, à terme, utilisés par des néophytes, c’est en mois qu’il faudrait compter. Et j’aime pas ré-inventer le roue, moi, surtout quand Chris Mavricos l’a déjà fait en vachement mieux que tout ce que je pourrais faire de mon côté.

UberMenu, pour la modique somme de $16, fournit un excellent megamenu pour WordPress, paramétrable à souhait sans aucune retouche de code nécessaire, même si je n’ai pas pu m’empêcher d’aller ajouter deux-trois bricoles persos, on ne se refait pas. Le panneau d’administration est très complet, permet de gérer une foule d’options pour paramétrer le ou les menus, la gestion de ces derniers se faisant par la partie classique de gestion des menus de WordPress. On peut ajouter des Widgets directement dans les menus, des liens (encore heureux) mis en forme par listes multi-colonnes (c’est mieux), avec des miniatures (encore mieux !), le tout avec la présentation qu’on veut.

05_UberMenu

Bref, un gain de temps important pour gérer un menu relativement simple comme celui d’OnEnAGros!, mais qui devient phénoménal pour un menu comme celui d’un thème pour restaurant/brasserie sur lequel je travaille également en ce moment, où il y a énormément de choses à intégrer : différents menus, carte des vins, des desserts, tarifs, contact, localisation, photos et vidéos, galeries, etc etc. Ma précédente solution pour avoir un rendu similaire consistait à spécifier mes styles persos via le champ « Titre » des éléments de menu, et passer le menu dans une fonction spécifique pour ajouter les styles et la mise en forme comme je le voulais. Sans même parler du principe bordélique, ça imposait aussi d’ajouter un nouvel élément à ma feuille de style à chaque fois que j’ajoutais un truc dans le menu. Vraiment pas pratique. Cling, $16, problèmes résolus, avec en plus la satisfaction d’avoir aidé un développeur compétent et talentueux à continuer de bosser. Que demande le peuple 🙂

Note : seul point négatif d’UberMenu, il est entièrement en anglais. Qui sait, j’aurais peut-être le temps d’en faire une version française un de ses quatre…

UberMenu : wpmegamenu.com
Chris Mavricos : sevenspark.com
Acheter UberMenu sur CodeCanyon : codecanyon.net

Order posts by real date in WordPress

Now this is an issue I encountered while designing some WordPress themes: posts and specifically pages don’t display in real date order. I still can’t precisely tell why this is happening, but it does, and it actually bothers me a bit since most of my websites use a precise page hierarchy. So here we go, short analyse and my personal solution.

Classic WordPress usage

WordPress provides simple functions to get pages/posts, and an easy way to order them by passing parameters through the functions. You can use get_posts or, in my case, get_pages; get_posts uses orderby (all available values here), get_pages uses sort_column (all available values here). You’d expect the following example to list you all your published pages ordered by date, latest first:

$pages = get_pages( array( 'post_type' => 'page',
                           'post_status' => 'publish',
                           'sort_column' => 'post_date',
                           'sort_order' => 'DESC' ) );

Well, that works great for me, as long as I don’t ever modify again one single page after I published it. If I edit a page, then post_modified is set to a new date, and that page will appear as the latest published… Which can be quite annoying.

I spent a couple of hours browsing the web, reading docs and trying to find why this is happening, not a clue up to now, be welcome to enlighten me if you have one! Still, I ended with a homemade solution: a little custom function to reorder my pages properly.

function sort_pages_by_realdate()

We just have to add this to our theme’s functions.php:

function sort_date( $a, $b ) {

    $a_date = strtotime( $a->post_date );
    $b_date = strtotime( $b->post_date );
    
    if ( $a_date == $b_date ) return 0;
    return ( $a_date < $b_date ) ? -1 : 1;
}

function sort_pages_by_realdate( $pages ) {
    usort( $pages, 'sort_date' );
    krsort( $pages);

    return $pages;
}

Our first example now become:

$pages = get_pages( array( 'post_type' => 'page',
                           'post_status' => 'publish',
                           'sort_column' => 'post_date',
                           'sort_order' => 'DESC' ) );

$pages = sort_pages_by_date( $pages );

That’s it 🙂 The important point is to use the posts’ timestamps to compare, not merely post_date. Also note that using krsort() is not an obligation; it depends of the pages order you want. usort will leave you a table ordered from the most ancient pages to the most recent; I use krsort() to reverse the array because I want the latest page by date to be the first listed, so don’t use it if you don’t want that specific order.

Post-it activity during Designing Creative Technology Playgrounds for Families: What are hackerspaces? #mozfest by ricarose