Commit 9af1f59d authored by marvil07's avatar marvil07 Committed by heddn

Issue #2958285 by benjifisher, marvil07, heddn: Allow replacing based on a xpath expression

parent 2c245a11
<?php
namespace Drupal\migrate_plus\Plugin\migrate\process;
use Drupal\migrate\MigrateSkipRowException;
use Drupal\migrate\ProcessPluginBase;
/**
* Base class for process plugins that work with \DOMDocument objects.
*
* Use Dom::import() to convert a string to a \DOMDocument object, then plugins
* derived from this class to manipulate the object, then Dom::export() to
* convert back to a string.
*/
abstract class DomProcessBase extends ProcessPluginBase {
/**
* Document to use.
*
* @var \DOMDocument
*/
protected $document;
/**
* Xpath query object.
*
* @var \DOMXPath
*/
protected $xpath;
/**
* Initialize the class properties.
*
* @param mixed $value
* Process plugin value.
* @param string $destination_property
* The name of the destination being processed. Used to generate an error
* message.
*
* @throws \Drupal\migrate\MigrateSkipRowException
* If $value is not a \DOMDocument object.
*/
protected function init($value, $destination_property) {
if (!($value instanceof \DOMDocument)) {
$message = sprintf(
'The %s plugin in the %s process pipeline requires a \DOMDocument object. You can use the dom plugin to convert a string to \DOMDocument.',
$this->getPluginId(),
$destination_property
);
throw new MigrateSkipRowException($message);
}
$this->document = $value;
$this->xpath = new \DOMXPath($this->document);
}
}
<?php
namespace Drupal\migrate_plus\Plugin\migrate\process;
use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException;
use Drupal\migrate\MigrateExecutableInterface;
use Drupal\migrate\Row;
/**
* String replacements on a source dom.
*
* Analogous to str_replace process plugin, but based on a \DOMDocument instead
* of a string.
* Meant to be used after dom process plugin.
*
* Available configuration keys:
* - mode: What to modify. Possible values:
* - attribute: One element attribute.
* - expression: XPath query expression that will produce the \DOMNodeList to
* walk.
* - attribute_options: A map of options related to the attribute mode. Required
* when mode is attribute. The keys can be:
* - name: Name of the attribute to match and modify.
* - search: pattern to match.
* - replace: value to replace the searched pattern with.
* - regex: Use regular expression replacement.
* - case_insensitive: Case insensitive search. Only valid when regex is false.
*
* Examples:
*
* @code
* process:
* 'body/value':
* -
* plugin: dom
* method: import
* source: 'body/0/value'
* -
* plugin: dom_str_replace
* mode: attribute
* expression: '//a'
* attribute_options:
* name: href
* search: 'foo'
* replace: 'bar'
* -
* plugin: dom_str_replace
* mode: attribute
* expression: '//a'
* attribute_options:
* name: href
* regex: true
* search: '/foo/'
* replace: 'bar'
* -
* plugin: dom
* method: export
* @endcode
*
* @MigrateProcessPlugin(
* id = "dom_str_replace"
* )
*/
class DomStrReplace extends DomProcessBase {
/**
* {@inheritdoc}
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->configuration += [
'case_insensitive' => FALSE,
'regex' => FALSE,
];
$options_validation = [
'expression' => NULL,
'mode' => ['attribute'],
// @todo Move out once another mode is supported.
// @see https://www.drupal.org/project/migrate_plus/issues/3042833
'attribute_options' => NULL,
'search' => NULL,
'replace' => NULL,
];
foreach ($options_validation as $option_name => $possible_values) {
if (empty($this->configuration[$option_name])) {
throw new InvalidPluginDefinitionException(
$this->getPluginId(),
"Configuration option '$option_name' is required."
);
}
if (!is_null($possible_values) && !in_array($this->configuration[$option_name], $possible_values)) {
throw new InvalidPluginDefinitionException(
$this->getPluginId(),
sprintf(
'Configuration option "%s" only accepts the following values: %s.',
$option_name,
implode(', ', $possible_values)
)
);
}
}
}
/**
* {@inheritdoc}
*/
public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
$this->init($value, $destination_property);
foreach ($this->xpath->query($this->configuration['expression']) as $html_node) {
$subject = $this->getSubject($html_node);
if (empty($subject)) {
// Could not find subject, skip processing.
continue;
}
$search = $this->getSearch();
$replace = $this->getReplace();
$this->doReplace($html_node, $search, $replace, $subject);
}
return $this->document;
}
/**
* Retrieves the right subject string.
*
* @param \DOMElement $node
* The current element from iteration.
*
* @return string
* The string to use a subject on search.
*/
protected function getSubject(\DOMElement $node) {
switch ($this->configuration['mode']) {
case 'attribute':
return $node->getAttribute($this->configuration['attribute_options']['name']);
}
}
/**
* Retrieves the right search string based on configuration.
*
* @return string
* The value to be searched.
*/
protected function getSearch() {
switch ($this->configuration['mode']) {
case 'attribute':
return $this->configuration['search'];
}
}
/**
* Retrieves the right replace string based on configuration.
*
* @return string
* The value to use for replacement.
*/
protected function getReplace() {
switch ($this->configuration['mode']) {
case 'attribute':
return $this->configuration['replace'];
}
}
/**
* Retrieves the right replace string based on configuration.
*
* @param \DOMElement $html_node
* The current element from iteration.
* @param string $search
* The search string or pattern.
* @param string $replace
* The replacement string.
* @param string $subject
* The string on which to perform the substitution.
*/
protected function doReplace(\DOMElement $html_node, $search, $replace, $subject) {
if ($this->configuration['regex']) {
$function = 'preg_replace';
}
elseif ($this->configuration['case_insensitive']) {
$function = 'str_ireplace';
}
else {
$function = "str_replace";
}
$new_subject = $function($search, $replace, $subject);
$this->postReplace($html_node, $new_subject);
}
/**
* Performs post-replace actions.
*
* @param \DOMElement $html_node
* The current element from iteration.
* @param string $new_subject
* The new value to use.
*/
protected function postReplace(\DOMElement $html_node, $new_subject) {
switch ($this->configuration['mode']) {
case 'attribute':
$html_node->setAttribute($this->configuration['attribute_options']['name'], $new_subject);
}
}
}
<?php
namespace Drupal\Tests\migrate_plus\Unit\process;
use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException;
use Drupal\Component\Utility\Html;
use Drupal\migrate\MigrateSkipRowException;
use Drupal\migrate_plus\Plugin\migrate\process\DomStrReplace;
use Drupal\Tests\migrate\Unit\process\MigrateProcessTestCase;
/**
* Tests the dom_str_replace process plugin.
*
* @group migrate
* @coversDefaultClass \Drupal\migrate_plus\Plugin\migrate\process\DomStrReplace
*/
class DomStrReplaceTest extends MigrateProcessTestCase {
/**
* Example configuration for the dom_str_replace process plugin.
*
* @var array
*/
protected $exampleConfiguration = [
'mode' => 'attribute',
'expression' => '//a',
'attribute_options' => [
'name' => 'href',
],
'search' => 'foo',
'replace' => 'bar',
];
/**
* @covers ::__construct
*
* @dataProvider providerTestConfigEmpty
*/
public function testConfigValidation(array $config_overrides, $message) {
$configuration = $config_overrides + $this->exampleConfiguration;
$value = '<p>A simple paragraph.</p>';
$this->setExpectedException(InvalidPluginDefinitionException::class, $message);
(new DomStrReplace($configuration, 'dom_str_replace', []))
->transform($value, $this->migrateExecutable, $this->row, 'destinationproperty');
}
/**
* Dataprovider for testConfigValidation().
*/
public function providerTestConfigEmpty() {
$cases = [
'expression-null' => [
['expression' => NULL],
"Configuration option 'expression' is required.",
],
'mode-null' => [
['mode' => NULL],
"Configuration option 'mode' is required.",
],
'mode-invalid' => [
['mode' => 'invalid'],
'Configuration option "mode" only accepts the following values: attribute.',
],
'attribute_options-null' => [
['attribute_options' => NULL],
"Configuration option 'attribute_options' is required.",
],
'search-null' => [
['search' => NULL],
"Configuration option 'search' is required.",
],
'replace-null' => [
['replace' => NULL],
"Configuration option 'replace' is required.",
],
];
return $cases;
}
/**
* @covers ::transform
*/
public function testTransformInvalidInput() {
$configuration = [
'expression' => '//a',
'mode' => 'attribute',
'attribute_options' => [
'name' => 'href',
],
'search' => 'foo',
'replace' => 'bar',
];
$value = 'string';
$this->setExpectedException(MigrateSkipRowException::class, 'The dom_str_replace plugin in the destinationproperty process pipeline requires a \DOMDocument object. You can use the dom plugin to convert a string to \DOMDocument.');
(new DomStrReplace($configuration, 'dom_str_replace', []))
->transform($value, $this->migrateExecutable, $this->row, 'destinationproperty');
}
/**
* @covers ::transform
*
* @dataProvider providerTestTransform
*/
public function testTransform($input_string, $configuration, $output_string) {
$value = Html::load($input_string);
$document = (new DomStrReplace($configuration, 'dom_str_replace', []))
->transform($value, $this->migrateExecutable, $this->row, 'destinationproperty');
$this->assertTrue($document instanceof \DOMDocument);
$this->assertEquals($output_string, Html::serialize($document));
}
/**
* Dataprovider for testTransform().
*/
public function providerTestTransform() {
$cases = [
'string:case_sensitive' => [
'<a href="/foo/Foo/foo">text</a>',
$this->exampleConfiguration,
'<a href="/bar/Foo/bar">text</a>',
],
'string:case_insensitive' => [
'<a href="/foo/Foo/foo">text</a>',
[
'case_insensitive' => TRUE,
] + $this->exampleConfiguration,
'<a href="/bar/bar/bar">text</a>',
],
'regex' => [
'<a href="/foo/Foo/foo">text</a>',
[
'search' => '/(.)\1/',
'regex' => TRUE,
] + $this->exampleConfiguration,
'<a href="/fbar/Fbar/fbar">text</a>',
],
];
return $cases;
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment