feeds_ex-8.x-1.0-alpha4/src/Feeds/Parser/ParserBase.php
src/Feeds/Parser/ParserBase.php
<?php namespace Drupal\feeds_ex\Feeds\Parser; use Drupal\Component\Render\FormattableMarkup; use Drupal\Component\Utility\Xss; use Drupal\Core\Form\FormState; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Logger\RfcLogLevel; use Drupal\Core\Messenger\MessengerInterface; use Drupal\Core\Plugin\PluginFormInterface; use Drupal\feeds\Exception\EmptyFeedException; use Drupal\feeds\FeedInterface; use Drupal\feeds\Feeds\Item\DynamicItem; use Drupal\feeds\Feeds\Parser\ParserBase as FeedsParserBase; use Drupal\feeds\Plugin\Type\MappingPluginFormInterface; use Drupal\feeds\Plugin\Type\Parser\ParserInterface; use Drupal\feeds\Result\FetcherResultInterface; use Drupal\feeds\Result\ParserResult; use Drupal\feeds\Result\ParserResultInterface; use Drupal\feeds\StateInterface; use Drupal\feeds_ex\Encoder\EncoderInterface; /** * The Feeds extensible parser. */ abstract class ParserBase extends FeedsParserBase implements ParserInterface, PluginFormInterface, MappingPluginFormInterface { /** * The messenger, for compatibility with Drupal 8.5. * * @var \Drupal\Core\Messenger\MessengerInterface */ protected $feedsExMessenger; /** * The class used as the text encoder. * * @var string */ protected $encoderClass = '\Drupal\feeds_ex\Encoder\TextEncoder'; /** * The encoder used to convert encodings. * * @var \Drupal\feeds_ex\Encoder\EncoderInterface */ protected $encoder; /** * The default list of HTML tags allowed by Xss::filter(). * * In addition of \Drupal\Component\Utility\Xss::$htmlTags also the <pre>-tag * is added to the list of allowed tags. This is because for the JMESPath * parser an error can be generated that needs to be displayed preformatted. * * @var array * * @see \Drupal\Component\Utility\Xss::filter() */ protected static $htmlTags = [ 'a', 'em', 'strong', 'cite', 'blockquote', 'br', 'pre', 'code', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', ]; /** * A list of sources to parse. * * @var array */ protected $sources; /** * Constructs a ParserBase object. * * @param array $configuration * The plugin configuration. * @param string $plugin_id * The plugin id. * @param array $plugin_definition * The plugin definition. */ public function __construct(array $configuration, $plugin_id, array $plugin_definition) { if (!$this->hasConfigForm()) { unset($plugin_definition['form']['configuration']); } $this->sources = []; parent::__construct($configuration, $plugin_id, $plugin_definition); } /** * Returns rows to be parsed. * * @param \Drupal\feeds\FeedInterface $feed * Source information. * @param \Drupal\feeds\Result\FetcherResultInterface $fetcher_result * The result returned by the fetcher. * @param \Drupal\feeds\StateInterface $state * The state object. * * @return array|Traversable * Some iterable that returns rows. */ abstract protected function executeContext(FeedInterface $feed, FetcherResultInterface $fetcher_result, StateInterface $state); /** * Executes a single source expression. * * @param string $machine_name * The source machine name being executed. * @param string $expression * The expression to execute. * @param mixed $row * The row to execute on. * * @return scalar|[]scalar * Either a scalar, or a list of scalars. If null, the value will be * ignored. */ abstract protected function executeSourceExpression($machine_name, $expression, $row); /** * Validates an expression. * * @param string &$expression * The expression to validate. * * @return string|null * Return the error string, or null if validation was passed. */ abstract protected function validateExpression(&$expression); /** * Returns the errors after parsing. * * @return array * A structured array array with keys: * - message: The error message. * - variables: The variables for the message. * - severity: The severity of the message. * * @see watchdog() */ abstract protected function getErrors(); /** * Allows subclasses to prepare for parsing. * * @param \Drupal\feeds\FeedInterface $feed * The feed we are parsing for. * @param \Drupal\feeds\Result\FetcherResultInterface $fetcher_result * The result of the fetching stage. * @param \Drupal\feeds\StateInterface $state * The state object. */ protected function setUp(FeedInterface $feed, FetcherResultInterface $fetcher_result, StateInterface $state) { } /** * Allows subclasses to cleanup after parsing. * * @param \Drupal\feeds\FeedInterface $feed * The feed we are parsing for. * @param \Drupal\feeds\Result\ParserResultInterface $parser_result * The result of parsing. * @param \Drupal\feeds\StateInterface $state * The state object. */ protected function cleanUp(FeedInterface $feed, ParserResultInterface $parser_result, StateInterface $state) { } /** * Starts internal error handling. * * Subclasses can override this to being error handling. */ protected function startErrorHandling() { } /** * Stops internal error handling. * * Subclasses can override this to end error handling. */ protected function stopErrorHandling() { } /** * Loads the necessary library. * * Subclasses can override this to load the necessary library. It will be * called automatically. * * @throws \RuntimeException * Thrown if the library does not exist. */ protected function loadLibrary() { } /** * Returns whether or not this parser uses a context query. * * Sub-classes can return false here if they don't require a user-configured * context query. * * @return bool * True if the parser uses a context query and false if not. */ protected function hasConfigurableContext() { return TRUE; } /** * Returns the label for single source. * * @return \Drupal\Core\StringTranslation\TranslatableMarkup|null * A translated string if the source has a special name. Null otherwise. */ protected function configSourceLabel() { return NULL; } /** * {@inheritdoc} */ public function parse(FeedInterface $feed, FetcherResultInterface $fetcher_result, StateInterface $state) { $this->loadLibrary(); $this->startErrorHandling(); $result = new ParserResult(); // @todo Find out what setting link in the D7 version of Feeds Extensible // Parsers meant and then determine whether or not this code is still needed // in some way. // @see Drupal\Tests\feeds_ex\Unit\Feeds\Parser\HtmlParserTest::testLinkIsSet() // @code // $fetcher_config = $feed->getConfigurationFor($feed->importer->fetcher); // phpcs:ignore Drupal.Files.LineLength.TooLong // $result->link = is_string($fetcher_config['source']) ? $fetcher_config['source'] : ''; // @endcode try { $this->setUp($feed, $fetcher_result, $state); $this->parseItems($feed, $fetcher_result, $result, $state); $this->cleanUp($feed, $result, $state); } catch (EmptyFeedException $e) { // The feed is empty. $this->getMessenger()->addMessage($this->t('The feed is empty.'), 'warning', FALSE); } catch (\Exception $exception) { // Do nothing. Store for later. } // Display errors. $errors = $this->getErrors(); $this->printErrors($errors, $this->configuration['display_errors'] ? RfcLogLevel::DEBUG : RfcLogLevel::ERROR); $this->stopErrorHandling(); if (isset($exception)) { throw $exception; } return $result; } /** * Performs the actual parsing. * * @param \Drupal\feeds\FeedInterface $feed * The feed source. * @param \Drupal\feeds\Result\FetcherResultInterface $fetcher_result * The fetcher result. * @param \Drupal\feeds\Result\ParserResultInterface $result * The parser result object to populate. * @param \Drupal\feeds\StateInterface $state * The state object. */ protected function parseItems(FeedInterface $feed, FetcherResultInterface $fetcher_result, ParserResultInterface $result, StateInterface $state) { $expressions = $this->prepareExpressions(); $variable_map = $this->prepareVariables($expressions); foreach ($this->executeContext($feed, $fetcher_result, $state) as $row) { if ($item = $this->executeSources($row, $expressions, $variable_map)) { $result->addItem($item); } } } /** * Prepares the expressions for parsing. * * At this point we just remove empty expressions. * * @return array * A map of machine name to expression. */ protected function prepareExpressions() { $expressions = []; foreach ($this->sources as $machine_name => $source) { if (strlen($source['value'])) { $expressions[$machine_name] = $source['value']; } } return $expressions; } /** * Prepares the variable map used to substitution. * * @param array $expressions * The expressions being parsed. * * @return array * A map of machine name to variable name. */ protected function prepareVariables(array $expressions) { $variable_map = []; foreach ($expressions as $machine_name => $expression) { $variable_map[$machine_name] = '$' . $machine_name; } return $variable_map; } /** * Executes the source expressions. * * @param mixed $row * A single item returned from the context expression. * @param array $expressions * A map of machine name to expression. * @param array $variable_map * A map of machine name to variable name. * * @return array * The fully-parsed item array. */ protected function executeSources($row, array $expressions, array $variable_map) { $item = new DynamicItem(); $variables = []; foreach ($expressions as $machine_name => $expression) { // Variable substitution. $expression = strtr($expression, $variables); $result = $this->executeSourceExpression($machine_name, $expression, $row); if ($result === NULL) { $variables[$variable_map[$machine_name]] = ''; continue; } $item->set($machine_name, $result); $variables[$variable_map[$machine_name]] = is_array($result) ? reset($result) : $result; } return $item; } /** * Prints errors to the screen. * * @param array $errors * A list of errors as returned by stopErrorHandling(). * @param int $severity * (optional) Limit to only errors of the specified severity. Defaults to * RfcLogLevel::ERROR. * * @see watchdog() */ protected function printErrors(array $errors, $severity = RfcLogLevel::ERROR) { foreach ($errors as $error) { if ($error['severity'] > $severity) { continue; } // phpcs:ignore Drupal.Semantics.FunctionT.NotLiteralString $this->getMessenger()->addMessage($this->t($error['message'], $error['variables']), $error['severity'] <= RfcLogLevel::ERROR ? 'error' : 'warning', FALSE); } } /** * Prepares the raw string for parsing. * * @param \Drupal\feeds\Result\FetcherResultInterface $fetcher_result * The fetcher result. * * @return string * The prepared raw string. */ protected function prepareRaw(FetcherResultInterface $fetcher_result) { $raw = $fetcher_result->getRaw(); // Check if the raw data is string. If not, abort. if (!is_string($raw)) { throw new EmptyFeedException(); } $raw = $this->getEncoder()->convertEncoding($raw); // Strip null bytes. $raw = trim(str_replace("\0", '', $raw)); // Check that the string has at least one character. if (!isset($raw[0])) { throw new EmptyFeedException(); } return $raw; } /** * {@inheritdoc} */ public function getMappingSources() { return []; } /** * {@inheritdoc} */ public function defaultConfiguration() { return [ 'context' => [ 'value' => '', ], 'display_errors' => FALSE, 'source_encoding' => ['auto'], 'line_limit' => 100, ]; } /** * {@inheritdoc} */ public function buildConfigurationForm(array $form, FormStateInterface $form_state) { $encoding_form_state = $this->createSubFormState('encoding', $form_state); $form['encoding'] = $this->getEncoder()->buildConfigurationForm([], $encoding_form_state); return $form; } /** * {@inheritdoc} */ public function validateConfigurationForm(array &$form, FormStateInterface $form_state) { $encoding_form_state = $this->createSubFormState('encoding', $form_state); $this->getEncoder()->validateConfigurationForm($form['encoding'], $encoding_form_state); $form_state->setValue('encoding', $encoding_form_state->getValues()); } /** * {@inheritdoc} */ public function submitConfigurationForm(array &$form, FormStateInterface $form_state) { // Preserve some configuration. $config = array_merge([ 'context' => $this->getConfiguration('context'), ], $form_state->getValues()); $config += $config['encoding']; unset($config['encoding']); $this->setConfiguration($config); } /** * Creates a FormState object for subforms. * * @param string|array $key * The form state key. * @param \Drupal\Core\Form\FormStateInterface $form_state * The form state to copy values from. * * @return \Drupal\Core\Form\FormStateInterface * A new form state object. * * @see \Drupal\Core\Form\FormStateInterface::getValue() */ protected function createSubFormState($key, FormStateInterface $form_state): FormStateInterface { // There might turn out to be other things that need to be copied and // passed. This works for now. return (new FormState())->setValues($form_state->getValue($key, [])); } /** * {@inheritdoc} */ public function mappingFormAlter(array &$form, FormStateInterface $form_state) { if ($this->hasConfigurableContext()) { $form['context'] = [ '#type' => 'textfield', '#title' => $this->t('Context'), '#default_value' => $this->configuration['context']['value'], '#description' => $this->t('The base query to run. See the <a href=":link" target="_new">Context query documentation</a> for more information.', [ ':link' => 'https://www.drupal.org/node/3227985', ]), '#size' => 50, '#required' => TRUE, '#maxlength' => 1024, '#weight' => -50, ]; } parent::mappingFormAlter($form, $form_state); } /** * {@inheritdoc} */ public function mappingFormValidate(array &$form, FormStateInterface $form_state) { try { // Validate context. if ($this->hasConfigurableContext()) { if ($message = $this->validateExpression($form_state->getValue('context'))) { $message = new FormattableMarkup(Xss::filter($message, static::$htmlTags), []); $form_state->setErrorByName('context', $message); } } // Validate new sources. $mappings = $form_state->getValue('mappings'); if (empty($mappings)) { return; } // Setup a list of select keys we are interested in. $select_keys = []; foreach ($this->getSupportedCustomSourcePlugins() as $custom_source_plugin_type) { $select_keys[] = 'custom__' . $custom_source_plugin_type; } foreach ($mappings as $i => $mapping) { foreach ($mapping['map'] as $subtarget => $map) { $select = $map['select']; // Check if a new custom source was added of a type we're interested // in. if (!in_array($select, $select_keys)) { // We're not interested in this selected source. continue; } // Check if a value was set for the custom source. if (!isset($map[$select]['value'])) { // No value was set for the custom source's value. continue; } if ($message = $this->validateExpression($map[$select]['value'])) { $message = new FormattableMarkup(Xss::filter($message, static::$htmlTags), []); $form_state->setErrorByName("mappings][$i][map][$subtarget][$select][value", $message); } } } } catch (\Exception $e) { // Exceptions due to missing libraries could occur, so catch these. $form_state->setError($form, $e->getMessage()); } } /** * {@inheritdoc} */ public function mappingFormSubmit(array &$form, FormStateInterface $form_state) { $config = []; // Set context. $config['context'] = [ 'value' => $form_state->getValue('context'), ]; $this->setConfiguration($config); } /** * {@inheritdoc} */ public function hasConfigForm() { return FALSE; } /** * Sets the encoder. * * @param \Drupal\feeds_ex\Encoder\EncoderInterface $encoder * The encoder. * * @return $this * The parser object. */ public function setEncoder(EncoderInterface $encoder) { $this->encoder = $encoder; return $this; } /** * Returns the encoder. * * @return \Drupal\feeds_ex\Encoder\EncoderInterface * The encoder object. */ public function getEncoder() { if (!isset($this->encoder)) { $class = $this->encoderClass; $this->encoder = new $class($this->configuration['source_encoding']); } return $this->encoder; } /** * Sets the messenger. * * For compatibility with both Drupal 8.5 and Drupal 8.6. * Basically only useful for automated tests. * * @param \Drupal\Core\Messenger\MessengerInterface $messenger * The messenger. */ public function setFeedsExMessenger(MessengerInterface $messenger) { if (method_exists($this, 'setMessenger')) { $this->setMessenger($messenger); } else { $this->feedsExMessenger = $messenger; } } /** * Gets the messenger. * * For compatibility with both Drupal 8.5 and Drupal 8.6. * * @return \Drupal\Core\Messenger\MessengerInterface * The messenger. */ public function getMessenger() { if (method_exists($this, 'messenger')) { return $this->messenger(); } if (isset($this->feedsExMessenger)) { return $this->feedsExMessenger; } return \Drupal::messenger(); } }