Contenu principal

Utilitaire WordPress : ajouter l’interface de gestion aux taxonomies de médias

Avez-vous déjà essayé d’ajouter des taxonomies (catégories, mots clés) aux médias de WordPress ? En général il s’en suit une bonne surprise (waouh, un champ « Catégories » est apparu tout seul dans mes médias !)… puis une mauvaise (ha ouais mais c’est qu’un simple champ, où sont les catégories existantes, comment les supprimer ensuite, etc).

Nota : cet article s’adresse à un public plutôt averti car sa finalité n’est pas de fournir un plugin permettant de créer des taxonomies pour les médias de WordPress (ils existent déjà), mais de fournir toute l’interface de gestion de ces taxonomies une fois qu’elles sont créées dans un thème ou un plugin. Le code fourni est donc à intégrer dans son thème ou dans le plugin qu’on est en train de créer.

Nota 2 : la version 3.5 de WordPress devrait amener pas mal de changements au niveau des médias. Il se peut donc qu’un mécanisme de taxonomies soit alors introduit et que cet article soit obsolète d’ici fin décembre (date prévue de sortie de WP 3.5).

Nota 3 : l’ensemble est téléchargeable ici-même ou sur GitHub.

[update] Depuis la sortie de WordPress 3.5, cet article est obsolète, puisque presque tout est maintenant intégré directement.

Pour la petite histoire

Pourquoi ajouter des taxonomies aux médias, à quoi cela peut-il servir ? Ces derniers jours j’ai travaillé à la création d’un site pour une artiste peintre, où je devais afficher ses toiles par catégories.
Première idée : créer un CPT « peintures » et lui ajouter une taxonomie.
Deuxième idée : à bien y réfléchir, j’ai très peu de texte à afficher, le contenu principal est la photo. Créer un CPT spécifique reviendrait donc à créer un CPT dont le but serait d’en afficher un autre… C’est un peu tordu comme réflexion mais dans le fond ce n’est pas faux.
Donc, supprimons l’étape intermédiaire et ajoutons ces catégories directement aux médias. Le court texte nécessaire sera ajouté dans les champs Légende ou Description, tous les médias auxquels une catégorie est assignée seront considérés comme une peinture. Mais je m’éloigne du sujet…

Avant tout, créer ses taxonomies

Un petit rappel pour la route, l’utilisation de register_taxonomy_for_object_type() et register_taxonomy().
Le premier, register_taxonomy_for_object_type(), sert à ajouter une taxonomie existante à un type de contenu, par exemple « category » qui est une taxonomie des articles. Par cette méthode, les catégories seront partagées, c’est à dire qu’une catégorie créée pour un média serait alors visible dans la liste des catégories des articles.

1

register_taxonomy_for_object_type('category', 'attachment');

register_taxonomy() quand à lui, permet de créer une nouvelle taxonomie pour un ou plusieurs types de contenu. Ici, $args sera un tableau servant à définir notre taxonomie (voir la page du codex – lien en début du paragraphe).

1

register_taxonomy('attachment_category', 'attachment', $args);

Astuce : Pour les attachments on peut préciser le mime_type :

1234

// Pour les images seulement :
register_taxonomy('attachment_category', 'attachment:image', $args);
// Pour les images et les fichiers pdf :
register_taxonomy('attachment_category', array('attachment:image', 'attachment:application/pdf'), $args);

La bonne surprise, puis la mauvaise

Comme je le disais en début d’article, l’ajout d’une taxonomie aux médias fait apparaitre un champ automatiquement dans la page d’édition du média.
champs-taxonomies-medias
Les termes sont alors listés, séparés par des virgules.
C’est tout de même une bonne surprise de voir WordPress intégrer directement ce genre de chose. Par contre, le travail est à peine commencé puisqu’on ne peut pas avoir la liste des termes existants, on ne peut pas non plus les modifier (dans le sens de modifier leur slug ou leur label, une fois créé c’est définitif), et pire, on ne peut pas les supprimer si nous nous sommes trompés ou si nous n’en avons plus besoin.
En clair, on aurait aimé avoir une gestion identique à celle des taxonomies des autres types de contenu : une page dédiée à cette gestion, des meta-boxes pour retrouver les termes existants, les plus utilisés, etc.

Le clonage humain est interdit, pas celui des id

