Umbraco: tri automatique

Dans le précédent article, j’explique comment injecter un pipeline de call handlers lors de la capture d’un événement dans le back-office d’Umbraco. Voici un exemple d’implémentation pour trier automatiquement des noeuds, par exemple suite à la publication d’un document ou à l’enregistrement d’un média.


Cet article s’inscrit dans une suite:

Note importante : ces trois articles sont adaptés à Umbraco 4.7.

Le tri automatique est particulièrement utile pour une rubrique d’actualités, un agenda ou une liste de fichiers.

Le call handler de cet exemple peut être injecté suite à la publication d’un document ou à l’enregistrement d’un média (respectivement DocumentAfterPublishMatchingRule et MediaAfterSaveMatchingRule). L’important est que la signature de la méthode interceptée (typiquement l’observateur d’événement) accepte un argument de type CMSNode ou dérivé.

Voici un exemple de mise en oeuvre à partir de la section de configuration policyInjection:


<?xml version="1.0" encoding="utf-8" ?>
<policyInjection>
        <policies>
 <add name="Doc AfterPublish">
                <matchingRules>
                    <add type="Umbraco.Interception.MatchingRule.Document.DocumentAfterPublishMatchingRule, Umbraco.Interception, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
                        name="DocAfterPublish" />
                </matchingRules>
                <handlers>
                    <add name="SortDocAfterPublish" 
   friendlyName="CountryName" 
type="Umbraco.Interception.CallHandler.SortCallHandler, Umbraco.Interception, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
   aliasType="News, Agenda" 
   aliasProperty="date"
   reverse="1" />
                </handlers>
            </add>
 <add name="Media AfterSave">
                <matchingRules>
                    <add type="Umbraco.Interception.MatchingRule.Media.MediaAfterSaveMatchingRule, Umbraco.Interception, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
                        name="MediaAfterSave" />
                </matchingRules>
                <handlers>
                    <add name="SortMediaAfterSave" 
   friendlyName="CountryName" 
type="Umbraco.Interception.CallHandler.SortCallHandler, Umbraco.Interception, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
   aliasType="Pdf" 
   aliasProperty="modificationDate" 
   reverse="1" />
                </handlers>
            </add>
</policyInjection>

Code source

Pour commencer, il peut être utile d’implémenter quelques méthodes helper. Il peut être pertinent de créer une classe abstraite de base pour tous nos call handlers, mais j’ai choisi ici de les exposer dans une classe statique pour plus de lisibilité. J’ai également simplifié la gestion d’erreur pour la clarté de cet exemple.

    public static class UmbracoCallHandlerHelper
    {
        private static readonly char[] ALIAS_SEPARATOR = new char[] { ',' };

        public static T ParseValue<T>(string value)
        {
            if (string.IsNullOrEmpty(value))
                throw new ArgumentNullException("value");

            if (typeof(T) == typeof(string))
                return (T)(object)value;
            else if (typeof(T) == typeof(bool))
            {
                bool boolean;
                int integer;
                if (bool.TryParse(value, out boolean))
                    return (T)(object)boolean;
                else if ((int.TryParse(value, out integer)) && ((integer.Equals(0)) || (integer.Equals(1))))
                    return (T)(object)(integer.Equals(1));
                else
                    throw new ArgumentOutOfRangeException(value, "La valeur doit être de type booléen.");
            }
            else if (typeof(T) == typeof(int))
            {
                int integer;
                if (int.TryParse(value, out integer))
                    return (T)(object)(integer);
                else
                    throw new ArgumentOutOfRangeException(value, "La valeur doit être de type integer.");
            }
            else
            {
                throw new ArgumentOutOfRangeException("T", string.Format("Le type générique '{0}' n'est pas prévu par cette méthode.", typeof(T).Name));
            }
        }

        public static string[] ParseListProperty(string value)
        {
            if (value == null)
                return new string[0];

            var properties = value
                .Split(ALIAS_SEPARATOR, StringSplitOptions.RemoveEmptyEntries)
                .Select(t => t.Trim())
                .Where(t => !string.IsNullOrEmpty(t))
                .ToArray();

            if (properties.Length > 1 && properties.Contains("*"))
                throw new FormatException(string.Format("La valeur ne peut définir plusieurs valeurs jokers *: {0}", value));
            
            return properties;
        }

        public static T GetArgument<T>(IMethodInvocation input, int startIndex, out int index) where T : class
        {
            if (startIndex < 0)
                throw new ArgumentOutOfRangeException("startIndex", "La valeur fournie est inférieure à 0.");
            const int NOT_FOUND_INDEX = -1;
            if (startIndex > input.Inputs.Count - 1)
            {
                index = NOT_FOUND_INDEX;
                return null;
            }

            // Tente de trouver un argument qui correspond exactement au type spécifié:
            for (int i = startIndex; i < input.Inputs.Count; i++)
            {
                if (input.Inputs[i].GetType().Equals(typeof(T)))
                {
                    index = i;
                    return (T)input.Inputs[i];
                }
            }

            // Tente de trouver un argument qui dérive du type spécifié:
            for (int i = startIndex; i < input.Inputs.Count; i++)
            {
                var a = input.Inputs[i] as T;
                if (a != null)
                {
                    index = i;
                    return a;
                }
            }

            umbraco.BusinessLogic.Log.Add(umbraco.BusinessLogic.LogTypes.Error, -1, string.Format("UmbracoCallHandlerBase: Pas d'argument de type {0} (index>={1}) trouvé dans la signature de méthode ({3}). Pile d'appels: {2}", typeof(T).Name, startIndex, HelperLibrary.GetCallStackDetails(5), string.Join(", ", input.Inputs.Cast<object>().Select(t => t.GetType().Name).ToArray())));
            index = NOT_FOUND_INDEX;
            return null;
        }
    }

