Files
2026-04-28 15:13:50 +02:00

596 lines
19 KiB
PHP

<?php
namespace AIOSEO\Plugin\Pro\SeoRevisions;
// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Keep helper methods for SEO Revisions.
*
* @since 4.4.0
*/
class Helpers {
/**
* Reduce the 'keyphrases' field to an array containing the 'focus' and 'additional' keys.
* Get rid of other array keys as e.g. 'score' and 'analysis'.
*
* @since 4.4.0
*
* @param array $revisionData The Revision Data (passed by reference).
* @return void
*/
public function reduceKeyphrases( &$revisionData ) {
if ( array_key_exists( 'keyphrases', $revisionData ) ) {
$reduced = [
'focus' => '',
'additional' => [],
];
foreach ( (array) $revisionData['keyphrases'] as $k => $keyphrase ) {
$keyphrase = (array) $keyphrase;
if ( 'focus' === $k ) {
$reduced['focus'] = $keyphrase['keyphrase'] ?? '';
} elseif ( 'additional' === $k ) {
$additionalPhrases = array_map( 'trim', array_column( $keyphrase, 'keyphrase' ) );
sort( $additionalPhrases );
$reduced['additional'] = $additionalPhrases;
}
}
$revisionData['keyphrases'] = $reduced;
}
}
/**
* Reduce a field to an array containing only the `$column` key values.
*
* @since 4.6.5
*
* @param array $revisionData The Revision Data (passed by reference).
* @return void
*/
public function reduceToColumn( &$revisionData, $field, $column ) {
if ( array_key_exists( $field, $revisionData ) ) {
$reduced = [];
foreach ( (array) $revisionData[ $field ] as $value ) {
if ( is_object( $value ) && isset( $value->$column ) ) {
$reduced[] = $value->$column;
} elseif ( is_array( $value ) && isset( $value[ $column ] ) ) {
$reduced[] = $value[ $column ];
} elseif ( is_string( $value ) || is_numeric( $value ) ) {
// If the value is already a scalar, use it directly.
$reduced[] = $value;
}
}
sort( $reduced );
$revisionData[ $field ] = array_filter( array_map( 'trim', $reduced ) );
}
}
/**
* Reduce the 'schema' field to an array containing the 'customGraphs', 'default' and 'graphs' keys.
* Get rid of other keys as e.g. 'blockGraphs'.
*
* @since 4.4.0
*
* @param array $revisionData The Revision Data (passed by reference).
* @return void
*/
public function reduceSchema( &$revisionData ) {
if ( array_key_exists( 'schema', $revisionData ) ) {
$reduced = [
'default' => [],
'graphs' => [],
'customGraphs' => []
];
foreach ( (array) $revisionData['schema'] as $k => $data ) {
if ( 'default' === $k ) {
// Prevent `$data['graphName']` from being watched.
$reduced[ $k ] = array_diff_key( $data, [ 'graphName' => '' ] );
}
if ( in_array( $k, [ 'graphs', 'customGraphs' ], true ) ) {
$reduced[ $k ] = $data;
}
}
aioseo()->helpers->arrayRecursiveKsort( $reduced );
$revisionData['schema'] = $reduced;
}
}
/**
* Retrieve the formatted Revision Data array for comparison.
* This was first created, so we have the Focus/Additional Keyphrases fields separate on the UI.
*
* @since 4.4.0
*
* @param array $data The Revision Data.
* @param ObjectRevisions $objectRevisions An instance of the ObjectRevisions class.
* @return array The formatted Revision Data.
*/
public function formatRevisionData( $data, $objectRevisions ) {
$wpObject = $objectRevisions->getWpObject();
// 1. Before filling the fake columns 'focus' and 'additional' check if 'keyphrases' is present otherwise they'd show for Terms.
if ( isset( $data['keyphrases'] ) ) {
$data['focus'] = isset( $data['keyphrases']['focus'] ) ? $data['keyphrases']['focus'] : '';
$data['additional'] = isset( $data['keyphrases']['additional'] ) ? $data['keyphrases']['additional'] : '';
}
// 2. Fill the fake column 'robots_all_settings'.
$data['robots_all_settings'] = [
'default' => ! empty( $data['robots_default'] )
];
// 3. If 'robots_default' is not true, then show the chosen custom settings.
if ( empty( $data['robots_default'] ) ) {
$data['robots_all_settings'] = [
'default' => ! empty( $data['robots_default'] ),
'noindex' => ! empty( $data['robots_noindex'] ),
'nofollow' => ! empty( $data['robots_nofollow'] ),
'noarchive' => ! empty( $data['robots_noarchive'] ),
'notranslate' => ! empty( $data['robots_notranslate'] ),
'noimageindex' => ! empty( $data['robots_noimageindex'] ),
'nosnippet' => ! empty( $data['robots_nosnippet'] ),
'noodp' => ! empty( $data['robots_noodp'] ),
'maxsnippet' => isset( $data['robots_max_snippet'] ) ? $data['robots_max_snippet'] : '',
'maxvideopreview' => isset( $data['robots_max_videopreview'] ) ? $data['robots_max_videopreview'] : '',
'maximagepreview' => isset( $data['robots_max_imagepreview'] ) ? $data['robots_max_imagepreview'] : ''
];
}
// 4. Don't ever show these fields alone on the UI.
unset( $data['keyphrases'] );
unset( $data['robots_default'] );
unset( $data['robots_noindex'] );
unset( $data['robots_noarchive'] );
unset( $data['robots_nosnippet'] );
unset( $data['robots_nofollow'] );
unset( $data['robots_noimageindex'] );
unset( $data['robots_noodp'] );
unset( $data['robots_notranslate'] );
unset( $data['robots_max_snippet'] );
unset( $data['robots_max_videopreview'] );
unset( $data['robots_max_imagepreview'] );
// 5. Don't show certain fields for attachments.
if ( 'attachment' === ( $wpObject->post_type ?? '' ) ) {
unset( $data['pillar_content'] );
}
// 6. Prepare 'breadcrumb_settings' for the UI.
if ( array_key_exists( 'breadcrumb_settings', $data ) ) {
// If 'breadcrumb_settings' is set to the default value, its value is actually `null`, and the `default` key is not present,
// so we need this workaround for making sure {@see $this->prepareRevisionDataFieldValue()} does its job.
$data['breadcrumb_settings'] = is_null( $data['breadcrumb_settings'] )
? [ 'default' => true ]
: $data['breadcrumb_settings'];
}
return $data;
}
/**
* Prepare the Revision Data field value for comparison rendering in Vue.
* Useful so the user sees something similar to what is displayed while editing a post/term.
*
* @since 4.4.0
*
* @param string $field The Revision Data field. E.g. 'title' or 'description'.
* @param string|array $value The field value.
* @param ObjectRevisions $objectRevisions An instance of the ObjectRevisions class.
* @return string The formatted value.
*/
public function prepareRevisionDataFieldValue( $field, $value, $objectRevisions ) {
$wpObject = $objectRevisions->getWpObject();
if ( aioseo()->helpers->isWooCommerceProductAttribute( $wpObject ) ) {
$wpObject->taxonomy = 'product_attributes';
}
switch ( $field ) {
case 'title':
case 'og_title':
case 'twitter_title':
if ( empty( $value ) ) {
$value = 'term' === $objectRevisions->objectType
? aioseo()->meta->title->getTaxonomyTitle( $wpObject->taxonomy )
: aioseo()->meta->title->getPostTitle( $objectRevisions->objectId, true );
}
$value = aioseo()->helpers->decodeHtmlEntities( $value );
break;
case 'description':
case 'og_description':
case 'twitter_description':
if ( empty( $value ) ) {
$value = 'term' === $objectRevisions->objectType
? aioseo()->meta->description->getTaxonomyDescription( $wpObject->taxonomy )
: aioseo()->meta->description->getPostDescription( $objectRevisions->objectId, true );
}
$value = aioseo()->helpers->decodeHtmlEntities( $value );
break;
case 'og_object_type':
case 'twitter_card':
if ( ! empty( $value ) ) {
$value = "{optionValue|$value}"; // Is replaced with option label in Vue.
}
break;
case 'og_image_type':
case 'twitter_image_type':
if ( ! empty( $value ) ) {
aioseo()->social->image->useCache = false;
$type = strpos( $field, 'twitter' ) !== false ? 'twitter' : 'facebook';
$image = aioseo()->social->image->getImage( $type, $value, $wpObject );
$url = is_array( $image ) ? $image[0] : $image;
$value = "{optionValue|$value}"; // Is replaced with the option label in Vue.
$value .= "{imageUrl|$url}"; // Is replaced with the image preview in Vue.
}
break;
case 'og_image_custom_url':
case 'twitter_image_custom_url':
if ( ! empty( $value ) ) {
$value = "$value{imageUrl|$value}"; // Is replaced with the image preview in Vue.
}
break;
case 'canonical_url':
if ( empty( $value ) ) {
// Translators: 1 - The string 'Post' or 'Term'.
$value = sprintf( esc_html__( 'Default (the %s URL)', 'aioseo-pro' ), ucfirst( $objectRevisions->objectType ) );
}
break;
case 'focus':
$value = empty( $value['keyphrase'] ) ? '' : $value['keyphrase'];
break;
case 'additional':
case 'og_article_tags':
case 'keywords':
$searchKey = 'additional' === $field ? 'keyphrase' : 'label';
$items = ! empty( $value[0][ $searchKey ] ) ? array_map( 'trim', array_column( $value, $searchKey ) ) : [];
sort( $items );
$value = implode( "\n", $items );
$value = esc_html( $value );
break;
case 'schema':
aioseo()->schema->reset();
$value = aioseo()->schema->getUserDefinedSchemaOutput( $objectRevisions->objectId, $value );
break;
case 'twitter_use_og':
case 'pillar_content':
if ( is_bool( $value ) ) {
$value = true === $value
? esc_html__( 'Yes', 'aioseo-pro' )
: esc_html__( 'No', 'aioseo-pro' );
}
break;
case 'robots_all_settings':
$settings = [];
foreach ( $value as $optionName => $optionValue ) {
$settings[] = '{' . "$optionName|$optionValue" . '}';
}
$value = implode( "\n", $settings );
break;
case 'priority':
$value = is_null( $value ) ? 'default' : strval( $value );
break;
case 'primary_term':
if ( is_array( $value ) ) {
ksort( $value );
$items = [];
foreach ( $value as $taxonomy => $termId ) {
// Fallback to the raw term ID and taxonomy key if the term object or taxonomy object is not available anymore.
$items[ $taxonomy ] = $taxonomy . ': ' . $termId;
$termObject = get_term( $termId, $taxonomy );
$taxonomyObject = get_taxonomy( $taxonomy );
// If the term object and taxonomy object are available, use the term name and taxonomy singular name.
if ( is_object( $termObject ) && is_object( $taxonomyObject ) ) {
$items[ $taxonomy ] = ( $taxonomyObject->labels->singular_name ?? $termObject->taxonomy ) . ': ' . $termObject->name;
}
}
$value = implode( "\n", $items );
$value = esc_html( $value );
}
break;
case 'breadcrumb_settings':
if ( is_array( $value ) ) {
$settings = [];
foreach ( $value as $optionName => $optionValue ) {
if ( 'taxonomy' === $optionName ) {
$wpTaxonomy = get_taxonomy( $optionValue );
$optionValue = is_object( $wpTaxonomy ) ? ( $wpTaxonomy->labels->singular_name ?? $optionValue ) : $optionValue;
}
$settings[] = '{' . "$optionName|$optionValue" . '}';
}
$value = implode( "\n", $settings );
$value = esc_html( $value );
}
break;
default:
if ( is_null( $value ) ) {
$value = '';
}
break;
}
return is_string( $value ) ? $value : '';
}
/**
* Retrieve a human-readable HTML representation of the difference.
*
* @since 4.4.0
*
* @param string $leftString "old" (left) version.
* @param string $rightString "new" (right) version.
* @param string $field The Revision Data field.
* @return string Empty string if parameters are equivalent or HTML with differences.
*/
public function renderFieldDiff( $leftString, $rightString, $field ) {
$args = [
'show_split_view' => true,
];
switch ( $field ) {
case 'additional':
case 'og_article_tags':
case 'keywords':
case 'primary_term':
$diff = $this->getTagsDiff( $leftString, $rightString );
break;
case 'schema':
$diff = $this->getSchemaDiff( $leftString, $rightString );
break;
case 'robots_all_settings':
case 'breadcrumb_settings':
$diff = $this->getSettingsDiff( $leftString, $rightString );
break;
default:
$leftString = normalize_whitespace( $leftString );
$rightString = normalize_whitespace( $rightString );
$leftLines = explode( "\n", $leftString );
$rightLines = explode( "\n", $rightString );
$textDiff = new \Text_Diff( $leftLines, $rightLines );
$renderer = new TextDiffRendererTable( $args );
$diff = $renderer->render( $textDiff );
break;
}
if ( ! $diff ) {
return '';
}
$isSplitView = ! empty( $args['show_split_view'] );
$isSplitViewClass = $isSplitView ? ' is-split-view' : '';
$output = "<table class='diff$isSplitViewClass'>";
$output .= "<tbody>$diff</tbody>";
$output .= '</table>';
// Replace e.g. `#<del>site_title</del>` with `<del>#site_title</del>`.
return preg_replace( "/#<([a-z]+?)>([a-zA-Z0-9_ -]{3,}?)<\/\\1>/", '<$1>#$2</$1>', (string) $output );
}
/**
* Parse the new Revision Data before using it under other operations.
*
* @since 4.4.0
*
* @param array $newData The new Revision Data (passed by reference).
* @return void
*/
public function parseNewRevisionData( &$newData ) {
foreach ( $newData as $key => &$value ) {
switch ( $key ) {
case 'schema':
case 'primary_term':
case 'breadcrumb_settings':
$value = json_decode( wp_json_encode( $value ), true );
break;
default:
break;
}
}
}
/**
* Retrieve the inner `<tbody>` HTML representation of the difference.
*
* @since 4.4.0
*
* @param string $leftString "old" (left) version.
* @param string $rightString "new" (right) version.
* @return string Empty string if parameters are equivalent or HTML with differences.
*/
private function getTagsDiff( $leftString, $rightString ) {
$leftHash = trim( serialize( $leftString ) );
$rightHash = trim( serialize( $rightString ) );
if ( $leftHash === $rightHash ) {
return '';
}
$leftAdditional = explode( "\n", $leftString );
$rightAdditional = explode( "\n", $rightString );
$html = '<tr>';
$html .= "<td class='diff-deletedline'>";
$html .= "<span class='dashicons dashicons-minus'></span>";
$innerData = [];
foreach ( $leftAdditional as $leftPhrase ) {
if ( ! in_array( $leftPhrase, $rightAdditional, true ) ) {
$innerData[] = "<del><span>$leftPhrase</span></del>";
} else {
$innerData[] = "<span>$leftPhrase</span>";
}
}
$html .= implode( "\n", $innerData );
$html .= '</td>';
$html .= "<td class='diff-addedline'>";
$html .= "<span class='dashicons dashicons-plus'></span>";
$innerData = [];
foreach ( $rightAdditional as $rightPhrase ) {
if ( ! in_array( $rightPhrase, $leftAdditional, true ) ) {
$innerData[] = "<ins><span>$rightPhrase</span></ins>";
} else {
$innerData[] = "<span>$rightPhrase</span>";
}
}
$html .= implode( "\n", $innerData );
$html .= '</td>';
return $html;
}
/**
* Retrieve the inner `<tbody>` HTML representation of the difference.
*
* @since 4.4.0
* @version 4.8.7 Renamed from getRobotsSettingDiff to getSettingsDiff.
*
* @param string $leftString "old" (left) version.
* @param string $rightString "new" (right) version.
* @return string Empty string if parameters are equivalent or HTML with differences.
*/
private function getSettingsDiff( $leftString, $rightString ) {
$leftHash = trim( serialize( $leftString ) );
$rightHash = trim( serialize( $rightString ) );
if ( $leftHash === $rightHash ) {
return '';
}
$html = '<tr>';
$html .= "<td class='diff-deletedline'>";
$html .= "<span class='dashicons dashicons-minus'></span>";
$html .= $leftString;
$html .= '</td>';
$html .= "<td class='diff-addedline'>";
$html .= "<span class='dashicons dashicons-plus'></span>";
$html .= $rightString;
$html .= '</td>';
return $html;
}
/**
* Retrieve the inner `<tbody>` HTML representation of the difference.
*
* @since 4.4.0
*
* @param string $leftString "old" (left) version.
* @param string $rightString "new" (right) version.
* @return string Empty string if parameters are equivalent or HTML with differences.
*/
private function getSchemaDiff( $leftString, $rightString ) {
$leftHash = trim( serialize( $leftString ) );
$rightHash = trim( serialize( $rightString ) );
if ( $leftHash === $rightHash ) {
return '';
}
$leftJson = json_decode( $leftString, true );
$rightJson = json_decode( $rightString, true );
$leftSchema = '';
$rightSchema = '';
if ( ! empty( $leftJson['@graph'] ) ) {
$leftJson = $this->schemaDiff( $leftJson, $rightJson, 'del' );
$leftSchema = wp_json_encode( $leftJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
}
if ( ! empty( $rightJson['@graph'] ) ) {
$rightJson = $this->schemaDiff( $rightJson, $leftJson );
$rightSchema = wp_json_encode( $rightJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
}
$html = '<tr>';
$html .= "<td class='diff-deletedline'>";
$html .= "<span class='dashicons dashicons-minus'></span>";
$html .= "<pre>$leftSchema</pre>";
$html .= '</td>';
$html .= "<td class='diff-addedline'>";
$html .= "<span class='dashicons dashicons-plus'></span>";
$html .= "<pre>$rightSchema</pre>";
$html .= '</td>';
return $html;
}
/**
* Compares schemaFrom to schemaTo and adds appropriate ins or del to schemaFrom.
*
* @since 4.4.0
*
* @param array $schemaFrom The schema array from.
* @param array $schemaTo The schema array to.
* @param string $insOrDel The ins or del.
* @return array The schema from with added ins or del tags to values.
*/
private function schemaDiff( $schemaFrom, $schemaTo, $insOrDel = 'ins' ) {
$schemaToTypes = array_column( ! empty( $schemaTo['@graph'] ) ? (array) $schemaTo['@graph'] : [], '@type' );
foreach ( $schemaFrom['@graph'] as &$graph ) {
if ( ! in_array( $graph['@type'], $schemaToTypes, true ) ) {
$this->addInsDelToSchemaGraph( $graph, $insOrDel );
} else {
$toGraphItems = $schemaTo['@graph'][ array_search( $graph['@type'], $schemaToTypes, true ) ];
foreach ( $graph as $key => &$value ) {
if (
! isset( $toGraphItems[ $key ] ) ||
$toGraphItems[ $key ] !== $value
) {
$this->addInsDelToSchemaGraph( $value, $insOrDel );
}
}
}
}
return $schemaFrom;
}
/**
* Add `<ins>` or `<del>` tag to the given graph values.
*
* @since 4.4.0
*
* @param array|string $graph The graph to add the `<del>` tag to.
* @param string $insOrDel The ins or del.
* @return void
*/
private function addInsDelToSchemaGraph( &$graph, $insOrDel = 'ins' ) {
if ( ! is_array( $graph ) ) {
$graph = 'ins' === $insOrDel ? "<ins>$graph</ins>" : "<del>$graph</del>";
} else {
foreach ( $graph as $key => &$value ) {
if ( is_array( $value ) ) {
$this->addInsDelToSchemaGraph( $value, $insOrDel );
} else {
$graph[ $key ] = 'ins' === $insOrDel ? "<ins>$value</ins>" : "<del>$value</del>";
}
}
}
}
}