L’ajout de ces fonctionnalités est donc articulée en deux parties : ajouter une page de gestion par taxonomie et remplacer les champs par les meta-boxes natives de WordPress. Une troisième étape annexe consiste à ajouter les taxonomies aux colonnes de la librairie.
Au début je voulais vous faire un tutoriel pour ajouter toute cette gestion, mais le principal problème ici est au niveau des meta-boxes : elles ne sont pas du tout conçues pour être multipliées dans cet environnement.

L’endroit critique sera la popup dans la page d’édition d’un article : dans chacun des onglets les médias sont listés les uns après les autres avec leurs champs, donc pour X médias nous aurons X fois le même champ et la même meta-box. Nous avons alors deux problèmes majeurs : l’attribut html name des inputs des meta-boxes ne contient pas l’identifiant du média en question, la valeur de ces inputs sera alors écrasée par celle du dernier de la liste et sera donc affectée à tous les médias.
Le second gros problème est que ces meta-boxes font une utilisation abondante des attributs html id, qui se retrouvent donc dupliqués X fois dans la popup. Ce n’est pas bien grave me direz-vous, sauf que tout le code javascript en charge de gérer l’affichage et la gestion des termes se base sur ces id, le javascript ne marchera alors que pour la première meta-box de la liste.

Le problème des attributs name est facilement solvable, celui du javascript obligera à réécrire presque totalement le code JS (ça c’est la grosse mauvaise nouvelle).
C’est pourquoi, au lieu de faire un tutoriel détaillé qui serait trop long et donc imbuvable, je préfère seulement présenter le but et quelques points particuliers de ce que j’ai fait.
[EDIT] En fait non, l’article est quand même un peu long ^^
Le code php n’est pas très long puisqu’il représente environ 260 lignes avec quelques commentaires, et un peu ventilé. A terme, il suffira de créer vos propres taxonomies, mon « utilitaire » se chargera de tout le reste automatiquement.

Les utilitaires

Premièrement je vais avoir besoin d’une fonction utilitaire.
En effet, comme je le disais dans le premier chapitre sur la création des taxonomies, on peut assigner une taxonomie à un mime_type. Or, la fonction get_taxonomies() permet bien de filtrer les taxonomies selon un type de post (attachment donc), mais échouera à retrouver les taxonomies créées avec « attachment:image » par exemple. D’où la création de get_attachments_taxonomies($output), à ne pas confondre avec get_attachment_taxonomies($attachment).

La page de gestion

Page de gestion

Nous allons utiliser le système natif de WordPress, c’est à dire la page edit-tags.php.
La première étape consiste à ajouter le lien dans le menu. L’url vers edit-tags.php est alors complétée avec les paramètres taxonomy et post_type.
Au passage j’ajoute un filtre permettant d’empêcher l’ajout de ce lien pour certaines taxonomies.

010203040506070809101112

// !Adds the management pages to the admin menu
add_action('admin_menu', 'w3p_taxos_admin_menu', 11);
function w3p_taxos_admin_menu() {
	$taxos = get_attachments_taxonomies('object');
	$taxos = apply_filters('attachment_taxonomies_in_menu', $taxos);

	if ( is_array($taxos) && count($taxos) ) {
		foreach ( $taxos as $tax ) {
			add_media_page( $tax->label, $tax->labels->menu_name, $tax->cap->manage_terms, 'edit-tags.php?taxonomy='.$tax->name.'&post_type=attachment' );
		}
	}
}

Une fois le lien ajouté, nous avons deux petits soucis à résoudre. Le premier étant que les onglets actifs dans le menu ne sont pas bons, ce ne sont pas « Médias » et « Catégorie » qui sont mis en valeur. Avec un simple filtre le problème est résolu : on filtre $parent_file (l’onglet « Médias ») et on en profite pour modifier $submenu_file (l’onglet « Catégories »). Une pierre, deux coups :)

01020304050607080910111213

// !Highlight the good items in the admin menu for the management pages
add_filter('parent_file', 'w3p_taxos_parent_file');
function w3p_taxos_parent_file( $parent_file ) {
	$taxos  = get_attachments_taxonomies();
	$screen = get_current_screen();

	if ( !count($taxos) || !in_array($screen->taxonomy, $taxos) )
		return $parent_file;

	global $submenu_file;
	$submenu_file = 'edit-tags.php?taxonomy='.$screen->taxonomy.'&post_type=attachment';
	return 'upload.php';
}

