drupal 8 - ParserService

ParserService, le service de parsing de contenu

Lors d'un précédent article de la série concernant la Création d'un module avec Drupal 8, nous avions pour voir comment créer un service orienté données afin de pouvoir l'utiliser dans différentes classes en utilisant l'injection de dépendance.

Réflexion

Dans le même ordre d'idée, nous allons créer un second service qui sera utilisé pour le parsing de contenu. Rappelons d'abord les besoins: nous voulons transformer des mots ou phrases particulières en lien, en ayant la possibilité d'indiquer quels sont les champs qui seront soumis au parsing de liens.

Un service serait tout à fait adéquat pour ce processus. Notre service nécessiterait plusieurs méthodes.

Tout d'abord une méthode de contrôle: est-ce que l'entité en cours de traitement possède une configuration active pour le parsing de lien ? Cette méthode prendrait en argument une entité (EntityInterface) sur laquelle nous pourrions effectuer les tests nécessaires afin de savoir si elle le parsing la concerne.

Ensuite, la méthode de parsing à proprement dit, à laquelle on passerait en argument le texte à parser ainsi que la configuration pour le bundle auquel l'entité appartient.

Génération

Classe de stockage

Pour les besoins de notre parseur, nous allons d'abord créer une petite classe qui va servir à stocker des informations. Chaque instance de cette classe prend en charge cinq variables:

  • $targetEntity: l'entité cible de type EntityInterface sur laquelle le parsing sera appliqué;
  • $entities: une liste d'entité de type InternalLink;
  • $settings: un objet de type InternalLinkSettings contenant la configuration requise pour l'entité;
  • $mode: le mode de parsing (automatique ou manuel);
  • $linkers: un tableau stockant les identifiants de liens internes ayant été traités lors du processus de parsing;

Cette classe nous permettra de faire transiter ces cinq informations dans un seul objet afin de ne pas avoir à multiplier les arguments de nos méthodes. Nous créons un fichier InternalLinkParserInfo.php dans le répertoire src/Entity et y mettons le code suivant:

/**
 * @file
 * Contains \Drupal\internal_link\Entity\InternalLinkParserInfo
 */
namespace Drupal\internal_link\Entity;
 
 
use Drupal\Core\Entity\EntityInterface;
 
/**
 * Storage class to hold internal link
 * parser useful informations.
 */
class InternalLinkParserInfo {
 
  /**
   * Target entity being tested for parsing.
   * @var \Drupal\Core\Entity\EntityInterface
   */
  protected $targetEntity = array();
  /**
   * Target entity getter
   *
   * @return \Drupal\Core\Entity\EntityInterface
   */
  public function getTargetEntity() {
    return $this->targetEntity;
  }
 
  /**
   * List of internal link entities to apply.
   *
   * @var \Drupal\internal_link\Entity\InternalLink[]
   */
  protected $entities = [];
  /**
   * Entities getter
   *
   * @return \Drupal\internal_link\Entity\InternalLink[]
   */
  public function getEntities() {
    return $this->entities;
  }
  /**
   * Internal link settings for the current
   * entity-type/bundle of the the target entity.
   *
   * @var \Drupal\internal_link\Entity\InternalLinkSettings
   */
  protected $settings;
 
  /**
   * Settings getter
   *
   * @return \Drupal\internal_link\Entity\InternalLinkSettings
   *   The InternalLink settings for an entity
   */
  public function getSettings() {
    return $this->settings;
  }
 
  /**
   * The base internal link mode for current entity.
   *
   * @var integer
   */
  protected $mode;
 
  /**
   * Mode getter.
   *
   * @return integer
   *   One of the constant INTERNAL_LINK_MODE_*.
   *   @see internal_link.module
   */
  public function getMode() {
    return $this->mode;
  }
 
  /**
   * An array to store internal link ID's used in text replacement
   *
   * @var array
   */
  protected $linkers;
 
  /**
   * Linkers getter
   * 
   * @return array
   *  The internal link ID's used in text replacement.
   */
  public function getLinkers() {
    return $this->linkers;
  }
  /**
   * Linkers setter to add ID in store.
   * 
   * @param integer $id
   *   An internal link ID.
   */
  public function addLinker($id) {
    $this->linkers[$id] = TRUE;
  }
 