La méthode ParseValue<T>(string) sert à convertir une chaîne en un booléen ou un entier. Selon vos besoins, il pourra être utile de l’étoffer. La méthode ParseListProperty(string) éclate simplement une chaîne en plusieurs valeurs, avec quelques règles spécifiques. La méthode GetArgument(IMethodInvocation, int, out int) sert à récupérer un argument de la méthode interceptée en fonction d’un type de base.

Ensuite, il nous faut un comparateur pour réaliser le tri proprement dit. Ce tri sera effectué en fonction d’une propriété. Nous acceptons un type date, entier, ou une chaîne:


public class CMSNodePropertyComparer<T> : Comparer<umbraco.cms.businesslogic.CMSNode>
    {
        private readonly Func<umbraco.cms.businesslogic.CMSNode, T> _selector;
        private readonly DataType _type;

        public CMSNodePropertyComparer(Func<umbraco.cms.businesslogic.CMSNode, T> key)
        {
            _selector = key;
            if (typeof(T) == typeof(string))
                _type = DataType.String;
            else if (typeof(T) == typeof(Int32))
                _type = DataType.Integer;
            else if (typeof(T) == typeof(DateTime))
                _type = DataType.DateTime;
            else
                throw new ArgumentException("Type non supporté par ce comparateur: " + typeof(T) + ". Types supportés: string, Int32, DateTime.", "T");
        }

        public override int Compare(umbraco.cms.businesslogic.CMSNode x, umbraco.cms.businesslogic.CMSNode y)
        {
            const int EQUAL = 0, X_LESS_THAN_Y = -1, X_GREATER_THAN_Y = 1;
            if (x == null && y == null)
                return EQUAL;
            else if (x == null)
                return X_GREATER_THAN_Y;
            else if (y == null)
                return X_LESS_THAN_Y;

            T xValue = _selector(x);
            T yValue = _selector(y);

            if (xValue == null && yValue == null)
                return EQUAL;
            else if (xValue == null)
                return X_GREATER_THAN_Y;
            else if (yValue == null)
                return X_LESS_THAN_Y;

            switch (_type)
            {
                case DataType.Integer:
                    {
                        var xInteger = (int)(object)xValue;
                        var yInteger = (int)(object)yValue;
                        return xInteger.CompareTo(yInteger);
                    }
                case DataType.DateTime:
                    {
                        var xDate = (DateTime)(object)xValue;
                        var yDate = (DateTime)(object)yValue;
                        return xDate.CompareTo(yDate);
                    }
                default: // TYPE_STRING
                    return StringComparer.OrdinalIgnoreCase.Compare(xValue.ToString(), yValue.ToString());
            }
        }
    }