Le second soucis est tout aussi gênant : à droite nous avons le nombre de médias utilisant cette catégorie, mais le lien n’est pas bon, il renvoie vers edit.php à la place de la bibliothèque (upload.php). La page utilise la classe php WP_Terms_List_Table qui bien sûr, étend WP_List_Table. Mais là on va avoir un vrai soucis, il n’y a pas de hook disponible pour modifier le contenu de ces colonnes !
Pas de panique, j’ai trouvé un moyen. Là, le hack est vraiment très bête et méchant ^^.
Si on étudie le fichier edit-tags.php on se rend compte que la classe est instanciée très tôt dans la page, avant l’include de admin-header.php, et bien sûr tout ce petit monde est bien rangé au chaud dans la variable globale $wp_list_table.
Bon, toujours pas deviné comment on va faire ? On va tout simplement hooker au niveau du header pour étendre WP_Terms_List_Table avec notre propre classe afin « d’écraser » les deux fonctions d’affichage des colonnes et ranger tout ça dans $wp_list_table :)
Je dis deux fonctions car tant qu’à y être on va remplacer le titre de colonne « Article » par « Média », juste histoire d’être raccord :D. Pour le lien il suffira de remplacer edit.php par upload.php. C’est tout, à part ça, les deux fonctions sont identiques aux originales, c’est du copier/coller.

01020304050607080910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455

// !Change the "Posts" column to display "Medias" as a title and to have the good link to the medias page (edit.php -> upload.php)
add_action('admin_head-edit-tags.php', 'w3p_taxos_posts_column');
function w3p_taxos_posts_column() {
	$taxos  = get_attachments_taxonomies();
	$screen = get_current_screen();

	if ( !count($taxos) || !in_array($screen->taxonomy, $taxos) )
		return;

	class WP_Media_Category_List_Table extends WP_Terms_List_Table {

		function get_columns() {
			global $taxonomy, $post_type;

			$columns = array(
				'cb'          => '',
				'name'        => _x( 'Name', 'term name' ),
				'description' => __( 'Description' ),
				'slug'        => __( 'Slug' ),
				'posts'       => __( 'Media' ),
			);

			return $columns;
		}

		function column_posts( $tag ) {
			global $taxonomy, $post_type;

			$count = number_format_i18n( $tag->count );

			$tax = get_taxonomy( $taxonomy );

			$ptype_object = get_post_type_object( $post_type );
			if ( ! $ptype_object->show_ui )
				return $count;

			if ( $tax->query_var ) {
				$args = array( $tax->query_var => $tag->slug );
			} else {
				$args = array( 'taxonomy' => $tax->name, 'term' => $tag->slug );
			}

			if ( 'post' != $post_type )
				$args['post_type'] = $post_type;

			return "<a>$count</a>";
		}

	}

	global $wp_list_table;
	$wp_list_table = new WP_Media_Category_List_Table;

	$wp_list_table->prepare_items();
}

Remplacer les champs par des meta-boxes

Meta-boxes de taxonomies
Ces meta-boxes seront disponibles partout, c’est à dire dans la page individuelle de chaque média (bibliothèque) et dans la popup de la page d’édition d’un article (ou d’une page, ou autre).
Bien sûr, le but est là encore d’utiliser le système natif de WordPress, on va donc utiliser les meta-boxes que l’on a pour les mots clés et catégories des articles. Nous allons utiliser les deux, selon si la taxonomie est hiérarchique ou non.
En résumé nous allons :
– ajouter le fichier javascript nécessaire et ses dépendances : à la base il s’agit de post.js mais suite aux problèmes d’attributs html id mentionnés plus haut, j’ai réécrit le code, ce nouveau fichier remplacera donc post.js. En gros, au lieu de cibler avec $('#toto') on fera $metabox.find('#toto'). En restreignant la « zone de recherche » du sélecteur au seul contenu de chaque meta-box (plutôt que le document entier) on aura la cible escomptée, même s’il y a plusieurs fois cet id dans le document. Il y a eu aussi d’autres soucis à régler mais je passe sur ces détails. Du coup ça m’a permis de virer le code inutile concernant les custom fields, images à la une, etc.
– inclure le fichier meta-boxes.php car il ne l’est pas sur ces pages.
– pour chaque taxonomie, remplacer le champ d’origine par l’une des deux meta-boxes.
– remplacer les attributs html name pour inclure l’id de l’attachment.
Nota : le champ d’origine utilise un attribut name sous la forme attachments[id_attachment][taxonomy_name][]. WordPress se charge ensuite d’enregistrer les valeurs automatiquement. Les meta-boxes vont utiliser un attribut sous la forme tax_input[id_attachment][taxonomy_name][]. Ce n’est pas gênant, au contraire. Les taxonomies hiérarchiques et non-hiérarchiques sont enregistrées un peu différemment avec les meta-boxes d’après ce que j’ai vu (un entier (term_id) ou une chaine de caractères (term_name)) donc ça nous permettra justement d’ajouter notre propre callback qui tiendra compte de ce critère. Si la meta-box n’est pas affichée (j’ai prévu un filtre au cas où), mais le champ d’origine plutôt, le système d’origine prendra alors le relais.