  /**
   * Constructor.
   *
   * @param array $entities
   * @param InternalLinkSettings $settings
   */
  public function __construct(EntityInterface $target_entity, array $entities = [], InternalLinkSettings $settings, $mode = INTERNAL_LINK_MODE_AUTO) {
    $this->targetEntity = $target_entity;
    $this->entities = $entities;
    $this->settings = $settings;
    $this->mode = $mode;
    $this->linkers = [];
  }
}

Maintenant que nous avons notre petite classe de "transport", nous pouvons générer et implémenter notre service.

Service

Cette fois encore, nous allons utiliser Drupal Console pour générer notre service de parsing:

MacPro:drupal8 titouille$ drupal generate:service
 
 // Welcome to the Drupal service generator
 Enter the module name [internal_link]:
 > 
 
 Enter the service name [internal_link.default]:
 > internal_link.parser
 
 Enter the Class name [DefaultService]:
 > ParserService
 
 Create an interface (yes/no) [yes]:
 > no
 
 Do you want to load services from the container (yes/no) [no]:
 > yes
 
Type the service name or use keyup or keydown.
This is optional, press enter to continue
 
 Enter your service [ ]:
 > internal_link.data
 Enter your service [ ]:
 > 
 
 Do you confirm generation? (yes/no) [yes]:
 > yes
 
Generated or updated files
 Site path: /Users/titouille/Dev/web/htdocs/sabugo/drupal8
 1 - modules/custom/internal_link/internal_link.services.yml
 2 - modules/custom/internal_link/src/ParserService.php
 // cache:rebuild
 
 Rebuilding cache(s), wait a moment please.
 
 [OK] Done clearing cache(s).                                                                                           
 
MacPro:drupal8 titouille$ 

Si nous observons un peu la génération, nous avons intégré deux services existants. Tout d'abord notre service personnalisé orienté données (internal_link.data) et le service de gestionnaire de langage, qui nous sera utile également.

Drupal Console a rajouté une entrée dans le fichier internal_link.services.yml:

  internal_link.parser:
    class: Drupal\internal_link\ParserService
    arguments: ["@internal_link.data"]

Et a créé notre squelette de service, en incluant le service injecté:

/**
 * @file
 * Contains \Drupal\internal_link\ParserService.
 */
 
namespace Drupal\internal_link;
 
use Drupal\internal_link\DataService;
 
/**
 * Class ParserService.
 *
 * @package Drupal\internal_link
 */
class ParserService {
 
  /**
   * Drupal\internal_link\DataService definition.
   *
   * @var Drupal\internal_link\DataService
   */
  protected $dataService;
 
  /**
   * Constructor.
   */
  public function __construct(DataService $data_service) {
    $this->dataService = $data_service;
  }
}

Comme d'habitude, j'ai modifié le nom des variables pour utiliser une syntaxe type "camelCase".

Implémentation

Nous avons notre nouveau service, il ne reste plus qu'à l'implémenter.

Méthode mustBeParsed

Nous allons d'abord créer une méthode mustBeParsed qui va prendre en argument un objet de type EntityInterface:

  public function mustBeParsed(EntityInterface $entity) {
 
    // Prepare useful data.
    $entities = [];
    $mode = INTERNAL_LINK_MODE_AUTO;
 
    // Retrieve internal link configuration for the current entity.
    $config = InternalLinkSettings::loadByEntityTypeBundle($entity->getEntityTypeId(), $entity->bundle());
 
    // Check if internal link are enabled.
    if (!$config->isAutomaticEnabled() && !$config->isManualEnabled()) {
      return FALSE;
    }
 
    // Check if manual mode is enabled.
    else if ($config->isManualEnabled()) {
 
      // Prepare an array to store selected internal links.
      $ids = [];
 
      // Retrieve the selected items.
      $field = [];
      if (isset($entity->internal_link)) {
        $field = $entity->internal_link;
      }
 
      // Iterate around each item and retrieve id.
      /** @var \Drupal\internal_link\Plugin\Field\FieldType\InternalLinkItem $item */
      foreach ($field as $delta => $item) {
        $value = $item->getValue();
        if (isset($value, $value['target_id'])) {
          $ids[] = $value['target_id'];
        }
      }
 
      // Retrieve entities if exists and
      // set mode to manual.
      if (!empty($ids)) {
        $entities = $this->dataService->getEntities($ids);
        $mode = INTERNAL_LINK_MODE_MANUAL;
      }
    }
 
    // No manual mode or no manual selection,
    // and automatic mode is enabled ? get
    // any internal link in automatic and both modes.
    if (empty($entities) && $config->isAutomaticEnabled()) {
      $entities = $this->dataService->getAutomaticEntities();
    }
 
    // Check if we found entities.
    if (!empty($entities)) {
 
      $ilinks = [];
 
      // Check visibility of entities.
      foreach ($entities as $il_id => $il) {
        if ($il->isVisible()) {
          $ilinks[$il_id] = $il;
        }
      }
      if (!empty($ilinks)) {
        return new InternalLinkParserInfo($entity, $ilinks, $config, $mode);
      }
    }
    return FALSE;
  }