Enfin, voici le call handler proprement dit:


using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Linq;
using Microsoft.Practices.EnterpriseLibrary.Common.Configuration;
using Microsoft.Practices.Unity.InterceptionExtension;

namespace UmbracoInterception.CallHandler
{
    enum DataType
    {
        Unknown,
        String,
        DateTime,
        Integer
    }

    [ConfigurationElementType(typeof(CustomCallHandlerData))]
    public class SortCallHandler : ICallHandler
    {
        private readonly bool _isValid, _reverse;
        private readonly string _aliasProperty;
        private readonly string[] _aliasTypes;

        // Cf. http://our.umbraco.org/wiki/reference/api-cheatsheet/relationtypes-and-relations/object-guids-for-creating-relation-types
        private static readonly Guid
            DocumentNodeObjectType = new Guid("C66BA18E-EAF3-4CFF-8A22-41B16D66A972"),
            MediaNodeObjectType = new Guid("B796F64C-1F99-4FFB-B886-4BF4BC011A9C");

        private static class ATTRIBUTE
        {
            public const string ALIAS_PROPERTY = "aliasproperty";
            public const string ALIAS_TYPE = "aliastype";
            public const string REVERSE = "reverse";
        }

        #region Ctor
        public SortCallHandler()
        {

        }

        public SortCallHandler(NameValueCollection attributes)
        {
            foreach (var key in attributes.AllKeys)
            {
                switch (key.ToLower())
                {
                    case ATTRIBUTE.ALIAS_PROPERTY:
                        _aliasProperty = attributes[key];
                        break;
                    case ATTRIBUTE.REVERSE:
                        _reverse = UmbracoCallHandlerHelper.ParseValue<bool>(key);
                        break;
                    case ATTRIBUTE.ALIAS_TYPE:
                        _aliasTypes = UmbracoCallHandlerHelper.ParseListProperty(attributes[key]);
                        break;
                }
            }
            _isValid = !string.IsNullOrEmpty(_aliasProperty) && _aliasTypes != null && _aliasTypes.Length != 0;
        } 
        #endregion