0102030405060708091011121314151617181920212223242526272829303132333435363738394041

// !Replace the simple text inputs with meta-boxes
add_filter('attachment_fields_to_edit', 'w3p_taxos_attachment_fields_to_edit', 0, 2);
function w3p_taxos_attachment_fields_to_edit($form_fields, $post) {
	$screen_id = get_current_screen()->id;
	if ( $screen_id != 'media' && $screen_id != 'media-upload' )
		return $form_fields;

	$taxos = get_attachments_taxonomies('object');
	$taxos = apply_filters('attachment_taxonomies_meta_box_fields', $taxos);

	if ( is_array($taxos) && count($taxos) ) {
		// The meta-boxes need javascript (but bad news, post.js must be rewritten because it does not work well with multiple meta-boxes: we have multiple identical ids)
		$script_url = str_replace(ABSPATH, site_url().'/', dirname(__FILE__)) . '/' . basename(__FILE__, '.php') . '.js';
		wp_enqueue_script ('w3p-attachments-taxonomies', $script_url, array('suggest', 'wp-lists'), '1.0', true);
		wp_localize_script('w3p-attachments-taxonomies', 'postL10n', array('comma' => _x( ',', 'tag delimiter' )));

		// The meta-boxes.php file is not included yet
		if ( !function_exists('post_categories_meta_box') )
			require(ABSPATH . '/wp-admin/includes/meta-boxes.php');

		foreach ( $taxos as $tax ) {
			if ( isset($form_fields[$tax->name]) ) {
				ob_start();
				if ( $tax->hierarchical ) {
					post_categories_meta_box( $post, array( 'args' => array('taxonomy' => $tax->name) ) );
					echo 'ID.'"/>';	// Attachment id is needed for ajax
				} else
					post_tags_meta_box( $post, array( 'args' => array('taxonomy' => $tax->name), 'title' => $tax->label ) );
				$html = ob_get_contents();
				ob_end_clean();

				// We must add the post id in inputs name, overwise it won't work with multiple meta-boxes
				$html = str_replace( 'tax_input['.$tax->name.']', 'tax_input['.$post->ID.']['.$tax->name.']', $html );
				$form_fields[$tax->name]['html']  = $html;
				$form_fields[$tax->name]['input'] = 'html';
			}
		}
	}

	return $form_fields;
}

A noter l’utilisation de ob_start() car les meta-boxes ne sont pas retournées mais imprimées de base.
Concernant le fichier javascript, il doit être placé dans le même dossier que le fichier php contenant ce code et doit porter le même nom (avec l’extension .js au lieu de .php bien sûr ;)).

Il ne reste plus qu’à créer le callback pour l’enregistrement. Pour le créer j’ai pioché par-ci par-là des bouts de code déjà existants afin d’en faire une fonction qui marche pour les deux types de taxonomies.

010203040506070809101112131415161718192021222324252627282930

// !Save the meta-boxes values
add_filter('attachment_fields_to_save', 'w3p_taxos_attachment_fields_to_save', 10, 2);
function w3p_taxos_attachment_fields_to_save($post, $attachment) {

	$post_id = (int) $post['ID'];
	$tax_input = array();

	if ( $post_id && isset($_POST['tax_input'][$post_id])) {
		foreach ( $_POST['tax_input'][$post_id] as $tax_name => $terms ) {
			if ( empty($terms) )
				continue;
			if ( is_taxonomy_hierarchical( $tax_name ) ) {
				$tax_input[ $tax_name ] = array_map( 'absint', $terms );
			} else {
				$comma = _x( ',', 'tag delimiter' );
				if ( ',' !== $comma )
					$terms = str_replace( $comma, ',', $terms );
				$tax_input[ $tax_name ] = explode( ',', trim( $terms, " ntrx0B," ) );
			}
		}

		$taxonomies = get_attachment_taxonomies($post);
		foreach ( $taxonomies as $t ) {
			if ( isset($tax_input[$t]) )
				wp_set_object_terms($post_id, $tax_input[$t], $t, false);
		}
	}

	return $post;
}