Comme expliqué dans la partie "réflexion", cette méthode effectue une série de tests afin de savoir si l'entité passée en argument est éligible au parsing.

Nous récupérons d'abord la configuration globale correspondante à notre entité. Nous contrôlons qu'au moins un des modes de configuration (automatique ou manuel) est actif pour cette configuration. Si c'est le cas, nous tentons de récupérer les liens internes succeptibles d'être utilisés pour le parsing. Nous utilisons, pour chaque lien récupéré, sa méthode "isVisible" qui est implémentée de manière à nous retourner vrai si le lien peut être inclu dans le parsing, faux si ce n'est pas le cas. En gros, toute la logique de visibilité est inclue directement dans le type d'entité InternalLink, nous n'avons qu'à interroger cette méthode isVisible pour savoir si le lien doit être pris en compte ou pas... pratique, n'est-ce pas ?

Nous stockons temporairement tous les liens pouvant être inclus dans le parsing, puis retournons un objet de type InternalLinkParserInfo qui va contenir l'entité cible, les liens, la configuration et le mode de parsing. Toutes ces informations vont être utiles par la suite, lors du véritable parsing de texte.

Méthode parse

La méthode parse est la méthode principale de notre service. Elle va prendre en charge le texte à parser, aini que l'instance de classe InternalLinkParserInfo qui contient les informations nécessaires au parsing:

  public function parse($markup, InternalLinkParserInfo $info) {
 
    $result = $this->convertText($markup, $info);
    return $result;
  }

Cette méthode sera appelée dès lors qu'un texte nécessite un parsing pour le remplacement de mots/phrases par des liens internes. Elle fait elle-même appel à une méthode privée convertText qui va s'occuper du traitement.

Les méthodes privées étant des méthodes de pur traitement de données, nous ne nous y pencherons pas. Sachez simplement que le traitement s'effectue en décomposant le texte sous forme de DomDocument, en utilisant XPath et des méthodes du type preg_match_all pour retrouver les mots/phrases à remplacer.

Voici enfin le code complet du service de parsing:

/**
 * @file
 * Contains \Drupal\internal_link\ParserService.
 */
 
namespace Drupal\internal_link;
 
use Drupal\Component\Utility\Unicode;
use Drupal\Component\Utility\Html;
use Drupal\Core\Entity\EntityInterface;
use Drupal\internal_link\DataService;
use Drupal\internal_link\Entity\InternalLinkSettings;
use Drupal\internal_link\Entity\InternalLinkParserInfo;
 
/**
 * Class ParserService.
 *
 * @package Drupal\internal_link
 */
class ParserService {
 
  /**
   * Drupal\internal_link\DataService definition.
   *
   * @var Drupal\internal_link\DataService
   */
  protected $dataService;
 