        #region ICallHandler
        public IMethodReturn Invoke(IMethodInvocation input, GetNextHandlerDelegate getNext)
        {
            const int indexNotFound = -1;
            bool isValid = _isValid;
            umbraco.cms.businesslogic.Content node = null;
            umbraco.cms.businesslogic.property.Property property = null;
            bool isDocument = false;

            if (isValid)
            {
                int index;
                node = UmbracoCallHandlerHelper.GetArgument<umbraco.cms.businesslogic.CMSNode>(input, 0, out index) as umbraco.cms.businesslogic.Content;
                if (index == indexNotFound
                    || node == null
                    || !((isDocument = node.nodeObjectType == DocumentNodeObjectType) || node.nodeObjectType == MediaNodeObjectType)
                    || node.Parent.ChildCount == 1
                    || (_aliasTypes[0] != "*") && !_aliasTypes.Contains(node.ContentType.Alias)
                    || (property = node.getProperty(_aliasProperty)) == null
                    || (isDocument && !((umbraco.cms.businesslogic.web.Document)node).Published))
                {
                    isValid = false;
                }
            }

            if (!isValid)
                return getNext()(input, getNext);

            var valueType = property.Value.GetType();
            var siblings = node.Parent.Children
                .OfType<umbraco.cms.businesslogic.CMSNode>()
                .Where(t =>
                {
                    if (isDocument && !new umbraco.cms.businesslogic.web.Document(t.Id).Published)
                        return false;
                    return true;
                });

            if (valueType == typeof(DateTime))
            {
                siblings = siblings.OrderBy(t => t, new CMSNodeComparer<DateTime>(n =>
                {
                    try
                    {

                        property = new umbraco.cms.businesslogic.Content(n.Id).getProperty(_aliasProperty);
                        return property != null && property.Value != null && !string.IsNullOrEmpty(property.Value.ToString()) ? (DateTime)property.Value : default(DateTime);
                    }
                    catch (Exception ex)
                    {
umbraco.BusinessLogic.Log.Add(umbraco.BusinessLogic.LogTypes.Error, node.Id, string.Format("Cast invalide de l'objet '{0}' en DateTime ({1})", property.Value, ex.Message));
                        return default(DateTime);
                    }
                }));
            }
            else if (valueType == typeof(Int32))
            {
                siblings = siblings.OrderBy(t => t, new CMSNodeComparer<Int32>(n =>
                {
                    try
                    {
                        property = new umbraco.cms.businesslogic.Content(n.Id).getProperty(_aliasProperty);
                        return property != null && property.Value != null && !string.IsNullOrEmpty(property.Value.ToString()) ? (Int32)property.Value : default(Int32);
                    }
                    catch (Exception ex)
                    {
umbraco.BusinessLogic.Log.Add(umbraco.BusinessLogic.LogTypes.Error, node.Id, string.Format("Cast invalide de l'objet '{0}' en Int32 ({1})", property.Value, ex.Message));
                        return default(Int32);
                    }
                }));
            }
            else // string
            {
                siblings = siblings.OrderBy(t => t, new CMSNodeComparer<string>(n =>
                {
                    property = new umbraco.cms.businesslogic.Content(n.Id).getProperty(_aliasProperty);
                    return property != null && property.Value == null ? null : property.Value.ToString();
                }));
            }

            if (_reverse)
                siblings = siblings.Reverse();
            var sortedArray = siblings.ToArray();

            List indexesToUpdate = new List(sortedArray.Length);
            for (int i = 0; i < sortedArray.Length; i++)
            {
                if (sortedArray[i].sortOrder != i)
                {
                    sortedArray[i].sortOrder = i;
                    indexesToUpdate.Add(i);
                }
            }

            if (indexesToUpdate.Count != 0)
            {
                umbraco.BusinessLogic.Log.Add(umbraco.BusinessLogic.LogTypes.Debug, node.Id, string.Format("SortCallHandler({0}.{1}): {2}", node.Text, _aliasProperty, string.Join(", ", sortedArray.Select(t => t.Text).ToArray())));
                indexesToUpdate.ForEach(t =>
                {
                    sortedArray[t].Save();
                });
                umbraco.library.RefreshContent();
            }

            return getNext()(input, getNext);
        }

        public int Order
        {
            get;
            set;
        } 
        #endregion
    }
}

La première chose à noter est le constructeur qui accepte un unique argument de type NameValueCollection. Celui-ci sera utilisé par Enterprise Library pour instancier le handler et lui transmettre ses attributs de configuration. Nous récupérons trois paramètres de configuration: un alias de propriété sur laquelle baser le tri, un alias de type de noeud pour ignorer les noeuds d’un type non prévu (évite de chercher une propriété qui n’existerai pas) et enfin un indicateur pour le sens du tri.

Toute la logique du handler est dans la méthode Invoke qui permet de récupérer les données de la méthode interceptée et le handler suivant dans le pipeline. Je pense que le code est relativement simple, voici en gros son cheminement:

  • Aucune action n’est entreprise si l’un des cas suivants est rencontré :
    • Les paramètres de configuration fournis au constructeur sont incomplets.
    • La méthode interceptée n’a aucun argument de type CMSNode ou dérivé.
    • Le noeud ne dérive pas de la classe Content.
    • Le noeud n’est ni un document, ni un média.
    • Le noeud n’a pas de frères.
    • Le type de document ou de média ne correspond pas au(x) type(s) prévu(s) par le paramètre de configuration « AliasType ».
    • Le noeud ne contient aucune valeur pour la propriété définie par le paramètre de configuration « AliasProperty ».
    • Le noeud est un document qui n’est pas publié.
  • Les frères du noeud courant sont récupérés, les éventuels documents non publiés sont ignorés.
  • En fonction du type sous-jacent de la propriété (date, entier, chaîne), on effectue le tri.
  • Selon le paramètre configuration « reverse », on inverse l’ordre obtenu.
  • Enfin, seuls les noeuds pour lesquels la position a été revue sont réellement mis à jour dans la base de données d’Umbraco.