Et c’est tout !

Dernière étape avant la ligne d’arrivée : l’ajout des colonnes dans la librairie

Colonnes des taxonomies
On finit par le plus simple. En premier on ajoute une colonne par taxonomie avec son titre. Au passage, on pousse la colonne de la date à droite.

010203040506070809101112131415161718192021

// !Add the taxonomies columns in the media table
add_filter( 'manage_media_columns', 'w3p_taxos_manage_media_column_title', 10, 2 );
function w3p_taxos_manage_media_column_title( $posts_columns, $detached ) {
	$taxos = get_attachments_taxonomies('object');

	if ( count($taxos) ) {
		// Add the taxonomies columns
		foreach ( $taxos as $tax ) {
			$posts_columns[$tax->name] = $tax->label;
		}

		// Push the "date" column to the end
		if ( isset($posts_columns['date']) ) {
			$date_column = $posts_columns['date'];
			unset($posts_columns['date']);
			$posts_columns['date'] = $date_column;
		}
	}

	return $posts_columns;
}

Et maintenant le contenu des colonnes, on liste les termes, séparés par des virgules.

01020304050607080910

// !Content of the taxonomies columns
add_action( 'manage_media_custom_column', 'w3p_taxos_manage_media_column_content', 10, 2 );
function w3p_taxos_manage_media_column_content( $column_name, $id ) {
	$taxos = get_attachments_taxonomies();

	if ( count($taxos) && array_search($column_name, $taxos) !== false ) {
		$terms = wp_get_post_terms( $id, $column_name, array( 'fields' => 'names' ) );
		echo implode( _x( ',', 'tag delimiter' ).' ', $terms );
	}
}

Voilà, on a presque fini, il ne reste plus qu’un détail à régler, il va nous falloir un peu de CSS car :
– les colonnes sont énormes, on va les réduire à 10% de la largeur (modifiable par filtre),
– les meta-boxes n’ont pas de largeur définie, elles s’étendent donc sur toute la page, on va les « cadrer » correctement :)
Ce petit CSS sera ajouté à 3 écrans : upload.php (librairie), media.php (page d’édition d’un media), media-upload-popup (popup médias).

01020304050607080910111213141516171819

// !Add CSS in upload.php head to set the taxonomies columns width
add_action('admin_print_styles-upload.php', 'w3p_taxos_admin_head');
// !Add CSS in media.php head to set the taxonomies meta-boxes width
add_action('admin_print_styles-media.php', 'w3p_taxos_admin_head');
// !Add CSS in media-upload-popup head to set the taxonomies meta-boxes width
add_action('admin_print_styles-media-upload-popup', 'w3p_taxos_admin_head');
function w3p_taxos_admin_head() {
	$taxos = get_attachments_taxonomies();

	if ( count($taxos) ) {
		$col_w = esc_attr(apply_filters('attachment_taxonomies_columns_width', '10%'));
		echo '';
		foreach ( $taxos as $tax ) {
			echo '.fixed .column-'.$tax.'{width:'.$col_w.';}';
		}
		echo '.categorydiv{width:462px;}.media-item .describe .tagsdiv .newtag{width:384px;}';
		echo '';
	}
}

Utilisation

Quelque chose comme ceci suffit  (pour l’intégration dans un thème par exemple)

123

register_taxonomy('attachment_category', 'attachment', array( /* blablabla */ ));
if ( is_admin() )
	include(TEMPLATEPATH . '/libs/w3p-attachments-taxonomies.php');

Les commentaires sont ouverts aux suggestions, améliorations possibles, rapports de bugs, questions, félicitations et crachats en pleine figure :)
J’utilise ce script depuis peu, il est donc possible que je sois passé à côté de quelque chose.

See ya!

[update] Version 1.0.1 : bugfix de la pagination et du tri dans la page edit-tags.php, bugfix de l’ajout de termes via ajax dans les metaboxes pour taxonomies hiérarchiques. Javascript modifié et ajout d’un hook sur les appels ajax. La fonction php de remplacement pour ce hook est un copié/collé de l’originale, à laquelle l’ID de l’attachment a été rajouté dans les attributs html « name » (à la place, la modification à la volée du html de retour de la requête était faisable mais demandait des « hacks sales », j’ai considéré de meilleure augure de remplacer la fonction originale, même si j’aurais préféré m’en passer).