  /**
   * Constructor.
   */
  public function __construct(DataService $data_service) {
    $this->dataService = $data_service;
  }
 
 
  /**
   * Check if entity must be parsed.
   * 
   * @param EntityInterface $entity
   * 
   * @return mixed FALSE 
   *   InternalLinkParserInfo or FALSE if entity
   *   must not be parsed. 
   */
  public function mustBeParsed(EntityInterface $entity) {
 
    // Prepare useful data.
    $entities = [];
    $mode = INTERNAL_LINK_MODE_AUTO;
 
    // Retrieve internal link configuration for the current entity.
    $config = InternalLinkSettings::loadByEntityTypeBundle($entity->getEntityTypeId(), $entity->bundle());
 
    // Check if internal link are enabled.
    if (!$config->isAutomaticEnabled() && !$config->isManualEnabled()) {
      return FALSE;
    }
 
    // Check if manual mode is enabled.
    else if ($config->isManualEnabled()) {
 
      // Prepare an array to store selected internal links.
      $ids = [];
 
      // Retrieve the selected items.
      $field = [];
      if (isset($entity->internal_link)) {
        $field = $entity->internal_link;
      }
 
      // Iterate around each item and retrieve id.
      /** @var \Drupal\internal_link\Plugin\Field\FieldType\InternalLinkItem $item */
      foreach ($field as $delta => $item) {
        $value = $item->getValue();
        if (isset($value, $value['target_id'])) {
          $ids[] = $value['target_id'];
        }
      }
 
      // Retrieve entities if exists and
      // set mode to manual.
      if (!empty($ids)) {
        $entities = $this->dataService->getEntities($ids);
        $mode = INTERNAL_LINK_MODE_MANUAL;
      }
    }
 
    // No manual mode or no manual selection,
    // and automatic mode is enabled ? get
    // any internal link in automatic and both modes.
    if (empty($entities) && $config->isAutomaticEnabled()) {
      $entities = $this->dataService->getAutomaticEntities();
    }
 
    // Check if we found entities.
    if (!empty($entities)) {
 
      $ilinks = [];
 
      // Check visibility of entities.
      foreach ($entities as $il_id => $il) {
        if ($il->isVisible()) {
          $ilinks[$il_id] = $il;
        }
      }
      if (!empty($ilinks)) {
        return new InternalLinkParserInfo($entity, $ilinks, $config, $mode);
      }
    }
    return FALSE;
  }
 
 
  /**
   * Parse text and try to search/replace words by links.
   *  
   * @param string $markup
   *   The text to search and replace links.
   * @param InternalLinkParserInfo $info
   *   parser informations containing current parsed entity, 
   *   internal links, settings and mode.
   */
  public function parse($markup, InternalLinkParserInfo $info) {
 
    $result = $this->convertText($markup, $info);
    return $result;
  }
 
 
  /**
   * Convert text by search and replace words by links.
   *
   * @param string $markup
   *   The text to search and replace links.
   * @param InternalLinkParserInfo $info
   *   parser informations containing current parsed entity,
   *   internal links, settings and mode.
   *
   * @return array
   */
  private function convertText($markup, InternalLinkParserInfo $info) {
 
    // Retrieve useful parsing informations.
    $entities = $info->getEntities();
    $settings = $info->getSettings();
    $mode = $info->getMode();
 
    // No words to replace, return same text.
    if (empty($entities)) {
      return $markup;
    }
 
    // Get disallowed tags and build except list.
    $disallowed = '';
    if (!empty($settings->getDisallowedHTMLTags())) {
      $disallowed_tags = preg_split('/\s+|<|>/', $settings->getDisallowedHTMLTags(), -1, PREG_SPLIT_NO_EMPTY);
      $disallowed = array();
      foreach ($disallowed_tags as $ancestor) {
        $disallowed[] = 'and not (ancestor::'.$ancestor.')';
      }
      $disallowed = implode(' ', $disallowed);
    }
 
    // Prepare stores
    $ilinks = [];
    $pattern = [];
 
    // Fill stores
    foreach ($entities as $id => $il) {
      $lower = preg_replace('/\s+/', ' ', trim($il->getLowerName()));
      $pattern[] = preg_replace('/ /', '\\s+', preg_quote($lower, '/'));
      $il->initializeCount();
      $ilinks[Unicode::strtolower($il->getName())] = $il;
    }
 
    // No words to replace, return same text.
    if (empty($ilinks)) {
      return $markup;
    }
 
    // Load text as dom element and filter disallowed elements.
    $dom = Html::load($markup);
    $xpath = new \DOMXPath($dom);
    $text_nodes = $xpath->query('//text()[not(ancestor::a) ' . $disallowed . ']');
 
    // Chunk pattern string to avoid preg_match_all() limits.
    $patterns = array_chunk($pattern, 1000, TRUE);
    foreach ($patterns as $pattern) {
      // Prepare pattern
      $p2 = $pattern;
      if ($settings->getWordBoundary()) {
        $p2 = '/(?<=)(' . implode('|', $p2) . ')/ui';
      }
      else {
        $p2 = '/(\b)(' . implode('|', $p2) . ')\b/ui';
      }
 
      $p3 = [];
 
      // We do the check ONLY for automatic mode.
      // When we use automatic mode, we can have so many
      // words to replace that we must absolutely "restrict"
      // number of words to replace.
      //
      // In manual mode, this is the content author who
      // choose the words to replace, it's it own responsability
      // to don't choose too many words.
      //
      if ($mode == INTERNAL_LINK_MODE_AUTO) {
        $ilinks = $this->checkTextRecursively($text_nodes, $p2, $ilinks, $settings);
 
        foreach ($ilinks as $ilink_text => $ilink) {
          $text_lower = preg_replace('/\s+/', ' ', trim($ilink->getLowerName()));
          $p3[] = preg_replace('/ /', '\\s+', preg_quote($text_lower, '/'));
        }
        if ($settings->getWordBoundary()) {
          $p2 = '/(?<=)(' . implode('|', $p3) . ')/ui';
        }
        else {
          $p2 = '/(\b)(' . implode('|', $p3) . ')\b/ui';
        }
      }
 
      // Start the conversion if there are words to replace.
      if (!empty($ilinks)) {
        $this->convertTextRecursively($dom, $markup, $text_nodes, $p2, $ilinks, $settings);
      }
    }
 
    // Set $linked to TRUE if at least one word was
    // replaced by a link.
    foreach ($ilinks as $id => $il) {
      if ($il->getCount() > 0) {
        $info->addLinker($il->id());
      }
    }
 
    // Return text and informations about linked words.
    return $markup;
  }
 
 
  /**
   * Check if internal link texts exists into
   * the text to alter.
   *
   * This is the "first pass". The goal is to
   * iterate on text to find any word to replace
   * by links. Each time a replacement is found,
   * we increment the internal-link counter to
   * finally know each word to replace.
   *
   * @param XPathQueryResult $text_nodes
   *   The XPath query result from the text
   * @param string $pattern
   *   Pattern to search, including all texts
   * @param \Drupal\internal_link\Entity\InternalLink[] $ilinks
   *   List of internal links entities, indexed by text in lowercase
   * @param InternalLinkSettings $settings
   *   Internal link settings for the current bundle
   */
  private function checkTextRecursively($text_nodes, $pattern, $ilinks, InternalLinkSettings $settings) {
 
    // Iterate on each node from the XPath query
    foreach ($text_nodes as $original_node) {
      $t = $original_node->nodeValue;
 
      // preg match to retrieve eventual words to replace
      $match_count = preg_match_all($pattern, $t, $matches, PREG_OFFSET_CAPTURE);
 
      if ($match_count > 0) {
 
        // Iterate on each matched word
        foreach ($matches[0] as $delta => $match) {
 
          // Get word
          $match_text = $match[0];
          $text_lower = Unicode::strtolower($match_text);
 
          /** @var InternalLink $ilink */
          $ilink = $ilinks[$text_lower];
 
          // Test if link can be applied
          if (($ilink->getCaseSensitive()
              && $ilink->getName() == $match_text)
              || !$ilink->getCaseSensitive()) {
 
                // Increment counter
                $ilink->setCount(1);
              }
        }
      }
    }
 
    // Prepare a store array
    $store = [];
 
    // Iterate on each links and check if each word
    // can be replaced.
    foreach ($ilinks as $text_lower => $ilink) {
      if ($ilink->getCount() > 0) {
        $ilink->initializeCount();
        $store[$text_lower] = $ilink;
      }
 
      // Stop iteration if we found more words than
      // links to proceed.
      if (count($store) >= $settings->getLinksToProcess()) {
        break;
      }
    }
    return $store;
  }
 
 
  /**
   *
   * @param \DOMDocument $dom
   *   The string loaded as dom document.
   * @param string $text
   *   The string to search into.
   * @param XPathQueryResult $text_nodes
   *   The XPath query result from the text
   * @param string $pattern
   *   Pattern to search, including all texts
   * @param \Drupal\internal_link\Entity\InternalLink[] $ilinks
   *   List of internal links entities, indexed by text in lowercase
   * @param InternalLinkSettings $settings
   *   Internal link settings for the current bundle
   */
  private function convertTextRecursively($dom, &$text, $text_nodes, $pattern, $ilinks, InternalLinkSettings $settings) {
 
    static $links = [];
 
    // Iterate on each node from the XPath query.
    foreach ($text_nodes as $original_node) {
      $text = $original_node->nodeValue;
 
      // Check if at least one pattern is found.
      $match_count = preg_match_all($pattern, $text, $matches, PREG_OFFSET_CAPTURE);
 
      if ($match_count > 0) {
        // Get parent and sibling, then remove current text
        $offset = 0;
        $parent = $original_node->parentNode;
        $next = $original_node->nextSibling;
        $parent->removeChild($original_node);
 
        // For each match found, get text and text position.
        foreach ($matches[0] as $delta => $match) {
          $match_text = $match[0];
          $match_pos = $match[1];
 
          // Transform as lower text and get matching internal link.
          $text_lower = Unicode::strtolower($match_text);
          $ilink = $ilinks[$text_lower];
 
          // Check case sensitive and maximum application.
          if ((($ilink->getCaseSensitive()
              && $ilink->getName() == $match_text)
              || !$ilink->getCaseSensitive())
              && $ilink->getCount() < $settings->getMaximumApplication()) {
 
                // Get prefix and insert it before "next" element.
                $prefix = substr($text, $offset, $match_pos - $offset);
                $parent->insertBefore($dom->createTextNode($prefix), $next);
 
                // Prepare current word to replace.
                $link = $dom->createDocumentFragment();
 
                // Check if we must replace current word by a
                // highlighted word or by a link.
                if ($settings->getHightlightWords()) {
                  $internal_link = $ilink->getHighlightedData($match_text);
                }
                else {
                  if (!isset($links[$match_text])) {
                    // Retrieve link as renderable data and store
                    // it in a temporary array.
                    $tmp = $ilink->getUrlData($match_text)->toRenderable();
                    $links[$match_text] = drupal_render($tmp);
                  }
                  $internal_link = $links[$match_text];
                }
 
                // If needed, prepare the tag to embed internal link.
                if (!empty($settings->getWrapHTMLTag())) {
                  $html_tag = [
                    '#type' => 'html_tag',
                    '#tag' => $settings->getWrapHTMLTag(),
                    '#value' => $internal_link,
                  ];
 
                  $internal_link = drupal_render($html_tag);
                  // Remove trailing carriage return to let html
                  // the same as original.
                  $internal_link = preg_replace('/\r\n|\r|\n/', '', $internal_link);
                }
 
                // Append final word to the "link" object,
                // insert it before the "next" object,
                // change offset, set linked to TRUE
                // and increment internal link counter.
                $link->appendXML($internal_link);
                $parent->insertBefore($link, $next);
                $offset = $match_pos + strlen($match_text);
                $ilink->setCount(1);
              }
              else {
                // If we can not replace the word, we re-insert
                // the same word in the flow and change offset
                // to continue process.
                $prefix = substr($text, $offset, $match_pos - $offset);
                $parent->insertBefore($dom->createTextNode($prefix), $next);
                $parent->insertBefore($dom->createTextNode($match_text), $next);
                $offset = $match_pos + strlen($match_text);
              }
 
              // Add suffix before the "next" object.
              if ($delta == $match_count - 1) {
                $suffix = substr($text, $offset);
                $parent->insertBefore($dom->createTextNode($suffix), $next);
              }
        }
      }
    }
    // Finally, serialize the dom object as text
    // and return it.
    $text = Html::serialize($dom);
  }
}

Conclusion

Après avoir implémenté notre type d'entité, nous avons déclaré un premier service orienté données, puis nous avons géré la configuration globale de notre module. Nous avons vu dans cet article comment mettre en place un service de parsing digne de ce nom.

Nous sommes maintenant armés pour finaliser notre module.

Ajouter un commentaire

CAPTCHA
Cette question permet de savoir si vous êtes un visiteur ou un robot de soumission de spams