This commit is contained in:
2024-05-20 15:37:46 +03:00
commit 00b7dbd0b7
10404 changed files with 3285853 additions and 0 deletions

View File

@ -0,0 +1,86 @@
<?php
namespace Yoast\WP\SEO\Actions\Addon_Installation;
use WPSEO_Addon_Manager;
use Yoast\WP\SEO\Exceptions\Addon_Installation\Addon_Activation_Error_Exception;
use Yoast\WP\SEO\Exceptions\Addon_Installation\User_Cannot_Activate_Plugins_Exception;
use Yoast\WP\SEO\Helpers\Require_File_Helper;
/**
* Represents the endpoint for activating a specific Yoast Plugin on WordPress.
*/
class Addon_Activate_Action {
/**
* The addon manager.
*
* @var WPSEO_Addon_Manager
*/
protected $addon_manager;
/**
* The require file helper.
*
* @var Require_File_Helper
*/
protected $require_file_helper;
/**
* Addon_Activate_Action constructor.
*
* @param WPSEO_Addon_Manager $addon_manager The addon manager.
* @param Require_File_Helper $require_file_helper A file helper.
*/
public function __construct(
WPSEO_Addon_Manager $addon_manager,
Require_File_Helper $require_file_helper
) {
$this->addon_manager = $addon_manager;
$this->require_file_helper = $require_file_helper;
}
/**
* Activates the plugin based on the given plugin file.
*
* @param string $plugin_slug The plugin slug to get download url for.
*
* @return bool True when activation is successful.
*
* @throws Addon_Activation_Error_Exception Exception when the activation encounters an error.
* @throws User_Cannot_Activate_Plugins_Exception Exception when the user is not allowed to activate.
*/
public function activate_addon( $plugin_slug ) {
if ( ! \current_user_can( 'activate_plugins' ) ) {
throw new User_Cannot_Activate_Plugins_Exception();
}
if ( $this->addon_manager->is_installed( $plugin_slug ) ) {
return true;
}
$this->load_wordpress_classes();
$plugin_file = $this->addon_manager->get_plugin_file( $plugin_slug );
$activation_result = \activate_plugin( $plugin_file );
if ( $activation_result !== null && \is_wp_error( $activation_result ) ) {
throw new Addon_Activation_Error_Exception( $activation_result->get_error_message() );
}
return true;
}
/**
* Requires the files needed from WordPress itself.
*
* @codeCoverageIgnore Only loads a WordPress file.
*
* @return void
*/
protected function load_wordpress_classes() {
if ( ! \function_exists( 'get_plugins' ) ) {
$this->require_file_helper->require_file_once( \ABSPATH . 'wp-admin/includes/plugin.php' );
}
}
}

View File

@ -0,0 +1,131 @@
<?php
namespace Yoast\WP\SEO\Actions\Addon_Installation;
use Plugin_Upgrader;
use WP_Error;
use WPSEO_Addon_Manager;
use Yoast\WP\SEO\Exceptions\Addon_Installation\Addon_Already_Installed_Exception;
use Yoast\WP\SEO\Exceptions\Addon_Installation\Addon_Installation_Error_Exception;
use Yoast\WP\SEO\Exceptions\Addon_Installation\User_Cannot_Install_Plugins_Exception;
use Yoast\WP\SEO\Helpers\Require_File_Helper;
/**
* Represents the endpoint for downloading and installing a zip-file from MyYoast.
*/
class Addon_Install_Action {
/**
* The addon manager.
*
* @var WPSEO_Addon_Manager
*/
protected $addon_manager;
/**
* The require file helper.
*
* @var Require_File_Helper
*/
protected $require_file_helper;
/**
* Addon_Activate_Action constructor.
*
* @param WPSEO_Addon_Manager $addon_manager The addon manager.
* @param Require_File_Helper $require_file_helper A helper that can require files.
*/
public function __construct(
WPSEO_Addon_Manager $addon_manager,
Require_File_Helper $require_file_helper
) {
$this->addon_manager = $addon_manager;
$this->require_file_helper = $require_file_helper;
}
/**
* Installs the plugin based on the given slug.
*
* @param string $plugin_slug The plugin slug to install.
* @param string $download_url The plugin download URL.
*
* @return bool True when install is successful.
*
* @throws Addon_Already_Installed_Exception When the addon is already installed.
* @throws Addon_Installation_Error_Exception When the installation encounters an error.
* @throws User_Cannot_Install_Plugins_Exception When the user does not have the permissions to install plugins.
*/
public function install_addon( $plugin_slug, $download_url ) {
if ( ! \current_user_can( 'install_plugins' ) ) {
throw new User_Cannot_Install_Plugins_Exception( $plugin_slug );
}
if ( $this->is_installed( $plugin_slug ) ) {
throw new Addon_Already_Installed_Exception( $plugin_slug );
}
$this->load_wordpress_classes();
$install_result = $this->install( $download_url );
if ( \is_wp_error( $install_result ) ) {
throw new Addon_Installation_Error_Exception( $install_result->get_error_message() );
}
return $install_result;
}
/**
* Requires the files needed from WordPress itself.
*
* @codeCoverageIgnore
*
* @return void
*/
protected function load_wordpress_classes() {
if ( ! \class_exists( 'WP_Upgrader' ) ) {
$this->require_file_helper->require_file_once( \ABSPATH . 'wp-admin/includes/class-wp-upgrader.php' );
}
if ( ! \class_exists( 'Plugin_Upgrader' ) ) {
$this->require_file_helper->require_file_once( \ABSPATH . 'wp-admin/includes/class-plugin-upgrader.php' );
}
if ( ! \class_exists( 'WP_Upgrader_Skin' ) ) {
$this->require_file_helper->require_file_once( \ABSPATH . 'wp-admin/includes/class-wp-upgrader-skin.php' );
}
if ( ! \function_exists( 'get_plugin_data' ) ) {
$this->require_file_helper->require_file_once( \ABSPATH . 'wp-admin/includes/plugin.php' );
}
if ( ! \function_exists( 'request_filesystem_credentials' ) ) {
$this->require_file_helper->require_file_once( \ABSPATH . 'wp-admin/includes/file.php' );
}
}
/**
* Checks is a plugin is installed.
*
* @param string $plugin_slug The plugin to check.
*
* @return bool True when plugin is installed.
*/
protected function is_installed( $plugin_slug ) {
return $this->addon_manager->get_plugin_file( $plugin_slug ) !== false;
}
/**
* Runs the installation by using the WordPress installation routine.
*
* @codeCoverageIgnore Contains WordPress specific logic.
*
* @param string $plugin_download The url to the download.
*
* @return bool|WP_Error True when success, WP_Error when something went wrong.
*/
protected function install( $plugin_download ) {
$plugin_upgrader = new Plugin_Upgrader();
return $plugin_upgrader->install( $plugin_download );
}
}

View File

@ -0,0 +1,215 @@
<?php
namespace Yoast\WP\SEO\Actions;
use Yoast\WP\SEO\Helpers\User_Helper;
/**
* Class Alert_Dismissal_Action.
*/
class Alert_Dismissal_Action {
const USER_META_KEY = '_yoast_alerts_dismissed';
/**
* Holds the user helper instance.
*
* @var User_Helper
*/
protected $user;
/**
* Constructs Alert_Dismissal_Action.
*
* @param User_Helper $user User helper.
*/
public function __construct( User_Helper $user ) {
$this->user = $user;
}
/**
* Dismisses an alert.
*
* @param string $alert_identifier Alert identifier.
*
* @return bool Whether the dismiss was successful or not.
*/
public function dismiss( $alert_identifier ) {
$user_id = $this->user->get_current_user_id();
if ( $user_id === 0 ) {
return false;
}
if ( $this->is_allowed( $alert_identifier ) === false ) {
return false;
}
$dismissed_alerts = $this->get_dismissed_alerts( $user_id );
if ( $dismissed_alerts === false ) {
return false;
}
if ( \array_key_exists( $alert_identifier, $dismissed_alerts ) === true ) {
// The alert is already dismissed.
return true;
}
// Add this alert to the dismissed alerts.
$dismissed_alerts[ $alert_identifier ] = true;
// Save.
return $this->user->update_meta( $user_id, static::USER_META_KEY, $dismissed_alerts ) !== false;
}
/**
* Resets an alert.
*
* @param string $alert_identifier Alert identifier.
*
* @return bool Whether the reset was successful or not.
*/
public function reset( $alert_identifier ) {
$user_id = $this->user->get_current_user_id();
if ( $user_id === 0 ) {
return false;
}
if ( $this->is_allowed( $alert_identifier ) === false ) {
return false;
}
$dismissed_alerts = $this->get_dismissed_alerts( $user_id );
if ( $dismissed_alerts === false ) {
return false;
}
$amount_of_dismissed_alerts = \count( $dismissed_alerts );
if ( $amount_of_dismissed_alerts === 0 ) {
// No alerts: nothing to reset.
return true;
}
if ( \array_key_exists( $alert_identifier, $dismissed_alerts ) === false ) {
// Alert not found: nothing to reset.
return true;
}
if ( $amount_of_dismissed_alerts === 1 ) {
// The 1 remaining dismissed alert is the alert to reset: delete the alerts user meta row.
return $this->user->delete_meta( $user_id, static::USER_META_KEY, $dismissed_alerts );
}
// Remove this alert from the dismissed alerts.
unset( $dismissed_alerts[ $alert_identifier ] );
// Save.
return $this->user->update_meta( $user_id, static::USER_META_KEY, $dismissed_alerts ) !== false;
}
/**
* Returns if an alert is dismissed or not.
*
* @param string $alert_identifier Alert identifier.
*
* @return bool Whether the alert has been dismissed.
*/
public function is_dismissed( $alert_identifier ) {
$user_id = $this->user->get_current_user_id();
if ( $user_id === 0 ) {
return false;
}
if ( $this->is_allowed( $alert_identifier ) === false ) {
return false;
}
$dismissed_alerts = $this->get_dismissed_alerts( $user_id );
if ( $dismissed_alerts === false ) {
return false;
}
return \array_key_exists( $alert_identifier, $dismissed_alerts );
}
/**
* Returns an object with all alerts dismissed by current user.
*
* @return array|false An array with the keys of all Alerts that have been dismissed
* by the current user or `false`.
*/
public function all_dismissed() {
$user_id = $this->user->get_current_user_id();
if ( $user_id === 0 ) {
return false;
}
$dismissed_alerts = $this->get_dismissed_alerts( $user_id );
if ( $dismissed_alerts === false ) {
return false;
}
return $dismissed_alerts;
}
/**
* Returns if an alert is allowed or not.
*
* @param string $alert_identifier Alert identifier.
*
* @return bool Whether the alert is allowed.
*/
public function is_allowed( $alert_identifier ) {
return \in_array( $alert_identifier, $this->get_allowed_dismissable_alerts(), true );
}
/**
* Retrieves the dismissed alerts.
*
* @param int $user_id User ID.
*
* @return string[]|false The dismissed alerts. False for an invalid $user_id.
*/
protected function get_dismissed_alerts( $user_id ) {
$dismissed_alerts = $this->user->get_meta( $user_id, static::USER_META_KEY, true );
if ( $dismissed_alerts === false ) {
// Invalid user ID.
return false;
}
if ( $dismissed_alerts === '' ) {
/*
* When no database row exists yet, an empty string is returned because of the `single` parameter.
* We do want a single result returned, but the default should be an empty array instead.
*/
return [];
}
return $dismissed_alerts;
}
/**
* Retrieves the allowed dismissable alerts.
*
* @return string[] The allowed dismissable alerts.
*/
protected function get_allowed_dismissable_alerts() {
/**
* Filter: 'wpseo_allowed_dismissable_alerts' - List of allowed dismissable alerts.
*
* @api string[] $allowed_dismissable_alerts Allowed dismissable alerts list.
*/
$allowed_dismissable_alerts = \apply_filters( 'wpseo_allowed_dismissable_alerts', [] );
if ( \is_array( $allowed_dismissable_alerts ) === false ) {
return [];
}
// Only allow strings.
$allowed_dismissable_alerts = \array_filter( $allowed_dismissable_alerts, 'is_string' );
// Filter unique and reorder indices.
$allowed_dismissable_alerts = \array_values( \array_unique( $allowed_dismissable_alerts ) );
return $allowed_dismissable_alerts;
}
}

View File

@ -0,0 +1,298 @@
<?php
namespace Yoast\WP\SEO\Actions\Configuration;
use Yoast\WP\SEO\Helpers\Options_Helper;
use Yoast\WP\SEO\Helpers\Social_Profiles_Helper;
/**
* Class First_Time_Configuration_Action.
*/
class First_Time_Configuration_Action {
/**
* The fields for the site representation payload.
*/
const SITE_REPRESENTATION_FIELDS = [
'company_or_person',
'company_name',
'website_name',
'company_logo',
'company_logo_id',
'person_logo',
'person_logo_id',
'company_or_person_user_id',
'description',
];
/**
* The Options_Helper instance.
*
* @var Options_Helper
*/
protected $options_helper;
/**
* The Social_Profiles_Helper instance.
*
* @var Social_Profiles_Helper
*/
protected $social_profiles_helper;
/**
* First_Time_Configuration_Action constructor.
*
* @param Options_Helper $options_helper The WPSEO options helper.
* @param Social_Profiles_Helper $social_profiles_helper The social profiles helper.
*/
public function __construct( Options_Helper $options_helper, Social_Profiles_Helper $social_profiles_helper ) {
$this->options_helper = $options_helper;
$this->social_profiles_helper = $social_profiles_helper;
}
/**
* Stores the values for the site representation.
*
* @param array $params The values to store.
*
* @return object The response object.
*/
public function set_site_representation( $params ) {
$failures = [];
foreach ( self::SITE_REPRESENTATION_FIELDS as $field_name ) {
if ( isset( $params[ $field_name ] ) ) {
if ( $field_name === 'description' && \current_user_can( 'manage_options' ) ) {
$result = \update_option( 'blogdescription', $params['description'] );
if ( ! $result && $params['description'] === \get_option( 'blogdescription' ) ) {
$result = true;
}
}
else {
$result = $this->options_helper->set( $field_name, $params[ $field_name ] );
}
if ( ! $result ) {
$failures[] = $field_name;
}
}
}
// Delete cached logos in the db.
$this->options_helper->set( 'company_logo_meta', false );
$this->options_helper->set( 'person_logo_meta', false );
if ( \count( $failures ) === 0 ) {
return (object) [
'success' => true,
'status' => 200,
];
}
return (object) [
'success' => false,
'status' => 500,
'error' => 'Could not save some options in the database',
'failures' => $failures,
];
}
/**
* Stores the values for the social profiles.
*
* @param array $params The values to store.
*
* @return object The response object.
*/
public function set_social_profiles( $params ) {
$failures = $this->social_profiles_helper->set_organization_social_profiles( $params );
if ( empty( $failures ) ) {
return (object) [
'success' => true,
'status' => 200,
];
}
return (object) [
'success' => false,
'status' => 200,
'error' => 'Could not save some options in the database',
'failures' => $failures,
];
}
/**
* Stores the values for the social profiles.
*
* @param array $params The values to store.
*
* @return object The response object.
*/
public function set_person_social_profiles( $params ) {
$social_profiles = \array_filter(
$params,
static function ( $key ) {
return $key !== 'user_id';
},
\ARRAY_FILTER_USE_KEY
);
$failures = $this->social_profiles_helper->set_person_social_profiles( $params['user_id'], $social_profiles );
if ( \count( $failures ) === 0 ) {
return (object) [
'success' => true,
'status' => 200,
];
}
return (object) [
'success' => false,
'status' => 200,
'error' => 'Could not save some options in the database',
'failures' => $failures,
];
}
/**
* Gets the values for the social profiles.
*
* @param int $user_id The person ID.
*
* @return object The response object.
*/
public function get_person_social_profiles( $user_id ) {
return (object) [
'success' => true,
'status' => 200,
'social_profiles' => $this->social_profiles_helper->get_person_social_profiles( $user_id ),
];
}
/**
* Stores the values to enable/disable tracking.
*
* @param array $params The values to store.
*
* @return object The response object.
*/
public function set_enable_tracking( $params ) {
$success = true;
$option_value = $this->options_helper->get( 'tracking' );
if ( $option_value !== $params['tracking'] ) {
$this->options_helper->set( 'toggled_tracking', true );
$success = $this->options_helper->set( 'tracking', $params['tracking'] );
}
if ( $success ) {
return (object) [
'success' => true,
'status' => 200,
];
}
return (object) [
'success' => false,
'status' => 500,
'error' => 'Could not save the option in the database',
];
}
/**
* Checks if the current user has the capability a specific user.
*
* @param int $user_id The id of the user to be edited.
*
* @return object The response object.
*/
public function check_capability( $user_id ) {
if ( $this->can_edit_profile( $user_id ) ) {
return (object) [
'success' => true,
'status' => 200,
];
}
return (object) [
'success' => false,
'status' => 403,
];
}
/**
* Stores the first time configuration state.
*
* @param array $params The values to store.
*
* @return object The response object.
*/
public function save_configuration_state( $params ) {
// If the finishedSteps param is not present in the REST request, it's a malformed request.
if ( ! isset( $params['finishedSteps'] ) ) {
return (object) [
'success' => false,
'status' => 400,
'error' => 'Bad request',
];
}
// Sanitize input.
$finished_steps = \array_map( '\sanitize_text_field', \wp_unslash( $params['finishedSteps'] ) );
$success = $this->options_helper->set( 'configuration_finished_steps', $finished_steps );
if ( ! $success ) {
return (object) [
'success' => false,
'status' => 500,
'error' => 'Could not save the option in the database',
];
}
// If all the five steps of the configuration have been completed, set first_time_install option to false.
if ( \count( $params['finishedSteps'] ) === 3 ) {
$this->options_helper->set( 'first_time_install', false );
}
return (object) [
'success' => true,
'status' => 200,
];
}
/**
* Gets the first time configuration state.
*
* @return object The response object.
*/
public function get_configuration_state() {
$configuration_option = $this->options_helper->get( 'configuration_finished_steps' );
if ( ! \is_null( $configuration_option ) ) {
return (object) [
'success' => true,
'status' => 200,
'data' => $configuration_option,
];
}
return (object) [
'success' => false,
'status' => 500,
'error' => 'Could not get data from the database',
];
}
/**
* Checks if the current user has the capability to edit a specific user.
*
* @param int $person_id The id of the person to edit.
*
* @return bool
*/
private function can_edit_profile( $person_id ) {
return \current_user_can( 'edit_user', $person_id );
}
}

View File

@ -0,0 +1,264 @@
<?php
namespace Yoast\WP\SEO\Actions\Importing;
use Exception;
use Yoast\WP\SEO\Helpers\Aioseo_Helper;
use Yoast\WP\SEO\Helpers\Import_Cursor_Helper;
use Yoast\WP\SEO\Helpers\Options_Helper;
use Yoast\WP\SEO\Helpers\Sanitization_Helper;
use Yoast\WP\SEO\Services\Importing\Aioseo\Aioseo_Replacevar_Service;
use Yoast\WP\SEO\Services\Importing\Aioseo\Aioseo_Robots_Provider_Service;
use Yoast\WP\SEO\Services\Importing\Aioseo\Aioseo_Robots_Transformer_Service;
/**
* Importing action interface.
*/
abstract class Abstract_Aioseo_Importing_Action implements Importing_Action_Interface {
/**
* The plugin the class deals with.
*
* @var string
*/
const PLUGIN = null;
/**
* The type the class deals with.
*
* @var string
*/
const TYPE = null;
/**
* The AIOSEO helper.
*
* @var Aioseo_Helper
*/
protected $aioseo_helper;
/**
* The import cursor helper.
*
* @var Import_Cursor_Helper
*/
protected $import_cursor;
/**
* The options helper.
*
* @var Options_Helper
*/
protected $options;
/**
* The sanitization helper.
*
* @var Sanitization_Helper
*/
protected $sanitization;
/**
* The replacevar handler.
*
* @var Aioseo_Replacevar_Service
*/
protected $replacevar_handler;
/**
* The robots provider service.
*
* @var Aioseo_Robots_Provider_Service
*/
protected $robots_provider;
/**
* The robots transformer service.
*
* @var Aioseo_Robots_Transformer_Service
*/
protected $robots_transformer;
/**
* Abstract_Aioseo_Importing_Action constructor.
*
* @param Import_Cursor_Helper $import_cursor The import cursor helper.
* @param Options_Helper $options The options helper.
* @param Sanitization_Helper $sanitization The sanitization helper.
* @param Aioseo_Replacevar_Service $replacevar_handler The replacevar handler.
* @param Aioseo_Robots_Provider_Service $robots_provider The robots provider service.
* @param Aioseo_Robots_Transformer_Service $robots_transformer The robots transfomer service.
*/
public function __construct(
Import_Cursor_Helper $import_cursor,
Options_Helper $options,
Sanitization_Helper $sanitization,
Aioseo_Replacevar_Service $replacevar_handler,
Aioseo_Robots_Provider_Service $robots_provider,
Aioseo_Robots_Transformer_Service $robots_transformer
) {
$this->import_cursor = $import_cursor;
$this->options = $options;
$this->sanitization = $sanitization;
$this->replacevar_handler = $replacevar_handler;
$this->robots_provider = $robots_provider;
$this->robots_transformer = $robots_transformer;
}
/**
* Sets the AIOSEO helper.
*
* @required
*
* @param Aioseo_Helper $aioseo_helper The AIOSEO helper.
*/
public function set_aioseo_helper( Aioseo_Helper $aioseo_helper ) {
$this->aioseo_helper = $aioseo_helper;
}
/**
* The name of the plugin we import from.
*
* @return string The plugin we import from.
*
* @throws Exception If the PLUGIN constant is not set in the child class.
*/
public function get_plugin() {
$class = \get_class( $this );
$plugin = $class::PLUGIN;
if ( $plugin === null ) {
throw new Exception( 'Importing action without explicit plugin' );
}
return $plugin;
}
/**
* The data type we import from the plugin.
*
* @return string The data type we import from the plugin.
*
* @throws Exception If the TYPE constant is not set in the child class.
*/
public function get_type() {
$class = \get_class( $this );
$type = $class::TYPE;
if ( $type === null ) {
throw new Exception( 'Importing action without explicit type' );
}
return $type;
}
/**
* Can the current action import the data from plugin $plugin of type $type?
*
* @param string|null $plugin The plugin to import from.
* @param string|null $type The type of data to import.
*
* @return bool True if this action can handle the combination of Plugin and Type.
*
* @throws Exception If the TYPE constant is not set in the child class.
*/
public function is_compatible_with( $plugin = null, $type = null ) {
if ( empty( $plugin ) && empty( $type ) ) {
return true;
}
if ( $plugin === $this->get_plugin() && empty( $type ) ) {
return true;
}
if ( empty( $plugin ) && $type === $this->get_type() ) {
return true;
}
if ( $plugin === $this->get_plugin() && $type === $this->get_type() ) {
return true;
}
return false;
}
/**
* Gets the completed id (to be used as a key for the importing_completed option).
*
* @return string The completed id.
*/
public function get_completed_id() {
return $this->get_cursor_id();
}
/**
* Returns the stored state of completedness.
*
* @return int The stored state of completedness.
*/
public function get_completed() {
$completed_id = $this->get_completed_id();
$importers_completions = $this->options->get( 'importing_completed', [] );
return ( isset( $importers_completions[ $completed_id ] ) ) ? $importers_completions[ $completed_id ] : false;
}
/**
* Stores the current state of completedness.
*
* @param bool $completed Whether the importer is completed.
*
* @return void
*/
public function set_completed( $completed ) {
$completed_id = $this->get_completed_id();
$current_importers_completions = $this->options->get( 'importing_completed', [] );
$current_importers_completions[ $completed_id ] = $completed;
$this->options->set( 'importing_completed', $current_importers_completions );
}
/**
* Returns whether the importing action is enabled.
*
* @return bool True by default unless a child class overrides it.
*/
public function is_enabled() {
return true;
}
/**
* Gets the cursor id.
*
* @return string The cursor id.
*/
protected function get_cursor_id() {
return $this->get_plugin() . '_' . $this->get_type();
}
/**
* Minimally transforms data to be imported.
*
* @param string $meta_data The meta data to be imported.
*
* @return string The transformed meta data.
*/
public function simple_import( $meta_data ) {
// Transform the replace vars into Yoast replace vars.
$transformed_data = $this->replacevar_handler->transform( $meta_data );
return $this->sanitization->sanitize_text_field( \html_entity_decode( $transformed_data ) );
}
/**
* Transforms URL to be imported.
*
* @param string $meta_data The meta data to be imported.
*
* @return string The transformed URL.
*/
public function url_import( $meta_data ) {
// We put null as the allowed protocols here, to have the WP default allowed protocols, see https://developer.wordpress.org/reference/functions/wp_allowed_protocols.
return $this->sanitization->sanitize_url( $meta_data, null );
}
}

View File

@ -0,0 +1,340 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Given it's a very specific case.
namespace Yoast\WP\SEO\Actions\Importing\Aioseo;
use Exception;
use Yoast\WP\SEO\Actions\Importing\Abstract_Aioseo_Importing_Action;
use Yoast\WP\SEO\Helpers\Import_Helper;
/**
* Abstract class for importing AIOSEO settings.
*/
abstract class Abstract_Aioseo_Settings_Importing_Action extends Abstract_Aioseo_Importing_Action {
/**
* The plugin the class deals with.
*
* @var string
*/
const PLUGIN = null;
/**
* The type the class deals with.
*
* @var string
*/
const TYPE = null;
/**
* The option_name of the AIOSEO option that contains the settings.
*/
const SOURCE_OPTION_NAME = null;
/**
* The map of aioseo_options to yoast settings.
*
* @var array
*/
protected $aioseo_options_to_yoast_map = [];
/**
* The tab of the aioseo settings we're working with, eg. taxonomies, posttypes.
*
* @var string
*/
protected $settings_tab = '';
/**
* Additional mapping between AiOSEO replace vars and Yoast replace vars.
*
* @var array
*
* @see https://yoast.com/help/list-available-snippet-variables-yoast-seo/
*/
protected $replace_vars_edited_map = [];
/**
* The import helper.
*
* @var Import_Helper
*/
protected $import_helper;
/**
* Builds the mapping that ties AOISEO option keys with Yoast ones and their data transformation method.
*
* @return void
*/
abstract protected function build_mapping();
/**
* Sets the import helper.
*
* @required
*
* @param Import_Helper $import_helper The import helper.
*/
public function set_import_helper( Import_Helper $import_helper ) {
$this->import_helper = $import_helper;
}
/**
* Retrieves the source option_name.
*
* @return string The source option_name.
*
* @throws Exception If the SOURCE_OPTION_NAME constant is not set in the child class.
*/
public function get_source_option_name() {
$source_option_name = static::SOURCE_OPTION_NAME;
if ( empty( $source_option_name ) ) {
throw new Exception( 'Importing settings action without explicit source option_name' );
}
return $source_option_name;
}
/**
* Returns the total number of unimported objects.
*
* @return int The total number of unimported objects.
*/
public function get_total_unindexed() {
return $this->get_unindexed_count();
}
/**
* Returns the limited number of unimported objects.
*
* @param int $limit The maximum number of unimported objects to be returned.
*
* @return int The limited number of unindexed posts.
*/
public function get_limited_unindexed_count( $limit ) {
return $this->get_unindexed_count( $limit );
}
/**
* Returns the number of unimported objects (limited if limit is applied).
*
* @param int|null $limit The maximum number of unimported objects to be returned.
*
* @return int The number of unindexed posts.
*/
protected function get_unindexed_count( $limit = null ) {
if ( ! \is_int( $limit ) || $limit < 1 ) {
$limit = null;
}
$settings_to_create = $this->query( $limit );
$number_of_settings_to_create = \count( $settings_to_create );
$completed = $number_of_settings_to_create === 0;
$this->set_completed( $completed );
return $number_of_settings_to_create;
}
/**
* Imports AIOSEO settings.
*
* @return array|false An array of the AIOSEO settings that were imported or false if aioseo data was not found.
*/
public function index() {
$limit = $this->get_limit();
$aioseo_settings = $this->query( $limit );
$created_settings = [];
$completed = \count( $aioseo_settings ) === 0;
$this->set_completed( $completed );
// Prepare the setting keys mapping.
$this->build_mapping();
// Prepare the replacement var mapping.
foreach ( $this->replace_vars_edited_map as $aioseo_var => $yoast_var ) {
$this->replacevar_handler->compose_map( $aioseo_var, $yoast_var );
}
$last_imported_setting = '';
try {
foreach ( $aioseo_settings as $setting => $setting_value ) {
// Map and import the values of the setting we're working with (eg. post, book-category, etc.) to the respective Yoast option.
$this->map( $setting_value, $setting );
// Save the type of the settings that were just imported, so that we can allow chunked imports.
$last_imported_setting = $setting;
$created_settings[] = $setting;
}
}
finally {
$cursor_id = $this->get_cursor_id();
$this->import_cursor->set_cursor( $cursor_id, $last_imported_setting );
}
return $created_settings;
}
/**
* Checks if the settings tab subsetting is set in the AIOSEO option.
*
* @param string $aioseo_settings The AIOSEO option.
*
* @return bool Whether the settings are set.
*/
public function isset_settings_tab( $aioseo_settings ) {
return isset( $aioseo_settings['searchAppearance'][ $this->settings_tab ] );
}
/**
* Queries the database and retrieves unimported AiOSEO settings (in chunks if a limit is applied).
*
* @param int|null $limit The maximum number of unimported objects to be returned.
*
* @return array The (maybe chunked) unimported AiOSEO settings to import.
*/
protected function query( $limit = null ) {
$aioseo_settings = \json_decode( \get_option( $this->get_source_option_name(), '' ), true );
if ( empty( $aioseo_settings ) ) {
return [];
}
// We specifically want the setttings of the tab we're working with, eg. postTypes, taxonomies, etc.
$settings_values = $aioseo_settings['searchAppearance'][ $this->settings_tab ];
if ( ! \is_array( $settings_values ) ) {
return [];
}
$flattened_settings = $this->import_helper->flatten_settings( $settings_values );
return $this->get_unimported_chunk( $flattened_settings, $limit );
}
/**
* Retrieves (a chunk of, if limit is applied) the unimported AIOSEO settings.
* To apply a chunk, we manipulate the cursor to the keys of the AIOSEO settings.
*
* @param array $importable_data All of the available AIOSEO settings.
* @param int $limit The maximum number of unimported objects to be returned.
*
* @return array The (chunk of, if limit is applied)) unimported AIOSEO settings.
*/
protected function get_unimported_chunk( $importable_data, $limit ) {
\ksort( $importable_data );
$cursor_id = $this->get_cursor_id();
$cursor = $this->import_cursor->get_cursor( $cursor_id, '' );
/**
* Filter 'wpseo_aioseo_<identifier>_import_cursor' - Allow filtering the value of the aioseo settings import cursor.
*
* @api int The value of the aioseo posttype default settings import cursor.
*/
$cursor = \apply_filters( 'wpseo_aioseo_' . $this->get_type() . '_import_cursor', $cursor );
if ( $cursor === '' ) {
return \array_slice( $importable_data, 0, $limit, true );
}
// Let's find the position of the cursor in the alphabetically sorted importable data, so we can return only the unimported data.
$keys = \array_flip( \array_keys( $importable_data ) );
// If the stored cursor now no longer exists in the data, we have no choice but to start over.
$position = ( isset( $keys[ $cursor ] ) ) ? ( $keys[ $cursor ] + 1 ) : 0;
return \array_slice( $importable_data, $position, $limit, true );
}
/**
* Returns the number of objects that will be imported in a single importing pass.
*
* @return int The limit.
*/
public function get_limit() {
/**
* Filter 'wpseo_aioseo_<identifier>_indexation_limit' - Allow filtering the number of settings imported during each importing pass.
*
* @api int The maximum number of posts indexed.
*/
$limit = \apply_filters( 'wpseo_aioseo_' . $this->get_type() . '_indexation_limit', 25 );
if ( ! \is_int( $limit ) || $limit < 1 ) {
$limit = 25;
}
return $limit;
}
/**
* Maps/imports AIOSEO settings into the respective Yoast settings.
*
* @param string|array $setting_value The value of the AIOSEO setting at hand.
* @param string $setting The setting at hand, eg. post or movie-category, separator etc.
*
* @return void
*/
protected function map( $setting_value, $setting ) {
$aioseo_options_to_yoast_map = $this->aioseo_options_to_yoast_map;
if ( isset( $aioseo_options_to_yoast_map[ $setting ] ) ) {
$this->import_single_setting( $setting, $setting_value, $aioseo_options_to_yoast_map[ $setting ] );
}
}
/**
* Imports a single setting in the db after transforming it to adhere to Yoast conventions.
*
* @param string $setting The name of the setting.
* @param string $setting_value The values of the setting.
* @param array $setting_mapping The mapping of the setting to Yoast formats.
*
* @return void
*/
protected function import_single_setting( $setting, $setting_value, $setting_mapping ) {
$yoast_key = $setting_mapping['yoast_name'];
// Check if we're supposed to save the setting.
if ( $this->options->get_default( 'wpseo_titles', $yoast_key ) !== null ) {
// Then, do any needed data transfomation before actually saving the incoming data.
$transformed_data = \call_user_func( [ $this, $setting_mapping['transform_method'] ], $setting_value, $setting_mapping );
$this->options->set( $yoast_key, $transformed_data );
}
}
/**
* Minimally transforms boolean data to be imported.
*
* @param bool $meta_data The boolean meta data to be imported.
*
* @return bool The transformed boolean meta data.
*/
public function simple_boolean_import( $meta_data ) {
return $meta_data;
}
/**
* Imports the noindex setting, taking into consideration whether they defer to global defaults.
*
* @param bool $noindex The noindex of the type, without taking into consideration whether the type defers to global defaults.
* @param array $mapping The mapping of the setting we're working with.
*
* @return bool The noindex setting.
*/
public function import_noindex( $noindex, $mapping ) {
return $this->robots_transformer->transform_robot_setting( 'noindex', $noindex, $mapping );
}
/**
* Returns a setting map of the robot setting for one subset of post types/taxonomies/archives.
* For custom archives, it returns an empty array because AIOSEO excludes some custom archives from this option structure, eg. WooCommerce's products and we don't want to raise a false alarm.
*
* @return array The setting map of the robot setting for one subset of post types/taxonomies/archives or an empty array.
*/
public function pluck_robot_setting_from_mapping() {
return [];
}
}

View File

@ -0,0 +1,176 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Given it's a very specific case.
namespace Yoast\WP\SEO\Actions\Importing\Aioseo;
use wpdb;
use Yoast\WP\SEO\Actions\Importing\Abstract_Aioseo_Importing_Action;
use Yoast\WP\SEO\Helpers\Options_Helper;
/**
* Importing action for cleaning up AIOSEO data.
*/
class Aioseo_Cleanup_Action extends Abstract_Aioseo_Importing_Action {
/**
* The plugin of the action.
*/
const PLUGIN = 'aioseo';
/**
* The type of the action.
*/
const TYPE = 'cleanup';
/**
* The AIOSEO meta_keys to be cleaned up.
*
* @var array
*/
protected $aioseo_postmeta_keys = [
'_aioseo_title',
'_aioseo_description',
'_aioseo_og_title',
'_aioseo_og_description',
'_aioseo_twitter_title',
'_aioseo_twitter_description',
];
/**
* The WordPress database instance.
*
* @var wpdb
*/
protected $wpdb;
/**
* Class constructor.
*
* @param wpdb $wpdb The WordPress database instance.
* @param Options_Helper $options The options helper.
*/
public function __construct(
wpdb $wpdb,
Options_Helper $options
) {
$this->wpdb = $wpdb;
$this->options = $options;
}
/**
* Retrieves the postmeta along with the db prefix.
*
* @return string The postmeta table name along with the db prefix.
*/
protected function get_postmeta_table() {
return $this->wpdb->prefix . 'postmeta';
}
/**
* Just checks if the cleanup has been completed in the past.
*
* @return int The total number of unimported objects.
*/
public function get_total_unindexed() {
if ( ! $this->aioseo_helper->aioseo_exists() ) {
return 0;
}
return ( ! $this->get_completed() ) ? 1 : 0;
}
/**
* Just checks if the cleanup has been completed in the past.
*
* @param int $limit The maximum number of unimported objects to be returned.
*
* @return int|false The limited number of unindexed posts. False if the query fails.
*/
public function get_limited_unindexed_count( $limit ) {
if ( ! $this->aioseo_helper->aioseo_exists() ) {
return 0;
}
return ( ! $this->get_completed() ) ? 1 : 0;
}
/**
* Cleans up AIOSEO data.
*
* @return Indexable[]|false An array of created indexables or false if aioseo data was not found.
*/
public function index() {
if ( $this->get_completed() ) {
return [];
}
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- Reason: There is no unescaped user input.
$meta_data = $this->wpdb->query( $this->cleanup_postmeta_query() );
$aioseo_table_truncate_done = $this->wpdb->query( $this->truncate_query() );
// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
if ( $meta_data === false && $aioseo_table_truncate_done === false ) {
return false;
}
$this->set_completed( true );
return [
'metadata_cleanup' => $meta_data,
'indexables_cleanup' => $aioseo_table_truncate_done,
];
}
/**
* Creates a DELETE query string for deleting AIOSEO postmeta data.
*
* @return string The query to use for importing or counting the number of items to import.
*/
public function cleanup_postmeta_query() {
$table = $this->get_postmeta_table();
$meta_keys_to_delete = $this->aioseo_postmeta_keys;
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Reason: There is no unescaped user input.
return $this->wpdb->prepare(
"DELETE FROM {$table} WHERE meta_key IN (" . \implode( ', ', \array_fill( 0, \count( $meta_keys_to_delete ), '%s' ) ) . ')',
$meta_keys_to_delete
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
/**
* Creates a TRUNCATE query string for emptying the AIOSEO indexable table, if it exists.
*
* @return string The query to use for importing or counting the number of items to import.
*/
public function truncate_query() {
if ( ! $this->aioseo_helper->aioseo_exists() ) {
// If the table doesn't exist, we need a string that will amount to a quick query that doesn't return false when ran.
return 'SELECT 1';
}
$table = $this->aioseo_helper->get_table();
return "TRUNCATE TABLE {$table}";
}
/**
* Used nowhere. Exists to comply with the interface.
*
* @return int The limit.
*/
public function get_limit() {
/**
* Filter 'wpseo_aioseo_cleanup_limit' - Allow filtering the number of posts indexed during each indexing pass.
*
* @api int The maximum number of posts cleaned up.
*/
$limit = \apply_filters( 'wpseo_aioseo_cleanup_limit', 25 );
if ( ! \is_int( $limit ) || $limit < 1 ) {
$limit = 25;
}
return $limit;
}
}

View File

@ -0,0 +1,111 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Given it's a very specific case.
namespace Yoast\WP\SEO\Actions\Importing\Aioseo;
use Yoast\WP\SEO\Helpers\Import_Cursor_Helper;
use Yoast\WP\SEO\Helpers\Options_Helper;
use Yoast\WP\SEO\Helpers\Post_Type_Helper;
use Yoast\WP\SEO\Helpers\Sanitization_Helper;
use Yoast\WP\SEO\Services\Importing\Aioseo\Aioseo_Replacevar_Service;
use Yoast\WP\SEO\Services\Importing\Aioseo\Aioseo_Robots_Provider_Service;
use Yoast\WP\SEO\Services\Importing\Aioseo\Aioseo_Robots_Transformer_Service;
/**
* Importing action for AIOSEO custom archive settings data.
*
* @phpcs:disable Yoast.NamingConventions.ObjectNameDepth.MaxExceeded
*/
class Aioseo_Custom_Archive_Settings_Importing_Action extends Abstract_Aioseo_Settings_Importing_Action {
/**
* The plugin of the action.
*/
const PLUGIN = 'aioseo';
/**
* The type of the action.
*/
const TYPE = 'custom_archive_settings';
/**
* The option_name of the AIOSEO option that contains the settings.
*/
const SOURCE_OPTION_NAME = 'aioseo_options_dynamic';
/**
* The map of aioseo_options to yoast settings.
*
* @var array
*/
protected $aioseo_options_to_yoast_map = [];
/**
* The tab of the aioseo settings we're working with.
*
* @var string
*/
protected $settings_tab = 'archives';
/**
* The post type helper.
*
* @var Post_Type_Helper
*/
protected $post_type;
/**
* Aioseo_Custom_Archive_Settings_Importing_Action constructor.
*
* @param Import_Cursor_Helper $import_cursor The import cursor helper.
* @param Options_Helper $options The options helper.
* @param Sanitization_Helper $sanitization The sanitization helper.
* @param Post_Type_Helper $post_type The post type helper.
* @param Aioseo_Replacevar_Service $replacevar_handler The replacevar handler.
* @param Aioseo_Robots_Provider_Service $robots_provider The robots provider service.
* @param Aioseo_Robots_Transformer_Service $robots_transformer The robots transfomer service.
*/
public function __construct(
Import_Cursor_Helper $import_cursor,
Options_Helper $options,
Sanitization_Helper $sanitization,
Post_Type_Helper $post_type,
Aioseo_Replacevar_Service $replacevar_handler,
Aioseo_Robots_Provider_Service $robots_provider,
Aioseo_Robots_Transformer_Service $robots_transformer
) {
parent::__construct( $import_cursor, $options, $sanitization, $replacevar_handler, $robots_provider, $robots_transformer );
$this->post_type = $post_type;
}
/**
* Builds the mapping that ties AOISEO option keys with Yoast ones and their data transformation method.
*
* @return void
*/
protected function build_mapping() {
$post_type_objects = \get_post_types( [ 'public' => true ], 'objects' );
foreach ( $post_type_objects as $pt ) {
// Use all the custom post types that have archives.
if ( ! $pt->_builtin && $this->post_type->has_archive( $pt ) ) {
$this->aioseo_options_to_yoast_map[ '/' . $pt->name . '/title' ] = [
'yoast_name' => 'title-ptarchive-' . $pt->name,
'transform_method' => 'simple_import',
];
$this->aioseo_options_to_yoast_map[ '/' . $pt->name . '/metaDescription' ] = [
'yoast_name' => 'metadesc-ptarchive-' . $pt->name,
'transform_method' => 'simple_import',
];
$this->aioseo_options_to_yoast_map[ '/' . $pt->name . '/advanced/robotsMeta/noindex' ] = [
'yoast_name' => 'noindex-ptarchive-' . $pt->name,
'transform_method' => 'import_noindex',
'type' => 'archives',
'subtype' => $pt->name,
'option_name' => 'aioseo_options_dynamic',
];
}
}
}
}

View File

@ -0,0 +1,103 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Given it's a very specific case.
namespace Yoast\WP\SEO\Actions\Importing\Aioseo;
/**
* Importing action for AIOSEO default archive settings data.
*
* @phpcs:disable Yoast.NamingConventions.ObjectNameDepth.MaxExceeded
*/
class Aioseo_Default_Archive_Settings_Importing_Action extends Abstract_Aioseo_Settings_Importing_Action {
/**
* The plugin of the action.
*/
const PLUGIN = 'aioseo';
/**
* The type of the action.
*/
const TYPE = 'default_archive_settings';
/**
* The option_name of the AIOSEO option that contains the settings.
*/
const SOURCE_OPTION_NAME = 'aioseo_options';
/**
* The map of aioseo_options to yoast settings.
*
* @var array
*/
protected $aioseo_options_to_yoast_map = [];
/**
* The tab of the aioseo settings we're working with.
*
* @var string
*/
protected $settings_tab = 'archives';
/**
* Builds the mapping that ties AOISEO option keys with Yoast ones and their data transformation method.
*
* @return void
*/
protected function build_mapping() {
$this->aioseo_options_to_yoast_map = [
'/author/title' => [
'yoast_name' => 'title-author-wpseo',
'transform_method' => 'simple_import',
],
'/author/metaDescription' => [
'yoast_name' => 'metadesc-author-wpseo',
'transform_method' => 'simple_import',
],
'/date/title' => [
'yoast_name' => 'title-archive-wpseo',
'transform_method' => 'simple_import',
],
'/date/metaDescription' => [
'yoast_name' => 'metadesc-archive-wpseo',
'transform_method' => 'simple_import',
],
'/search/title' => [
'yoast_name' => 'title-search-wpseo',
'transform_method' => 'simple_import',
],
'/author/advanced/robotsMeta/noindex' => [
'yoast_name' => 'noindex-author-wpseo',
'transform_method' => 'import_noindex',
'type' => 'archives',
'subtype' => 'author',
'option_name' => 'aioseo_options',
],
'/date/advanced/robotsMeta/noindex' => [
'yoast_name' => 'noindex-archive-wpseo',
'transform_method' => 'import_noindex',
'type' => 'archives',
'subtype' => 'date',
'option_name' => 'aioseo_options',
],
];
}
/**
* Returns a setting map of the robot setting for author archives.
*
* @return array The setting map of the robot setting for author archives.
*/
public function pluck_robot_setting_from_mapping() {
$this->build_mapping();
foreach ( $this->aioseo_options_to_yoast_map as $setting ) {
// Return the first archive setting map.
if ( $setting['transform_method'] === 'import_noindex' && isset( $setting['subtype'] ) && $setting['subtype'] === 'author' ) {
return $setting;
}
}
return [];
}
}

View File

@ -0,0 +1,213 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Given it's a very specific case.
namespace Yoast\WP\SEO\Actions\Importing\Aioseo;
use Yoast\WP\SEO\Helpers\Image_Helper;
use Yoast\WP\SEO\Helpers\Import_Cursor_Helper;
use Yoast\WP\SEO\Helpers\Options_Helper;
use Yoast\WP\SEO\Helpers\Sanitization_Helper;
use Yoast\WP\SEO\Services\Importing\Aioseo\Aioseo_Replacevar_Service;
use Yoast\WP\SEO\Services\Importing\Aioseo\Aioseo_Robots_Provider_Service;
use Yoast\WP\SEO\Services\Importing\Aioseo\Aioseo_Robots_Transformer_Service;
/**
* Importing action for AIOSEO general settings.
*/
class Aioseo_General_Settings_Importing_Action extends Abstract_Aioseo_Settings_Importing_Action {
/**
* The plugin of the action.
*/
const PLUGIN = 'aioseo';
/**
* The type of the action.
*/
const TYPE = 'general_settings';
/**
* The option_name of the AIOSEO option that contains the settings.
*/
const SOURCE_OPTION_NAME = 'aioseo_options';
/**
* The map of aioseo_options to yoast settings.
*
* @var array
*/
protected $aioseo_options_to_yoast_map = [];
/**
* The tab of the aioseo settings we're working with.
*
* @var string
*/
protected $settings_tab = 'global';
/**
* The image helper.
*
* @var Image_Helper
*/
protected $image;
/**
* Aioseo_General_Settings_Importing_Action constructor.
*
* @param Import_Cursor_Helper $import_cursor The import cursor helper.
* @param Options_Helper $options The options helper.
* @param Sanitization_Helper $sanitization The sanitization helper.
* @param Image_Helper $image The image helper.
* @param Aioseo_Replacevar_Service $replacevar_handler The replacevar handler.
* @param Aioseo_Robots_Provider_Service $robots_provider The robots provider service.
* @param Aioseo_Robots_Transformer_Service $robots_transformer The robots transfomer service.
*/
public function __construct(
Import_Cursor_Helper $import_cursor,
Options_Helper $options,
Sanitization_Helper $sanitization,
Image_Helper $image,
Aioseo_Replacevar_Service $replacevar_handler,
Aioseo_Robots_Provider_Service $robots_provider,
Aioseo_Robots_Transformer_Service $robots_transformer
) {
parent::__construct( $import_cursor, $options, $sanitization, $replacevar_handler, $robots_provider, $robots_transformer );
$this->image = $image;
}
/**
* Builds the mapping that ties AOISEO option keys with Yoast ones and their data transformation method.
*
* @return void
*/
protected function build_mapping() {
$this->aioseo_options_to_yoast_map = [
'/separator' => [
'yoast_name' => 'separator',
'transform_method' => 'transform_separator',
],
'/siteTitle' => [
'yoast_name' => 'title-home-wpseo',
'transform_method' => 'simple_import',
],
'/metaDescription' => [
'yoast_name' => 'metadesc-home-wpseo',
'transform_method' => 'simple_import',
],
'/schema/siteRepresents' => [
'yoast_name' => 'company_or_person',
'transform_method' => 'transform_site_represents',
],
'/schema/person' => [
'yoast_name' => 'company_or_person_user_id',
'transform_method' => 'simple_import',
],
'/schema/organizationName' => [
'yoast_name' => 'company_name',
'transform_method' => 'simple_import',
],
'/schema/organizationLogo' => [
'yoast_name' => 'company_logo',
'transform_method' => 'import_company_logo',
],
'/schema/personLogo' => [
'yoast_name' => 'person_logo',
'transform_method' => 'import_person_logo',
],
];
}
/**
* Imports the organization logo while also accounting for the id of the log to be saved in the separate Yoast option.
*
* @param string $logo_url The company logo url coming from AIOSEO settings.
*
* @return string The transformed company logo url.
*/
public function import_company_logo( $logo_url ) {
$logo_id = $this->image->get_attachment_by_url( $logo_url );
$this->options->set( 'company_logo_id', $logo_id );
$this->options->set( 'company_logo_meta', false );
$logo_meta = $this->image->get_attachment_meta_from_settings( 'company_logo' );
$this->options->set( 'company_logo_meta', $logo_meta );
return $this->url_import( $logo_url );
}
/**
* Imports the person logo while also accounting for the id of the log to be saved in the separate Yoast option.
*
* @param string $logo_url The person logo url coming from AIOSEO settings.
*
* @return string The transformed person logo url.
*/
public function import_person_logo( $logo_url ) {
$logo_id = $this->image->get_attachment_by_url( $logo_url );
$this->options->set( 'person_logo_id', $logo_id );
$this->options->set( 'person_logo_meta', false );
$logo_meta = $this->image->get_attachment_meta_from_settings( 'person_logo' );
$this->options->set( 'person_logo_meta', $logo_meta );
return $this->url_import( $logo_url );
}
/**
* Transforms the site represents setting.
*
* @param string $site_represents The site represents setting.
*
* @return string The transformed site represents setting.
*/
public function transform_site_represents( $site_represents ) {
switch ( $site_represents ) {
case 'person':
return 'person';
case 'organization':
default:
return 'company';
}
}
/**
* Transforms the separator setting.
*
* @param string $separator The separator setting.
*
* @return string The transformed separator.
*/
public function transform_separator( $separator ) {
switch ( $separator ) {
case '&#45;':
return 'sc-dash';
case '&ndash;':
return 'sc-ndash';
case '&mdash;':
return 'sc-mdash';
case '&raquo;':
return 'sc-raquo';
case '&laquo;':
return 'sc-laquo';
case '&gt;':
return 'sc-gt';
case '&bull;':
return 'sc-bull';
case '&#124;':
return 'sc-pipe';
default:
return 'sc-dash';
}
}
}

View File

@ -0,0 +1,623 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Given it's a very specific case.
namespace Yoast\WP\SEO\Actions\Importing\Aioseo;
use wpdb;
use Yoast\WP\SEO\Actions\Importing\Abstract_Aioseo_Importing_Action;
use Yoast\WP\SEO\Helpers\Image_Helper;
use Yoast\WP\SEO\Helpers\Import_Cursor_Helper;
use Yoast\WP\SEO\Helpers\Indexable_Helper;
use Yoast\WP\SEO\Helpers\Indexable_To_Postmeta_Helper;
use Yoast\WP\SEO\Helpers\Options_Helper;
use Yoast\WP\SEO\Helpers\Sanitization_Helper;
use Yoast\WP\SEO\Models\Indexable;
use Yoast\WP\SEO\Repositories\Indexable_Repository;
use Yoast\WP\SEO\Services\Importing\Aioseo\Aioseo_Replacevar_Service;
use Yoast\WP\SEO\Services\Importing\Aioseo\Aioseo_Robots_Provider_Service;
use Yoast\WP\SEO\Services\Importing\Aioseo\Aioseo_Robots_Transformer_Service;
use Yoast\WP\SEO\Services\Importing\Aioseo\Aioseo_Social_Images_Provider_Service;
/**
* Importing action for AIOSEO post data.
*/
class Aioseo_Posts_Importing_Action extends Abstract_Aioseo_Importing_Action {
/**
* The plugin of the action.
*/
const PLUGIN = 'aioseo';
/**
* The type of the action.
*/
const TYPE = 'posts';
/**
* The map of aioseo to yoast meta.
*
* @var array
*/
protected $aioseo_to_yoast_map = [
'title' => [
'yoast_name' => 'title',
'transform_method' => 'simple_import_post',
],
'description' => [
'yoast_name' => 'description',
'transform_method' => 'simple_import_post',
],
'og_title' => [
'yoast_name' => 'open_graph_title',
'transform_method' => 'simple_import_post',
],
'og_description' => [
'yoast_name' => 'open_graph_description',
'transform_method' => 'simple_import_post',
],
'twitter_title' => [
'yoast_name' => 'twitter_title',
'transform_method' => 'simple_import_post',
'twitter_import' => true,
],
'twitter_description' => [
'yoast_name' => 'twitter_description',
'transform_method' => 'simple_import_post',
'twitter_import' => true,
],
'canonical_url' => [
'yoast_name' => 'canonical',
'transform_method' => 'url_import_post',
],
'keyphrases' => [
'yoast_name' => 'primary_focus_keyword',
'transform_method' => 'keyphrase_import',
],
'og_image_url' => [
'yoast_name' => 'open_graph_image',
'social_image_import' => true,
'social_setting_prefix_aioseo' => 'og_',
'social_setting_prefix_yoast' => 'open_graph_',
'transform_method' => 'social_image_url_import',
],
'twitter_image_url' => [
'yoast_name' => 'twitter_image',
'social_image_import' => true,
'social_setting_prefix_aioseo' => 'twitter_',
'social_setting_prefix_yoast' => 'twitter_',
'transform_method' => 'social_image_url_import',
],
'robots_noindex' => [
'yoast_name' => 'is_robots_noindex',
'transform_method' => 'post_robots_noindex_import',
'robots_import' => true,
],
'robots_nofollow' => [
'yoast_name' => 'is_robots_nofollow',
'transform_method' => 'post_general_robots_import',
'robots_import' => true,
'robot_type' => 'nofollow',
],
'robots_noarchive' => [
'yoast_name' => 'is_robots_noarchive',
'transform_method' => 'post_general_robots_import',
'robots_import' => true,
'robot_type' => 'noarchive',
],
'robots_nosnippet' => [
'yoast_name' => 'is_robots_nosnippet',
'transform_method' => 'post_general_robots_import',
'robots_import' => true,
'robot_type' => 'nosnippet',
],
'robots_noimageindex' => [
'yoast_name' => 'is_robots_noimageindex',
'transform_method' => 'post_general_robots_import',
'robots_import' => true,
'robot_type' => 'noimageindex',
],
];
/**
* Represents the indexables repository.
*
* @var Indexable_Repository
*/
protected $indexable_repository;
/**
* The WordPress database instance.
*
* @var wpdb
*/
protected $wpdb;
/**
* The image helper.
*
* @var Image_Helper
*/
protected $image;
/**
* The indexable_to_postmeta helper.
*
* @var Indexable_To_Postmeta_Helper
*/
protected $indexable_to_postmeta;
/**
* The indexable helper.
*
* @var Indexable_Helper
*/
protected $indexable_helper;
/**
* The social images provider service.
*
* @var Aioseo_Social_Images_Provider_Service
*/
protected $social_images_provider;
/**
* Class constructor.
*
* @param Indexable_Repository $indexable_repository The indexables repository.
* @param wpdb $wpdb The WordPress database instance.
* @param Import_Cursor_Helper $import_cursor The import cursor helper.
* @param Indexable_Helper $indexable_helper The indexable helper.
* @param Indexable_To_Postmeta_Helper $indexable_to_postmeta The indexable_to_postmeta helper.
* @param Options_Helper $options The options helper.
* @param Image_Helper $image The image helper.
* @param Sanitization_Helper $sanitization The sanitization helper.
* @param Aioseo_Replacevar_Service $replacevar_handler The replacevar handler.
* @param Aioseo_Robots_Provider_Service $robots_provider The robots provider service.
* @param Aioseo_Robots_Transformer_Service $robots_transformer The robots transfomer service.
* @param Aioseo_Social_Images_Provider_Service $social_images_provider The social images provider service.
*/
public function __construct(
Indexable_Repository $indexable_repository,
wpdb $wpdb,
Import_Cursor_Helper $import_cursor,
Indexable_Helper $indexable_helper,
Indexable_To_Postmeta_Helper $indexable_to_postmeta,
Options_Helper $options,
Image_Helper $image,
Sanitization_Helper $sanitization,
Aioseo_Replacevar_Service $replacevar_handler,
Aioseo_Robots_Provider_Service $robots_provider,
Aioseo_Robots_Transformer_Service $robots_transformer,
Aioseo_Social_Images_Provider_Service $social_images_provider ) {
parent::__construct( $import_cursor, $options, $sanitization, $replacevar_handler, $robots_provider, $robots_transformer );
$this->indexable_repository = $indexable_repository;
$this->wpdb = $wpdb;
$this->image = $image;
$this->indexable_helper = $indexable_helper;
$this->indexable_to_postmeta = $indexable_to_postmeta;
$this->social_images_provider = $social_images_provider;
}
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- Reason: They are already prepared.
/**
* Returns the total number of unimported objects.
*
* @return int The total number of unimported objects.
*/
public function get_total_unindexed() {
if ( ! $this->aioseo_helper->aioseo_exists() ) {
return 0;
}
$limit = false;
$just_detect = true;
$indexables_to_create = $this->wpdb->get_col( $this->query( $limit, $just_detect ) );
$number_of_indexables_to_create = \count( $indexables_to_create );
$completed = $number_of_indexables_to_create === 0;
$this->set_completed( $completed );
return $number_of_indexables_to_create;
}
/**
* Returns the limited number of unimported objects.
*
* @param int $limit The maximum number of unimported objects to be returned.
*
* @return int|false The limited number of unindexed posts. False if the query fails.
*/
public function get_limited_unindexed_count( $limit ) {
if ( ! $this->aioseo_helper->aioseo_exists() ) {
return 0;
}
$just_detect = true;
$indexables_to_create = $this->wpdb->get_col( $this->query( $limit, $just_detect ) );
$number_of_indexables_to_create = \count( $indexables_to_create );
$completed = $number_of_indexables_to_create === 0;
$this->set_completed( $completed );
return $number_of_indexables_to_create;
}
/**
* Imports AIOSEO meta data and creates the respective Yoast indexables and postmeta.
*
* @return Indexable[]|false An array of created indexables or false if aioseo data was not found.
*/
public function index() {
if ( ! $this->aioseo_helper->aioseo_exists() ) {
return false;
}
$limit = $this->get_limit();
$aioseo_indexables = $this->wpdb->get_results( $this->query( $limit ), \ARRAY_A );
$created_indexables = [];
$completed = \count( $aioseo_indexables ) === 0;
$this->set_completed( $completed );
// Let's build the list of fields to check their defaults, to identify whether we're gonna import AIOSEO data in the indexable or not.
$check_defaults_fields = [];
foreach ( $this->aioseo_to_yoast_map as $yoast_mapping ) {
// We don't want to check all the imported fields.
if ( ! \in_array( $yoast_mapping['yoast_name'], [ 'open_graph_image', 'twitter_image' ], true ) ) {
$check_defaults_fields[] = $yoast_mapping['yoast_name'];
}
}
$last_indexed_aioseo_id = 0;
foreach ( $aioseo_indexables as $aioseo_indexable ) {
$last_indexed_aioseo_id = $aioseo_indexable['id'];
$indexable = $this->indexable_repository->find_by_id_and_type( $aioseo_indexable['post_id'], 'post' );
// Let's ensure that the current post id represents something that we want to index (eg. *not* shop_order).
if ( ! \is_a( $indexable, 'Yoast\WP\SEO\Models\Indexable' ) ) {
continue;
}
if ( $this->indexable_helper->check_if_default_indexable( $indexable, $check_defaults_fields ) ) {
$indexable = $this->map( $indexable, $aioseo_indexable );
$indexable->save();
// To ensure that indexables can be rebuild after a reset, we have to store the data in the postmeta table too.
$this->indexable_to_postmeta->map_to_postmeta( $indexable );
}
$last_indexed_aioseo_id = $aioseo_indexable['id'];
$created_indexables[] = $indexable;
}
$cursor_id = $this->get_cursor_id();
$this->import_cursor->set_cursor( $cursor_id, $last_indexed_aioseo_id );
return $created_indexables;
}
// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
/**
* Maps AIOSEO meta data to Yoast meta data.
*
* @param Indexable $indexable The Yoast indexable.
* @param array $aioseo_indexable The AIOSEO indexable.
*
* @return Indexable The created indexables.
*/
public function map( $indexable, $aioseo_indexable ) {
foreach ( $this->aioseo_to_yoast_map as $aioseo_key => $yoast_mapping ) {
// For robots import.
if ( isset( $yoast_mapping['robots_import'] ) && $yoast_mapping['robots_import'] ) {
$yoast_mapping['subtype'] = $indexable->object_sub_type;
$indexable->{$yoast_mapping['yoast_name']} = $this->transform_import_data( $yoast_mapping['transform_method'], $aioseo_indexable, $aioseo_key, $yoast_mapping, $indexable );
continue;
}
// For social images, like open graph and twitter image.
if ( isset( $yoast_mapping['social_image_import'] ) && $yoast_mapping['social_image_import'] ) {
$image_url = $this->transform_import_data( $yoast_mapping['transform_method'], $aioseo_indexable, $aioseo_key, $yoast_mapping, $indexable );
// Update the indexable's social image only where there's actually a url to import, so as not to lose the social images that we came up with when we originally built the indexable.
if ( ! empty( $image_url ) ) {
$indexable->{$yoast_mapping['yoast_name']} = $image_url;
$image_source_key = $yoast_mapping['social_setting_prefix_yoast'] . 'image_source';
$indexable->$image_source_key = 'imported';
$image_id_key = $yoast_mapping['social_setting_prefix_yoast'] . 'image_id';
$indexable->$image_id_key = $this->image->get_attachment_by_url( $image_url );
if ( $yoast_mapping['yoast_name'] === 'open_graph_image' ) {
$indexable->open_graph_image_meta = null;
}
}
continue;
}
// For twitter import, take the respective open graph data if the appropriate setting is enabled.
if ( isset( $yoast_mapping['twitter_import'] ) && $yoast_mapping['twitter_import'] && $aioseo_indexable['twitter_use_og'] ) {
$aioseo_indexable['twitter_title'] = $aioseo_indexable['og_title'];
$aioseo_indexable['twitter_description'] = $aioseo_indexable['og_description'];
}
if ( ! empty( $aioseo_indexable[ $aioseo_key ] ) ) {
$indexable->{$yoast_mapping['yoast_name']} = $this->transform_import_data( $yoast_mapping['transform_method'], $aioseo_indexable, $aioseo_key, $yoast_mapping, $indexable );
}
}
return $indexable;
}
/**
* Transforms the data to be imported.
*
* @param string $transform_method The method that is going to be used for transforming the data.
* @param array $aioseo_indexable The data of the AIOSEO indexable data that is being imported.
* @param string $aioseo_key The name of the specific set of data that is going to be transformed.
* @param array $yoast_mapping Extra details for the import of the specific data that is going to be transformed.
* @param Indexable $indexable The Yoast indexable that we are going to import the transformed data into.
*
* @return string|bool|null The transformed data to be imported.
*/
protected function transform_import_data( $transform_method, $aioseo_indexable, $aioseo_key, $yoast_mapping, $indexable ) {
return \call_user_func( [ $this, $transform_method ], $aioseo_indexable, $aioseo_key, $yoast_mapping, $indexable );
}
/**
* Returns the number of objects that will be imported in a single importing pass.
*
* @return int The limit.
*/
public function get_limit() {
/**
* Filter 'wpseo_aioseo_post_indexation_limit' - Allow filtering the number of posts indexed during each indexing pass.
*
* @api int The maximum number of posts indexed.
*/
$limit = \apply_filters( 'wpseo_aioseo_post_indexation_limit', 25 );
if ( ! \is_int( $limit ) || $limit < 1 ) {
$limit = 25;
}
return $limit;
}
/**
* Populates the needed data array based on which columns we use from the AIOSEO indexable table.
*
* @return array The needed data array that contains all the needed columns.
*/
public function get_needed_data() {
$needed_data = \array_keys( $this->aioseo_to_yoast_map );
\array_push( $needed_data, 'id', 'post_id', 'robots_default', 'og_image_custom_url', 'og_image_type', 'twitter_image_custom_url', 'twitter_image_type', 'twitter_use_og' );
return $needed_data;
}
/**
* Populates the needed robot data array to be used in validating against its structure.
*
* @return array The needed data array that contains all the needed columns.
*/
public function get_needed_robot_data() {
$needed_robot_data = [];
foreach ( $this->aioseo_to_yoast_map as $yoast_mapping ) {
if ( isset( $yoast_mapping['robot_type'] ) ) {
$needed_robot_data[] = $yoast_mapping['robot_type'];
}
}
return $needed_robot_data;
}
/**
* Creates a query for gathering AiOSEO data from the database.
*
* @param int $limit The maximum number of unimported objects to be returned.
* @param bool $just_detect Whether we want to just detect if there are unimported objects. If false, we want to actually import them too.
*
* @return string The query to use for importing or counting the number of items to import.
*/
public function query( $limit = false, $just_detect = false ) {
$table = $this->aioseo_helper->get_table();
$select_statement = 'id';
if ( ! $just_detect ) {
// If we want to import too, we need the actual needed data from AIOSEO indexables.
$needed_data = $this->get_needed_data();
$select_statement = \implode( ', ', $needed_data );
}
$cursor_id = $this->get_cursor_id();
$cursor = $this->import_cursor->get_cursor( $cursor_id );
/**
* Filter 'wpseo_aioseo_post_cursor' - Allow filtering the value of the aioseo post import cursor.
*
* @api int The value of the aioseo post import cursor.
*/
$cursor = \apply_filters( 'wpseo_aioseo_post_import_cursor', $cursor );
$replacements = [ $cursor ];
$limit_statement = '';
if ( ! empty( $limit ) ) {
$replacements[] = $limit;
$limit_statement = ' LIMIT %d';
}
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Reason: There is no unescaped user input.
return $this->wpdb->prepare(
"SELECT {$select_statement} FROM {$table} WHERE id > %d ORDER BY id{$limit_statement}",
$replacements
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
/**
* Minimally transforms data to be imported.
*
* @param array $aioseo_data All of the AIOSEO data to be imported.
* @param string $aioseo_key The AIOSEO key that contains the setting we're working with.
*
* @return string The transformed meta data.
*/
public function simple_import_post( $aioseo_data, $aioseo_key ) {
return $this->simple_import( $aioseo_data[ $aioseo_key ] );
}
/**
* Transforms URL to be imported.
*
* @param array $aioseo_data All of the AIOSEO data to be imported.
* @param string $aioseo_key The AIOSEO key that contains the setting we're working with.
*
* @return string The transformed URL.
*/
public function url_import_post( $aioseo_data, $aioseo_key ) {
return $this->url_import( $aioseo_data[ $aioseo_key ] );
}
/**
* Plucks the keyphrase to be imported from the AIOSEO array of keyphrase meta data.
*
* @param array $aioseo_data All of the AIOSEO data to be imported.
* @param string $aioseo_key The AIOSEO key that contains the setting we're working with, aka keyphrases.
*
* @return string|null The plucked keyphrase.
*/
public function keyphrase_import( $aioseo_data, $aioseo_key ) {
$meta_data = \json_decode( $aioseo_data[ $aioseo_key ], true );
if ( ! isset( $meta_data['focus']['keyphrase'] ) ) {
return null;
}
return $this->sanitization->sanitize_text_field( $meta_data['focus']['keyphrase'] );
}
/**
* Imports the post's noindex setting.
*
* @param bool $aioseo_robots_settings AIOSEO's set of robot settings for the post.
*
* @return bool|null The value of Yoast's noindex setting for the post.
*/
public function post_robots_noindex_import( $aioseo_robots_settings ) {
// If robot settings defer to default settings, we have null in the is_robots_noindex field.
if ( $aioseo_robots_settings['robots_default'] ) {
return null;
}
return $aioseo_robots_settings['robots_noindex'];
}
/**
* Imports the post's robots setting.
*
* @param bool $aioseo_robots_settings AIOSEO's set of robot settings for the post.
* @param string $aioseo_key The AIOSEO key that contains the robot setting we're working with.
* @param array $mapping The mapping of the setting we're working with.
*
* @return bool|null The value of Yoast's noindex setting for the post.
*/
public function post_general_robots_import( $aioseo_robots_settings, $aioseo_key, $mapping ) {
$mapping = $this->enhance_mapping( $mapping );
if ( $aioseo_robots_settings['robots_default'] ) {
// Let's first get the subtype's setting value and then transform it taking into consideration whether it defers to global defaults.
$subtype_setting = $this->robots_provider->get_subtype_robot_setting( $mapping );
return $this->robots_transformer->transform_robot_setting( $mapping['robot_type'], $subtype_setting, $mapping );
}
return $aioseo_robots_settings[ $aioseo_key ];
}
/**
* Enhances the mapping of the setting we're working with, with type and the option name, so that we can retrieve the settings for the object we're working with.
*
* @param array $mapping The mapping of the setting we're working with.
*
* @return array The enhanced mapping.
*/
public function enhance_mapping( $mapping = [] ) {
$mapping['type'] = 'postTypes';
$mapping['option_name'] = 'aioseo_options_dynamic';
return $mapping;
}
/**
* Imports the og and twitter image url.
*
* @param bool $aioseo_social_image_settings AIOSEO's set of social image settings for the post.
* @param string $aioseo_key The AIOSEO key that contains the robot setting we're working with.
* @param array $mapping The mapping of the setting we're working with.
* @param Indexable $indexable The Yoast indexable we're importing into.
*
* @return bool|null The url of the social image we're importing, null if there's none.
*/
public function social_image_url_import( $aioseo_social_image_settings, $aioseo_key, $mapping, $indexable ) {
if ( $mapping['social_setting_prefix_aioseo'] === 'twitter_' && $aioseo_social_image_settings['twitter_use_og'] ) {
$mapping['social_setting_prefix_aioseo'] = 'og_';
}
$social_setting = \rtrim( $mapping['social_setting_prefix_aioseo'], '_' );
$image_type = $aioseo_social_image_settings[ $mapping['social_setting_prefix_aioseo'] . 'image_type' ];
if ( $image_type === 'default' ) {
$image_type = $this->social_images_provider->get_default_social_image_source( $social_setting );
}
switch ( $image_type ) {
case 'attach':
$image_url = $this->social_images_provider->get_first_attached_image( $indexable->object_id );
break;
case 'auto':
if ( $this->social_images_provider->get_featured_image( $indexable->object_id ) ) {
// If there's a featured image, lets not import it, as our indexable calculation has already set that as active social image. That way we achieve dynamicality.
return null;
}
$image_url = $this->social_images_provider->get_auto_image( $indexable->object_id );
break;
case 'content':
$image_url = $this->social_images_provider->get_first_image_in_content( $indexable->object_id );
break;
case 'custom_image':
$image_url = $aioseo_social_image_settings[ $mapping['social_setting_prefix_aioseo'] . 'image_custom_url' ];
break;
case 'featured':
return null; // Our auto-calculation when the indexable was built/updated has taken care of it, so it's not needed to transfer any data now.
case 'author':
return null;
case 'custom':
return null;
case 'default':
$image_url = $this->social_images_provider->get_default_custom_social_image( $social_setting );
break;
default:
$image_url = $aioseo_social_image_settings[ $mapping['social_setting_prefix_aioseo'] . 'image_url' ];
break;
}
if ( empty( $image_url ) ) {
$image_url = $this->social_images_provider->get_default_custom_social_image( $social_setting );
}
if ( empty( $image_url ) ) {
return null;
}
return $this->sanitization->sanitize_url( $image_url, null );
}
}

View File

@ -0,0 +1,99 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Given it's a very specific case.
namespace Yoast\WP\SEO\Actions\Importing\Aioseo;
/**
* Importing action for AIOSEO posttype defaults settings data.
*
* @phpcs:disable Yoast.NamingConventions.ObjectNameDepth.MaxExceeded
*/
class Aioseo_Posttype_Defaults_Settings_Importing_Action extends Abstract_Aioseo_Settings_Importing_Action {
/**
* The plugin of the action.
*/
const PLUGIN = 'aioseo';
/**
* The type of the action.
*/
const TYPE = 'posttype_default_settings';
/**
* The option_name of the AIOSEO option that contains the settings.
*/
const SOURCE_OPTION_NAME = 'aioseo_options_dynamic';
/**
* The map of aioseo_options to yoast settings.
*
* @var array
*/
protected $aioseo_options_to_yoast_map = [];
/**
* The tab of the aioseo settings we're working with.
*
* @var string
*/
protected $settings_tab = 'postTypes';
/**
* Builds the mapping that ties AOISEO option keys with Yoast ones and their data transformation method.
*
* @return void
*/
protected function build_mapping() {
$post_type_objects = \get_post_types( [ 'public' => true ], 'objects' );
foreach ( $post_type_objects as $pt ) {
// Use all the custom post types that are public.
$this->aioseo_options_to_yoast_map[ '/' . $pt->name . '/title' ] = [
'yoast_name' => 'title-' . $pt->name,
'transform_method' => 'simple_import',
];
$this->aioseo_options_to_yoast_map[ '/' . $pt->name . '/metaDescription' ] = [
'yoast_name' => 'metadesc-' . $pt->name,
'transform_method' => 'simple_import',
];
$this->aioseo_options_to_yoast_map[ '/' . $pt->name . '/advanced/showMetaBox' ] = [
'yoast_name' => 'display-metabox-pt-' . $pt->name,
'transform_method' => 'simple_boolean_import',
];
$this->aioseo_options_to_yoast_map[ '/' . $pt->name . '/advanced/robotsMeta/noindex' ] = [
'yoast_name' => 'noindex-' . $pt->name,
'transform_method' => 'import_noindex',
'type' => 'postTypes',
'subtype' => $pt->name,
'option_name' => 'aioseo_options_dynamic',
];
if ( $pt->name === 'attachment' ) {
$this->aioseo_options_to_yoast_map['/attachment/redirectAttachmentUrls'] = [
'yoast_name' => 'disable-attachment',
'transform_method' => 'import_redirect_attachment',
];
}
}
}
/**
* Transforms the redirect_attachment setting.
*
* @param string $redirect_attachment The redirect_attachment setting.
*
* @return bool The transformed redirect_attachment setting.
*/
public function import_redirect_attachment( $redirect_attachment ) {
switch ( $redirect_attachment ) {
case 'disabled':
return false;
case 'attachment':
case 'attachment_parent':
default:
return true;
}
}
}

View File

@ -0,0 +1,108 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Given it's a very specific case.
namespace Yoast\WP\SEO\Actions\Importing\Aioseo;
/**
* Importing action for AIOSEO taxonomies settings data.
*/
class Aioseo_Taxonomy_Settings_Importing_Action extends Abstract_Aioseo_Settings_Importing_Action {
/**
* The plugin of the action.
*/
const PLUGIN = 'aioseo';
/**
* The type of the action.
*/
const TYPE = 'taxonomy_settings';
/**
* The option_name of the AIOSEO option that contains the settings.
*/
const SOURCE_OPTION_NAME = 'aioseo_options_dynamic';
/**
* The map of aioseo_options to yoast settings.
*
* @var array
*/
protected $aioseo_options_to_yoast_map = [];
/**
* The tab of the aioseo settings we're working with.
*
* @var string
*/
protected $settings_tab = 'taxonomies';
/**
* Additional mapping between AiOSEO replace vars and Yoast replace vars.
*
* @var array
*
* @see https://yoast.com/help/list-available-snippet-variables-yoast-seo/
*/
protected $replace_vars_edited_map = [
'#breadcrumb_404_error_format' => '', // Empty string, as AIOSEO shows nothing for that tag.
'#breadcrumb_archive_post_type_format' => '', // Empty string, as AIOSEO shows nothing for that tag.
'#breadcrumb_archive_post_type_name' => '', // Empty string, as AIOSEO shows nothing for that tag.
'#breadcrumb_author_display_name' => '', // Empty string, as AIOSEO shows nothing for that tag.
'#breadcrumb_author_first_name' => '', // Empty string, as AIOSEO shows nothing for that tag.
'#breadcrumb_blog_page_title' => '', // Empty string, as AIOSEO shows nothing for that tag.
'#breadcrumb_label' => '', // Empty string, as AIOSEO shows nothing for that tag.
'#breadcrumb_link' => '', // Empty string, as AIOSEO shows nothing for that tag.
'#breadcrumb_search_result_format' => '', // Empty string, as AIOSEO shows nothing for that tag.
'#breadcrumb_search_string' => '', // Empty string, as AIOSEO shows nothing for that tag.
'#breadcrumb_separator' => '', // Empty string, as AIOSEO shows nothing for that tag.
'#breadcrumb_taxonomy_title' => '', // Empty string, as AIOSEO shows nothing for that tag.
'#taxonomy_title' => '%%term_title%%',
];
/**
* Builds the mapping that ties AOISEO option keys with Yoast ones and their data transformation method.
*
* @return void
*/
protected function build_mapping() {
$taxonomy_objects = \get_taxonomies( [ 'public' => true ], 'object' );
foreach ( $taxonomy_objects as $tax ) {
// Use all the public taxonomies.
$this->aioseo_options_to_yoast_map[ '/' . $tax->name . '/title' ] = [
'yoast_name' => 'title-tax-' . $tax->name,
'transform_method' => 'simple_import',
];
$this->aioseo_options_to_yoast_map[ '/' . $tax->name . '/metaDescription' ] = [
'yoast_name' => 'metadesc-tax-' . $tax->name,
'transform_method' => 'simple_import',
];
$this->aioseo_options_to_yoast_map[ '/' . $tax->name . '/advanced/robotsMeta/noindex' ] = [
'yoast_name' => 'noindex-tax-' . $tax->name,
'transform_method' => 'import_noindex',
'type' => 'taxonomies',
'subtype' => $tax->name,
'option_name' => 'aioseo_options_dynamic',
];
}
}
/**
* Returns a setting map of the robot setting for post category taxonomies.
*
* @return array The setting map of the robot setting for post category taxonomies.
*/
public function pluck_robot_setting_from_mapping() {
$this->build_mapping();
foreach ( $this->aioseo_options_to_yoast_map as $setting ) {
// Return the first archive setting map.
if ( $setting['transform_method'] === 'import_noindex' && isset( $setting['subtype'] ) && $setting['subtype'] === 'category' ) {
return $setting;
}
}
return [];
}
}

View File

@ -0,0 +1,255 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Given it's a very specific case.
namespace Yoast\WP\SEO\Actions\Importing\Aioseo;
use wpdb;
use Yoast\WP\SEO\Actions\Importing\Abstract_Aioseo_Importing_Action;
use Yoast\WP\SEO\Exceptions\Importing\Aioseo_Validation_Exception;
use Yoast\WP\SEO\Helpers\Options_Helper;
/**
* Importing action for validating AIOSEO data before the import occurs.
*/
class Aioseo_Validate_Data_Action extends Abstract_Aioseo_Importing_Action {
/**
* The plugin of the action.
*/
const PLUGIN = 'aioseo';
/**
* The type of the action.
*/
const TYPE = 'validate_data';
/**
* The WordPress database instance.
*
* @var wpdb
*/
protected $wpdb;
/**
* The Post Importing action.
*
* @var Aioseo_Posts_Importing_Action
*/
protected $post_importing_action;
/**
* The settings importing actions.
*
* @var array
*/
protected $settings_importing_actions;
/**
* Class constructor.
*
* @param wpdb $wpdb The WordPress database instance.
* @param Options_Helper $options The options helper.
* @param Aioseo_Custom_Archive_Settings_Importing_Action $custom_archive_action The Custom Archive Settings importing action.
* @param Aioseo_Default_Archive_Settings_Importing_Action $default_archive_action The Default Archive Settings importing action.
* @param Aioseo_General_Settings_Importing_Action $general_settings_action The General Settings importing action.
* @param Aioseo_Posttype_Defaults_Settings_Importing_Action $posttype_defaults_settings_action The Posttype Defaults Settings importing action.
* @param Aioseo_Taxonomy_Settings_Importing_Action $taxonomy_settings_action The Taxonomy Settings importing action.
* @param Aioseo_Posts_Importing_Action $post_importing_action The Post importing action.
*/
public function __construct(
wpdb $wpdb,
Options_Helper $options,
Aioseo_Custom_Archive_Settings_Importing_Action $custom_archive_action,
Aioseo_Default_Archive_Settings_Importing_Action $default_archive_action,
Aioseo_General_Settings_Importing_Action $general_settings_action,
Aioseo_Posttype_Defaults_Settings_Importing_Action $posttype_defaults_settings_action,
Aioseo_Taxonomy_Settings_Importing_Action $taxonomy_settings_action,
Aioseo_Posts_Importing_Action $post_importing_action
) {
$this->wpdb = $wpdb;
$this->options = $options;
$this->post_importing_action = $post_importing_action;
$this->settings_importing_actions = [
$custom_archive_action,
$default_archive_action,
$general_settings_action,
$posttype_defaults_settings_action,
$taxonomy_settings_action,
];
}
/**
* Just checks if the action has been completed in the past.
*
* @return int 1 if it hasn't been completed in the past, 0 if it has.
*/
public function get_total_unindexed() {
return ( ! $this->get_completed() ) ? 1 : 0;
}
/**
* Just checks if the action has been completed in the past.
*
* @param int $limit The maximum number of unimported objects to be returned. Not used, exists to comply with the interface.
*
* @return int 1 if it hasn't been completed in the past, 0 if it has.
*/
public function get_limited_unindexed_count( $limit ) {
return ( ! $this->get_completed() ) ? 1 : 0;
}
/**
* Validates AIOSEO data.
*
* @return array An array of validated data or false if aioseo data did not pass validation.
*
* @throws Aioseo_Validation_Exception If the validation fails.
*/
public function index() {
if ( $this->get_completed() ) {
return [];
}
$validated_aioseo_table = $this->validate_aioseo_table();
$validated_aioseo_settings = $this->validate_aioseo_settings();
$validated_robot_settings = $this->validate_robot_settings();
if ( $validated_aioseo_table === false || $validated_aioseo_settings === false || $validated_robot_settings === false ) {
throw new Aioseo_Validation_Exception();
}
$this->set_completed( true );
return [
'validated_aioseo_table' => $validated_aioseo_table,
'validated_aioseo_settings' => $validated_aioseo_settings,
'validated_robot_settings' => $validated_robot_settings,
];
}
/**
* Validates the AIOSEO indexable table.
*
* @return bool Whether the AIOSEO table exists and has the structure we expect.
*/
public function validate_aioseo_table() {
if ( ! $this->aioseo_helper->aioseo_exists() ) {
return false;
}
$table = $this->aioseo_helper->get_table();
$needed_data = $this->post_importing_action->get_needed_data();
$aioseo_columns = $this->wpdb->get_col(
"SHOW COLUMNS FROM {$table}", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Reason: There is no unescaped user input.
0
);
return $needed_data === \array_intersect( $needed_data, $aioseo_columns );
}
/**
* Validates the AIOSEO settings from the options table.
*
* @return bool Whether the AIOSEO settings from the options table exist and have the structure we expect.
*/
public function validate_aioseo_settings() {
foreach ( $this->settings_importing_actions as $settings_import_action ) {
$aioseo_settings = \json_decode( \get_option( $settings_import_action->get_source_option_name(), '' ), true );
if ( ! $settings_import_action->isset_settings_tab( $aioseo_settings ) ) {
return false;
}
}
return true;
}
/**
* Validates the AIOSEO robots settings from the options table.
*
* @return bool Whether the AIOSEO robots settings from the options table exist and have the structure we expect.
*/
public function validate_robot_settings() {
if ( $this->validate_post_robot_settings() && $this->validate_default_robot_settings() ) {
return true;
}
return false;
}
/**
* Validates the post AIOSEO robots settings from the options table.
*
* @return bool Whether the post AIOSEO robots settings from the options table exist and have the structure we expect.
*/
public function validate_post_robot_settings() {
$post_robot_mapping = $this->post_importing_action->enhance_mapping();
// We're gonna validate against posttype robot settings only for posts, assuming the robot settings stay the same for other post types.
$post_robot_mapping['subtype'] = 'post';
// Let's get both the aioseo_options and the aioseo_options_dynamic options.
$aioseo_global_settings = $this->aioseo_helper->get_global_option();
$aioseo_posts_settings = \json_decode( \get_option( $post_robot_mapping['option_name'], '' ), true );
$needed_robots_data = $this->post_importing_action->get_needed_robot_data();
\array_push( $needed_robots_data, 'default', 'noindex' );
foreach ( $needed_robots_data as $robot_setting ) {
// Validate against global settings.
if ( ! isset( $aioseo_global_settings['searchAppearance']['advanced']['globalRobotsMeta'][ $robot_setting ] ) ) {
return false;
}
// Validate against posttype settings.
if ( ! isset( $aioseo_posts_settings['searchAppearance'][ $post_robot_mapping['type'] ][ $post_robot_mapping['subtype'] ]['advanced']['robotsMeta'][ $robot_setting ] ) ) {
return false;
}
}
return true;
}
/**
* Validates the default AIOSEO robots settings for search appearance settings from the options table.
*
* @return bool Whether the AIOSEO robots settings for search appearance settings from the options table exist and have the structure we expect.
*/
public function validate_default_robot_settings() {
foreach ( $this->settings_importing_actions as $settings_import_action ) {
$robot_setting_map = $settings_import_action->pluck_robot_setting_from_mapping();
// Some actions return empty robot settings, let's not validate against those.
if ( ! empty( $robot_setting_map ) ) {
$aioseo_settings = \json_decode( \get_option( $robot_setting_map['option_name'], '' ), true );
if ( ! isset( $aioseo_settings['searchAppearance'][ $robot_setting_map['type'] ][ $robot_setting_map['subtype'] ]['advanced']['robotsMeta']['default'] ) ) {
return false;
}
}
}
return true;
}
/**
* Used nowhere. Exists to comply with the interface.
*
* @return int The limit.
*/
public function get_limit() {
/**
* Filter 'wpseo_aioseo_cleanup_limit' - Allow filtering the number of validations during each action pass.
*
* @api int The maximum number of validations.
*/
$limit = \apply_filters( 'wpseo_aioseo_validation_limit', 25 );
if ( ! \is_int( $limit ) || $limit < 1 ) {
$limit = 25;
}
return $limit;
}
}

View File

@ -0,0 +1,141 @@
<?php
namespace Yoast\WP\SEO\Actions\Importing;
use Yoast\WP\SEO\Conditionals\Updated_Importer_Framework_Conditional;
use Yoast\WP\SEO\Config\Conflicting_Plugins;
use Yoast\WP\SEO\Helpers\Import_Cursor_Helper;
use Yoast\WP\SEO\Helpers\Options_Helper;
use Yoast\WP\SEO\Helpers\Sanitization_Helper;
use Yoast\WP\SEO\Services\Importing\Aioseo\Aioseo_Replacevar_Service;
use Yoast\WP\SEO\Services\Importing\Aioseo\Aioseo_Robots_Provider_Service;
use Yoast\WP\SEO\Services\Importing\Aioseo\Aioseo_Robots_Transformer_Service;
use Yoast\WP\SEO\Services\Importing\Conflicting_Plugins_Service;
/**
* Deactivates plug-ins that cause conflicts with Yoast SEO.
*/
class Deactivate_Conflicting_Plugins_Action extends Abstract_Aioseo_Importing_Action {
/**
* The plugin the class deals with.
*
* @var string
*/
const PLUGIN = 'conflicting-plugins';
/**
* The type the class deals with.
*
* @var string
*/
const TYPE = 'deactivation';
/**
* The replacevar handler.
*
* @var Aioseo_Replacevar_Service
*/
protected $replacevar_handler;
/**
* Knows all plugins that might possibly conflict.
*
* @var Conflicting_Plugins_Service
*/
protected $conflicting_plugins;
/**
* The list of conflicting plugins
*
* @var array
*/
protected $detected_plugins;
/**
* Class constructor.
*
* @param Import_Cursor_Helper $import_cursor The import cursor helper.
* @param Options_Helper $options The options helper.
* @param Sanitization_Helper $sanitization The sanitization helper.
* @param Aioseo_Replacevar_Service $replacevar_handler The replacevar handler.
* @param Aioseo_Robots_Provider_Service $robots_provider The robots provider service.
* @param Aioseo_Robots_Transformer_Service $robots_transformer The robots transfomer service.
* @param Conflicting_Plugins_Service $conflicting_plugins_service The Conflicting plugins Service.
*/
public function __construct(
Import_Cursor_Helper $import_cursor,
Options_Helper $options,
Sanitization_Helper $sanitization,
Aioseo_Replacevar_Service $replacevar_handler,
Aioseo_Robots_Provider_Service $robots_provider,
Aioseo_Robots_Transformer_Service $robots_transformer,
Conflicting_Plugins_Service $conflicting_plugins_service
) {
parent::__construct( $import_cursor, $options, $sanitization, $replacevar_handler, $robots_provider, $robots_transformer );
$this->conflicting_plugins = $conflicting_plugins_service;
$this->detected_plugins = [];
}
/**
* Get the total number of conflicting plugins.
*/
public function get_total_unindexed() {
return \count( $this->get_detected_plugins() );
}
/**
* Returns whether the updated importer framework is enabled.
*
* @return bool True if the updated importer framework is enabled.
*/
public function is_enabled() {
$updated_importer_framework_conditional = \YoastSEO()->classes->get( Updated_Importer_Framework_Conditional::class );
return $updated_importer_framework_conditional->is_met();
}
/**
* Deactivate conflicting plugins.
*/
public function index() {
$detected_plugins = $this->get_detected_plugins();
$this->conflicting_plugins->deactivate_conflicting_plugins( $detected_plugins );
// We need to conform to the interface, so we report that no indexables were created.
return [];
}
/**
* {@inheritDoc}
*/
public function get_limit() {
return \count( Conflicting_Plugins::all_plugins() );
}
/**
* Returns the total number of unindexed objects up to a limit.
*
* @param int $limit The maximum.
*
* @return int The total number of unindexed objects.
*/
public function get_limited_unindexed_count( $limit ) {
$count = \count( $this->get_detected_plugins() );
return ( $count <= $limit ) ? $count : $limit;
}
/**
* Returns all detected plugins.
*
* @return array The detected plugins.
*/
protected function get_detected_plugins() {
// The active plugins won't change much. We can reuse the result for the duration of the request.
if ( \count( $this->detected_plugins ) < 1 ) {
$this->detected_plugins = $this->conflicting_plugins->detect_conflicting_plugins();
}
return $this->detected_plugins;
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace Yoast\WP\SEO\Actions\Importing;
use Yoast\WP\SEO\Actions\Indexing\Limited_Indexing_Action_Interface;
interface Importing_Action_Interface extends Importing_Indexation_Action_Interface, Limited_Indexing_Action_Interface {
/**
* Returns the name of the plugin we import from.
*
* @return string The plugin name.
*/
public function get_plugin();
/**
* Returns the type of data we import.
*
* @return string The type of data.
*/
public function get_type();
/**
* Whether or not this action is capable of importing given a specific plugin and type.
*
* @param string|null $plugin The name of the plugin being imported.
* @param string|null $type The component of the plugin being imported.
*
* @return bool True if the action can import the given plugin's data of the given type.
*/
public function is_compatible_with( $plugin = null, $type = null );
}

View File

@ -0,0 +1,34 @@
<?php
namespace Yoast\WP\SEO\Actions\Importing;
/**
* Interface definition of reindexing action for indexables.
*/
interface Importing_Indexation_Action_Interface {
/**
* Returns the total number of unindexed objects.
*
* @return int The total number of unindexed objects.
*/
public function get_total_unindexed();
/**
* Indexes a number of objects.
*
* NOTE: ALWAYS use limits, this method is intended to be called multiple times over several requests.
*
* For indexing that requires JavaScript simply return the objects that should be indexed.
*
* @return array The reindexed objects.
*/
public function index();
/**
* Returns the number of objects that will be indexed in a single indexing pass.
*
* @return int The limit.
*/
public function get_limit();
}

View File

@ -0,0 +1,174 @@
<?php
namespace Yoast\WP\SEO\Actions\Indexables;
use Yoast\WP\SEO\Surfaces\Meta_Surface;
use Yoast\WP\SEO\Surfaces\Values\Meta;
/**
* Get head action for indexables.
*/
class Indexable_Head_Action {
/**
* Caches the output.
*
* @var mixed
*/
protected $cache;
/**
* The meta surface.
*
* @var Meta_Surface
*/
private $meta_surface;
/**
* Indexable_Head_Action constructor.
*
* @param Meta_Surface $meta_surface The meta surface.
*/
public function __construct( Meta_Surface $meta_surface ) {
$this->meta_surface = $meta_surface;
}
/**
* Retrieves the head for a url.
*
* @param string $url The url to get the head for.
*
* @return object Object with head and status properties.
*/
public function for_url( $url ) {
if ( $url === \trailingslashit( \get_home_url() ) ) {
return $this->with_404_fallback( $this->with_cache( 'home_page' ) );
}
return $this->with_404_fallback( $this->with_cache( 'url', $url ) );
}
/**
* Retrieves the head for a post.
*
* @param int $id The id.
*
* @return object Object with head and status properties.
*/
public function for_post( $id ) {
return $this->with_404_fallback( $this->with_cache( 'post', $id ) );
}
/**
* Retrieves the head for a term.
*
* @param int $id The id.
*
* @return object Object with head and status properties.
*/
public function for_term( $id ) {
return $this->with_404_fallback( $this->with_cache( 'term', $id ) );
}
/**
* Retrieves the head for an author.
*
* @param int $id The id.
*
* @return object Object with head and status properties.
*/
public function for_author( $id ) {
return $this->with_404_fallback( $this->with_cache( 'author', $id ) );
}
/**
* Retrieves the head for a post type archive.
*
* @param int $type The id.
*
* @return object Object with head and status properties.
*/
public function for_post_type_archive( $type ) {
return $this->with_404_fallback( $this->with_cache( 'post_type_archive', $type ) );
}
/**
* Retrieves the head for the posts page.
*
* @return object Object with head and status properties.
*/
public function for_posts_page() {
return $this->with_404_fallback( $this->with_cache( 'posts_page' ) );
}
/**
* Retrieves the head for the 404 page. Always sets the status to 404.
*
* @return object Object with head and status properties.
*/
public function for_404() {
$meta = $this->with_cache( '404' );
if ( ! $meta ) {
return (object) [
'html' => '',
'json' => [],
'status' => 404,
];
}
$head = $meta->get_head();
return (object) [
'html' => $head->html,
'json' => $head->json,
'status' => 404,
];
}
/**
* Retrieves the head for a successful page load.
*
* @param object $head The calculated Yoast head.
*
* @return object The presentations and status code 200.
*/
protected function for_200( $head ) {
return (object) [
'html' => $head->html,
'json' => $head->json,
'status' => 200,
];
}
/**
* Returns the head with 404 fallback
*
* @param Meta|false $meta The meta object.
*
* @return object The head response.
*/
protected function with_404_fallback( $meta ) {
if ( $meta === false ) {
return $this->for_404();
}
else {
return $this->for_200( $meta->get_head() );
}
}
/**
* Retrieves a value from the meta surface cached.
*
* @param string $type The type of value to retrieve.
* @param string $argument Optional. The argument for the value.
*
* @return Meta The meta object.
*/
protected function with_cache( $type, $argument = '' ) {
if ( ! isset( $this->cache[ $type ][ $argument ] ) ) {
$this->cache[ $type ][ $argument ] = \call_user_func( [ $this->meta_surface, "for_$type" ], $argument );
}
return $this->cache[ $type ][ $argument ];
}
}

View File

@ -0,0 +1,103 @@
<?php
namespace Yoast\WP\SEO\Actions\Indexing;
/**
* Base class of indexing actions.
*/
abstract class Abstract_Indexing_Action implements Indexation_Action_Interface, Limited_Indexing_Action_Interface {
/**
* The transient name.
*
* This is a trick to force derived classes to define a transient themselves.
*
* @var string
*/
const UNINDEXED_COUNT_TRANSIENT = null;
/**
* The transient cache key for limited counts.
*
* @var string
*/
const UNINDEXED_LIMITED_COUNT_TRANSIENT = self::UNINDEXED_COUNT_TRANSIENT . '_limited';
/**
* Builds a query for selecting the ID's of unindexed posts.
*
* @param bool $limit The maximum number of post IDs to return.
*
* @return string The prepared query string.
*/
abstract protected function get_select_query( $limit );
/**
* Builds a query for counting the number of unindexed posts.
*
* @return string The prepared query string.
*/
abstract protected function get_count_query();
/**
* Returns a limited number of unindexed posts.
*
* @param int $limit Limit the maximum number of unindexed posts that are counted.
*
* @return int The limited number of unindexed posts. 0 if the query fails.
*/
public function get_limited_unindexed_count( $limit ) {
$transient = \get_transient( static::UNINDEXED_LIMITED_COUNT_TRANSIENT );
if ( $transient !== false ) {
return (int) $transient;
}
\set_transient( static::UNINDEXED_LIMITED_COUNT_TRANSIENT, 0, ( \MINUTE_IN_SECONDS * 15 ) );
$query = $this->get_select_query( $limit );
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Function get_count_query returns a prepared query.
$unindexed_object_ids = $this->wpdb->get_col( $query );
$count = (int) \count( $unindexed_object_ids );
\set_transient( static::UNINDEXED_LIMITED_COUNT_TRANSIENT, $count, ( \MINUTE_IN_SECONDS * 15 ) );
return $count;
}
/**
* Returns the total number of unindexed posts.
*
* @return int|false The total number of unindexed posts. False if the query fails.
*/
public function get_total_unindexed() {
$transient = \get_transient( static::UNINDEXED_COUNT_TRANSIENT );
if ( $transient !== false ) {
return (int) $transient;
}
// Store transient before doing the query so multiple requests won't make multiple queries.
// Only store this for 15 minutes to ensure that if the query doesn't complete a wrong count is not kept too long.
\set_transient( static::UNINDEXED_COUNT_TRANSIENT, 0, ( \MINUTE_IN_SECONDS * 15 ) );
$query = $this->get_count_query();
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Function get_count_query returns a prepared query.
$count = $this->wpdb->get_var( $query );
if ( \is_null( $count ) ) {
return false;
}
\set_transient( static::UNINDEXED_COUNT_TRANSIENT, $count, \DAY_IN_SECONDS );
/**
* Action: 'wpseo_indexables_unindexed_calculated' - sets an option to timestamp when there are no unindexed indexables left.
*
* @internal
*/
\do_action( 'wpseo_indexables_unindexed_calculated', static::UNINDEXED_COUNT_TRANSIENT, $count );
return (int) $count;
}
}

View File

@ -0,0 +1,112 @@
<?php
namespace Yoast\WP\SEO\Actions\Indexing;
use wpdb;
use Yoast\WP\SEO\Builders\Indexable_Link_Builder;
use Yoast\WP\SEO\Models\SEO_Links;
use Yoast\WP\SEO\Repositories\Indexable_Repository;
/**
* Reindexing action for link indexables.
*/
abstract class Abstract_Link_Indexing_Action extends Abstract_Indexing_Action {
/**
* The link builder.
*
* @var Indexable_Link_Builder
*/
protected $link_builder;
/**
* The indexable repository.
*
* @var Indexable_Repository
*/
protected $repository;
/**
* The WordPress database instance.
*
* @var wpdb
*/
protected $wpdb;
/**
* Indexable_Post_Indexing_Action constructor
*
* @param Indexable_Link_Builder $link_builder The indexable link builder.
* @param Indexable_Repository $repository The indexable repository.
* @param wpdb $wpdb The WordPress database instance.
*/
public function __construct(
Indexable_Link_Builder $link_builder,
Indexable_Repository $repository,
wpdb $wpdb
) {
$this->link_builder = $link_builder;
$this->repository = $repository;
$this->wpdb = $wpdb;
}
/**
* Builds links for indexables which haven't had their links indexed yet.
*
* @return SEO_Links[] The created SEO links.
*/
public function index() {
$objects = $this->get_objects();
$indexables = [];
foreach ( $objects as $object ) {
$indexable = $this->repository->find_by_id_and_type( $object->id, $object->type );
if ( $indexable ) {
$this->link_builder->build( $indexable, $object->content );
$indexable->save();
$indexables[] = $indexable;
}
}
if ( \count( $indexables ) > 0 ) {
\delete_transient( static::UNINDEXED_COUNT_TRANSIENT );
\delete_transient( static::UNINDEXED_LIMITED_COUNT_TRANSIENT );
}
return $indexables;
}
/**
* In the case of term-links and post-links we want to use the total unindexed count, because using
* the limited unindexed count actually leads to worse performance.
*
* @param int|bool $limit Unused.
*
* @return int The total number of unindexed links.
*/
public function get_limited_unindexed_count( $limit = false ) {
return $this->get_total_unindexed();
}
/**
* Returns the number of texts that will be indexed in a single link indexing pass.
*
* @return int The limit.
*/
public function get_limit() {
/**
* Filter 'wpseo_link_indexing_limit' - Allow filtering the number of texts indexed during each link indexing pass.
*
* @api int The maximum number of texts indexed.
*/
return \apply_filters( 'wpseo_link_indexing_limit', 5 );
}
/**
* Returns objects to be indexed.
*
* @return array Objects to be indexed, should be an array of objects with object_id, object_type and content.
*/
abstract protected function get_objects();
}

View File

@ -0,0 +1,138 @@
<?php
namespace Yoast\WP\SEO\Actions\Indexing;
use Yoast\WP\SEO\Models\Indexable;
use Yoast\WP\SEO\Repositories\Indexable_Repository;
/**
* General reindexing action for indexables.
*/
class Indexable_General_Indexation_Action implements Indexation_Action_Interface, Limited_Indexing_Action_Interface {
/**
* The transient cache key.
*/
const UNINDEXED_COUNT_TRANSIENT = 'wpseo_total_unindexed_general_items';
/**
* Represents the indexables repository.
*
* @var Indexable_Repository
*/
protected $indexable_repository;
/**
* Indexable_General_Indexation_Action constructor.
*
* @param Indexable_Repository $indexable_repository The indexables repository.
*/
public function __construct( Indexable_Repository $indexable_repository ) {
$this->indexable_repository = $indexable_repository;
}
/**
* Returns the total number of unindexed objects.
*
* @return int The total number of unindexed objects.
*/
public function get_total_unindexed() {
$transient = \get_transient( static::UNINDEXED_COUNT_TRANSIENT );
if ( $transient !== false ) {
return (int) $transient;
}
$indexables_to_create = $this->query();
$result = \count( $indexables_to_create );
\set_transient( static::UNINDEXED_COUNT_TRANSIENT, $result, \DAY_IN_SECONDS );
/**
* Action: 'wpseo_indexables_unindexed_calculated' - sets an option to timestamp when there are no unindexed indexables left.
*
* @internal
*/
\do_action( 'wpseo_indexables_unindexed_calculated', static::UNINDEXED_COUNT_TRANSIENT, $result );
return $result;
}
/**
* Returns a limited number of unindexed posts.
*
* @param int $limit Limit the maximum number of unindexed posts that are counted.
*
* @return int|false The limited number of unindexed posts. False if the query fails.
*/
public function get_limited_unindexed_count( $limit ) {
return $this->get_total_unindexed();
}
/**
* Creates indexables for unindexed system pages, the date archive, and the homepage.
*
* @return Indexable[] The created indexables.
*/
public function index() {
$indexables = [];
$indexables_to_create = $this->query();
if ( isset( $indexables_to_create['404'] ) ) {
$indexables[] = $this->indexable_repository->find_for_system_page( '404' );
}
if ( isset( $indexables_to_create['search'] ) ) {
$indexables[] = $this->indexable_repository->find_for_system_page( 'search-result' );
}
if ( isset( $indexables_to_create['date_archive'] ) ) {
$indexables[] = $this->indexable_repository->find_for_date_archive();
}
if ( isset( $indexables_to_create['home_page'] ) ) {
$indexables[] = $this->indexable_repository->find_for_home_page();
}
\set_transient( static::UNINDEXED_COUNT_TRANSIENT, 0, \DAY_IN_SECONDS );
return $indexables;
}
/**
* Returns the number of objects that will be indexed in a single indexing pass.
*
* @return int The limit.
*/
public function get_limit() {
// This matches the maximum number of indexables created by this action.
return 4;
}
/**
* Check which indexables already exist and return the values of the ones to create.
*
* @return array The indexable types to create.
*/
private function query() {
$indexables_to_create = [];
if ( ! $this->indexable_repository->find_for_system_page( '404', false ) ) {
$indexables_to_create['404'] = true;
}
if ( ! $this->indexable_repository->find_for_system_page( 'search-result', false ) ) {
$indexables_to_create['search'] = true;
}
if ( ! $this->indexable_repository->find_for_date_archive( false ) ) {
$indexables_to_create['date_archive'] = true;
}
$need_home_page_indexable = ( (int) \get_option( 'page_on_front' ) === 0 && \get_option( 'show_on_front' ) === 'posts' );
if ( $need_home_page_indexable && ! $this->indexable_repository->find_for_home_page( false ) ) {
$indexables_to_create['home_page'] = true;
}
return $indexables_to_create;
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace Yoast\WP\SEO\Actions\Indexing;
use Yoast\WP\SEO\Helpers\Indexable_Helper;
/**
* Indexing action to call when the indexable indexing process is completed.
*/
class Indexable_Indexing_Complete_Action {
/**
* The options helper.
*
* @var Indexable_Helper
*/
protected $indexable_helper;
/**
* Indexable_Indexing_Complete_Action constructor.
*
* @param Indexable_Helper $indexable_helper The indexable helper.
*/
public function __construct( Indexable_Helper $indexable_helper ) {
$this->indexable_helper = $indexable_helper;
}
/**
* Wraps up the indexing process.
*
* @return void
*/
public function complete() {
$this->indexable_helper->finish_indexing();
}
}

View File

@ -0,0 +1,207 @@
<?php
namespace Yoast\WP\SEO\Actions\Indexing;
use wpdb;
use Yoast\WP\Lib\Model;
use Yoast\WP\SEO\Helpers\Post_Helper;
use Yoast\WP\SEO\Helpers\Post_Type_Helper;
use Yoast\WP\SEO\Models\Indexable;
use Yoast\WP\SEO\Repositories\Indexable_Repository;
use Yoast\WP\SEO\Values\Indexables\Indexable_Builder_Versions;
/**
* Reindexing action for post indexables.
*/
class Indexable_Post_Indexation_Action extends Abstract_Indexing_Action {
/**
* The transient cache key.
*
* @var string
*/
const UNINDEXED_COUNT_TRANSIENT = 'wpseo_total_unindexed_posts';
/**
* The transient cache key for limited counts.
*
* @var string
*/
const UNINDEXED_LIMITED_COUNT_TRANSIENT = self::UNINDEXED_COUNT_TRANSIENT . '_limited';
/**
* The post type helper.
*
* @var Post_Type_Helper
*/
protected $post_type_helper;
/**
* The post helper.
*
* @var Post_Helper
*/
protected $post_helper;
/**
* The indexable repository.
*
* @var Indexable_Repository
*/
protected $repository;
/**
* The WordPress database instance.
*
* @var wpdb
*/
protected $wpdb;
/**
* The latest version of Post Indexables.
*
* @var int
*/
protected $version;
/**
* Indexable_Post_Indexing_Action constructor
*
* @param Post_Type_Helper $post_type_helper The post type helper.
* @param Indexable_Repository $repository The indexable repository.
* @param wpdb $wpdb The WordPress database instance.
* @param Indexable_Builder_Versions $builder_versions The latest versions for each Indexable type.
* @param Post_Helper $post_helper The post helper.
*/
public function __construct(
Post_Type_Helper $post_type_helper,
Indexable_Repository $repository,
wpdb $wpdb,
Indexable_Builder_Versions $builder_versions,
Post_Helper $post_helper
) {
$this->post_type_helper = $post_type_helper;
$this->repository = $repository;
$this->wpdb = $wpdb;
$this->version = $builder_versions->get_latest_version_for_type( 'post' );
$this->post_helper = $post_helper;
}
/**
* Creates indexables for unindexed posts.
*
* @return Indexable[] The created indexables.
*/
public function index() {
$query = $this->get_select_query( $this->get_limit() );
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Function get_select_query returns a prepared query.
$post_ids = $this->wpdb->get_col( $query );
$indexables = [];
foreach ( $post_ids as $post_id ) {
$indexables[] = $this->repository->find_by_id_and_type( (int) $post_id, 'post' );
}
if ( \count( $indexables ) > 0 ) {
\delete_transient( static::UNINDEXED_COUNT_TRANSIENT );
\delete_transient( static::UNINDEXED_LIMITED_COUNT_TRANSIENT );
}
return $indexables;
}
/**
* Returns the number of posts that will be indexed in a single indexing pass.
*
* @return int The limit.
*/
public function get_limit() {
/**
* Filter 'wpseo_post_indexation_limit' - Allow filtering the amount of posts indexed during each indexing pass.
*
* @api int The maximum number of posts indexed.
*/
$limit = \apply_filters( 'wpseo_post_indexation_limit', 25 );
if ( ! \is_int( $limit ) || $limit < 1 ) {
$limit = 25;
}
return $limit;
}
/**
* Builds a query for counting the number of unindexed posts.
*
* @return string The prepared query string.
*/
protected function get_count_query() {
$indexable_table = Model::get_table_name( 'Indexable' );
$post_types = $this->post_type_helper->get_indexable_post_types();
$excluded_post_statuses = $this->post_helper->get_excluded_post_statuses();
$replacements = \array_merge(
$post_types,
$excluded_post_statuses
);
$replacements[] = $this->version;
// Warning: If this query is changed, makes sure to update the query in get_select_query as well.
// @phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
return $this->wpdb->prepare(
"
SELECT COUNT(P.ID)
FROM {$this->wpdb->posts} AS P
WHERE P.post_type IN (" . \implode( ', ', \array_fill( 0, \count( $post_types ), '%s' ) ) . ')
AND P.post_status NOT IN (' . \implode( ', ', \array_fill( 0, \count( $excluded_post_statuses ), '%s' ) ) . ")
AND P.ID not in (
SELECT I.object_id from $indexable_table as I
WHERE I.object_type = 'post'
AND I.version = %d )",
$replacements
);
}
/**
* Builds a query for selecting the ID's of unindexed posts.
*
* @param bool $limit The maximum number of post IDs to return.
*
* @return string The prepared query string.
*/
protected function get_select_query( $limit = false ) {
$indexable_table = Model::get_table_name( 'Indexable' );
$post_types = $this->post_type_helper->get_indexable_post_types();
$excluded_post_statuses = $this->post_helper->get_excluded_post_statuses();
$replacements = \array_merge(
$post_types,
$excluded_post_statuses
);
$replacements[] = $this->version;
$limit_query = '';
if ( $limit ) {
$limit_query = 'LIMIT %d';
$replacements[] = $limit;
}
// Warning: If this query is changed, makes sure to update the query in get_count_query as well.
// @phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
return $this->wpdb->prepare(
"
SELECT P.ID
FROM {$this->wpdb->posts} AS P
WHERE P.post_type IN (" . \implode( ', ', \array_fill( 0, \count( $post_types ), '%s' ) ) . ')
AND P.post_status NOT IN (' . \implode( ', ', \array_fill( 0, \count( $excluded_post_statuses ), '%s' ) ) . ")
AND P.ID not in (
SELECT I.object_id from $indexable_table as I
WHERE I.object_type = 'post'
AND I.version = %d )
$limit_query",
$replacements
);
}
}

View File

@ -0,0 +1,212 @@
<?php
namespace Yoast\WP\SEO\Actions\Indexing;
use Yoast\WP\SEO\Builders\Indexable_Builder;
use Yoast\WP\SEO\Helpers\Post_Type_Helper;
use Yoast\WP\SEO\Models\Indexable;
use Yoast\WP\SEO\Repositories\Indexable_Repository;
use Yoast\WP\SEO\Values\Indexables\Indexable_Builder_Versions;
/**
* Reindexing action for post type archive indexables.
*
* @phpcs:disable Yoast.NamingConventions.ObjectNameDepth.MaxExceeded
*/
class Indexable_Post_Type_Archive_Indexation_Action implements Indexation_Action_Interface, Limited_Indexing_Action_Interface {
/**
* The transient cache key.
*/
const UNINDEXED_COUNT_TRANSIENT = 'wpseo_total_unindexed_post_type_archives';
/**
* The post type helper.
*
* @var Post_Type_Helper
*/
protected $post_type;
/**
* The indexable repository.
*
* @var Indexable_Repository
*/
protected $repository;
/**
* The indexable builder.
*
* @var Indexable_Builder
*/
protected $builder;
/**
* The current version of the post type archive indexable builder.
*
* @var int
*/
protected $version;
/**
* Indexation_Post_Type_Archive_Action constructor.
*
* @param Indexable_Repository $repository The indexable repository.
* @param Indexable_Builder $builder The indexable builder.
* @param Post_Type_Helper $post_type The post type helper.
* @param Indexable_Builder_Versions $versions The current versions of all indexable builders.
*/
public function __construct(
Indexable_Repository $repository,
Indexable_Builder $builder,
Post_Type_Helper $post_type,
Indexable_Builder_Versions $versions
) {
$this->repository = $repository;
$this->builder = $builder;
$this->post_type = $post_type;
$this->version = $versions->get_latest_version_for_type( 'post-type-archive' );
}
/**
* Returns the total number of unindexed post type archives.
*
* @param int $limit Limit the number of counted objects.
*
* @return int The total number of unindexed post type archives.
*/
public function get_total_unindexed( $limit = false ) {
$transient = \get_transient( static::UNINDEXED_COUNT_TRANSIENT );
if ( $transient !== false ) {
return (int) $transient;
}
\set_transient( static::UNINDEXED_COUNT_TRANSIENT, 0, \DAY_IN_SECONDS );
$result = \count( $this->get_unindexed_post_type_archives( $limit ) );
\set_transient( static::UNINDEXED_COUNT_TRANSIENT, $result, \DAY_IN_SECONDS );
/**
* Action: 'wpseo_indexables_unindexed_calculated' - sets an option to timestamp when there are no unindexed indexables left.
*
* @internal
*/
\do_action( 'wpseo_indexables_unindexed_calculated', static::UNINDEXED_COUNT_TRANSIENT, $result );
return $result;
}
/**
* Creates indexables for post type archives.
*
* @return Indexable[] The created indexables.
*/
public function index() {
$unindexed_post_type_archives = $this->get_unindexed_post_type_archives( $this->get_limit() );
$indexables = [];
foreach ( $unindexed_post_type_archives as $post_type_archive ) {
$indexables[] = $this->builder->build_for_post_type_archive( $post_type_archive );
}
if ( \count( $indexables ) > 0 ) {
\delete_transient( static::UNINDEXED_COUNT_TRANSIENT );
}
return $indexables;
}
/**
* Returns the number of post type archives that will be indexed in a single indexing pass.
*
* @return int The limit.
*/
public function get_limit() {
/**
* Filter 'wpseo_post_type_archive_indexation_limit' - Allow filtering the number of posts indexed during each indexing pass.
*
* @api int The maximum number of posts indexed.
*/
$limit = \apply_filters( 'wpseo_post_type_archive_indexation_limit', 25 );
if ( ! \is_int( $limit ) || $limit < 1 ) {
$limit = 25;
}
return $limit;
}
/**
* Retrieves the list of post types for which no indexable for its archive page has been made yet.
*
* @param int|false $limit Limit the number of retrieved indexables to this number.
*
* @return array The list of post types for which no indexable for its archive page has been made yet.
*/
protected function get_unindexed_post_type_archives( $limit = false ) {
$post_types_with_archive_pages = $this->get_post_types_with_archive_pages();
$indexed_post_types = $this->get_indexed_post_type_archives();
$unindexed_post_types = \array_diff( $post_types_with_archive_pages, $indexed_post_types );
if ( $limit ) {
return \array_slice( $unindexed_post_types, 0, $limit );
}
return $unindexed_post_types;
}
/**
* Returns the names of all the post types that have archive pages.
*
* @return array The list of names of all post types that have archive pages.
*/
protected function get_post_types_with_archive_pages() {
// We only want to index archive pages of public post types that have them.
$post_types_with_archive = $this->post_type->get_indexable_post_archives();
// We only need the post type names, not the objects.
$post_types = [];
foreach ( $post_types_with_archive as $post_type_with_archive ) {
$post_types[] = $post_type_with_archive->name;
}
return $post_types;
}
/**
* Retrieves the list of post type names for which an archive indexable exists.
*
* @return array The list of names of post types with unindexed archive pages.
*/
protected function get_indexed_post_type_archives() {
$results = $this->repository->query()
->select( 'object_sub_type' )
->where( 'object_type', 'post-type-archive' )
->where_equal( 'version', $this->version )
->find_array();
if ( $results === false ) {
return [];
}
$callback = static function( $result ) {
return $result['object_sub_type'];
};
return \array_map( $callback, $results );
}
/**
* Returns a limited number of unindexed posts.
*
* @param int $limit Limit the maximum number of unindexed posts that are counted.
*
* @return int|false The limited number of unindexed posts. False if the query fails.
*/
public function get_limited_unindexed_count( $limit ) {
return $this->get_total_unindexed( $limit );
}
}

View File

@ -0,0 +1,188 @@
<?php
namespace Yoast\WP\SEO\Actions\Indexing;
use wpdb;
use Yoast\WP\Lib\Model;
use Yoast\WP\SEO\Helpers\Taxonomy_Helper;
use Yoast\WP\SEO\Models\Indexable;
use Yoast\WP\SEO\Repositories\Indexable_Repository;
use Yoast\WP\SEO\Values\Indexables\Indexable_Builder_Versions;
/**
* Reindexing action for term indexables.
*/
class Indexable_Term_Indexation_Action extends Abstract_Indexing_Action {
/**
* The transient cache key.
*/
const UNINDEXED_COUNT_TRANSIENT = 'wpseo_total_unindexed_terms';
/**
* The transient cache key for limited counts.
*
* @var string
*/
const UNINDEXED_LIMITED_COUNT_TRANSIENT = self::UNINDEXED_COUNT_TRANSIENT . '_limited';
/**
* The post type helper.
*
* @var Taxonomy_Helper
*/
protected $taxonomy;
/**
* The indexable repository.
*
* @var Indexable_Repository
*/
protected $repository;
/**
* The WordPress database instance.
*
* @var wpdb
*/
protected $wpdb;
/**
* The latest version of the Indexable term builder
*
* @var int
*/
protected $version;
/**
* Indexable_Term_Indexation_Action constructor
*
* @param Taxonomy_Helper $taxonomy The taxonomy helper.
* @param Indexable_Repository $repository The indexable repository.
* @param wpdb $wpdb The WordPress database instance.
* @param Indexable_Builder_Versions $builder_versions The latest versions of all indexable builders.
*/
public function __construct(
Taxonomy_Helper $taxonomy,
Indexable_Repository $repository,
wpdb $wpdb,
Indexable_Builder_Versions $builder_versions
) {
$this->taxonomy = $taxonomy;
$this->repository = $repository;
$this->wpdb = $wpdb;
$this->version = $builder_versions->get_latest_version_for_type( 'term' );
}
/**
* Creates indexables for unindexed terms.
*
* @return Indexable[] The created indexables.
*/
public function index() {
$query = $this->get_select_query( $this->get_limit() );
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Function get_select_query returns a prepared query.
$term_ids = $this->wpdb->get_col( $query );
$indexables = [];
foreach ( $term_ids as $term_id ) {
$indexables[] = $this->repository->find_by_id_and_type( (int) $term_id, 'term' );
}
if ( \count( $indexables ) > 0 ) {
\delete_transient( static::UNINDEXED_COUNT_TRANSIENT );
\delete_transient( static::UNINDEXED_LIMITED_COUNT_TRANSIENT );
}
return $indexables;
}
/**
* Returns the number of terms that will be indexed in a single indexing pass.
*
* @return int The limit.
*/
public function get_limit() {
/**
* Filter 'wpseo_term_indexation_limit' - Allow filtering the number of terms indexed during each indexing pass.
*
* @api int The maximum number of terms indexed.
*/
$limit = \apply_filters( 'wpseo_term_indexation_limit', 25 );
if ( ! \is_int( $limit ) || $limit < 1 ) {
$limit = 25;
}
return $limit;
}
/**
* Builds a query for counting the number of unindexed terms.
*
* @return string The prepared query string.
*/
protected function get_count_query() {
$indexable_table = Model::get_table_name( 'Indexable' );
$taxonomy_table = $this->wpdb->term_taxonomy;
$public_taxonomies = $this->taxonomy->get_indexable_taxonomies();
$taxonomies_placeholders = \implode( ', ', \array_fill( 0, \count( $public_taxonomies ), '%s' ) );
$replacements = [ $this->version ];
\array_push( $replacements, ...$public_taxonomies );
// Warning: If this query is changed, makes sure to update the query in get_count_query as well.
return $this->wpdb->prepare(
"
SELECT COUNT(term_id)
FROM {$taxonomy_table} AS T
LEFT JOIN $indexable_table AS I
ON T.term_id = I.object_id
AND I.object_type = 'term'
AND I.version = %d
WHERE I.object_id IS NULL
AND taxonomy IN ($taxonomies_placeholders)",
$replacements
);
}
/**
* Builds a query for selecting the ID's of unindexed terms.
*
* @param bool $limit The maximum number of term IDs to return.
*
* @return string The prepared query string.
*/
protected function get_select_query( $limit = false ) {
$indexable_table = Model::get_table_name( 'Indexable' );
$taxonomy_table = $this->wpdb->term_taxonomy;
$public_taxonomies = $this->taxonomy->get_indexable_taxonomies();
$placeholders = \implode( ', ', \array_fill( 0, \count( $public_taxonomies ), '%s' ) );
$replacements = [ $this->version ];
\array_push( $replacements, ...$public_taxonomies );
$limit_query = '';
if ( $limit ) {
$limit_query = 'LIMIT %d';
$replacements[] = $limit;
}
// Warning: If this query is changed, makes sure to update the query in get_count_query as well.
return $this->wpdb->prepare(
"
SELECT term_id
FROM {$taxonomy_table} AS T
LEFT JOIN $indexable_table AS I
ON T.term_id = I.object_id
AND I.object_type = 'term'
AND I.version = %d
WHERE I.object_id IS NULL
AND taxonomy IN ($placeholders)
$limit_query",
$replacements
);
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace Yoast\WP\SEO\Actions\Indexing;
/**
* Interface definition of reindexing action for indexables.
*/
interface Indexation_Action_Interface {
/**
* Returns the total number of unindexed objects.
*
* @return int The total number of unindexed objects.
*/
public function get_total_unindexed();
/**
* Indexes a number of objects.
*
* NOTE: ALWAYS use limits, this method is intended to be called multiple times over several requests.
*
* For indexing that requires JavaScript simply return the objects that should be indexed.
*
* @return array The reindexed objects.
*/
public function index();
/**
* Returns the number of objects that will be indexed in a single indexing pass.
*
* @return int The limit.
*/
public function get_limit();
}

View File

@ -0,0 +1,36 @@
<?php
namespace Yoast\WP\SEO\Actions\Indexing;
use Yoast\WP\SEO\Helpers\Indexing_Helper;
/**
* Indexing action to call when the indexing is completed.
*/
class Indexing_Complete_Action {
/**
* The indexing helper.
*
* @var Indexing_Helper
*/
protected $indexing_helper;
/**
* Indexing_Complete_Action constructor.
*
* @param Indexing_Helper $indexing_helper The indexing helper.
*/
public function __construct( Indexing_Helper $indexing_helper ) {
$this->indexing_helper = $indexing_helper;
}
/**
* Wraps up the indexing process.
*
* @return void
*/
public function complete() {
$this->indexing_helper->complete();
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace Yoast\WP\SEO\Actions\Indexing;
use Yoast\WP\SEO\Helpers\Indexing_Helper;
/**
* Class Indexing_Prepare_Action.
*
* Action for preparing the indexing routine.
*/
class Indexing_Prepare_Action {
/**
* The indexing helper.
*
* @var Indexing_Helper
*/
protected $indexing_helper;
/**
* Action for preparing the indexing routine.
*
* @param Indexing_Helper $indexing_helper The indexing helper.
*/
public function __construct(
Indexing_Helper $indexing_helper
) {
$this->indexing_helper = $indexing_helper;
}
/**
* Prepares the indexing routine.
*
* @return void
*/
public function prepare() {
$this->indexing_helper->prepare();
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace Yoast\WP\SEO\Actions\Indexing;
/**
* Interface definition of a reindexing action for indexables that have a limited unindexed count.
*/
interface Limited_Indexing_Action_Interface {
/**
* Returns a limited number of unindexed posts.
*
* @param int $limit Limit the maximum number of unindexed posts that are counted.
*
* @return int|false The limited number of unindexed posts. False if the query fails.
*/
public function get_limited_unindexed_count( $limit );
}

View File

@ -0,0 +1,142 @@
<?php
namespace Yoast\WP\SEO\Actions\Indexing;
use Yoast\WP\Lib\Model;
use Yoast\WP\SEO\Helpers\Post_Type_Helper;
/**
* Reindexing action for post link indexables.
*/
class Post_Link_Indexing_Action extends Abstract_Link_Indexing_Action {
/**
* The transient name.
*
* @var string
*/
const UNINDEXED_COUNT_TRANSIENT = 'wpseo_unindexed_post_link_count';
/**
* The transient cache key for limited counts.
*
* @var string
*/
const UNINDEXED_LIMITED_COUNT_TRANSIENT = self::UNINDEXED_COUNT_TRANSIENT . '_limited';
/**
* The post type helper.
*
* @var Post_Type_Helper
*/
protected $post_type_helper;
/**
* Sets the required helper.
*
* @required
*
* @param Post_Type_Helper $post_type_helper The post type helper.
*
* @return void
*/
public function set_helper( Post_Type_Helper $post_type_helper ) {
$this->post_type_helper = $post_type_helper;
}
/**
* Returns objects to be indexed.
*
* @return array Objects to be indexed.
*/
protected function get_objects() {
$query = $this->get_select_query( $this->get_limit() );
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Function get_select_query returns a prepared query.
$posts = $this->wpdb->get_results( $query );
return \array_map(
static function ( $post ) {
return (object) [
'id' => (int) $post->ID,
'type' => 'post',
'content' => $post->post_content,
];
},
$posts
);
}
/**
* Builds a query for counting the number of unindexed post links.
*
* @return string The prepared query string.
*/
protected function get_count_query() {
$public_post_types = $this->post_type_helper->get_indexable_post_types();
$indexable_table = Model::get_table_name( 'Indexable' );
$links_table = Model::get_table_name( 'SEO_Links' );
// Warning: If this query is changed, makes sure to update the query in get_select_query as well.
return $this->wpdb->prepare(
"SELECT COUNT(P.ID)
FROM {$this->wpdb->posts} AS P
LEFT JOIN $indexable_table AS I
ON P.ID = I.object_id
AND I.link_count IS NOT NULL
AND I.object_type = 'post'
LEFT JOIN $links_table AS L
ON L.post_id = P.ID
AND L.target_indexable_id IS NULL
AND L.type = 'internal'
AND L.target_post_id IS NOT NULL
AND L.target_post_id != 0
WHERE ( I.object_id IS NULL OR L.post_id IS NOT NULL )
AND P.post_status = 'publish'
AND P.post_type IN (" . \implode( ', ', \array_fill( 0, \count( $public_post_types ), '%s' ) ) . ')',
$public_post_types
);
}
/**
* Builds a query for selecting the ID's of unindexed post links.
*
* @param int|false $limit The maximum number of post link IDs to return.
*
* @return string The prepared query string.
*/
protected function get_select_query( $limit = false ) {
$public_post_types = $this->post_type_helper->get_indexable_post_types();
$indexable_table = Model::get_table_name( 'Indexable' );
$links_table = Model::get_table_name( 'SEO_Links' );
$replacements = $public_post_types;
$limit_query = '';
if ( $limit ) {
$limit_query = 'LIMIT %d';
$replacements[] = $limit;
}
// Warning: If this query is changed, makes sure to update the query in get_count_query as well.
return $this->wpdb->prepare(
"
SELECT P.ID, P.post_content
FROM {$this->wpdb->posts} AS P
LEFT JOIN $indexable_table AS I
ON P.ID = I.object_id
AND I.link_count IS NOT NULL
AND I.object_type = 'post'
LEFT JOIN $links_table AS L
ON L.post_id = P.ID
AND L.target_indexable_id IS NULL
AND L.type = 'internal'
AND L.target_post_id IS NOT NULL
AND L.target_post_id != 0
WHERE ( I.object_id IS NULL OR L.post_id IS NOT NULL )
AND P.post_status = 'publish'
AND P.post_type IN (" . \implode( ', ', \array_fill( 0, \count( $public_post_types ), '%s' ) ) . ")
$limit_query",
$replacements
);
}
}

View File

@ -0,0 +1,129 @@
<?php
namespace Yoast\WP\SEO\Actions\Indexing;
use Yoast\WP\Lib\Model;
use Yoast\WP\SEO\Helpers\Taxonomy_Helper;
/**
* Reindexing action for term link indexables.
*/
class Term_Link_Indexing_Action extends Abstract_Link_Indexing_Action {
/**
* The transient name.
*
* @var string
*/
const UNINDEXED_COUNT_TRANSIENT = 'wpseo_unindexed_term_link_count';
/**
* The transient cache key for limited counts.
*
* @var string
*/
const UNINDEXED_LIMITED_COUNT_TRANSIENT = self::UNINDEXED_COUNT_TRANSIENT . '_limited';
/**
* The post type helper.
*
* @var Taxonomy_Helper
*/
protected $taxonomy_helper;
/**
* Sets the required helper.
*
* @required
*
* @param Taxonomy_Helper $taxonomy_helper The taxonomy helper.
*
* @return void
*/
public function set_helper( Taxonomy_Helper $taxonomy_helper ) {
$this->taxonomy_helper = $taxonomy_helper;
}
/**
* Returns objects to be indexed.
*
* @return array Objects to be indexed.
*/
protected function get_objects() {
$query = $this->get_select_query( $this->get_limit() );
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Function get_select_query returns a prepared query.
$terms = $this->wpdb->get_results( $query );
return \array_map(
static function ( $term ) {
return (object) [
'id' => (int) $term->term_id,
'type' => 'term',
'content' => $term->description,
];
},
$terms
);
}
/**
* Builds a query for counting the number of unindexed term links.
*
* @return string The prepared query string.
*/
protected function get_count_query() {
$public_taxonomies = $this->taxonomy_helper->get_indexable_taxonomies();
$placeholders = \implode( ', ', \array_fill( 0, \count( $public_taxonomies ), '%s' ) );
$indexable_table = Model::get_table_name( 'Indexable' );
// Warning: If this query is changed, makes sure to update the query in get_select_query as well.
return $this->wpdb->prepare(
"
SELECT COUNT(T.term_id)
FROM {$this->wpdb->term_taxonomy} AS T
LEFT JOIN $indexable_table AS I
ON T.term_id = I.object_id
AND I.object_type = 'term'
AND I.link_count IS NOT NULL
WHERE I.object_id IS NULL
AND T.taxonomy IN ($placeholders)",
$public_taxonomies
);
}
/**
* Builds a query for selecting the ID's of unindexed term links.
*
* @param int|false $limit The maximum number of term link IDs to return.
*
* @return string The prepared query string.
*/
protected function get_select_query( $limit = false ) {
$public_taxonomies = $this->taxonomy_helper->get_indexable_taxonomies();
$indexable_table = Model::get_table_name( 'Indexable' );
$replacements = $public_taxonomies;
$limit_query = '';
if ( $limit ) {
$limit_query = 'LIMIT %d';
$replacements[] = $limit;
}
// Warning: If this query is changed, makes sure to update the query in get_count_query as well.
return $this->wpdb->prepare(
"
SELECT T.term_id, T.description
FROM {$this->wpdb->term_taxonomy} AS T
LEFT JOIN $indexable_table AS I
ON T.term_id = I.object_id
AND I.object_type = 'term'
AND I.link_count IS NOT NULL
WHERE I.object_id IS NULL
AND T.taxonomy IN (" . \implode( ', ', \array_fill( 0, \count( $public_taxonomies ), '%s' ) ) . ")
$limit_query",
$replacements
);
}
}

View File

@ -0,0 +1,57 @@
<?php
namespace Yoast\WP\SEO\Actions;
use Yoast\WP\SEO\Helpers\Options_Helper;
/**
* Class Integrations_Action.
*/
class Integrations_Action {
/**
* The Options_Helper instance.
*
* @var Options_Helper
*/
protected $options_helper;
/**
* Integrations_Action constructor.
*
* @param Options_Helper $options_helper The WPSEO options helper.
*/
public function __construct( Options_Helper $options_helper ) {
$this->options_helper = $options_helper;
}
/**
* Sets an integration state.
*
* @param string $integration_name The name of the integration to activate/deactivate.
* @param bool $value The value to store.
*
* @return object The response object.
*/
public function set_integration_active( $integration_name, $value ) {
$option_name = $integration_name . '_integration_active';
$success = true;
$option_value = $this->options_helper->get( $option_name );
if ( $option_value !== $value ) {
$success = $this->options_helper->set( $option_name, $value );
}
if ( $success ) {
return (object) [
'success' => true,
'status' => 200,
];
}
return (object) [
'success' => false,
'status' => 500,
'error' => 'Could not save the option in the database',
];
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace Yoast\WP\SEO\Actions\SEMrush;
use Yoast\WP\SEO\Config\SEMrush_Client;
use Yoast\WP\SEO\Exceptions\OAuth\Authentication_Failed_Exception;
/**
* Class SEMrush_Login_Action
*/
class SEMrush_Login_Action {
/**
* The SEMrush_Client instance.
*
* @var SEMrush_Client
*/
protected $client;
/**
* SEMrush_Login_Action constructor.
*
* @param SEMrush_Client $client The API client.
*/
public function __construct( SEMrush_Client $client ) {
$this->client = $client;
}
/**
* Authenticates with SEMrush to request the necessary tokens.
*
* @param string $code The authentication code to use to request a token with.
*
* @return object The response object.
*/
public function authenticate( $code ) {
// Code has already been validated at this point. No need to do that again.
try {
$tokens = $this->client->request_tokens( $code );
return (object) [
'tokens' => $tokens->to_array(),
'status' => 200,
];
} catch ( Authentication_Failed_Exception $e ) {
return $e->get_response();
}
}
/**
* Performs the login request, if necessary.
*/
public function login() {
if ( $this->client->has_valid_tokens() ) {
return;
}
// Prompt with login screen.
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace Yoast\WP\SEO\Actions\SEMrush;
use Yoast\WP\SEO\Helpers\Options_Helper;
/**
* Class SEMrush_Options_Action
*/
class SEMrush_Options_Action {
/**
* The Options_Helper instance.
*
* @var Options_Helper
*/
protected $options_helper;
/**
* SEMrush_Options_Action constructor.
*
* @param Options_Helper $options_helper The WPSEO options helper.
*/
public function __construct( Options_Helper $options_helper ) {
$this->options_helper = $options_helper;
}
/**
* Stores SEMrush country code in the WPSEO options.
*
* @param string $country_code The country code to store.
*
* @return object The response object.
*/
public function set_country_code( $country_code ) {
// The country code has already been validated at this point. No need to do that again.
$success = $this->options_helper->set( 'semrush_country_code', $country_code );
if ( $success ) {
return (object) [
'success' => true,
'status' => 200,
];
}
return (object) [
'success' => false,
'status' => 500,
'error' => 'Could not save option in the database',
];
}
}

View File

@ -0,0 +1,96 @@
<?php
namespace Yoast\WP\SEO\Actions\SEMrush;
use Exception;
use Yoast\WP\SEO\Config\SEMrush_Client;
/**
* Class SEMrush_Phrases_Action
*/
class SEMrush_Phrases_Action {
/**
* The transient cache key.
*/
const TRANSIENT_CACHE_KEY = 'wpseo_semrush_related_keyphrases_%s_%s';
/**
* The SEMrush keyphrase URL.
*
* @var string
*/
const KEYPHRASES_URL = 'https://oauth.semrush.com/api/v1/keywords/phrase_fullsearch';
/**
* The SEMrush_Client instance.
*
* @var SEMrush_Client
*/
protected $client;
/**
* SEMrush_Phrases_Action constructor.
*
* @param SEMrush_Client $client The API client.
*/
public function __construct( SEMrush_Client $client ) {
$this->client = $client;
}
/**
* Gets the related keyphrases and data based on the passed keyphrase and database country code.
*
* @param string $keyphrase The keyphrase to search for.
* @param string $database The database's country code.
*
* @return object The response object.
*/
public function get_related_keyphrases( $keyphrase, $database ) {
try {
$transient_key = \sprintf( static::TRANSIENT_CACHE_KEY, $keyphrase, $database );
$transient = \get_transient( $transient_key );
if ( $transient !== false ) {
return $this->to_result_object( $transient );
}
$options = [
'params' => [
'phrase' => $keyphrase,
'database' => $database,
'export_columns' => 'Ph,Nq,Td',
'display_limit' => 10,
'display_offset' => 0,
'display_sort' => 'nq_desc',
'display_filter' => '%2B|Nq|Lt|1000',
],
];
$results = $this->client->get( self::KEYPHRASES_URL, $options );
\set_transient( $transient_key, $results, \DAY_IN_SECONDS );
return $this->to_result_object( $results );
} catch ( Exception $e ) {
return (object) [
'error' => $e->getMessage(),
'status' => $e->getCode(),
];
}
}
/**
* Converts the passed dataset to an object.
*
* @param array $result The result dataset to convert to an object.
*
* @return object The result object.
*/
protected function to_result_object( $result ) {
return (object) [
'results' => $result['data'],
'status' => $result['status'],
];
}
}

View File

@ -0,0 +1,102 @@
<?php
namespace Yoast\WP\SEO\Actions\Wincher;
use Yoast\WP\SEO\Config\Wincher_Client;
use Yoast\WP\SEO\Helpers\Options_Helper;
/**
* Class Wincher_Account_Action
*/
class Wincher_Account_Action {
const ACCOUNT_URL = 'https://api.wincher.com/beta/account';
const UPGRADE_CAMPAIGN_URL = 'https://api.wincher.com/v1/yoast/upgrade-campaign';
/**
* The Wincher_Client instance.
*
* @var Wincher_Client
*/
protected $client;
/**
* The Options_Helper instance.
*
* @var Options_Helper
*/
protected $options_helper;
/**
* Wincher_Account_Action constructor.
*
* @param Wincher_Client $client The API client.
* @param Options_Helper $options_helper The options helper.
*/
public function __construct( Wincher_Client $client, Options_Helper $options_helper ) {
$this->client = $client;
$this->options_helper = $options_helper;
}
/**
* Checks the account limit for tracking keyphrases.
*
* @return object The response object.
*/
public function check_limit() {
// Code has already been validated at this point. No need to do that again.
try {
$results = $this->client->get( self::ACCOUNT_URL );
$usage = $results['limits']['keywords']['usage'];
$limit = $results['limits']['keywords']['limit'];
return (object) [
'canTrack' => \is_null( $limit ) || $usage < $limit,
'limit' => $limit,
'usage' => $usage,
'status' => 200,
];
} catch ( \Exception $e ) {
return (object) [
'status' => $e->getCode(),
'error' => $e->getMessage(),
];
}
}
/**
* Gets the upgrade campaign.
*
* @return object The response object.
*/
public function get_upgrade_campaign() {
try {
$result = $this->client->get( self::UPGRADE_CAMPAIGN_URL );
$type = $result['type'];
$months = $result['months'];
// We display upgrade discount only if it's a rate discount and positive months.
if ( $type === 'RATE' && $months && $months > 0 ) {
$discount = $result['value'];
return (object) [
'discount' => $discount,
'months' => $months,
'status' => 200,
];
}
return (object) [
'discount' => null,
'months' => null,
'status' => 200,
];
} catch ( \Exception $e ) {
return (object) [
'status' => $e->getCode(),
'error' => $e->getMessage(),
];
}
}
}

View File

@ -0,0 +1,361 @@
<?php
namespace Yoast\WP\SEO\Actions\Wincher;
use Exception;
use WP_Post;
use WPSEO_Utils;
use Yoast\WP\SEO\Config\Wincher_Client;
use Yoast\WP\SEO\Helpers\Options_Helper;
use Yoast\WP\SEO\Repositories\Indexable_Repository;
/**
* Class Wincher_Keyphrases_Action
*/
class Wincher_Keyphrases_Action {
/**
* The Wincher keyphrase URL for bulk addition.
*
* @var string
*/
const KEYPHRASES_ADD_URL = 'https://api.wincher.com/beta/websites/%s/keywords/bulk';
/**
* The Wincher tracked keyphrase retrieval URL.
*
* @var string
*/
const KEYPHRASES_URL = 'https://api.wincher.com/beta/yoast/%s';
/**
* The Wincher delete tracked keyphrase URL.
*
* @var string
*/
const KEYPHRASE_DELETE_URL = 'https://api.wincher.com/beta/websites/%s/keywords/%s';
/**
* The Wincher_Client instance.
*
* @var Wincher_Client
*/
protected $client;
/**
* The Options_Helper instance.
*
* @var Options_Helper
*/
protected $options_helper;
/**
* The Indexable_Repository instance.
*
* @var Indexable_Repository
*/
protected $indexable_repository;
/**
* Wincher_Keyphrases_Action constructor.
*
* @param Wincher_Client $client The API client.
* @param Options_Helper $options_helper The options helper.
* @param Indexable_Repository $indexable_repository The indexables repository.
*/
public function __construct(
Wincher_Client $client,
Options_Helper $options_helper,
Indexable_Repository $indexable_repository
) {
$this->client = $client;
$this->options_helper = $options_helper;
$this->indexable_repository = $indexable_repository;
}
/**
* Sends the tracking API request for one or more keyphrases.
*
* @param string|array $keyphrases One or more keyphrases that should be tracked.
* @param Object $limits The limits API call response data.
*
* @return Object The reponse object.
*/
public function track_keyphrases( $keyphrases, $limits ) {
try {
$endpoint = \sprintf(
self::KEYPHRASES_ADD_URL,
$this->options_helper->get( 'wincher_website_id' )
);
// Enforce arrrays to ensure a consistent way of preparing the request.
if ( ! \is_array( $keyphrases ) ) {
$keyphrases = [ $keyphrases ];
}
// Calculate if the user would exceed their limit.
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- To ensure JS code style, this can be ignored.
if ( ! $limits->canTrack || $this->would_exceed_limits( $keyphrases, $limits ) ) {
$response = [
'limit' => $limits->limit,
'error' => 'Account limit exceeded',
'status' => 400,
];
return $this->to_result_object( $response );
}
$formatted_keyphrases = \array_values(
\array_map(
static function ( $keyphrase ) {
return [
'keyword' => $keyphrase,
'groups' => [],
];
},
$keyphrases
)
);
$results = $this->client->post( $endpoint, WPSEO_Utils::format_json_encode( $formatted_keyphrases ) );
if ( ! \array_key_exists( 'data', $results ) ) {
return $this->to_result_object( $results );
}
// The endpoint returns a lot of stuff that we don't want/need.
$results['data'] = \array_map(
static function( $keyphrase ) {
return [
'id' => $keyphrase['id'],
'keyword' => $keyphrase['keyword'],
];
},
$results['data']
);
$results['data'] = \array_combine(
\array_column( $results['data'], 'keyword' ),
\array_values( $results['data'] )
);
return $this->to_result_object( $results );
} catch ( Exception $e ) {
return (object) [
'error' => $e->getMessage(),
'status' => $e->getCode(),
];
}
}
/**
* Sends an untrack request for the passed keyword ID.
*
* @param int $keyphrase_id The ID of the keyphrase to untrack.
*
* @return object The response object.
*/
public function untrack_keyphrase( $keyphrase_id ) {
try {
$endpoint = \sprintf(
self::KEYPHRASE_DELETE_URL,
$this->options_helper->get( 'wincher_website_id' ),
$keyphrase_id
);
$this->client->delete( $endpoint );
return (object) [
'status' => 200,
];
} catch ( Exception $e ) {
return (object) [
'error' => $e->getMessage(),
'status' => $e->getCode(),
];
}
}
/**
* Gets the keyphrase data for the passed keyphrases.
* Retrieves all available data if no keyphrases are provided.
*
* @param array|null $used_keyphrases The currently used keyphrases. Optional.
* @param string|null $permalink The current permalink. Optional.
*
* @return object The keyphrase chart data.
*/
public function get_tracked_keyphrases( $used_keyphrases = null, $permalink = null ) {
try {
if ( $used_keyphrases === null ) {
$used_keyphrases = $this->collect_all_keyphrases();
}
// If we still have no keyphrases the API will return an error, so
// don't even bother sending a request.
if ( empty( $used_keyphrases ) ) {
return $this->to_result_object(
[
'data' => [],
'status' => 200,
]
);
}
$endpoint = \sprintf(
self::KEYPHRASES_URL,
$this->options_helper->get( 'wincher_website_id' )
);
$results = $this->client->post(
$endpoint,
WPSEO_Utils::format_json_encode(
[
'keywords' => $used_keyphrases,
'url' => $permalink,
]
),
[
'timeout' => 60,
]
);
if ( ! \array_key_exists( 'data', $results ) ) {
return $this->to_result_object( $results );
}
$results['data'] = $this->filter_results_by_used_keyphrases( $results['data'], $used_keyphrases );
// Extract the positional data and assign it to the keyphrase.
$results['data'] = \array_combine(
\array_column( $results['data'], 'keyword' ),
\array_values( $results['data'] )
);
return $this->to_result_object( $results );
} catch ( Exception $e ) {
return (object) [
'error' => $e->getMessage(),
'status' => $e->getCode(),
];
}
}
/**
* Collects the keyphrases associated with the post.
*
* @param WP_Post $post The post object.
*
* @return array The keyphrases.
*/
public function collect_keyphrases_from_post( $post ) {
$keyphrases = [];
$primary_keyphrase = $this->indexable_repository
->query()
->select( 'primary_focus_keyword' )
->where( 'object_id', $post->ID )
->find_one();
if ( $primary_keyphrase ) {
$keyphrases[] = $primary_keyphrase->primary_focus_keyword;
}
/**
* Filters the keyphrases collected by the Wincher integration from the post.
*
* @param array $keyphrases The keyphrases array.
* @param int $post_id The ID of the post.
*/
return \apply_filters( 'wpseo_wincher_keyphrases_from_post', $keyphrases, $post->ID );
}
/**
* Collects all keyphrases known to Yoast.
*
* @return array
*/
protected function collect_all_keyphrases() {
// Collect primary keyphrases first.
$keyphrases = \array_column(
$this->indexable_repository
->query()
->select( 'primary_focus_keyword' )
->where_not_null( 'primary_focus_keyword' )
->where( 'object_type', 'post' )
->where_not_equal( 'post_status', 'trash' )
->distinct()
->find_array(),
'primary_focus_keyword'
);
/**
* Filters the keyphrases collected by the Wincher integration from all the posts.
*
* @param array $keyphrases The keyphrases array.
*/
$keyphrases = \apply_filters( 'wpseo_wincher_all_keyphrases', $keyphrases );
// Filter out empty entries.
return \array_filter( $keyphrases );
}
/**
* Filters the results based on the passed keyphrases.
*
* @param array $results The results to filter.
* @param array $used_keyphrases The used keyphrases.
*
* @return array The filtered results.
*/
protected function filter_results_by_used_keyphrases( $results, $used_keyphrases ) {
return \array_filter(
$results,
static function( $result ) use ( $used_keyphrases ) {
return \in_array( $result['keyword'], \array_map( 'strtolower', $used_keyphrases ), true );
}
);
}
/**
* Determines whether the amount of keyphrases would mean the user exceeds their account limits.
*
* @param string|array $keyphrases The keyphrases to be added.
* @param object $limits The current account limits.
*
* @return bool Whether the limit is exceeded.
*/
protected function would_exceed_limits( $keyphrases, $limits ) {
if ( ! \is_array( $keyphrases ) ) {
$keyphrases = [ $keyphrases ];
}
if ( \is_null( $limits->limit ) ) {
return false;
}
return ( \count( $keyphrases ) + $limits->usage ) > $limits->limit;
}
/**
* Converts the passed dataset to an object.
*
* @param array $result The result dataset to convert to an object.
*
* @return object The result object.
*/
protected function to_result_object( $result ) {
if ( \array_key_exists( 'data', $result ) ) {
$result['results'] = (object) $result['data'];
unset( $result['data'] );
}
if ( \array_key_exists( 'message', $result ) ) {
$result['error'] = $result['message'];
unset( $result['message'] );
}
return (object) $result;
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace Yoast\WP\SEO\Actions\Wincher;
use Yoast\WP\SEO\Config\Wincher_Client;
use Yoast\WP\SEO\Exceptions\OAuth\Authentication_Failed_Exception;
use Yoast\WP\SEO\Helpers\Options_Helper;
/**
* Class Wincher_Login_Action
*/
class Wincher_Login_Action {
/**
* The Wincher_Client instance.
*
* @var Wincher_Client
*/
protected $client;
/**
* The Options_Helper instance.
*
* @var Options_Helper
*/
protected $options_helper;
/**
* Wincher_Login_Action constructor.
*
* @param Wincher_Client $client The API client.
* @param Options_Helper $options_helper The options helper.
*/
public function __construct( Wincher_Client $client, Options_Helper $options_helper ) {
$this->client = $client;
$this->options_helper = $options_helper;
}
/**
* Returns the authorization URL.
*
* @return object The response object.
*/
public function get_authorization_url() {
return (object) [
'status' => 200,
'url' => $this->client->get_authorization_url(),
];
}
/**
* Authenticates with Wincher to request the necessary tokens.
*
* @param string $code The authentication code to use to request a token with.
* @param string $website_id The website id associated with the code.
*
* @return object The response object.
*/
public function authenticate( $code, $website_id ) {
// Code has already been validated at this point. No need to do that again.
try {
$tokens = $this->client->request_tokens( $code );
$this->options_helper->set( 'wincher_website_id', $website_id );
return (object) [
'tokens' => $tokens->to_array(),
'status' => 200,
];
} catch ( Authentication_Failed_Exception $e ) {
return $e->get_response();
}
}
}

View File

@ -0,0 +1,70 @@
<?php
namespace Yoast\WP\SEO\Analytics\Application;
use WPSEO_Collection;
use Yoast\WP\SEO\Actions\Indexing\Indexation_Action_Interface;
use Yoast\WP\SEO\Analytics\Domain\Missing_Indexable_Bucket;
use Yoast\WP\SEO\Analytics\Domain\Missing_Indexable_Count;
/**
* Manages the collection of the missing indexable data.
*
* @makePublic
*/
class Missing_Indexables_Collector implements WPSEO_Collection {
/**
* All the indexation actions.
*
* @var array<Indexation_Action_Interface>
*/
private $indexation_actions;
/**
* The collector constructor.
*
* @param Indexation_Action_Interface ...$indexation_actions All the Indexation actions.
*/
public function __construct( Indexation_Action_Interface ...$indexation_actions ) {
$this->indexation_actions = $indexation_actions;
$this->add_additional_indexing_actions();
}
/**
* Gets the data for the tracking collector.
*
* @return array The list of missing indexables.
*/
public function get() {
$missing_indexable_bucket = new Missing_Indexable_Bucket();
foreach ( $this->indexation_actions as $indexation_action ) {
$missing_indexable_count = new Missing_Indexable_Count( \get_class( $indexation_action ), $indexation_action->get_total_unindexed() );
$missing_indexable_bucket->add_missing_indexable_count( $missing_indexable_count );
}
return [ 'missing_indexables' => $missing_indexable_bucket->to_array() ];
}
/**
* Adds additional indexing actions to count from the 'wpseo_indexable_collector_add_indexation_actions' filter.
*
* @return void
*/
private function add_additional_indexing_actions() {
/**
* Filter: Adds the possibility to add additional indexation actions to be included in the count routine.
*
* @internal
* @api Indexation_Action_Interface This filter expects a list of Indexation_Action_Interface instances and expects only Indexation_Action_Interface implementations to be added to the list.
*/
$indexing_actions = (array) \apply_filters( 'wpseo_indexable_collector_add_indexation_actions', $this->indexation_actions );
$this->indexation_actions = \array_filter(
$indexing_actions,
static function ( $indexing_action ) {
return \is_a( $indexing_action, Indexation_Action_Interface::class );
}
);
}
}

View File

@ -0,0 +1,82 @@
<?php
namespace Yoast\WP\SEO\Analytics\Application;
use WPSEO_Collection;
use Yoast\WP\SEO\Analytics\Domain\To_Be_Cleaned_Indexable_Bucket;
use Yoast\WP\SEO\Analytics\Domain\To_Be_Cleaned_Indexable_Count;
use Yoast\WP\SEO\Repositories\Indexable_Cleanup_Repository;
/**
* Collects data about to-be-cleaned indexables.
*
* @makePublic
*/
class To_Be_Cleaned_Indexables_Collector implements WPSEO_Collection {
/**
* The cleanup query repository.
*
* @var Indexable_Cleanup_Repository
*/
private $indexable_cleanup_repository;
/**
* The constructor.
*
* @param Indexable_Cleanup_Repository $indexable_cleanup_repository The Indexable cleanup repository.
*/
public function __construct( Indexable_Cleanup_Repository $indexable_cleanup_repository ) {
$this->indexable_cleanup_repository = $indexable_cleanup_repository;
}
/**
* Gets the data for the collector.
*/
public function get() {
$to_be_cleaned_indexable_bucket = new To_Be_Cleaned_Indexable_Bucket();
$cleanup_tasks = [
'indexables_with_post_object_type_and_shop_order_object_sub_type' => $this->indexable_cleanup_repository->count_indexables_with_object_type_and_object_sub_type( 'post', 'shop_order' ),
'indexables_with_auto-draft_post_status' => $this->indexable_cleanup_repository->count_indexables_with_post_status( 'auto-draft' ),
'indexables_for_non_publicly_viewable_post' => $this->indexable_cleanup_repository->count_indexables_for_non_publicly_viewable_post(),
'indexables_for_non_publicly_viewable_taxonomies' => $this->indexable_cleanup_repository->count_indexables_for_non_publicly_viewable_taxonomies(),
'indexables_for_non_publicly_viewable_post_type_archive_pages' => $this->indexable_cleanup_repository->count_indexables_for_non_publicly_post_type_archive_pages(),
'indexables_for_authors_archive_disabled' => $this->indexable_cleanup_repository->count_indexables_for_authors_archive_disabled(),
'indexables_for_authors_without_archive' => $this->indexable_cleanup_repository->count_indexables_for_authors_without_archive(),
'indexables_for_object_type_and_source_table_users' => $this->indexable_cleanup_repository->count_indexables_for_orphaned_users(),
'indexables_for_object_type_and_source_table_posts' => $this->indexable_cleanup_repository->count_indexables_for_object_type_and_source_table( 'posts', 'ID', 'post' ),
'indexables_for_object_type_and_source_table_terms' => $this->indexable_cleanup_repository->count_indexables_for_object_type_and_source_table( 'terms', 'term_id', 'term' ),
'orphaned_from_table_indexable_hierarchy' => $this->indexable_cleanup_repository->count_orphaned_from_table( 'Indexable_Hierarchy', 'indexable_id' ),
'orphaned_from_table_indexable_id' => $this->indexable_cleanup_repository->count_orphaned_from_table( 'SEO_Links', 'indexable_id' ),
'orphaned_from_table_target_indexable_id' => $this->indexable_cleanup_repository->count_orphaned_from_table( 'SEO_Links', 'target_indexable_id' ),
];
foreach ( $cleanup_tasks as $name => $count ) {
if ( $count !== null ) {
$count_object = new To_Be_Cleaned_Indexable_Count( $name, $count );
$to_be_cleaned_indexable_bucket->add_to_be_cleaned_indexable_count( $count_object );
}
}
$this->add_additional_counts( $to_be_cleaned_indexable_bucket );
return [ 'to_be_cleaned_indexables' => $to_be_cleaned_indexable_bucket->to_array() ];
}
/**
* Allows additional tasks to be added via the 'wpseo_add_cleanup_counts_to_indexable_bucket' action.
*
* @param To_Be_Cleaned_Indexable_Bucket $to_be_cleaned_indexable_bucket The current bucket with data.
*
* @return void
*/
private function add_additional_counts( $to_be_cleaned_indexable_bucket ) {
/**
* Action: Adds the possibility to add additional to be cleaned objects.
*
* @internal
* @api To_Be_Cleaned_Indexable_Bucket An indexable cleanup bucket. New values are instances of To_Be_Cleaned_Indexable_Count.
*/
\do_action( 'wpseo_add_cleanup_counts_to_indexable_bucket', $to_be_cleaned_indexable_bucket );
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace Yoast\WP\SEO\Analytics\Domain;
/**
* A collection domain object.
*/
class Missing_Indexable_Bucket {
/**
* All the missing indexable count objects.
*
* @var array<Missing_Indexable_Count>
*/
private $missing_indexable_counts;
/**
* The constructor.
*/
public function __construct() {
$this->missing_indexable_counts = [];
}
/**
* Adds a missing indexable count object to this bucket.
*
* @param Missing_Indexable_Count $missing_indexable_count The missing indexable count object.
*
* @return void
*/
public function add_missing_indexable_count( Missing_Indexable_Count $missing_indexable_count ): void {
$this->missing_indexable_counts[] = $missing_indexable_count;
}
/**
* Returns the array representation of all indexable counts.
*
* @return array
*/
public function to_array() {
return \array_map(
static function ( $item ) {
return $item->to_array();
},
$this->missing_indexable_counts
);
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace Yoast\WP\SEO\Analytics\Domain;
/**
* Domain object that holds indexable count information.
*/
class Missing_Indexable_Count {
/**
* The indexable type that is represented by this.
*
* @var string
*/
private $indexable_type;
/**
* The amount of missing indexables.
*
* @var int
*/
private $count;
/**
* The constructor.
*
* @param string $indexable_type The indexable type that is represented by this.
* @param int $count The amount of missing indexables.
*/
public function __construct( $indexable_type, $count ) {
$this->indexable_type = $indexable_type;
$this->count = $count;
}
/**
* Returns an array representation of the data.
*
* @return array Returns both values in an array format.
*/
public function to_array() {
return [
'indexable_type' => $this->get_indexable_type(),
'count' => $this->get_count(),
];
}
/**
* Gets the indexable type.
*
* @return string Returns the indexable type.
*/
public function get_indexable_type() {
return $this->indexable_type;
}
/**
* Gets the count.
*
* @return int Returns the amount of missing indexables.
*/
public function get_count() {
return $this->count;
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace Yoast\WP\SEO\Analytics\Domain;
/**
* A collection domain object.
*/
class To_Be_Cleaned_Indexable_Bucket {
/**
* All the to be cleaned indexable count objects.
*
* @var array<To_Be_Cleaned_Indexable_Count>
*/
private $to_be_cleaned_indexable_counts;
/**
* The constructor.
*/
public function __construct() {
$this->to_be_cleaned_indexable_counts = [];
}
/**
* Adds a 'to be cleaned' indexable count object to this bucket.
*
* @param To_Be_Cleaned_Indexable_Count $to_be_cleaned_indexable_counts The to be cleaned indexable count object.
*
* @return void
*/
public function add_to_be_cleaned_indexable_count( To_Be_Cleaned_Indexable_Count $to_be_cleaned_indexable_counts ) {
$this->to_be_cleaned_indexable_counts[] = $to_be_cleaned_indexable_counts;
}
/**
* Returns the array representation of all indexable counts.
*
* @return array
*/
public function to_array() {
return \array_map(
static function ( $item ) {
return $item->to_array();
},
$this->to_be_cleaned_indexable_counts
);
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace Yoast\WP\SEO\Analytics\Domain;
/**
* The to be cleaned indexable domain object.
*/
class To_Be_Cleaned_Indexable_Count {
/**
* The cleanup task that is represented by this.
*
* @var string
*/
private $cleanup_name;
/**
* The amount of missing indexables.
*
* @var int
*/
private $count;
/**
* The constructor.
*
* @param string $cleanup_name The indexable type that is represented by this.
* @param int $count The amount of missing indexables.
*/
public function __construct( $cleanup_name, $count ) {
$this->cleanup_name = $cleanup_name;
$this->count = $count;
}
/**
* Returns an array representation of the data.
*
* @return array Returns both values in an array format.
*/
public function to_array() {
return [
'cleanup_name' => $this->get_cleanup_name(),
'count' => $this->get_count(),
];
}
/**
* Gets the name.
*
* @return string
*/
public function get_cleanup_name() {
return $this->cleanup_name;
}
/**
* Gets the count.
*
* @return int Returns the amount of missing indexables.
*/
public function get_count() {
return $this->count;
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace Yoast\WP\SEO\Analytics\User_Interface;
use Yoast\WP\SEO\Conditionals\No_Conditionals;
use Yoast\WP\SEO\Helpers\Options_Helper;
use Yoast\WP\SEO\Integrations\Integration_Interface;
/**
* Handles setting a timestamp when the indexation of a specific indexation action is completed.
*/
class Last_Completed_Indexation_Integration implements Integration_Interface {
use No_Conditionals;
/**
* The options helper.
*
* @var Options_Helper The options helper.
*/
private $options_helper;
/**
* The constructor.
*
* @param Options_Helper $options_helper The options helper.
*/
public function __construct( Options_Helper $options_helper ) {
$this->options_helper = $options_helper;
}
/**
* Registers action hook to maybe save an option.
*
* @return void
*/
public function register_hooks(): void {
\add_action(
'wpseo_indexables_unindexed_calculated',
[
$this,
'maybe_set_indexables_unindexed_calculated',
],
10,
2
);
}
/**
* Saves a timestamp option when there are no unindexed indexables.
*
* @param string $indexable_name The name of the indexable that is being checked.
* @param int $count The amount of missing indexables.
*
* @return void
*/
public function maybe_set_indexables_unindexed_calculated( string $indexable_name, int $count ): void {
if ( $count === 0 ) {
$no_index = $this->options_helper->get( 'last_known_no_unindexed', [] );
$no_index[ $indexable_name ] = \time();
$this->options_helper->set( 'last_known_no_unindexed', $no_index );
}
}
}

View File

@ -0,0 +1,232 @@
<?php
namespace Yoast\WP\SEO\Builders;
use wpdb;
use Yoast\WP\SEO\Exceptions\Indexable\Author_Not_Built_Exception;
use Yoast\WP\SEO\Helpers\Author_Archive_Helper;
use Yoast\WP\SEO\Helpers\Post_Helper;
use Yoast\WP\SEO\Models\Indexable;
use Yoast\WP\SEO\Values\Indexables\Indexable_Builder_Versions;
/**
* Author Builder for the indexables.
*
* Formats the author meta to indexable format.
*/
class Indexable_Author_Builder {
use Indexable_Social_Image_Trait;
/**
* The author archive helper.
*
* @var Author_Archive_Helper
*/
private $author_archive;
/**
* The latest version of the Indexable_Author_Builder.
*
* @var int
*/
protected $version;
/**
* Holds the taxonomy helper instance.
*
* @var Post_Helper
*/
protected $post_helper;
/**
* The WPDB instance.
*
* @var wpdb
*/
protected $wpdb;
/**
* Indexable_Author_Builder constructor.
*
* @param Author_Archive_Helper $author_archive The author archive helper.
* @param Indexable_Builder_Versions $versions The Indexable version manager.
* @param Post_Helper $post_helper The post helper.
* @param wpdb $wpdb The WPDB instance.
*/
public function __construct(
Author_Archive_Helper $author_archive,
Indexable_Builder_Versions $versions,
Post_Helper $post_helper,
wpdb $wpdb
) {
$this->author_archive = $author_archive;
$this->version = $versions->get_latest_version_for_type( 'user' );
$this->post_helper = $post_helper;
$this->wpdb = $wpdb;
}
/**
* Formats the data.
*
* @param int $user_id The user to retrieve the indexable for.
* @param Indexable $indexable The indexable to format.
*
* @return Indexable The extended indexable.
*
* @throws Author_Not_Built_Exception When author is not built.
*/
public function build( $user_id, Indexable $indexable ) {
$exception = $this->check_if_user_should_be_indexed( $user_id );
if ( $exception ) {
throw $exception;
}
$meta_data = $this->get_meta_data( $user_id );
$indexable->object_id = $user_id;
$indexable->object_type = 'user';
$indexable->permalink = \get_author_posts_url( $user_id );
$indexable->title = $meta_data['wpseo_title'];
$indexable->description = $meta_data['wpseo_metadesc'];
$indexable->is_cornerstone = false;
$indexable->is_robots_noindex = ( $meta_data['wpseo_noindex_author'] === 'on' );
$indexable->is_robots_nofollow = null;
$indexable->is_robots_noarchive = null;
$indexable->is_robots_noimageindex = null;
$indexable->is_robots_nosnippet = null;
$indexable->is_public = ( $indexable->is_robots_noindex ) ? false : null;
$indexable->has_public_posts = $this->author_archive->author_has_public_posts( $user_id );
$indexable->blog_id = \get_current_blog_id();
$this->reset_social_images( $indexable );
$this->handle_social_images( $indexable );
$timestamps = $this->get_object_timestamps( $user_id );
$indexable->object_published_at = $timestamps->published_at;
$indexable->object_last_modified = $timestamps->last_modified;
$indexable->version = $this->version;
return $indexable;
}
/**
* Retrieves the meta data for this indexable.
*
* @param int $user_id The user to retrieve the meta data for.
*
* @return array List of meta entries.
*/
protected function get_meta_data( $user_id ) {
$keys = [
'wpseo_title',
'wpseo_metadesc',
'wpseo_noindex_author',
];
$output = [];
foreach ( $keys as $key ) {
$output[ $key ] = $this->get_author_meta( $user_id, $key );
}
return $output;
}
/**
* Retrieves the author meta.
*
* @param int $user_id The user to retrieve the indexable for.
* @param string $key The meta entry to retrieve.
*
* @return string|null The value of the meta field.
*/
protected function get_author_meta( $user_id, $key ) {
$value = \get_the_author_meta( $key, $user_id );
if ( \is_string( $value ) && $value === '' ) {
return null;
}
return $value;
}
/**
* Finds an alternative image for the social image.
*
* @param Indexable $indexable The indexable.
*
* @return array|bool False when not found, array with data when found.
*/
protected function find_alternative_image( Indexable $indexable ) {
$gravatar_image = \get_avatar_url(
$indexable->object_id,
[
'size' => 500,
'scheme' => 'https',
]
);
if ( $gravatar_image ) {
return [
'image' => $gravatar_image,
'source' => 'gravatar-image',
];
}
return false;
}
/**
* Returns the timestamps for a given author.
*
* @param int $author_id The author ID.
*
* @return object An object with last_modified and published_at timestamps.
*/
protected function get_object_timestamps( $author_id ) {
$post_statuses = $this->post_helper->get_public_post_statuses();
$sql = "
SELECT MAX(p.post_modified_gmt) AS last_modified, MIN(p.post_date_gmt) AS published_at
FROM {$this->wpdb->posts} AS p
WHERE p.post_status IN (" . \implode( ', ', \array_fill( 0, \count( $post_statuses ), '%s' ) ) . ")
AND p.post_password = ''
AND p.post_author = %d
";
$replacements = \array_merge( $post_statuses, [ $author_id ] );
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- We are using wpdb prepare.
return $this->wpdb->get_row( $this->wpdb->prepare( $sql, $replacements ) );
}
/**
* Checks if the user should be indexed.
* Returns an exception with an appropriate message if not.
*
* @param string $user_id The user id.
*
* @return Author_Not_Built_Exception|null The exception if it should not be indexed, or `null` if it should.
*/
protected function check_if_user_should_be_indexed( $user_id ) {
$exception = null;
if ( $this->author_archive->are_disabled() ) {
$exception = Author_Not_Built_Exception::author_archives_are_disabled( $user_id );
}
// We will check if the author has public posts the WP way, instead of the indexable way, to make sure we get proper results even if SEO optimization is not run.
if ( $this->author_archive->author_has_public_posts_wp( $user_id ) === false ) {
$exception = Author_Not_Built_Exception::author_archives_are_not_indexed_for_users_without_posts( $user_id );
}
/**
* Filter: Include or exclude a user from being build and saved as an indexable.
* Return an `Author_Not_Built_Exception` when the indexable should not be build, with an appropriate message telling why it should not be built.
* Return `null` if the indexable should be build.
*
* @param Author_Not_Built_Exception|null $exception An exception if the indexable is not being built, `null` if the indexable should be built.
* @param string $user_id The ID of the user that should or should not be excluded.
*/
return \apply_filters( 'wpseo_should_build_and_save_user_indexable', $exception, $user_id );
}
}

View File

@ -0,0 +1,460 @@
<?php
namespace Yoast\WP\SEO\Builders;
use Yoast\WP\SEO\Exceptions\Indexable\Not_Built_Exception;
use Yoast\WP\SEO\Exceptions\Indexable\Source_Exception;
use Yoast\WP\SEO\Helpers\Indexable_Helper;
use Yoast\WP\SEO\Models\Indexable;
use Yoast\WP\SEO\Repositories\Indexable_Repository;
use Yoast\WP\SEO\Services\Indexables\Indexable_Version_Manager;
/**
* Builder for the indexables.
*
* Creates all the indexables.
*/
class Indexable_Builder {
/**
* The author builder.
*
* @var Indexable_Author_Builder
*/
private $author_builder;
/**
* The post builder.
*
* @var Indexable_Post_Builder
*/
private $post_builder;
/**
* The term builder.
*
* @var Indexable_Term_Builder
*/
private $term_builder;
/**
* The home page builder.
*
* @var Indexable_Home_Page_Builder
*/
private $home_page_builder;
/**
* The post type archive builder.
*
* @var Indexable_Post_Type_Archive_Builder
*/
private $post_type_archive_builder;
/**
* The data archive builder.
*
* @var Indexable_Date_Archive_Builder
*/
private $date_archive_builder;
/**
* The system page builder.
*
* @var Indexable_System_Page_Builder
*/
private $system_page_builder;
/**
* The indexable hierarchy builder.
*
* @var Indexable_Hierarchy_Builder
*/
private $hierarchy_builder;
/**
* The primary term builder
*
* @var Primary_Term_Builder
*/
private $primary_term_builder;
/**
* The link builder
*
* @var Indexable_Link_Builder
*/
private $link_builder;
/**
* The indexable repository.
*
* @var Indexable_Repository
*/
private $indexable_repository;
/**
* The indexable helper.
*
* @var Indexable_Helper
*/
protected $indexable_helper;
/**
* The Indexable Version Manager.
*
* @var Indexable_Version_Manager
*/
protected $version_manager;
/**
* Returns the instance of this class constructed through the ORM Wrapper.
*
* @param Indexable_Author_Builder $author_builder The author builder for creating missing indexables.
* @param Indexable_Post_Builder $post_builder The post builder for creating missing indexables.
* @param Indexable_Term_Builder $term_builder The term builder for creating missing indexables.
* @param Indexable_Home_Page_Builder $home_page_builder The front page builder for creating missing indexables.
* @param Indexable_Post_Type_Archive_Builder $post_type_archive_builder The post type archive builder for creating missing indexables.
* @param Indexable_Date_Archive_Builder $date_archive_builder The date archive builder for creating missing indexables.
* @param Indexable_System_Page_Builder $system_page_builder The search result builder for creating missing indexables.
* @param Indexable_Hierarchy_Builder $hierarchy_builder The hierarchy builder for creating the indexable hierarchy.
* @param Primary_Term_Builder $primary_term_builder The primary term builder for creating primary terms for posts.
* @param Indexable_Helper $indexable_helper The indexable helper.
* @param Indexable_Version_Manager $version_manager The indexable version manager.
* @param Indexable_Link_Builder $link_builder The link builder for creating missing SEO links.
*/
public function __construct(
Indexable_Author_Builder $author_builder,
Indexable_Post_Builder $post_builder,
Indexable_Term_Builder $term_builder,
Indexable_Home_Page_Builder $home_page_builder,
Indexable_Post_Type_Archive_Builder $post_type_archive_builder,
Indexable_Date_Archive_Builder $date_archive_builder,
Indexable_System_Page_Builder $system_page_builder,
Indexable_Hierarchy_Builder $hierarchy_builder,
Primary_Term_Builder $primary_term_builder,
Indexable_Helper $indexable_helper,
Indexable_Version_Manager $version_manager,
Indexable_Link_Builder $link_builder
) {
$this->author_builder = $author_builder;
$this->post_builder = $post_builder;
$this->term_builder = $term_builder;
$this->home_page_builder = $home_page_builder;
$this->post_type_archive_builder = $post_type_archive_builder;
$this->date_archive_builder = $date_archive_builder;
$this->system_page_builder = $system_page_builder;
$this->hierarchy_builder = $hierarchy_builder;
$this->primary_term_builder = $primary_term_builder;
$this->indexable_helper = $indexable_helper;
$this->version_manager = $version_manager;
$this->link_builder = $link_builder;
}
/**
* Sets the indexable repository. Done to avoid circular dependencies.
*
* @required
*
* @param Indexable_Repository $indexable_repository The indexable repository.
*/
public function set_indexable_repository( Indexable_Repository $indexable_repository ) {
$this->indexable_repository = $indexable_repository;
}
/**
* Creates a clean copy of an Indexable to allow for later database operations.
*
* @param Indexable $indexable The Indexable to copy.
*
* @return bool|Indexable
*/
protected function deep_copy_indexable( $indexable ) {
return $this->indexable_repository
->query()
->create( $indexable->as_array() );
}
/**
* Creates an indexable by its ID and type.
*
* @param int $object_id The indexable object ID.
* @param string $object_type The indexable object type.
* @param Indexable|bool $indexable Optional. An existing indexable to overwrite.
*
* @return bool|Indexable Instance of indexable. False when unable to build.
*/
public function build_for_id_and_type( $object_id, $object_type, $indexable = false ) {
$defaults = [
'object_type' => $object_type,
'object_id' => $object_id,
];
$indexable = $this->build( $indexable, $defaults );
return $indexable;
}
/**
* Creates an indexable for the homepage.
*
* @param Indexable|bool $indexable Optional. An existing indexable to overwrite.
*
* @return Indexable The home page indexable.
*/
public function build_for_home_page( $indexable = false ) {
return $this->build( $indexable, [ 'object_type' => 'home-page' ] );
}
/**
* Creates an indexable for the date archive.
*
* @param Indexable|bool $indexable Optional. An existing indexable to overwrite.
*
* @return Indexable The date archive indexable.
*/
public function build_for_date_archive( $indexable = false ) {
return $this->build( $indexable, [ 'object_type' => 'date-archive' ] );
}
/**
* Creates an indexable for a post type archive.
*
* @param string $post_type The post type.
* @param Indexable|bool $indexable Optional. An existing indexable to overwrite.
*
* @return Indexable The post type archive indexable.
*/
public function build_for_post_type_archive( $post_type, $indexable = false ) {
$defaults = [
'object_type' => 'post-type-archive',
'object_sub_type' => $post_type,
];
return $this->build( $indexable, $defaults );
}
/**
* Creates an indexable for a system page.
*
* @param string $page_type The type of system page.
* @param Indexable|bool $indexable Optional. An existing indexable to overwrite.
*
* @return Indexable The search result indexable.
*/
public function build_for_system_page( $page_type, $indexable = false ) {
$defaults = [
'object_type' => 'system-page',
'object_sub_type' => $page_type,
];
return $this->build( $indexable, $defaults );
}
/**
* Ensures we have a valid indexable. Creates one if false is passed.
*
* @param Indexable|false $indexable The indexable.
* @param array $defaults The initial properties of the Indexable.
*
* @return Indexable The indexable.
*/
protected function ensure_indexable( $indexable, $defaults = [] ) {
if ( ! $indexable ) {
return $this->indexable_repository->query()->create( $defaults );
}
return $indexable;
}
/**
* Saves and returns an indexable (on production environments only).
*
* @param Indexable $indexable The indexable.
* @param Indexable|null $indexable_before The indexable before possible changes.
*
* @return Indexable The indexable.
*/
protected function save_indexable( $indexable, $indexable_before = null ) {
$intend_to_save = $this->indexable_helper->should_index_indexables();
/**
* Filter: 'wpseo_should_save_indexable' - Allow developers to enable / disable
* saving the indexable when the indexable is updated. Warning: overriding
* the intended action may cause problems when moving from a staging to a
* production environment because indexable permalinks may get set incorrectly.
*
* @param Indexable $indexable The indexable to be saved.
*
* @api bool $intend_to_save True if YoastSEO intends to save the indexable.
*/
$intend_to_save = \apply_filters( 'wpseo_should_save_indexable', $intend_to_save, $indexable );
if ( ! $intend_to_save ) {
return $indexable;
}
// Save the indexable before running the WordPress hook.
$indexable->save();
if ( $indexable_before ) {
/**
* Action: 'wpseo_save_indexable' - Allow developers to perform an action
* when the indexable is updated.
*
* @param Indexable $indexable_before The indexable before saving.
*
* @api Indexable $indexable The saved indexable.
*/
\do_action( 'wpseo_save_indexable', $indexable, $indexable_before );
}
return $indexable;
}
/**
* Build and author indexable from an author id if it does not exist yet, or if the author indexable needs to be upgraded.
*
* @param int $author_id The author id.
*
* @return Indexable|false The author indexable if it has been built, `false` if it could not be built.
*/
protected function maybe_build_author_indexable( $author_id ) {
$author_indexable = $this->indexable_repository->find_by_id_and_type(
$author_id,
'user',
false
);
if ( ! $author_indexable || $this->version_manager->indexable_needs_upgrade( $author_indexable ) ) {
// Try to build the author.
$author_defaults = [
'object_type' => 'user',
'object_id' => $author_id,
];
$author_indexable = $this->build( $author_indexable, $author_defaults );
}
return $author_indexable;
}
/**
* Checks if the indexable type is one that is not supposed to have object ID for.
*
* @param string $type The type of the indexable.
*
* @return bool Whether the indexable type is one that is not supposed to have object ID for.
*/
protected function is_type_with_no_id( $type ) {
return \in_array( $type, [ 'home-page', 'date-archive', 'post-type-archive', 'system-page' ], true );
}
// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.Missing -- Exceptions are handled by the catch statement in the method.
/**
* Rebuilds an Indexable from scratch.
*
* @param Indexable $indexable The Indexable to (re)build.
* @param array|null $defaults The object type of the Indexable.
*
* @return Indexable|false The resulting Indexable.
*/
public function build( $indexable, $defaults = null ) {
// Backup the previous Indexable, if there was one.
$indexable_before = ( $indexable ) ? $this->deep_copy_indexable( $indexable ) : null;
// Make sure we have an Indexable to work with.
$indexable = $this->ensure_indexable( $indexable, $defaults );
try {
if ( $indexable->object_id === 0 ) {
throw Not_Built_Exception::invalid_object_id( $indexable->object_id );
}
switch ( $indexable->object_type ) {
case 'post':
$indexable = $this->post_builder->build( $indexable->object_id, $indexable );
// Save indexable, to make sure it can be queried when building related objects like the author indexable and hierarchy.
$indexable = $this->save_indexable( $indexable, $indexable_before );
// For attachments, we have to make sure to patch any potentially previously cleaned up SEO links.
if ( \is_a( $indexable, Indexable::class ) && $indexable->object_sub_type === 'attachment' ) {
$this->link_builder->patch_seo_links( $indexable );
}
// Always rebuild the primary term.
$this->primary_term_builder->build( $indexable->object_id );
// Always rebuild the hierarchy; this needs the primary term to run correctly.
$this->hierarchy_builder->build( $indexable );
$this->maybe_build_author_indexable( $indexable->author_id );
// The indexable is already saved, so return early.
return $indexable;
case 'user':
$indexable = $this->author_builder->build( $indexable->object_id, $indexable );
break;
case 'term':
$indexable = $this->term_builder->build( $indexable->object_id, $indexable );
// Save indexable, to make sure it can be queried when building hierarchy.
$indexable = $this->save_indexable( $indexable, $indexable_before );
$this->hierarchy_builder->build( $indexable );
// The indexable is already saved, so return early.
return $indexable;
case 'home-page':
$indexable = $this->home_page_builder->build( $indexable );
break;
case 'date-archive':
$indexable = $this->date_archive_builder->build( $indexable );
break;
case 'post-type-archive':
$indexable = $this->post_type_archive_builder->build( $indexable->object_sub_type, $indexable );
break;
case 'system-page':
$indexable = $this->system_page_builder->build( $indexable->object_sub_type, $indexable );
break;
}
return $this->save_indexable( $indexable, $indexable_before );
}
catch ( Source_Exception $exception ) {
if ( ! $this->is_type_with_no_id( $indexable->object_type ) && ( ! isset( $indexable->object_id ) || \is_null( $indexable->object_id ) ) ) {
return false;
}
/**
* The current indexable could not be indexed. Create a placeholder indexable, so we can
* skip this indexable in future indexing runs.
*
* @var Indexable $indexable
*/
$indexable = $this->ensure_indexable(
$indexable,
[
'object_id' => $indexable->object_id,
'object_type' => $indexable->object_type,
'post_status' => 'unindexed',
'version' => 0,
]
);
// If we already had an existing indexable, mark it as unindexed. We cannot rely on its validity anymore.
$indexable->post_status = 'unindexed';
// Make sure that the indexing process doesn't get stuck in a loop on this broken indexable.
$indexable = $this->version_manager->set_latest( $indexable );
return $this->save_indexable( $indexable, $indexable_before );
}
catch ( Not_Built_Exception $exception ) {
return false;
}
}
// phpcs:enable
}

View File

@ -0,0 +1,63 @@
<?php
namespace Yoast\WP\SEO\Builders;
use Yoast\WP\SEO\Helpers\Options_Helper;
use Yoast\WP\SEO\Models\Indexable;
use Yoast\WP\SEO\Values\Indexables\Indexable_Builder_Versions;
/**
* Date Archive Builder for the indexables.
*
* Formats the date archive meta to indexable format.
*/
class Indexable_Date_Archive_Builder {
/**
* The options helper.
*
* @var Options_Helper
*/
private $options;
/**
* The latest version of the Indexable_Date_Archive_Builder.
*
* @var int
*/
protected $version;
/**
* Indexable_Date_Archive_Builder constructor.
*
* @param Options_Helper $options The options helper.
* @param Indexable_Builder_Versions $versions The latest version for all indexable builders.
*/
public function __construct(
Options_Helper $options,
Indexable_Builder_Versions $versions
) {
$this->options = $options;
$this->version = $versions->get_latest_version_for_type( 'date-archive' );
}
/**
* Formats the data.
*
* @param Indexable $indexable The indexable to format.
*
* @return Indexable The extended indexable.
*/
public function build( $indexable ) {
$indexable->object_type = 'date-archive';
$indexable->title = $this->options->get( 'title-archive-wpseo' );
$indexable->description = $this->options->get( 'metadesc-archive-wpseo' );
$indexable->is_robots_noindex = $this->options->get( 'noindex-archive-wpseo' );
$indexable->is_public = ( (int) $indexable->is_robots_noindex !== 1 );
$indexable->blog_id = \get_current_blog_id();
$indexable->permalink = null;
$indexable->version = $this->version;
return $indexable;
}
}

View File

@ -0,0 +1,400 @@
<?php
namespace Yoast\WP\SEO\Builders;
use WP_Post;
use WP_Term;
use WPSEO_Meta;
use Yoast\WP\SEO\Helpers\Options_Helper;
use Yoast\WP\SEO\Helpers\Post_Helper;
use Yoast\WP\SEO\Models\Indexable;
use Yoast\WP\SEO\Repositories\Indexable_Hierarchy_Repository;
use Yoast\WP\SEO\Repositories\Indexable_Repository;
use Yoast\WP\SEO\Repositories\Primary_Term_Repository;
/**
* Builder for the indexables hierarchy.
*
* Builds the indexable hierarchy for indexables.
*/
class Indexable_Hierarchy_Builder {
/**
* Holds a list of indexables where the ancestors are saved for.
*
* @var array
*/
protected $saved_ancestors = [];
/**
* The indexable repository.
*
* @var Indexable_Repository
*/
private $indexable_repository;
/**
* The indexable hierarchy repository.
*
* @var Indexable_Hierarchy_Repository
*/
private $indexable_hierarchy_repository;
/**
* The primary term repository.
*
* @var Primary_Term_Repository
*/
private $primary_term_repository;
/**
* The options helper.
*
* @var Options_Helper
*/
private $options;
/**
* Holds the Post_Helper instance.
*
* @var Post_Helper
*/
private $post;
/**
* Indexable_Author_Builder constructor.
*
* @param Indexable_Hierarchy_Repository $indexable_hierarchy_repository The indexable hierarchy repository.
* @param Primary_Term_Repository $primary_term_repository The primary term repository.
* @param Options_Helper $options The options helper.
* @param Post_Helper $post The post helper.
*/
public function __construct(
Indexable_Hierarchy_Repository $indexable_hierarchy_repository,
Primary_Term_Repository $primary_term_repository,
Options_Helper $options,
Post_Helper $post
) {
$this->indexable_hierarchy_repository = $indexable_hierarchy_repository;
$this->primary_term_repository = $primary_term_repository;
$this->options = $options;
$this->post = $post;
}
/**
* Sets the indexable repository. Done to avoid circular dependencies.
*
* @required
*
* @param Indexable_Repository $indexable_repository The indexable repository.
*/
public function set_indexable_repository( Indexable_Repository $indexable_repository ) {
$this->indexable_repository = $indexable_repository;
}
/**
* Builds the ancestor hierarchy for an indexable.
*
* @param Indexable $indexable The indexable.
*
* @return Indexable The indexable.
*/
public function build( Indexable $indexable ) {
if ( $this->hierarchy_is_built( $indexable ) ) {
return $indexable;
}
$this->indexable_hierarchy_repository->clear_ancestors( $indexable->id );
$indexable_id = $this->get_indexable_id( $indexable );
$ancestors = [];
if ( $indexable->object_type === 'post' ) {
$this->add_ancestors_for_post( $indexable_id, $indexable->object_id, $ancestors );
}
if ( $indexable->object_type === 'term' ) {
$this->add_ancestors_for_term( $indexable_id, $indexable->object_id, $ancestors );
}
$indexable->ancestors = \array_reverse( \array_values( $ancestors ) );
$indexable->has_ancestors = ! empty( $ancestors );
if ( $indexable->id ) {
$this->save_ancestors( $indexable );
}
return $indexable;
}
/**
* Checks if a hierarchy is built already for the given indexable.
*
* @param Indexable $indexable The indexable to check.
*
* @return bool True when indexable has a built hierarchy.
*/
protected function hierarchy_is_built( Indexable $indexable ) {
if ( \in_array( $indexable->id, $this->saved_ancestors, true ) ) {
return true;
}
$this->saved_ancestors[] = $indexable->id;
return false;
}
/**
* Saves the ancestors.
*
* @param Indexable $indexable The indexable.
*
* @return void
*/
private function save_ancestors( $indexable ) {
if ( empty( $indexable->ancestors ) ) {
$this->indexable_hierarchy_repository->add_ancestor( $indexable->id, 0, 0 );
return;
}
$depth = \count( $indexable->ancestors );
foreach ( $indexable->ancestors as $ancestor ) {
$this->indexable_hierarchy_repository->add_ancestor( $indexable->id, $ancestor->id, $depth );
--$depth;
}
}
/**
* Adds ancestors for a post.
*
* @param int $indexable_id The indexable id, this is the id of the original indexable.
* @param int $post_id The post id, this is the id of the post currently being evaluated.
* @param int[] $parents The indexable IDs of all parents.
*
* @return void
*/
private function add_ancestors_for_post( $indexable_id, $post_id, &$parents ) {
$post = $this->post->get_post( $post_id );
if ( ! isset( $post->post_parent ) ) {
return;
}
if ( $post->post_parent !== 0 && $this->post->get_post( $post->post_parent ) !== null ) {
$ancestor = $this->indexable_repository->find_by_id_and_type( $post->post_parent, 'post' );
if ( $this->is_invalid_ancestor( $ancestor, $indexable_id, $parents ) ) {
return;
}
$parents[ $this->get_indexable_id( $ancestor ) ] = $ancestor;
$this->add_ancestors_for_post( $indexable_id, $ancestor->object_id, $parents );
return;
}
$primary_term_id = $this->find_primary_term_id_for_post( $post );
if ( $primary_term_id === 0 ) {
return;
}
$ancestor = $this->indexable_repository->find_by_id_and_type( $primary_term_id, 'term' );
if ( $this->is_invalid_ancestor( $ancestor, $indexable_id, $parents ) ) {
return;
}
$parents[ $this->get_indexable_id( $ancestor ) ] = $ancestor;
$this->add_ancestors_for_term( $indexable_id, $ancestor->object_id, $parents );
}
/**
* Adds ancestors for a term.
*
* @param int $indexable_id The indexable id, this is the id of the original indexable.
* @param int $term_id The term id, this is the id of the term currently being evaluated.
* @param int[] $parents The indexable IDs of all parents.
*
* @return void
*/
private function add_ancestors_for_term( $indexable_id, $term_id, &$parents = [] ) {
$term = \get_term( $term_id );
$term_parents = $this->get_term_parents( $term );
foreach ( $term_parents as $parent ) {
$ancestor = $this->indexable_repository->find_by_id_and_type( $parent->term_id, 'term' );
if ( $this->is_invalid_ancestor( $ancestor, $indexable_id, $parents ) ) {
continue;
}
$parents[ $this->get_indexable_id( $ancestor ) ] = $ancestor;
}
}
/**
* Gets the primary term ID for a post.
*
* @param WP_Post $post The post.
*
* @return int The primary term ID. 0 if none exists.
*/
private function find_primary_term_id_for_post( $post ) {
$main_taxonomy = $this->options->get( 'post_types-' . $post->post_type . '-maintax' );
if ( ! $main_taxonomy || $main_taxonomy === '0' ) {
return 0;
}
$primary_term_id = $this->get_primary_term_id( $post->ID, $main_taxonomy );
if ( $primary_term_id ) {
$term = \get_term( $primary_term_id );
if ( $term !== null && ! \is_wp_error( $term ) ) {
return $primary_term_id;
}
}
$terms = \get_the_terms( $post->ID, $main_taxonomy );
if ( ! \is_array( $terms ) || empty( $terms ) ) {
return 0;
}
return $this->find_deepest_term_id( $terms );
}
/**
* Find the deepest term in an array of term objects.
*
* @param array $terms Terms set.
*
* @return int The deepest term ID.
*/
private function find_deepest_term_id( $terms ) {
/*
* Let's find the deepest term in this array, by looping through and then
* unsetting every term that is used as a parent by another one in the array.
*/
$terms_by_id = [];
foreach ( $terms as $term ) {
$terms_by_id[ $term->term_id ] = $term;
}
foreach ( $terms as $term ) {
unset( $terms_by_id[ $term->parent ] );
}
/*
* As we could still have two subcategories, from different parent categories,
* let's pick the one with the lowest ordered ancestor.
*/
$parents_count = -1;
$term_order = 9999; // Because ASC.
$deepest_term = \reset( $terms_by_id );
foreach ( $terms_by_id as $term ) {
$parents = $this->get_term_parents( $term );
$new_parents_count = \count( $parents );
if ( $new_parents_count < $parents_count ) {
continue;
}
$parent_order = 9999; // Set default order.
foreach ( $parents as $parent ) {
if ( $parent->parent === 0 && isset( $parent->term_order ) ) {
$parent_order = $parent->term_order;
}
}
// Check if parent has lowest order.
if ( $new_parents_count > $parents_count || $parent_order < $term_order ) {
$term_order = $parent_order;
$deepest_term = $term;
}
$parents_count = $new_parents_count;
}
return $deepest_term->term_id;
}
/**
* Get a term's parents.
*
* @param WP_Term $term Term to get the parents for.
*
* @return WP_Term[] An array of all this term's parents.
*/
private function get_term_parents( $term ) {
$tax = $term->taxonomy;
$parents = [];
while ( (int) $term->parent !== 0 ) {
$term = \get_term( $term->parent, $tax );
$parents[] = $term;
}
return $parents;
}
/**
* Checks if an ancestor is valid to add.
*
* @param Indexable $ancestor The ancestor (presumed indexable) to check.
* @param int $indexable_id The indexable id we're adding ancestors for.
* @param int[] $parents The indexable ids of the parents already added.
*
* @return bool
*/
private function is_invalid_ancestor( $ancestor, $indexable_id, $parents ) {
// If the ancestor is not an Indexable, it is invalid by default.
if ( ! \is_a( $ancestor, 'Yoast\WP\SEO\Models\Indexable' ) ) {
return true;
}
// Don't add ancestors if they're unindexed, already added or the same as the main object.
if ( $ancestor->post_status === 'unindexed' ) {
return true;
}
$ancestor_id = $this->get_indexable_id( $ancestor );
if ( \array_key_exists( $ancestor_id, $parents ) ) {
return true;
}
if ( $ancestor_id === $indexable_id ) {
return true;
}
return false;
}
/**
* Returns the ID for an indexable. Catches situations where the id is null due to errors.
*
* @param Indexable $indexable The indexable.
*
* @return string|int A unique ID for the indexable.
*/
private function get_indexable_id( Indexable $indexable ) {
if ( $indexable->id === 0 ) {
return "{$indexable->object_type}:{$indexable->object_id}";
}
return $indexable->id;
}
/**
* Returns the primary term id of a post.
*
* @param int $post_id The post ID.
* @param string $main_taxonomy The main taxonomy.
*
* @return int The ID of the primary term.
*/
private function get_primary_term_id( $post_id, $main_taxonomy ) {
$primary_term = $this->primary_term_repository->find_by_post_id_and_taxonomy( $post_id, $main_taxonomy, false );
if ( $primary_term ) {
return $primary_term->term_id;
}
return \get_post_meta( $post_id, WPSEO_Meta::$meta_prefix . 'primary_' . $main_taxonomy, true );
}
}

View File

@ -0,0 +1,143 @@
<?php
namespace Yoast\WP\SEO\Builders;
use wpdb;
use Yoast\WP\SEO\Helpers\Options_Helper;
use Yoast\WP\SEO\Helpers\Post_Helper;
use Yoast\WP\SEO\Helpers\Url_Helper;
use Yoast\WP\SEO\Models\Indexable;
use Yoast\WP\SEO\Values\Indexables\Indexable_Builder_Versions;
/**
* Homepage Builder for the indexables.
*
* Formats the homepage meta to indexable format.
*/
class Indexable_Home_Page_Builder {
use Indexable_Social_Image_Trait;
/**
* The options helper.
*
* @var Options_Helper
*/
protected $options;
/**
* The URL helper.
*
* @var Url_Helper
*/
protected $url_helper;
/**
* The latest version of the Indexable-Home-Page-Builder.
*
* @var int
*/
protected $version;
/**
* Holds the taxonomy helper instance.
*
* @var Post_Helper
*/
protected $post_helper;
/**
* The WPDB instance.
*
* @var wpdb
*/
protected $wpdb;
/**
* Indexable_Home_Page_Builder constructor.
*
* @param Options_Helper $options The options helper.
* @param Url_Helper $url_helper The url helper.
* @param Indexable_Builder_Versions $versions Knows the latest version of each Indexable type.
* @param Post_Helper $post_helper The post helper.
* @param wpdb $wpdb The WPDB instance.
*/
public function __construct(
Options_Helper $options,
Url_Helper $url_helper,
Indexable_Builder_Versions $versions,
Post_Helper $post_helper,
wpdb $wpdb
) {
$this->options = $options;
$this->url_helper = $url_helper;
$this->version = $versions->get_latest_version_for_type( 'home-page' );
$this->post_helper = $post_helper;
$this->wpdb = $wpdb;
}
/**
* Formats the data.
*
* @param Indexable $indexable The indexable to format.
*
* @return Indexable The extended indexable.
*/
public function build( $indexable ) {
$indexable->object_type = 'home-page';
$indexable->title = $this->options->get( 'title-home-wpseo' );
$indexable->breadcrumb_title = $this->options->get( 'breadcrumbs-home' );
$indexable->permalink = $this->url_helper->home();
$indexable->blog_id = \get_current_blog_id();
$indexable->description = $this->options->get( 'metadesc-home-wpseo' );
if ( empty( $indexable->description ) ) {
$indexable->description = \get_bloginfo( 'description' );
}
$indexable->is_robots_noindex = \get_option( 'blog_public' ) === '0';
$indexable->open_graph_title = $this->options->get( 'open_graph_frontpage_title' );
$indexable->open_graph_image = $this->options->get( 'open_graph_frontpage_image' );
$indexable->open_graph_image_id = $this->options->get( 'open_graph_frontpage_image_id' );
$indexable->open_graph_description = $this->options->get( 'open_graph_frontpage_desc' );
// Reset the OG image source & meta.
$indexable->open_graph_image_source = null;
$indexable->open_graph_image_meta = null;
// When the image or image id is set.
if ( $indexable->open_graph_image || $indexable->open_graph_image_id ) {
$indexable->open_graph_image_source = 'set-by-user';
$this->set_open_graph_image_meta_data( $indexable );
}
$timestamps = $this->get_object_timestamps();
$indexable->object_published_at = $timestamps->published_at;
$indexable->object_last_modified = $timestamps->last_modified;
$indexable->version = $this->version;
return $indexable;
}
/**
* Returns the timestamps for the homepage.
*
* @return object An object with last_modified and published_at timestamps.
*/
protected function get_object_timestamps() {
$post_statuses = $this->post_helper->get_public_post_statuses();
$sql = "
SELECT MAX(p.post_modified_gmt) AS last_modified, MIN(p.post_date_gmt) AS published_at
FROM {$this->wpdb->posts} AS p
WHERE p.post_status IN (" . \implode( ', ', \array_fill( 0, \count( $post_statuses ), '%s' ) ) . ")
AND p.post_password = ''
AND p.post_type = 'post'
";
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- We are using wpdb prepare.
return $this->wpdb->get_row( $this->wpdb->prepare( $sql, $post_statuses ) );
}
}

View File

@ -0,0 +1,577 @@
<?php
namespace Yoast\WP\SEO\Builders;
use WPSEO_Image_Utils;
use Yoast\WP\SEO\Helpers\Image_Helper;
use Yoast\WP\SEO\Helpers\Options_Helper;
use Yoast\WP\SEO\Helpers\Post_Helper;
use Yoast\WP\SEO\Helpers\Url_Helper;
use Yoast\WP\SEO\Models\Indexable;
use Yoast\WP\SEO\Models\SEO_Links;
use Yoast\WP\SEO\Repositories\Indexable_Repository;
use Yoast\WP\SEO\Repositories\SEO_Links_Repository;
/**
* Indexable link builder.
*/
class Indexable_Link_Builder {
/**
* The SEO links repository.
*
* @var SEO_Links_Repository
*/
protected $seo_links_repository;
/**
* The url helper.
*
* @var Url_Helper
*/
protected $url_helper;
/**
* The image helper.
*
* @var Image_Helper
*/
protected $image_helper;
/**
* The post helper.
*
* @var Post_Helper
*/
protected $post_helper;
/**
* The options helper.
*
* @var Options_Helper
*/
protected $options_helper;
/**
* The indexable repository.
*
* @var Indexable_Repository
*/
protected $indexable_repository;
/**
* Indexable_Link_Builder constructor.
*
* @param SEO_Links_Repository $seo_links_repository The SEO links repository.
* @param Url_Helper $url_helper The URL helper.
* @param Post_Helper $post_helper The post helper.
* @param Options_Helper $options_helper The options helper.
*/
public function __construct(
SEO_Links_Repository $seo_links_repository,
Url_Helper $url_helper,
Post_Helper $post_helper,
Options_Helper $options_helper
) {
$this->seo_links_repository = $seo_links_repository;
$this->url_helper = $url_helper;
$this->post_helper = $post_helper;
$this->options_helper = $options_helper;
}
/**
* Sets the indexable repository.
*
* @required
*
* @param Indexable_Repository $indexable_repository The indexable repository.
* @param Image_Helper $image_helper The image helper.
*
* @return void
*/
public function set_dependencies(
Indexable_Repository $indexable_repository,
Image_Helper $image_helper
) {
$this->indexable_repository = $indexable_repository;
$this->image_helper = $image_helper;
}
/**
* Builds the links for a post.
*
* @param Indexable $indexable The indexable.
* @param string $content The content. Expected to be unfiltered.
*
* @return SEO_Links[] The created SEO links.
*/
public function build( $indexable, $content ) {
global $post;
if ( $indexable->object_type === 'post' ) {
$post_backup = $post;
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- To setup the post we need to do this explicitly.
$post = $this->post_helper->get_post( $indexable->object_id );
\setup_postdata( $post );
$content = \apply_filters( 'the_content', $content );
\wp_reset_postdata();
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- To setup the post we need to do this explicitly.
$post = $post_backup;
}
$content = \str_replace( ']]>', ']]&gt;', $content );
$links = $this->gather_links( $content );
$images = $this->gather_images( $content );
if ( empty( $links ) && empty( $images ) ) {
$indexable->link_count = 0;
$this->update_related_indexables( $indexable, [] );
return [];
}
$links = $this->create_links( $indexable, $links, $images );
$this->update_related_indexables( $indexable, $links );
$indexable->link_count = $this->get_internal_link_count( $links );
return $links;
}
/**
* Deletes all SEO links for an indexable.
*
* @param Indexable $indexable The indexable.
*
* @return void
*/
public function delete( $indexable ) {
$links = ( $this->seo_links_repository->find_all_by_indexable_id( $indexable->id ) );
$this->seo_links_repository->delete_all_by_indexable_id( $indexable->id );
$linked_indexable_ids = [];
foreach ( $links as $link ) {
if ( $link->target_indexable_id ) {
$linked_indexable_ids[] = $link->target_indexable_id;
}
}
$this->update_incoming_links_for_related_indexables( $linked_indexable_ids );
}
/**
* Fixes existing SEO links that are supposed to have a target indexable but don't, because of prior indexable cleanup.
*
* @param Indexable $indexable The indexable to be the target of SEO Links.
*
* @return void
*/
public function patch_seo_links( Indexable $indexable ) {
if ( ! empty( $indexable->id ) && ! empty( $indexable->object_id ) ) {
$links = $this->seo_links_repository->find_all_by_target_post_id( $indexable->object_id );
$updated_indexable = false;
foreach ( $links as $link ) {
if ( \is_a( $link, SEO_Links::class ) && empty( $link->target_indexable_id ) ) {
// Since that post ID exists in an SEO link but has no target_indexable_id, it's probably because of prior indexable cleanup.
$this->seo_links_repository->update_target_indexable_id( $link->id, $indexable->id );
$updated_indexable = true;
}
}
if ( $updated_indexable ) {
$updated_indexable_id = [ $indexable->id ];
$this->update_incoming_links_for_related_indexables( $updated_indexable_id );
}
}
}
/**
* Gathers all links from content.
*
* @param string $content The content.
*
* @return string[] An array of urls.
*/
protected function gather_links( $content ) {
if ( \strpos( $content, 'href' ) === false ) {
// Nothing to do.
return [];
}
$links = [];
$regexp = '<a\s[^>]*href=("??)([^" >]*?)\1[^>]*>';
// Used modifiers iU to match case insensitive and make greedy quantifiers lazy.
if ( \preg_match_all( "/$regexp/iU", $content, $matches, \PREG_SET_ORDER ) ) {
foreach ( $matches as $match ) {
$links[] = \trim( $match[2], "'" );
}
}
return $links;
}
/**
* Gathers all images from content.
*
* @param string $content The content.
*
* @return string[] An array of urls.
*/
protected function gather_images( $content ) {
if ( \strpos( $content, 'src' ) === false ) {
// Nothing to do.
return [];
}
$images = [];
$regexp = '<img\s[^>]*src=("??)([^" >]*?)\\1[^>]*>';
// Used modifiers iU to match case insensitive and make greedy quantifiers lazy.
if ( \preg_match_all( "/$regexp/iU", $content, $matches, \PREG_SET_ORDER ) ) {
foreach ( $matches as $match ) {
$images[] = \trim( $match[2], "'" );
}
}
return $images;
}
/**
* Creates link models from lists of URLs and image sources.
*
* @param Indexable $indexable The indexable.
* @param string[] $links The link URLs.
* @param string[] $images The image sources.
*
* @return SEO_Links[] The link models.
*/
protected function create_links( $indexable, $links, $images ) {
$home_url = \wp_parse_url( \home_url() );
$current_url = \wp_parse_url( $indexable->permalink );
$links = \array_map(
function( $link ) use ( $home_url, $indexable ) {
return $this->create_internal_link( $link, $home_url, $indexable );
},
$links
);
// Filter out links to the same page with a fragment or query.
$links = \array_filter(
$links,
function ( $link ) use ( $current_url ) {
return $this->filter_link( $link, $current_url );
}
);
$images = \array_map(
function( $link ) use ( $home_url, $indexable ) {
return $this->create_internal_link( $link, $home_url, $indexable, true );
},
$images
);
return \array_merge( $links, $images );
}
/**
* Get the post ID based on the link's type and its target's permalink.
*
* @param string $type The type of link (either SEO_Links::TYPE_INTERNAL or SEO_Links::TYPE_INTERNAL_IMAGE).
* @param string $permalink The permalink of the link's target.
*
* @return int The post ID.
*/
protected function get_post_id( $type, $permalink ) {
if ( $type === SEO_Links::TYPE_INTERNAL ) {
return \url_to_postid( $permalink );
}
return $this->image_helper->get_attachment_by_url( $permalink );
}
/**
* Creates an internal link.
*
* @param string $url The url of the link.
* @param array $home_url The home url, as parsed by wp_parse_url.
* @param Indexable $indexable The indexable of the post containing the link.
* @param bool $is_image Whether or not the link is an image.
*
* @return SEO_Links The created link.
*/
protected function create_internal_link( $url, $home_url, $indexable, $is_image = false ) {
$parsed_url = \wp_parse_url( $url );
$link_type = $this->url_helper->get_link_type( $parsed_url, $home_url, $is_image );
/**
* ORM representing a link in the SEO Links table.
*
* @var SEO_Links $model
*/
$model = $this->seo_links_repository->query()->create(
[
'url' => $url,
'type' => $link_type,
'indexable_id' => $indexable->id,
'post_id' => $indexable->object_id,
]
);
$model->parsed_url = $parsed_url;
if ( $model->type === SEO_Links::TYPE_INTERNAL ) {
$permalink = $this->build_permalink( $url, $home_url );
return $this->enhance_link_from_indexable( $model, $permalink );
}
if ( $model->type === SEO_Links::TYPE_INTERNAL_IMAGE ) {
$permalink = $this->build_permalink( $url, $home_url );
if ( ! $this->options_helper->get( 'disable-attachment' ) ) {
$model = $this->enhance_link_from_indexable( $model, $permalink );
}
else {
$target_post_id = WPSEO_Image_Utils::get_attachment_by_url( $permalink );
if ( ! empty( $target_post_id ) ) {
$model->target_post_id = $target_post_id;
}
}
if ( $model->target_post_id ) {
$file = \get_attached_file( $model->target_post_id );
if ( $file ) {
if ( \file_exists( $file ) ) {
$model->size = \filesize( $file );
}
else {
$model->size = null;
}
list( , $width, $height ) = \wp_get_attachment_image_src( $model->target_post_id, 'full' );
$model->width = $width;
$model->height = $height;
}
else {
$model->width = 0;
$model->height = 0;
$model->size = 0;
}
}
}
return $model;
}
/**
* Enhances the link model with information from its indexable.
*
* @param SEO_Links $model The link's model.
* @param string $permalink The link's permalink.
*
* @return SEO_Links The enhanced link model.
*/
protected function enhance_link_from_indexable( $model, $permalink ) {
$target = $this->indexable_repository->find_by_permalink( $permalink );
if ( ! $target ) {
// If target indexable cannot be found, create one based on the post's post ID.
$post_id = $this->get_post_id( $model->type, $permalink );
if ( $post_id && $post_id !== 0 ) {
$target = $this->indexable_repository->find_by_id_and_type( $post_id, 'post' );
}
}
if ( ! $target ) {
return $model;
}
$model->target_indexable_id = $target->id;
if ( $target->object_type === 'post' ) {
$model->target_post_id = $target->object_id;
}
if ( $model->target_indexable_id ) {
$model->language = $target->language;
$model->region = $target->region;
}
return $model;
}
/**
* Builds the link's permalink.
*
* @param string $url The url of the link.
* @param array $home_url The home url, as parsed by wp_parse_url.
*
* @return string The link's permalink.
*/
protected function build_permalink( $url, $home_url ) {
$permalink = $this->get_permalink( $url, $home_url );
if ( $this->url_helper->is_relative( $permalink ) ) {
// Make sure we're checking against the absolute URL, and add a trailing slash if the site has a trailing slash in its permalink settings.
$permalink = $this->url_helper->ensure_absolute_url( \user_trailingslashit( $permalink ) );
}
return $permalink;
}
/**
* Filters out links that point to the same page with a fragment or query.
*
* @param SEO_Links $link The link.
* @param array $current_url The url of the page the link is on, as parsed by wp_parse_url.
*
* @return bool Whether or not the link should be filtered.
*/
protected function filter_link( SEO_Links $link, $current_url ) {
$url = $link->parsed_url;
// Always keep external links.
if ( $link->type === SEO_Links::TYPE_EXTERNAL ) {
return true;
}
// Always keep links with an empty path or pointing to other pages.
if ( isset( $url['path'] ) ) {
return empty( $url['path'] ) || $url['path'] !== $current_url['path'];
}
// Only keep links to the current page without a fragment or query.
return ( ! isset( $url['fragment'] ) && ! isset( $url['query'] ) );
}
/**
* Updates the link counts for related indexables.
*
* @param Indexable $indexable The indexable.
* @param SEO_Links[] $links The link models.
*
* @return void
*/
protected function update_related_indexables( $indexable, $links ) {
// Old links were only stored by post id, so remove all old seo links for this post that have no indexable id.
// This can be removed if we ever fully clear all seo links.
if ( $indexable->object_type === 'post' ) {
$this->seo_links_repository->delete_all_by_post_id_where_indexable_id_null( $indexable->object_id );
}
$updated_indexable_ids = [];
$old_links = $this->seo_links_repository->find_all_by_indexable_id( $indexable->id );
$links_to_remove = $this->links_diff( $old_links, $links );
$links_to_add = $this->links_diff( $links, $old_links );
if ( ! empty( $links_to_remove ) ) {
$this->seo_links_repository->delete_many_by_id( \wp_list_pluck( $links_to_remove, 'id' ) );
}
if ( ! empty( $links_to_add ) ) {
$this->seo_links_repository->insert_many( $links_to_add );
}
foreach ( $links_to_add as $link ) {
if ( $link->target_indexable_id ) {
$updated_indexable_ids[] = $link->target_indexable_id;
}
}
foreach ( $links_to_remove as $link ) {
if ( $link->target_indexable_id ) {
$updated_indexable_ids[] = $link->target_indexable_id;
}
}
$this->update_incoming_links_for_related_indexables( $updated_indexable_ids );
}
/**
* Creates a diff between two arrays of SEO links, based on urls.
*
* @param SEO_Links[] $links_a The array to compare.
* @param SEO_Links[] $links_b The array to compare against.
*
* @return SEO_Links[] Links that are in $links_a, but not in $links_b.
*/
protected function links_diff( $links_a, $links_b ) {
return \array_udiff(
$links_a,
$links_b,
static function( SEO_Links $link_a, SEO_Links $link_b ) {
return \strcmp( $link_a->url, $link_b->url );
}
);
}
/**
* Returns the number of internal links in an array of link models.
*
* @param SEO_Links[] $links The link models.
*
* @return int The number of internal links.
*/
protected function get_internal_link_count( $links ) {
$internal_link_count = 0;
foreach ( $links as $link ) {
if ( $link->type === SEO_Links::TYPE_INTERNAL ) {
++$internal_link_count;
}
}
return $internal_link_count;
}
/**
* Returns a cleaned permalink for a given link.
*
* @param string $link The raw URL.
* @param array $home_url The home URL, as parsed by wp_parse_url.
*
* @return string The cleaned permalink.
*/
protected function get_permalink( $link, $home_url ) {
// Get rid of the #anchor.
$url_split = \explode( '#', $link );
$link = $url_split[0];
// Get rid of URL ?query=string.
$url_split = \explode( '?', $link );
$link = $url_split[0];
// Set the correct URL scheme.
$link = \set_url_scheme( $link, $home_url['scheme'] );
// Add 'www.' if it is absent and should be there.
if ( \strpos( $home_url['host'], 'www.' ) === 0 && \strpos( $link, '://www.' ) === false ) {
$link = \str_replace( '://', '://www.', $link );
}
// Strip 'www.' if it is present and shouldn't be.
if ( \strpos( $home_url['host'], 'www.' ) !== 0 ) {
$link = \str_replace( '://www.', '://', $link );
}
return $link;
}
/**
* Updates incoming link counts for related indexables.
*
* @param int[] $related_indexable_ids The IDs of all related indexables.
*
* @return void
*/
protected function update_incoming_links_for_related_indexables( $related_indexable_ids ) {
if ( empty( $related_indexable_ids ) ) {
return;
}
$counts = $this->seo_links_repository->get_incoming_link_counts_for_indexable_ids( $related_indexable_ids );
foreach ( $counts as $count ) {
$this->indexable_repository->update_incoming_link_count( $count['target_indexable_id'], $count['incoming'] );
}
}
}

View File

@ -0,0 +1,431 @@
<?php
namespace Yoast\WP\SEO\Builders;
use WP_Error;
use WP_Post;
use Yoast\WP\SEO\Exceptions\Indexable\Post_Not_Built_Exception;
use Yoast\WP\SEO\Exceptions\Indexable\Post_Not_Found_Exception;
use Yoast\WP\SEO\Helpers\Meta_Helper;
use Yoast\WP\SEO\Helpers\Post_Helper;
use Yoast\WP\SEO\Helpers\Post_Type_Helper;
use Yoast\WP\SEO\Models\Indexable;
use Yoast\WP\SEO\Repositories\Indexable_Repository;
use Yoast\WP\SEO\Values\Indexables\Indexable_Builder_Versions;
/**
* Post Builder for the indexables.
*
* Formats the post meta to indexable format.
*/
class Indexable_Post_Builder {
use Indexable_Social_Image_Trait;
/**
* The indexable repository.
*
* @var Indexable_Repository
*/
protected $indexable_repository;
/**
* Holds the Post_Helper instance.
*
* @var Post_Helper
*/
protected $post_helper;
/**
* The post type helper.
*
* @var Post_Type_Helper
*/
protected $post_type_helper;
/**
* Knows the latest version of the Indexable post builder type.
*
* @var int
*/
protected $version;
/**
* The meta helper.
*
* @var Meta_Helper
*/
protected $meta;
/**
* Indexable_Post_Builder constructor.
*
* @param Post_Helper $post_helper The post helper.
* @param Post_Type_Helper $post_type_helper The post type helper.
* @param Indexable_Builder_Versions $versions The indexable builder versions.
* @param Meta_Helper $meta The meta helper.
*/
public function __construct(
Post_Helper $post_helper,
Post_Type_Helper $post_type_helper,
Indexable_Builder_Versions $versions,
Meta_Helper $meta
) {
$this->post_helper = $post_helper;
$this->post_type_helper = $post_type_helper;
$this->version = $versions->get_latest_version_for_type( 'post' );
$this->meta = $meta;
}
/**
* Sets the indexable repository. Done to avoid circular dependencies.
*
* @required
*
* @param Indexable_Repository $indexable_repository The indexable repository.
*/
public function set_indexable_repository( Indexable_Repository $indexable_repository ) {
$this->indexable_repository = $indexable_repository;
}
/**
* Formats the data.
*
* @param int $post_id The post ID to use.
* @param Indexable $indexable The indexable to format.
*
* @return bool|Indexable The extended indexable. False when unable to build.
*
* @throws Post_Not_Found_Exception When the post could not be found.
* @throws Post_Not_Built_Exception When the post should not be indexed.
*/
public function build( $post_id, $indexable ) {
if ( ! $this->post_helper->is_post_indexable( $post_id ) ) {
throw Post_Not_Built_Exception::because_not_indexable( $post_id );
}
$post = $this->post_helper->get_post( $post_id );
if ( $post === null ) {
throw new Post_Not_Found_Exception();
}
if ( $this->should_exclude_post( $post ) ) {
throw Post_Not_Built_Exception::because_post_type_excluded( $post_id );
}
$indexable->object_id = $post_id;
$indexable->object_type = 'post';
$indexable->object_sub_type = $post->post_type;
$indexable->permalink = $this->get_permalink( $post->post_type, $post_id );
$indexable->primary_focus_keyword_score = $this->get_keyword_score(
$this->meta->get_value( 'focuskw', $post_id ),
(int) $this->meta->get_value( 'linkdex', $post_id )
);
$indexable->readability_score = (int) $this->meta->get_value( 'content_score', $post_id );
$indexable->inclusive_language_score = (int) $this->meta->get_value( 'inclusive_language_score', $post_id );
$indexable->is_cornerstone = ( $this->meta->get_value( 'is_cornerstone', $post_id ) === '1' );
$indexable->is_robots_noindex = $this->get_robots_noindex(
(int) $this->meta->get_value( 'meta-robots-noindex', $post_id )
);
// Set additional meta-robots values.
$indexable->is_robots_nofollow = ( $this->meta->get_value( 'meta-robots-nofollow', $post_id ) === '1' );
$noindex_advanced = $this->meta->get_value( 'meta-robots-adv', $post_id );
$meta_robots = \explode( ',', $noindex_advanced );
foreach ( $this->get_robots_options() as $meta_robots_option ) {
$indexable->{'is_robots_' . $meta_robots_option} = \in_array( $meta_robots_option, $meta_robots, true ) ? 1 : null;
}
$this->reset_social_images( $indexable );
foreach ( $this->get_indexable_lookup() as $meta_key => $indexable_key ) {
$indexable->{$indexable_key} = $this->empty_string_to_null( $this->meta->get_value( $meta_key, $post_id ) );
}
if ( empty( $indexable->breadcrumb_title ) ) {
$indexable->breadcrumb_title = \wp_strip_all_tags( \get_the_title( $post_id ), true );
}
$this->handle_social_images( $indexable );
$indexable->author_id = $post->post_author;
$indexable->post_parent = $post->post_parent;
$indexable->number_of_pages = $this->get_number_of_pages_for_post( $post );
$indexable->post_status = $post->post_status;
$indexable->is_protected = $post->post_password !== '';
$indexable->is_public = $this->is_public( $indexable );
$indexable->has_public_posts = $this->has_public_posts( $indexable );
$indexable->blog_id = \get_current_blog_id();
$indexable->schema_page_type = $this->empty_string_to_null( $this->meta->get_value( 'schema_page_type', $post_id ) );
$indexable->schema_article_type = $this->empty_string_to_null( $this->meta->get_value( 'schema_article_type', $post_id ) );
$indexable->object_last_modified = $post->post_modified_gmt;
$indexable->object_published_at = $post->post_date_gmt;
$indexable->version = $this->version;
return $indexable;
}
/**
* Retrieves the permalink for a post with the given post type and ID.
*
* @param string $post_type The post type.
* @param int $post_id The post ID.
*
* @return false|string|WP_Error The permalink.
*/
protected function get_permalink( $post_type, $post_id ) {
if ( $post_type !== 'attachment' ) {
return \get_permalink( $post_id );
}
return \wp_get_attachment_url( $post_id );
}
/**
* Determines the value of is_public.
*
* @param Indexable $indexable The indexable.
*
* @return bool|null Whether or not the post type is public. Null if no override is set.
*/
protected function is_public( $indexable ) {
if ( $indexable->is_protected === true ) {
return false;
}
if ( $indexable->is_robots_noindex === true ) {
return false;
}
// Attachments behave differently than the other post types, since they inherit from their parent.
if ( $indexable->object_sub_type === 'attachment' ) {
return $this->is_public_attachment( $indexable );
}
if ( ! \in_array( $indexable->post_status, $this->post_helper->get_public_post_statuses(), true ) ) {
return false;
}
if ( $indexable->is_robots_noindex === false ) {
return true;
}
return null;
}
/**
* Determines the value of is_public for attachments.
*
* @param Indexable $indexable The indexable.
*
* @return bool|null False when it has no parent. Null when it has a parent.
*/
protected function is_public_attachment( $indexable ) {
// If the attachment has no parent, it should not be public.
if ( empty( $indexable->post_parent ) ) {
return false;
}
// If the attachment has a parent, the is_public should be NULL.
return null;
}
/**
* Determines the value of has_public_posts.
*
* @param Indexable $indexable The indexable.
*
* @return bool|null Whether the attachment has a public parent, can be true, false and null. Null when it is not an attachment.
*/
protected function has_public_posts( $indexable ) {
// Only attachments (and authors) have this value.
if ( $indexable->object_sub_type !== 'attachment' ) {
return null;
}
// The attachment should have a post parent.
if ( empty( $indexable->post_parent ) ) {
return false;
}
// The attachment should inherit the post status.
if ( $indexable->post_status !== 'inherit' ) {
return false;
}
// The post parent should be public.
$post_parent_indexable = $this->indexable_repository->find_by_id_and_type( $indexable->post_parent, 'post' );
if ( $post_parent_indexable !== false ) {
return $post_parent_indexable->is_public;
}
return false;
}
/**
* Converts the meta robots noindex value to the indexable value.
*
* @param int $value Meta value to convert.
*
* @return bool|null True for noindex, false for index, null for default of parent/type.
*/
protected function get_robots_noindex( $value ) {
$value = (int) $value;
switch ( $value ) {
case 1:
return true;
case 2:
return false;
}
return null;
}
/**
* Retrieves the robot options to search for.
*
* @return array List of robots values.
*/
protected function get_robots_options() {
return [ 'noimageindex', 'noarchive', 'nosnippet' ];
}
/**
* Determines the focus keyword score.
*
* @param string $keyword The focus keyword that is set.
* @param int $score The score saved on the meta data.
*
* @return int|null Score to use.
*/
protected function get_keyword_score( $keyword, $score ) {
if ( empty( $keyword ) ) {
return null;
}
return $score;
}
/**
* Retrieves the lookup table.
*
* @return array Lookup table for the indexable fields.
*/
protected function get_indexable_lookup() {
return [
'focuskw' => 'primary_focus_keyword',
'canonical' => 'canonical',
'title' => 'title',
'metadesc' => 'description',
'bctitle' => 'breadcrumb_title',
'opengraph-title' => 'open_graph_title',
'opengraph-image' => 'open_graph_image',
'opengraph-image-id' => 'open_graph_image_id',
'opengraph-description' => 'open_graph_description',
'twitter-title' => 'twitter_title',
'twitter-image' => 'twitter_image',
'twitter-image-id' => 'twitter_image_id',
'twitter-description' => 'twitter_description',
'estimated-reading-time-minutes' => 'estimated_reading_time_minutes',
];
}
/**
* Finds an alternative image for the social image.
*
* @param Indexable $indexable The indexable.
*
* @return array|bool False when not found, array with data when found.
*/
protected function find_alternative_image( Indexable $indexable ) {
if (
$indexable->object_sub_type === 'attachment'
&& $this->image->is_valid_attachment( $indexable->object_id )
) {
return [
'image_id' => $indexable->object_id,
'source' => 'attachment-image',
];
}
$featured_image_id = $this->image->get_featured_image_id( $indexable->object_id );
if ( $featured_image_id ) {
return [
'image_id' => $featured_image_id,
'source' => 'featured-image',
];
}
$gallery_image = $this->image->get_gallery_image( $indexable->object_id );
if ( $gallery_image ) {
return [
'image' => $gallery_image,
'source' => 'gallery-image',
];
}
$content_image = $this->image->get_post_content_image( $indexable->object_id );
if ( $content_image ) {
return [
'image' => $content_image,
'source' => 'first-content-image',
];
}
return false;
}
/**
* Gets the number of pages for a post.
*
* @param object $post The post object.
*
* @return int|null The number of pages or null if the post isn't paginated.
*/
protected function get_number_of_pages_for_post( $post ) {
$number_of_pages = ( \substr_count( $post->post_content, '<!--nextpage-->' ) + 1 );
if ( $number_of_pages <= 1 ) {
return null;
}
return $number_of_pages;
}
/**
* Checks whether an indexable should be built for this post.
*
* @param WP_Post $post The post for which an indexable should be built.
*
* @return bool `true` if the post should be excluded from building, `false` if not.
*/
protected function should_exclude_post( $post ) {
return $this->post_type_helper->is_excluded( $post->post_type );
}
/**
* Transforms an empty string into null. Leaves non-empty strings intact.
*
* @param string $text The string.
*
* @return string|null The input string or null.
*/
protected function empty_string_to_null( $text ) {
if ( ! \is_string( $text ) || $text === '' ) {
return null;
}
return $text;
}
}

View File

@ -0,0 +1,164 @@
<?php
namespace Yoast\WP\SEO\Builders;
use wpdb;
use Yoast\WP\SEO\Exceptions\Indexable\Post_Type_Not_Built_Exception;
use Yoast\WP\SEO\Helpers\Options_Helper;
use Yoast\WP\SEO\Helpers\Post_Helper;
use Yoast\WP\SEO\Helpers\Post_Type_Helper;
use Yoast\WP\SEO\Models\Indexable;
use Yoast\WP\SEO\Values\Indexables\Indexable_Builder_Versions;
/**
* Post type archive builder for the indexables.
*
* Formats the post type archive meta to indexable format.
*/
class Indexable_Post_Type_Archive_Builder {
/**
* The options helper.
*
* @var Options_Helper
*/
protected $options;
/**
* The latest version of the Indexable_Post_Type_Archive_Builder.
*
* @var int
*/
protected $version;
/**
* Holds the post helper instance.
*
* @var Post_Helper
*/
protected $post_helper;
/**
* Holds the post type helper instance.
*
* @var Post_Type_Helper
*/
protected $post_type_helper;
/**
* The WPDB instance.
*
* @var wpdb
*/
protected $wpdb;
/**
* Indexable_Post_Type_Archive_Builder constructor.
*
* @param Options_Helper $options The options helper.
* @param Indexable_Builder_Versions $versions The latest version of each Indexable builder.
* @param Post_Helper $post_helper The post helper.
* @param Post_Type_Helper $post_type_helper The post type helper.
* @param wpdb $wpdb The WPDB instance.
*/
public function __construct(
Options_Helper $options,
Indexable_Builder_Versions $versions,
Post_Helper $post_helper,
Post_Type_Helper $post_type_helper,
wpdb $wpdb
) {
$this->options = $options;
$this->version = $versions->get_latest_version_for_type( 'post-type-archive' );
$this->post_helper = $post_helper;
$this->post_type_helper = $post_type_helper;
$this->wpdb = $wpdb;
}
/**
* Formats the data.
*
* @param string $post_type The post type to build the indexable for.
* @param Indexable $indexable The indexable to format.
*
* @return Indexable The extended indexable.
* @throws Post_Type_Not_Built_Exception Throws exception if the post type is excluded.
*/
public function build( $post_type, Indexable $indexable ) {
if ( ! $this->post_type_helper->is_post_type_archive_indexable( $post_type ) ) {
throw Post_Type_Not_Built_Exception::because_not_indexable( $post_type );
}
$indexable->object_type = 'post-type-archive';
$indexable->object_sub_type = $post_type;
$indexable->title = $this->options->get( 'title-ptarchive-' . $post_type );
$indexable->description = $this->options->get( 'metadesc-ptarchive-' . $post_type );
$indexable->breadcrumb_title = $this->get_breadcrumb_title( $post_type );
$indexable->permalink = \get_post_type_archive_link( $post_type );
$indexable->is_robots_noindex = $this->options->get( 'noindex-ptarchive-' . $post_type );
$indexable->is_public = ( (int) $indexable->is_robots_noindex !== 1 );
$indexable->blog_id = \get_current_blog_id();
$indexable->version = $this->version;
$timestamps = $this->get_object_timestamps( $post_type );
$indexable->object_published_at = $timestamps->published_at;
$indexable->object_last_modified = $timestamps->last_modified;
return $indexable;
}
/**
* Returns the fallback breadcrumb title for a given post.
*
* @param string $post_type The post type to get the fallback breadcrumb title for.
*
* @return string
*/
private function get_breadcrumb_title( $post_type ) {
$options_breadcrumb_title = $this->options->get( 'bctitle-ptarchive-' . $post_type );
if ( $options_breadcrumb_title !== '' ) {
return $options_breadcrumb_title;
}
$post_type_obj = \get_post_type_object( $post_type );
if ( ! \is_object( $post_type_obj ) ) {
return '';
}
if ( isset( $post_type_obj->label ) && $post_type_obj->label !== '' ) {
return $post_type_obj->label;
}
if ( isset( $post_type_obj->labels->menu_name ) && $post_type_obj->labels->menu_name !== '' ) {
return $post_type_obj->labels->menu_name;
}
return $post_type_obj->name;
}
/**
* Returns the timestamps for a given post type.
*
* @param string $post_type The post type.
*
* @return object An object with last_modified and published_at timestamps.
*/
protected function get_object_timestamps( $post_type ) {
$post_statuses = $this->post_helper->get_public_post_statuses();
$sql = "
SELECT MAX(p.post_modified_gmt) AS last_modified, MIN(p.post_date_gmt) AS published_at
FROM {$this->wpdb->posts} AS p
WHERE p.post_status IN (" . \implode( ', ', \array_fill( 0, \count( $post_statuses ), '%s' ) ) . ")
AND p.post_password = ''
AND p.post_type = %s
";
$replacements = \array_merge( $post_statuses, [ $post_type ] );
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- We are using wpdb prepare.
return $this->wpdb->get_row( $this->wpdb->prepare( $sql, $replacements ) );
}
}

View File

@ -0,0 +1,158 @@
<?php
namespace Yoast\WP\SEO\Builders;
use WPSEO_Utils;
use Yoast\WP\SEO\Helpers\Image_Helper;
use Yoast\WP\SEO\Helpers\Open_Graph\Image_Helper as Open_Graph_Image_Helper;
use Yoast\WP\SEO\Helpers\Twitter\Image_Helper as Twitter_Image_Helper;
use Yoast\WP\SEO\Models\Indexable;
/**
* Trait for determine the social image to use in the indexable.
*
* Represents the trait used in builders for handling social images.
*/
trait Indexable_Social_Image_Trait {
/**
* The image helper.
*
* @var Image_Helper
*/
protected $image;
/**
* The Open Graph image helper.
*
* @var Open_Graph_Image_Helper
*/
protected $open_graph_image;
/**
* The Twitter image helper.
*
* @var Twitter_Image_Helper
*/
protected $twitter_image;
/**
* Sets the helpers for the trait.
*
* @required
*
* @param Image_Helper $image The image helper.
* @param Open_Graph_Image_Helper $open_graph_image The Open Graph image helper.
* @param Twitter_Image_Helper $twitter_image The Twitter image helper.
*/
public function set_social_image_helpers(
Image_Helper $image,
Open_Graph_Image_Helper $open_graph_image,
Twitter_Image_Helper $twitter_image
) {
$this->image = $image;
$this->open_graph_image = $open_graph_image;
$this->twitter_image = $twitter_image;
}
/**
* Sets the alternative on an indexable.
*
* @param array $alternative_image The alternative image to set.
* @param Indexable $indexable The indexable to set image for.
*/
protected function set_alternative_image( array $alternative_image, Indexable $indexable ) {
if ( ! empty( $alternative_image['image_id'] ) ) {
if ( ! $indexable->open_graph_image_source && ! $indexable->open_graph_image_id ) {
$indexable->open_graph_image_id = $alternative_image['image_id'];
$indexable->open_graph_image_source = $alternative_image['source'];
$this->set_open_graph_image_meta_data( $indexable );
}
if ( ! $indexable->twitter_image && ! $indexable->twitter_image_id ) {
$indexable->twitter_image = $this->twitter_image->get_by_id( $alternative_image['image_id'] );
$indexable->twitter_image_id = $alternative_image['image_id'];
$indexable->twitter_image_source = $alternative_image['source'];
}
}
if ( ! empty( $alternative_image['image'] ) ) {
if ( ! $indexable->open_graph_image_source && ! $indexable->open_graph_image_id ) {
$indexable->open_graph_image = $alternative_image['image'];
$indexable->open_graph_image_source = $alternative_image['source'];
}
if ( ! $indexable->twitter_image && ! $indexable->twitter_image_id ) {
$indexable->twitter_image = $alternative_image['image'];
$indexable->twitter_image_source = $alternative_image['source'];
}
}
}
/**
* Sets the Open Graph image meta data for an og image
*
* @param Indexable $indexable The indexable.
*/
protected function set_open_graph_image_meta_data( Indexable $indexable ) {
if ( ! $indexable->open_graph_image_id ) {
return;
}
$image = $this->open_graph_image->get_image_by_id( $indexable->open_graph_image_id );
if ( ! empty( $image ) ) {
$indexable->open_graph_image = $image['url'];
$indexable->open_graph_image_meta = WPSEO_Utils::format_json_encode( $image );
}
}
/**
* Handles the social images.
*
* @param Indexable $indexable The indexable to handle.
*/
protected function handle_social_images( Indexable $indexable ) {
// When the image or image id is set.
if ( $indexable->open_graph_image || $indexable->open_graph_image_id ) {
$indexable->open_graph_image_source = 'set-by-user';
$this->set_open_graph_image_meta_data( $indexable );
}
if ( $indexable->twitter_image || $indexable->twitter_image_id ) {
$indexable->twitter_image_source = 'set-by-user';
}
if ( $indexable->twitter_image_id ) {
$indexable->twitter_image = $this->twitter_image->get_by_id( $indexable->twitter_image_id );
}
// When image sources are set already.
if ( $indexable->open_graph_image_source && $indexable->twitter_image_source ) {
return;
}
$alternative_image = $this->find_alternative_image( $indexable );
if ( ! empty( $alternative_image ) ) {
$this->set_alternative_image( $alternative_image, $indexable );
}
}
/**
* Resets the social images.
*
* @param Indexable $indexable The indexable to set images for.
*/
protected function reset_social_images( Indexable $indexable ) {
$indexable->open_graph_image = null;
$indexable->open_graph_image_id = null;
$indexable->open_graph_image_source = null;
$indexable->open_graph_image_meta = null;
$indexable->twitter_image = null;
$indexable->twitter_image_id = null;
$indexable->twitter_image_source = null;
}
}

View File

@ -0,0 +1,80 @@
<?php
namespace Yoast\WP\SEO\Builders;
use Yoast\WP\SEO\Helpers\Options_Helper;
use Yoast\WP\SEO\Models\Indexable;
use Yoast\WP\SEO\Values\Indexables\Indexable_Builder_Versions;
/**
* System page builder for the indexables.
*
* Formats system pages ( search and error ) meta to indexable format.
*/
class Indexable_System_Page_Builder {
/**
* Mapping of object type to title option keys.
*/
const OPTION_MAPPING = [
'search-result' => [
'title' => 'title-search-wpseo',
],
'404' => [
'title' => 'title-404-wpseo',
'breadcrumb_title' => 'breadcrumbs-404crumb',
],
];
/**
* The options helper.
*
* @var Options_Helper
*/
protected $options;
/**
* The latest version of the Indexable_System_Page_Builder.
*
* @var int
*/
protected $version;
/**
* Indexable_System_Page_Builder constructor.
*
* @param Options_Helper $options The options helper.
* @param Indexable_Builder_Versions $versions The latest version of each Indexable Builder.
*/
public function __construct(
Options_Helper $options,
Indexable_Builder_Versions $versions
) {
$this->options = $options;
$this->version = $versions->get_latest_version_for_type( 'system-page' );
}
/**
* Formats the data.
*
* @param string $object_sub_type The object sub type of the system page.
* @param Indexable $indexable The indexable to format.
*
* @return Indexable The extended indexable.
*/
public function build( $object_sub_type, Indexable $indexable ) {
$indexable->object_type = 'system-page';
$indexable->object_sub_type = $object_sub_type;
$indexable->title = $this->options->get( static::OPTION_MAPPING[ $object_sub_type ]['title'] );
$indexable->is_robots_noindex = true;
$indexable->blog_id = \get_current_blog_id();
if ( \array_key_exists( 'breadcrumb_title', static::OPTION_MAPPING[ $object_sub_type ] ) ) {
$indexable->breadcrumb_title = $this->options->get( static::OPTION_MAPPING[ $object_sub_type ]['breadcrumb_title'] );
}
$indexable->version = $this->version;
return $indexable;
}
}

View File

@ -0,0 +1,279 @@
<?php
namespace Yoast\WP\SEO\Builders;
use wpdb;
use Yoast\WP\SEO\Exceptions\Indexable\Invalid_Term_Exception;
use Yoast\WP\SEO\Exceptions\Indexable\Term_Not_Built_Exception;
use Yoast\WP\SEO\Exceptions\Indexable\Term_Not_Found_Exception;
use Yoast\WP\SEO\Helpers\Post_Helper;
use Yoast\WP\SEO\Helpers\Taxonomy_Helper;
use Yoast\WP\SEO\Models\Indexable;
use Yoast\WP\SEO\Values\Indexables\Indexable_Builder_Versions;
/**
* Term Builder for the indexables.
*
* Formats the term meta to indexable format.
*/
class Indexable_Term_Builder {
use Indexable_Social_Image_Trait;
/**
* Holds the taxonomy helper instance.
*
* @var Taxonomy_Helper
*/
protected $taxonomy_helper;
/**
* The latest version of the Indexable_Term_Builder.
*
* @var int
*/
protected $version;
/**
* Holds the taxonomy helper instance.
*
* @var Post_Helper
*/
protected $post_helper;
/**
* The WPDB instance.
*
* @var wpdb
*/
protected $wpdb;
/**
* Indexable_Term_Builder constructor.
*
* @param Taxonomy_Helper $taxonomy_helper The taxonomy helper.
* @param Indexable_Builder_Versions $versions The latest version of each Indexable Builder.
* @param Post_Helper $post_helper The post helper.
* @param wpdb $wpdb The WPDB instance.
*/
public function __construct(
Taxonomy_Helper $taxonomy_helper,
Indexable_Builder_Versions $versions,
Post_Helper $post_helper,
wpdb $wpdb
) {
$this->taxonomy_helper = $taxonomy_helper;
$this->version = $versions->get_latest_version_for_type( 'term' );
$this->post_helper = $post_helper;
$this->wpdb = $wpdb;
}
/**
* Formats the data.
*
* @param int $term_id ID of the term to save data for.
* @param Indexable $indexable The indexable to format.
*
* @return bool|Indexable The extended indexable. False when unable to build.
*
* @throws Invalid_Term_Exception When the term is invalid.
* @throws Term_Not_Built_Exception When the term is not viewable.
* @throws Term_Not_Found_Exception When the term is not found.
*/
public function build( $term_id, $indexable ) {
$term = \get_term( $term_id );
if ( $term === null ) {
throw new Term_Not_Found_Exception();
}
if ( \is_wp_error( $term ) ) {
throw new Invalid_Term_Exception( $term->get_error_message() );
}
$indexable_taxonomies = $this->taxonomy_helper->get_indexable_taxonomies();
if ( ! \in_array( $term->taxonomy, $indexable_taxonomies, true ) ) {
throw Term_Not_Built_Exception::because_not_indexable( $term_id );
}
$term_link = \get_term_link( $term, $term->taxonomy );
if ( \is_wp_error( $term_link ) ) {
throw new Invalid_Term_Exception( $term_link->get_error_message() );
}
$term_meta = $this->taxonomy_helper->get_term_meta( $term );
$indexable->object_id = $term_id;
$indexable->object_type = 'term';
$indexable->object_sub_type = $term->taxonomy;
$indexable->permalink = $term_link;
$indexable->blog_id = \get_current_blog_id();
$indexable->primary_focus_keyword_score = $this->get_keyword_score(
$this->get_meta_value( 'wpseo_focuskw', $term_meta ),
$this->get_meta_value( 'wpseo_linkdex', $term_meta )
);
$indexable->is_robots_noindex = $this->get_noindex_value( $this->get_meta_value( 'wpseo_noindex', $term_meta ) );
$indexable->is_public = ( $indexable->is_robots_noindex === null ) ? null : ! $indexable->is_robots_noindex;
$this->reset_social_images( $indexable );
foreach ( $this->get_indexable_lookup() as $meta_key => $indexable_key ) {
$indexable->{$indexable_key} = $this->get_meta_value( $meta_key, $term_meta );
}
if ( empty( $indexable->breadcrumb_title ) ) {
$indexable->breadcrumb_title = $term->name;
}
$this->handle_social_images( $indexable );
$indexable->is_cornerstone = $this->get_meta_value( 'wpseo_is_cornerstone', $term_meta );
// Not implemented yet.
$indexable->is_robots_nofollow = null;
$indexable->is_robots_noarchive = null;
$indexable->is_robots_noimageindex = null;
$indexable->is_robots_nosnippet = null;
$timestamps = $this->get_object_timestamps( $term_id, $term->taxonomy );
$indexable->object_published_at = $timestamps->published_at;
$indexable->object_last_modified = $timestamps->last_modified;
$indexable->version = $this->version;
return $indexable;
}
/**
* Converts the meta noindex value to the indexable value.
*
* @param string $meta_value Term meta to base the value on.
*
* @return bool|null
*/
protected function get_noindex_value( $meta_value ) {
if ( $meta_value === 'noindex' ) {
return true;
}
if ( $meta_value === 'index' ) {
return false;
}
return null;
}
/**
* Determines the focus keyword score.
*
* @param string $keyword The focus keyword that is set.
* @param int $score The score saved on the meta data.
*
* @return int|null Score to use.
*/
protected function get_keyword_score( $keyword, $score ) {
if ( empty( $keyword ) ) {
return null;
}
return $score;
}
/**
* Retrieves the lookup table.
*
* @return array Lookup table for the indexable fields.
*/
protected function get_indexable_lookup() {
return [
'wpseo_canonical' => 'canonical',
'wpseo_focuskw' => 'primary_focus_keyword',
'wpseo_title' => 'title',
'wpseo_desc' => 'description',
'wpseo_content_score' => 'readability_score',
'wpseo_inclusive_language_score' => 'inclusive_language_score',
'wpseo_bctitle' => 'breadcrumb_title',
'wpseo_opengraph-title' => 'open_graph_title',
'wpseo_opengraph-description' => 'open_graph_description',
'wpseo_opengraph-image' => 'open_graph_image',
'wpseo_opengraph-image-id' => 'open_graph_image_id',
'wpseo_twitter-title' => 'twitter_title',
'wpseo_twitter-description' => 'twitter_description',
'wpseo_twitter-image' => 'twitter_image',
'wpseo_twitter-image-id' => 'twitter_image_id',
];
}
/**
* Retrieves a meta value from the given meta data.
*
* @param string $meta_key The key to extract.
* @param array $term_meta The meta data.
*
* @return string|null The meta value.
*/
protected function get_meta_value( $meta_key, $term_meta ) {
if ( ! $term_meta || ! \array_key_exists( $meta_key, $term_meta ) ) {
return null;
}
$value = $term_meta[ $meta_key ];
if ( \is_string( $value ) && $value === '' ) {
return null;
}
return $value;
}
/**
* Finds an alternative image for the social image.
*
* @param Indexable $indexable The indexable.
*
* @return array|bool False when not found, array with data when found.
*/
protected function find_alternative_image( Indexable $indexable ) {
$content_image = $this->image->get_term_content_image( $indexable->object_id );
if ( $content_image ) {
return [
'image' => $content_image,
'source' => 'first-content-image',
];
}
return false;
}
/**
* Returns the timestamps for a given term.
*
* @param int $term_id The term ID.
* @param string $taxonomy The taxonomy.
*
* @return object An object with last_modified and published_at timestamps.
*/
protected function get_object_timestamps( $term_id, $taxonomy ) {
$post_statuses = $this->post_helper->get_public_post_statuses();
$sql = "
SELECT MAX(p.post_modified_gmt) AS last_modified, MIN(p.post_date_gmt) AS published_at
FROM {$this->wpdb->posts} AS p
INNER JOIN {$this->wpdb->term_relationships} AS term_rel
ON term_rel.object_id = p.ID
INNER JOIN {$this->wpdb->term_taxonomy} AS term_tax
ON term_tax.term_taxonomy_id = term_rel.term_taxonomy_id
AND term_tax.taxonomy = %s
AND term_tax.term_id = %d
WHERE p.post_status IN (" . \implode( ', ', \array_fill( 0, \count( $post_statuses ), '%s' ) ) . ")
AND p.post_password = ''
";
$replacements = \array_merge( [ $taxonomy, $term_id ], $post_statuses );
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- We are using wpdb prepare.
return $this->wpdb->get_row( $this->wpdb->prepare( $sql, $replacements ) );
}
}

View File

@ -0,0 +1,96 @@
<?php
namespace Yoast\WP\SEO\Builders;
use Yoast\WP\SEO\Helpers\Meta_Helper;
use Yoast\WP\SEO\Helpers\Primary_Term_Helper;
use Yoast\WP\SEO\Repositories\Primary_Term_Repository;
/**
* Primary term builder.
*
* Creates the primary term for a post.
*/
class Primary_Term_Builder {
/**
* The primary term repository.
*
* @var Primary_Term_Repository
*/
protected $repository;
/**
* The primary term helper.
*
* @var Primary_Term_Helper
*/
private $primary_term;
/**
* The meta helper.
*
* @var Meta_Helper
*/
private $meta;
/**
* Primary_Term_Builder constructor.
*
* @param Primary_Term_Repository $repository The primary term repository.
* @param Primary_Term_Helper $primary_term The primary term helper.
* @param Meta_Helper $meta The meta helper.
*/
public function __construct(
Primary_Term_Repository $repository,
Primary_Term_Helper $primary_term,
Meta_Helper $meta
) {
$this->repository = $repository;
$this->primary_term = $primary_term;
$this->meta = $meta;
}
/**
* Formats and saves the primary terms for the post with the given post id.
*
* @param int $post_id The post ID.
*
* @return void
*/
public function build( $post_id ) {
foreach ( $this->primary_term->get_primary_term_taxonomies( $post_id ) as $taxonomy ) {
$this->save_primary_term( $post_id, $taxonomy->name );
}
}
/**
* Save the primary term for a specific taxonomy.
*
* @param int $post_id Post ID to save primary term for.
* @param string $taxonomy Taxonomy to save primary term for.
*
* @return void
*/
protected function save_primary_term( $post_id, $taxonomy ) {
$term_id = $this->meta->get_value( 'primary_' . $taxonomy, $post_id );
$term_selected = ! empty( $term_id );
$primary_term = $this->repository->find_by_post_id_and_taxonomy( $post_id, $taxonomy, $term_selected );
// Removes the indexable when no term found.
if ( ! $term_selected ) {
if ( $primary_term ) {
$primary_term->delete();
}
return;
}
$primary_term->term_id = $term_id;
$primary_term->post_id = $post_id;
$primary_term->taxonomy = $taxonomy;
$primary_term->blog_id = \get_current_blog_id();
$primary_term->save();
}
}

View File

@ -0,0 +1,196 @@
<?php
namespace Yoast\WP\SEO\Commands;
use WP_CLI;
use WP_CLI\ExitException;
use WP_CLI\Utils;
use Yoast\WP\SEO\Integrations\Cleanup_Integration;
use Yoast\WP\SEO\Main;
/**
* A WP CLI command that helps with cleaning up unwanted records from our custom tables.
*/
final class Cleanup_Command implements Command_Interface {
/**
* The integration that cleans up on cron.
*
* @var Cleanup_Integration
*/
private $cleanup_integration;
/**
* The constructor.
*
* @param Cleanup_Integration $cleanup_integration The integration that cleans up on cron.
*/
public function __construct( Cleanup_Integration $cleanup_integration ) {
$this->cleanup_integration = $cleanup_integration;
}
/**
* Returns the namespace of this command.
*
* @return string
*/
public static function get_namespace() {
return Main::WP_CLI_NAMESPACE;
}
/**
* Performs a cleanup of custom Yoast tables.
*
* This removes unused, unwanted or orphaned database records, which ensures the best performance. Including:
* - Indexables
* - Indexable hierarchy
* - SEO links
*
* ## OPTIONS
*
* [--batch-size=<batch-size>]
* : The number of database records to clean up in a single sql query.
* ---
* default: 1000
* ---
*
* [--interval=<interval>]
* : The number of microseconds (millionths of a second) to wait between cleanup batches.
* ---
* default: 500000
* ---
*
* [--network]
* : Performs the cleanup on all sites within the network.
*
* ## EXAMPLES
*
* wp yoast cleanup
*
* @when after_wp_load
*
* @param array|null $args The arguments.
* @param array|null $assoc_args The associative arguments.
*
* @return void
*
* @throws ExitException When the input args are invalid.
*/
public function cleanup( $args = null, $assoc_args = null ) {
if ( isset( $assoc_args['interval'] ) && (int) $assoc_args['interval'] < 0 ) {
WP_CLI::error( \__( 'The value for \'interval\' must be a positive integer.', 'wordpress-seo' ) );
}
if ( isset( $assoc_args['batch-size'] ) && (int) $assoc_args['batch-size'] < 1 ) {
WP_CLI::error( \__( 'The value for \'batch-size\' must be a positive integer higher than equal to 1.', 'wordpress-seo' ) );
}
if ( isset( $assoc_args['network'] ) && \is_multisite() ) {
$total_removed = $this->cleanup_network( $assoc_args );
}
else {
$total_removed = $this->cleanup_current_site( $assoc_args );
}
WP_CLI::success(
\sprintf(
/* translators: %1$d is the number of records that are removed. */
\_n(
'Cleaned up %1$d record.',
'Cleaned up %1$d records.',
$total_removed,
'wordpress-seo'
),
$total_removed
)
);
}
/**
* Performs the cleanup for the entire network.
*
* @param array|null $assoc_args The associative arguments.
*
* @return int The number of cleaned up records.
*/
private function cleanup_network( $assoc_args ) {
$criteria = [
'fields' => 'ids',
'spam' => 0,
'deleted' => 0,
'archived' => 0,
];
$blog_ids = \get_sites( $criteria );
$total_removed = 0;
foreach ( $blog_ids as $blog_id ) {
\switch_to_blog( $blog_id );
$total_removed += $this->cleanup_current_site( $assoc_args );
\restore_current_blog();
}
return $total_removed;
}
/**
* Performs the cleanup for a single site.
*
* @param array|null $assoc_args The associative arguments.
*
* @return int The number of cleaned up records.
*/
private function cleanup_current_site( $assoc_args ) {
$site_url = \site_url();
$total_removed = 0;
if ( ! \is_plugin_active( \WPSEO_BASENAME ) ) {
/* translators: %1$s is the site url of the site that is skipped. %2$s is Yoast SEO. */
WP_CLI::warning( \sprintf( \__( 'Skipping %1$s. %2$s is not active on this site.', 'wordpress-seo' ), $site_url, 'Yoast SEO' ) );
return $total_removed;
}
// Make sure the DB is up to date first.
\do_action( '_yoast_run_migrations' );
$tasks = $this->cleanup_integration->get_cleanup_tasks();
$limit = (int) $assoc_args['batch-size'];
$interval = (int) $assoc_args['interval'];
/* translators: %1$s is the site url of the site that is cleaned up. %2$s is the name of the cleanup task that is currently running. */
$progress_bar_title_format = \__( 'Cleaning up %1$s [%2$s]', 'wordpress-seo' );
$progress = Utils\make_progress_bar( \sprintf( $progress_bar_title_format, $site_url, \key( $tasks ) ), \count( $tasks ) );
foreach ( $tasks as $task_name => $task ) {
// Update the progressbar title with the current task name.
$progress->tick( 0, \sprintf( $progress_bar_title_format, $site_url, $task_name ) );
do {
$items_cleaned = $task( $limit );
if ( \is_int( $items_cleaned ) ) {
$total_removed += $items_cleaned;
}
\usleep( $interval );
// Update the timer.
$progress->tick( 0 );
} while ( $items_cleaned !== false && $items_cleaned > 0 );
$progress->tick();
}
$progress->finish();
$this->cleanup_integration->reset_cleanup();
WP_CLI::log(
\sprintf(
/* translators: %1$d is the number of records that were removed. %2$s is the site url. */
\_n(
'Cleaned up %1$d record from %2$s.',
'Cleaned up %1$d records from %2$s.',
$total_removed,
'wordpress-seo'
),
$total_removed,
$site_url
)
);
return $total_removed;
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace Yoast\WP\SEO\Commands;
/**
* Interface definition for WP CLI commands.
*
* An interface for registering integrations with WordPress.
*/
interface Command_Interface {
/**
* Returns the namespace of this command.
*
* @return string
*/
public static function get_namespace();
}

View File

@ -0,0 +1,294 @@
<?php
namespace Yoast\WP\SEO\Commands;
use WP_CLI;
use WP_CLI\Utils;
use Yoast\WP\Lib\Model;
use Yoast\WP\SEO\Actions\Indexing\Indexable_General_Indexation_Action;
use Yoast\WP\SEO\Actions\Indexing\Indexable_Indexing_Complete_Action;
use Yoast\WP\SEO\Actions\Indexing\Indexable_Post_Indexation_Action;
use Yoast\WP\SEO\Actions\Indexing\Indexable_Post_Type_Archive_Indexation_Action;
use Yoast\WP\SEO\Actions\Indexing\Indexable_Term_Indexation_Action;
use Yoast\WP\SEO\Actions\Indexing\Indexation_Action_Interface;
use Yoast\WP\SEO\Actions\Indexing\Indexing_Prepare_Action;
use Yoast\WP\SEO\Actions\Indexing\Post_Link_Indexing_Action;
use Yoast\WP\SEO\Actions\Indexing\Term_Link_Indexing_Action;
use Yoast\WP\SEO\Helpers\Indexable_Helper;
use Yoast\WP\SEO\Main;
/**
* Command to generate indexables for all posts and terms.
*/
class Index_Command implements Command_Interface {
/**
* The post indexation action.
*
* @var Indexable_Post_Indexation_Action
*/
private $post_indexation_action;
/**
* The term indexation action.
*
* @var Indexable_Term_Indexation_Action
*/
private $term_indexation_action;
/**
* The post type archive indexation action.
*
* @var Indexable_Post_Type_Archive_Indexation_Action
*/
private $post_type_archive_indexation_action;
/**
* The general indexation action.
*
* @var Indexable_General_Indexation_Action
*/
private $general_indexation_action;
/**
* The term link indexing action.
*
* @var Term_Link_Indexing_Action
*/
private $term_link_indexing_action;
/**
* The post link indexing action.
*
* @var Post_Link_Indexing_Action
*/
private $post_link_indexing_action;
/**
* The complete indexation action.
*
* @var Indexable_Indexing_Complete_Action
*/
private $complete_indexation_action;
/**
* The indexing prepare action.
*
* @var Indexing_Prepare_Action
*/
private $prepare_indexing_action;
/**
* Represents the indexable helper.
*
* @var Indexable_Helper
*/
protected $indexable_helper;
/**
* Generate_Indexables_Command constructor.
*
* @param Indexable_Post_Indexation_Action $post_indexation_action The post indexation
* action.
* @param Indexable_Term_Indexation_Action $term_indexation_action The term indexation
* action.
* @param Indexable_Post_Type_Archive_Indexation_Action $post_type_archive_indexation_action The post type archive
* indexation action.
* @param Indexable_General_Indexation_Action $general_indexation_action The general indexation
* action.
* @param Indexable_Indexing_Complete_Action $complete_indexation_action The complete indexation
* action.
* @param Indexing_Prepare_Action $prepare_indexing_action The prepare indexing
* action.
* @param Post_Link_Indexing_Action $post_link_indexing_action The post link indexation
* action.
* @param Term_Link_Indexing_Action $term_link_indexing_action The term link indexation
* action.
* @param Indexable_Helper $indexable_helper The indexable helper.
*/
public function __construct(
Indexable_Post_Indexation_Action $post_indexation_action,
Indexable_Term_Indexation_Action $term_indexation_action,
Indexable_Post_Type_Archive_Indexation_Action $post_type_archive_indexation_action,
Indexable_General_Indexation_Action $general_indexation_action,
Indexable_Indexing_Complete_Action $complete_indexation_action,
Indexing_Prepare_Action $prepare_indexing_action,
Post_Link_Indexing_Action $post_link_indexing_action,
Term_Link_Indexing_Action $term_link_indexing_action,
Indexable_Helper $indexable_helper
) {
$this->post_indexation_action = $post_indexation_action;
$this->term_indexation_action = $term_indexation_action;
$this->post_type_archive_indexation_action = $post_type_archive_indexation_action;
$this->general_indexation_action = $general_indexation_action;
$this->complete_indexation_action = $complete_indexation_action;
$this->prepare_indexing_action = $prepare_indexing_action;
$this->post_link_indexing_action = $post_link_indexing_action;
$this->term_link_indexing_action = $term_link_indexing_action;
$this->indexable_helper = $indexable_helper;
}
/**
* Gets the namespace.
*
* @return string
*/
public static function get_namespace() {
return Main::WP_CLI_NAMESPACE;
}
/**
* Indexes all your content to ensure the best performance.
*
* ## OPTIONS
*
* [--network]
* : Performs the indexation on all sites within the network.
*
* [--reindex]
* : Removes all existing indexables and then reindexes them.
*
* [--skip-confirmation]
* : Skips the confirmations (for automated systems).
*
* [--interval=<interval>]
* : The number of microseconds (millionths of a second) to wait between index actions.
* ---
* default: 500000
* ---
*
* ## EXAMPLES
*
* wp yoast index
*
* @when after_wp_load
*
* @param array|null $args The arguments.
* @param array|null $assoc_args The associative arguments.
*
* @return void
*/
public function index( $args = null, $assoc_args = null ) {
if ( ! $this->indexable_helper->should_index_indexables() ) {
WP_CLI::log(
\__( 'Your WordPress environment is running on a non-production site. Indexables can only be created on production environments. Please check your `WP_ENVIRONMENT_TYPE` settings.', 'wordpress-seo' )
);
return;
}
if ( ! isset( $assoc_args['network'] ) ) {
$this->run_indexation_actions( $assoc_args );
return;
}
$criteria = [
'fields' => 'ids',
'spam' => 0,
'deleted' => 0,
'archived' => 0,
];
$blog_ids = \get_sites( $criteria );
foreach ( $blog_ids as $blog_id ) {
\switch_to_blog( $blog_id );
\do_action( '_yoast_run_migrations' );
$this->run_indexation_actions( $assoc_args );
\restore_current_blog();
}
}
/**
* Runs all indexation actions.
*
* @param array $assoc_args The associative arguments.
*
* @return void
*/
protected function run_indexation_actions( $assoc_args ) {
// See if we need to clear all indexables before repopulating.
if ( isset( $assoc_args['reindex'] ) ) {
// Argument --skip-confirmation to prevent confirmation (for automated systems).
if ( ! isset( $assoc_args['skip-confirmation'] ) ) {
WP_CLI::confirm( 'This will clear all previously indexed objects. Are you certain you wish to proceed?' );
}
// Truncate the tables.
$this->clear();
// Delete the transients to make sure re-indexing runs every time.
\delete_transient( Indexable_Post_Indexation_Action::UNINDEXED_COUNT_TRANSIENT );
\delete_transient( Indexable_Post_Type_Archive_Indexation_Action::UNINDEXED_COUNT_TRANSIENT );
\delete_transient( Indexable_Term_Indexation_Action::UNINDEXED_COUNT_TRANSIENT );
}
$indexation_actions = [
'posts' => $this->post_indexation_action,
'terms' => $this->term_indexation_action,
'post type archives' => $this->post_type_archive_indexation_action,
'general objects' => $this->general_indexation_action,
'post links' => $this->post_link_indexing_action,
'term links' => $this->term_link_indexing_action,
];
$this->prepare_indexing_action->prepare();
$interval = (int) $assoc_args['interval'];
foreach ( $indexation_actions as $name => $indexation_action ) {
$this->run_indexation_action( $name, $indexation_action, $interval );
}
$this->complete_indexation_action->complete();
}
/**
* Runs an indexation action.
*
* @param string $name The name of the object to be indexed.
* @param Indexation_Action_Interface $indexation_action The indexation action.
* @param int $interval Number of microseconds (millionths of a second) to wait between index actions.
*
* @return void
*/
protected function run_indexation_action( $name, Indexation_Action_Interface $indexation_action, $interval ) {
$total = $indexation_action->get_total_unindexed();
if ( $total > 0 ) {
$limit = $indexation_action->get_limit();
$progress = Utils\make_progress_bar( 'Indexing ' . $name, $total );
do {
$indexables = $indexation_action->index();
$count = \count( $indexables );
$progress->tick( $count );
\usleep( $interval );
Utils\wp_clear_object_cache();
} while ( $count >= $limit );
$progress->finish();
}
}
/**
* Clears the database related to the indexables.
*/
protected function clear() {
global $wpdb;
// For the PreparedSQLPlaceholders issue, see: https://github.com/WordPress/WordPress-Coding-Standards/issues/1903.
// For the DirectDBQuery issue, see: https://github.com/WordPress/WordPress-Coding-Standards/issues/1947.
// phpcs:disable WordPress.DB -- Table names should not be quoted and truncate queries can not be cached.
$wpdb->query(
$wpdb->prepare(
'TRUNCATE TABLE %1$s',
Model::get_table_name( 'Indexable' )
)
);
$wpdb->query(
$wpdb->prepare(
'TRUNCATE TABLE %1$s',
Model::get_table_name( 'Indexable_Hierarchy' )
)
);
// phpcs:enable
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace Yoast\WP\SEO\Conditionals;
/**
* Checks if the Addon_Installation constant is set.
*/
class Addon_Installation_Conditional extends Feature_Flag_Conditional {
/**
* Returns the name of the feature flag.
* 'YOAST_SEO_' is automatically prepended to it and it will be uppercased.
*
* @return string the name of the feature flag.
*/
protected function get_feature_flag() {
return 'ADDON_INSTALLATION';
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace Yoast\WP\SEO\Conditionals;
/**
* Conditional that is only met when in the admin.
*/
class Admin_Conditional implements Conditional {
/**
* Returns whether or not this conditional is met.
*
* @return bool Whether or not the conditional is met.
*/
public function is_met() {
return \is_admin();
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace Yoast\WP\SEO\Conditionals\Admin;
use Yoast\WP\SEO\Conditionals\Conditional;
/**
* Checks if the post is saved by inline-save. This is the case when doing quick edit.
*
* @phpcs:disable Yoast.NamingConventions.ObjectNameDepth.MaxExceeded -- Base class can't be written shorter without abbreviating.
*/
class Doing_Post_Quick_Edit_Save_Conditional implements Conditional {
/**
* Checks if the current request is ajax and the action is inline-save.
*
* @return bool True when the quick edit action is executed.
*/
public function is_met() {
if ( ! \wp_doing_ajax() ) {
return false;
}
// Do the same nonce check as is done in wp_ajax_inline_save because we hook into that request.
if ( ! \check_ajax_referer( 'inlineeditnonce', '_inline_edit', false ) ) {
return false;
}
if ( ! isset( $_POST['action'] ) ) {
return false;
}
$sanitized_action = \sanitize_text_field( \wp_unslash( $_POST['action'] ) );
return ( $sanitized_action === 'inline-save' );
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace Yoast\WP\SEO\Conditionals\Admin;
use Yoast\WP\SEO\Conditionals\Conditional;
/**
* Conditional that is only when we want the Estimated Reading Time.
*/
class Estimated_Reading_Time_Conditional implements Conditional {
/**
* The Post Conditional.
*
* @var Post_Conditional
*/
protected $post_conditional;
/**
* Constructs the Estimated Reading Time Conditional.
*
* @param Post_Conditional $post_conditional The post conditional.
*/
public function __construct( Post_Conditional $post_conditional ) {
$this->post_conditional = $post_conditional;
}
/**
* Returns whether this conditional is met.
*
* @return bool Whether the conditional is met.
*/
public function is_met() {
// phpcs:disable WordPress.Security.NonceVerification.Recommended,WordPress.Security.NonceVerification.Missing -- Reason: Nonce verification should not be done in a conditional but rather in the classes using the conditional.
// Check if we are in our Elementor ajax request (for saving).
if ( \wp_doing_ajax() && isset( $_POST['action'] ) && \is_string( $_POST['action'] ) ) {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Reason: We are only strictly comparing the variable.
$post_action = \wp_unslash( $_POST['action'] );
if ( $post_action === 'wpseo_elementor_save' ) {
return true;
}
}
if ( ! $this->post_conditional->is_met() ) {
return false;
}
// We don't support Estimated Reading Time on the attachment post type.
if ( isset( $_GET['post'] ) && \is_string( $_GET['post'] ) ) {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Reason: We are casting to an integer.
$post_id = (int) \wp_unslash( $_GET['post'] );
if ( $post_id !== 0 && \get_post_type( $post_id ) === 'attachment' ) {
return false;
}
}
return true;
// phpcs:enable WordPress.Security.NonceVerification.Recommended,WordPress.Security.NonceVerification.Missing
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace Yoast\WP\SEO\Conditionals\Admin;
use Yoast\WP\SEO\Conditionals\Conditional;
/**
* Conditional that is only met when current page is the tools page.
*/
class Licenses_Page_Conditional implements Conditional {
/**
* Returns whether or not this conditional is met.
*
* @return bool Whether or not the conditional is met.
*/
public function is_met() {
global $pagenow;
if ( $pagenow !== 'admin.php' ) {
return false;
}
// phpcs:ignore WordPress.Security.NonceVerification -- This is not a form.
if ( isset( $_GET['page'] ) && $_GET['page'] === 'wpseo_licenses' ) {
return true;
}
return false;
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace Yoast\WP\SEO\Conditionals\Admin;
use Yoast\WP\SEO\Conditionals\Conditional;
/**
* Conditional that is only met when not on a network admin page.
*/
class Non_Network_Admin_Conditional implements Conditional {
/**
* Returns whether or not this conditional is met.
*
* @return bool Whether or not the conditional is met.
*/
public function is_met() {
return ! \is_network_admin();
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace Yoast\WP\SEO\Conditionals\Admin;
use Yoast\WP\SEO\Conditionals\Conditional;
/**
* Conditional that is only met when on a post edit or new post page.
*/
class Post_Conditional implements Conditional {
/**
* Returns whether or not this conditional is met.
*
* @return bool Whether or not the conditional is met.
*/
public function is_met() {
global $pagenow;
// Current page is the creation of a new post (type, i.e. post, page, custom post or attachment).
if ( $pagenow === 'post-new.php' ) {
return true;
}
// Current page is the edit page of an existing post (type, i.e. post, page, custom post or attachment).
if ( $pagenow === 'post.php' ) {
return true;
}
return false;
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace Yoast\WP\SEO\Conditionals\Admin;
use Yoast\WP\SEO\Conditionals\Conditional;
/**
* Conditional that is only met when on a post overview page or during an ajax request.
*/
class Posts_Overview_Or_Ajax_Conditional implements Conditional {
/**
* Returns whether or not this conditional is met.
*
* @return bool Whether or not the conditional is met.
*/
public function is_met() {
global $pagenow;
return $pagenow === 'edit.php' || \wp_doing_ajax();
}
}

View File

@ -0,0 +1,41 @@
<?php // phpcs:ignore Yoast.Files.FileName.InvalidClassFileName -- Reason: this explicitly concerns the Yoast admin.
namespace Yoast\WP\SEO\Conditionals\Admin;
use Yoast\WP\SEO\Conditionals\Conditional;
use Yoast\WP\SEO\Helpers\Current_Page_Helper;
/**
* Conditional that is only met when on a Yoast SEO admin page.
*/
class Yoast_Admin_Conditional implements Conditional {
/**
* Holds the Current_Page_Helper.
*
* @var Current_Page_Helper
*/
private $current_page_helper;
/**
* Constructs the conditional.
*
* @param \Yoast\WP\SEO\Helpers\Current_Page_Helper $current_page_helper The current page helper.
*/
public function __construct( Current_Page_Helper $current_page_helper ) {
$this->current_page_helper = $current_page_helper;
}
/**
* Returns `true` when on the admin dashboard, update or Yoast SEO pages.
*
* @return bool `true` when on the admin dashboard, update or Yoast SEO pages.
*/
public function is_met() {
if ( ! \is_admin() ) {
return false;
}
return $this->current_page_helper->is_yoast_seo_page();
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace Yoast\WP\SEO\Conditionals;
use Yoast\WP\SEO\Helpers\Options_Helper;
/**
* Conditional that is only met when the 'Redirect attachment URLs to the attachment itself' setting is enabled.
*/
class Attachment_Redirections_Enabled_Conditional implements Conditional {
/**
* The options helper.
*
* @var Options_Helper
*/
private $options;
/**
* Attachment_Redirections_Enabled_Conditional constructor.
*
* @param Options_Helper $options The options helper.
*/
public function __construct( Options_Helper $options ) {
$this->options = $options;
}
/**
* Returns whether the 'Redirect attachment URLs to the attachment itself' setting has been enabled.
*
* @return bool `true` when the 'Redirect attachment URLs to the attachment itself' setting has been enabled.
*/
public function is_met() {
return $this->options->get( 'disable-attachment' );
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace Yoast\WP\SEO\Conditionals;
/**
* Conditional interface, used to prevent integrations from loading.
*/
interface Conditional {
/**
* Returns whether or not this conditional is met.
*
* @return bool Whether or not the conditional is met.
*/
public function is_met();
}

View File

@ -0,0 +1,23 @@
<?php
namespace Yoast\WP\SEO\Conditionals;
/**
* Conditional that is only met when on deactivating Yoast SEO.
*/
class Deactivating_Yoast_Seo_Conditional implements Conditional {
/**
* Returns whether this conditional is met.
*
* @return bool Whether the conditional is met.
*/
public function is_met() {
// phpcs:ignore WordPress.Security.NonceVerification -- We can't verify nonce since this might run from any user.
if ( isset( $_GET['action'] ) && \sanitize_text_field( \wp_unslash( $_GET['action'] ) ) === 'deactivate' && isset( $_GET['plugin'] ) && \sanitize_text_field( \wp_unslash( $_GET['plugin'] === 'wordpress-seo/wp-seo.php' ) ) ) {
return true;
}
return false;
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace Yoast\WP\SEO\Conditionals;
use WPSEO_Utils;
/**
* Conditional that is only met when in development mode.
*/
class Development_Conditional implements Conditional {
/**
* Returns whether or not this conditional is met.
*
* @return bool Whether or not the conditional is met.
*/
public function is_met() {
return WPSEO_Utils::is_development_mode();
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace Yoast\WP\SEO\Conditionals;
/**
* Abstract class for creating conditionals based on feature flags.
*/
abstract class Feature_Flag_Conditional implements Conditional {
/**
* Returns whether or not this conditional is met.
*
* @return bool Whether or not the conditional is met.
*/
public function is_met() {
$feature_flag = \strtoupper( $this->get_feature_flag() );
return \defined( 'YOAST_SEO_' . $feature_flag ) && \constant( 'YOAST_SEO_' . $feature_flag ) === true;
}
/**
* Returns the name of the feature flag.
* 'YOAST_SEO_' is automatically prepended to it and it will be uppercased.
*
* @return string the name of the feature flag.
*/
abstract protected function get_feature_flag();
/**
* Returns the feature name.
*
* @return string the name of the feature flag.
*/
public function get_feature_name() {
return $this->get_feature_flag();
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace Yoast\WP\SEO\Conditionals;
/**
* Conditional that is only met when NOT in the admin.
*/
class Front_End_Conditional implements Conditional {
/**
* Returns `true` when NOT on an admin page.
*
* @return bool `true` when NOT on an admin page.
*/
public function is_met() {
return ! \is_admin();
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace Yoast\WP\SEO\Conditionals;
/**
* Conditional that is only met when the current request uses the GET method.
*/
class Get_Request_Conditional implements Conditional {
/**
* Returns whether or not this conditional is met.
*
* @return bool Whether or not the conditional is met.
*/
public function is_met() {
if ( isset( $_SERVER['REQUEST_METHOD'] ) && $_SERVER['REQUEST_METHOD'] === 'GET' ) {
return true;
}
return false;
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace Yoast\WP\SEO\Conditionals;
use Yoast\WP\SEO\Helpers\Options_Helper;
/**
* Conditional that is only met when the headless rest endpoints are enabled.
*/
class Headless_Rest_Endpoints_Enabled_Conditional implements Conditional {
/**
* The options helper.
*
* @var Options_Helper
*/
private $options;
/**
* Headless_Rest_Endpoints_Enabled_Conditional constructor.
*
* @param Options_Helper $options The options helper.
*/
public function __construct( Options_Helper $options ) {
$this->options = $options;
}
/**
* Returns `true` whether the headless REST endpoints have been enabled.
*
* @return bool `true` when the headless REST endpoints have been enabled.
*/
public function is_met() {
return $this->options->get( 'enable_headless_rest_endpoints' );
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace Yoast\WP\SEO\Conditionals;
/**
* Conditional that is only met when current page is not a specific tool's page.
*/
class Import_Tool_Selected_Conditional implements Conditional {
/**
* Returns whether or not this conditional is met.
*
* @return bool Whether or not the conditional is met.
*/
public function is_met() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- We just check whether a URL parameter does not exist.
return ( isset( $_GET['tool'] ) && $_GET['tool'] === 'import-export' );
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace Yoast\WP\SEO\Conditionals;
/**
* Conditional that is only met when Jetpack exists.
*/
class Jetpack_Conditional implements Conditional {
/**
* Returns `true` when the Jetpack plugin exists on this
* WordPress installation.
*
* @return bool `true` when the Jetpack plugin exists on this WordPress installation.
*/
public function is_met() {
return \class_exists( 'Jetpack' );
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace Yoast\WP\SEO\Conditionals;
use Yoast\WP\SEO\Config\Migration_Status;
/**
* Class for integrations that depend on having all migrations run.
*/
class Migrations_Conditional implements Conditional {
/**
* The migration status.
*
* @var Migration_Status
*/
protected $migration_status;
/**
* Migrations_Conditional constructor.
*
* @param Migration_Status $migration_status The migration status object.
*/
public function __construct( Migration_Status $migration_status ) {
$this->migration_status = $migration_status;
}
/**
* Returns `true` when all database migrations have been run.
*
* @return bool `true` when all database migrations have been run.
*/
public function is_met() {
return $this->migration_status->is_version( 'free', \WPSEO_VERSION );
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace Yoast\WP\SEO\Conditionals;
/**
* Feature flag conditional for the new settings UI.
*/
class New_Settings_Ui_Conditional extends Feature_Flag_Conditional {
/**
* Returns the name of the feature flag.
*
* @return string The name of the feature flag.
*/
protected function get_feature_flag() {
return 'NEW_SETTINGS_UI';
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace Yoast\WP\SEO\Conditionals;
/**
* Conditional that is only met when news SEO is activated.
*/
class News_Conditional implements Conditional {
/**
* Returns whether or not this conditional is met.
*
* @return bool Whether or not the conditional is met.
*/
public function is_met() {
return \defined( 'WPSEO_NEWS_VERSION' );
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace Yoast\WP\SEO\Conditionals;
/**
* Trait for integrations that do not have any conditionals.
*/
trait No_Conditionals {
/**
* Returns an empty array, meaning no conditionals are required to load whatever uses this trait.
*
* @return array The conditionals that must be met to load this.
*/
public static function get_conditionals() {
return [];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace Yoast\WP\SEO\Conditionals;
/**
* Conditional that is only met when current page is not a specific tool's page.
*/
class No_Tool_Selected_Conditional implements Conditional {
/**
* Returns whether or not this conditional is met.
*
* @return bool Whether or not the conditional is met.
*/
public function is_met() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- We just check whether a URL parameter does not exist.
return ! isset( $_GET['tool'] );
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace Yoast\WP\SEO\Conditionals;
/**
* Conditional that is only met when we aren't in a multisite setup.
*/
class Non_Multisite_Conditional implements Conditional {
/**
* Returns `true` when we aren't in a multisite setup.
*
* @return bool `true` when we aren't in a multisite setup.
*/
public function is_met() {
return ! \is_multisite();
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace Yoast\WP\SEO\Conditionals;
/**
* Conditional that is only met when not in a admin-ajax request.
*/
class Not_Admin_Ajax_Conditional implements Conditional {
/**
* Returns whether or not this conditional is met.
*
* @return bool Whether or not the conditional is met.
*/
public function is_met() {
return ( ! \wp_doing_ajax() );
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace Yoast\WP\SEO\Conditionals;
use Yoast\WP\SEO\Helpers\Options_Helper;
/**
* Conditional that is only met when the Open Graph feature is enabled.
*/
class Open_Graph_Conditional implements Conditional {
/**
* The options helper.
*
* @var Options_Helper
*/
private $options;
/**
* Open_Graph_Conditional constructor.
*
* @param Options_Helper $options The options helper.
*/
public function __construct( Options_Helper $options ) {
$this->options = $options;
}
/**
* Returns `true` when the Open Graph feature is enabled.
*
* @return bool `true` when the Open Graph feature is enabled.
*/
public function is_met() {
return $this->options->get( 'opengraph' ) === true;
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace Yoast\WP\SEO\Conditionals;
/**
* Class Premium_Active_Conditional.
*/
class Premium_Active_Conditional implements Conditional {
/**
* Returns whether or not this conditional is met.
*
* @return bool Whether or not the conditional is met.
*/
public function is_met() {
return \YoastSEO()->helpers->product->is_premium();
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace Yoast\WP\SEO\Conditionals;
/**
* Abstract class for creating conditionals based on feature flags.
*/
class Premium_Inactive_Conditional implements Conditional {
/**
* Returns whether or not this conditional is met.
*
* @return bool Whether or not the conditional is met.
*/
public function is_met() {
return ! \YoastSEO()->helpers->product->is_premium();
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace Yoast\WP\SEO\Conditionals;
use Yoast\WP\SEO\Helpers\Current_Page_Helper;
/**
* Conditional that is only met when in frontend or page is a post overview or post add/edit form.
*/
class Primary_Category_Conditional implements Conditional {
/**
* The current page helper.
*
* @var Current_Page_Helper
*/
private $current_page;
/**
* Primary_Category_Conditional constructor.
*
* @param Current_Page_Helper $current_page The current page helper.
*/
public function __construct( Current_Page_Helper $current_page ) {
$this->current_page = $current_page;
}
/**
* Returns `true` when on the frontend,
* or when on the post overview, post edit or new post admin page,
* or when on additional admin pages, allowed by filter.
*
* @return bool `true` when on the frontend, or when on the post overview,
* post edit, new post admin page or additional admin pages, allowed by filter.
*/
public function is_met() {
if ( ! \is_admin() ) {
return true;
}
/**
* Filter: Adds the possibility to use primary category at additional admin pages.
*
* @param array $admin_pages List of additional admin pages.
*/
$additional_pages = \apply_filters( 'wpseo_primary_category_admin_pages', [] );
return \in_array( $this->current_page->get_current_admin_page(), \array_merge( [ 'edit.php', 'post.php', 'post-new.php' ], $additional_pages ), true );
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace Yoast\WP\SEO\Conditionals;
/**
* Conditional that is only met when on the front end or Yoast file editor page.
*/
class Robots_Txt_Conditional implements Conditional {
/**
* Holds the Front_End_Conditional instance.
*
* @var Front_End_Conditional
*/
protected $front_end_conditional;
/**
* Constructs the class.
*
* @param Front_End_Conditional $front_end_conditional The front end conditional.
*/
public function __construct( Front_End_Conditional $front_end_conditional ) {
$this->front_end_conditional = $front_end_conditional;
}
/**
* Returns whether or not this conditional is met.
*
* @return bool Whether or not the conditional is met.
*/
public function is_met() {
return $this->front_end_conditional->is_met() || $this->is_file_editor_page();
}
/**
* Returns whether the current page is the file editor page.
*
* This checks for two locations:
* - Multisite network admin file editor page
* - Single site file editor page (under tools)
*
* @return bool
*/
protected function is_file_editor_page() {
global $pagenow;
if ( $pagenow !== 'admin.php' ) {
return false;
}
// phpcs:ignore WordPress.Security.NonceVerification -- This is not a form.
if ( isset( $_GET['page'] ) && $_GET['page'] === 'wpseo_files' && \is_multisite() && \is_network_admin() ) {
return true;
}
// phpcs:ignore WordPress.Security.NonceVerification -- This is not a form.
if ( ! ( isset( $_GET['page'] ) && $_GET['page'] === 'wpseo_tools' ) ) {
return false;
}
// phpcs:ignore WordPress.Security.NonceVerification -- This is not a form.
if ( isset( $_GET['tool'] ) && $_GET['tool'] === 'file-editor' ) {
return true;
}
return false;
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace Yoast\WP\SEO\Conditionals;
use Yoast\WP\SEO\Helpers\Options_Helper;
/**
* Conditional that is only met when the SEMrush integration is enabled.
*/
class SEMrush_Enabled_Conditional implements Conditional {
/**
* The options helper.
*
* @var Options_Helper
*/
private $options;
/**
* SEMrush_Enabled_Conditional constructor.
*
* @param Options_Helper $options The options helper.
*/
public function __construct( Options_Helper $options ) {
$this->options = $options;
}
/**
* Returns whether or not this conditional is met.
*
* @return bool Whether or not the conditional is met.
*/
public function is_met() {
return $this->options->get( 'semrush_integration_active', false );
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace Yoast\WP\SEO\Conditionals;
/**
* Class Settings_Conditional.
*/
class Settings_Conditional implements Conditional {
/**
* Holds User_Can_Manage_Wpseo_Options_Conditional.
*
* @var User_Can_Manage_Wpseo_Options_Conditional
*/
protected $user_can_manage_wpseo_options_conditional;
/**
* Constructs Settings_Conditional.
*
* @param User_Can_Manage_Wpseo_Options_Conditional $user_can_manage_wpseo_options_conditional The User_Can_Manage_Wpseo_Options_Conditional.
*/
public function __construct(
User_Can_Manage_Wpseo_Options_Conditional $user_can_manage_wpseo_options_conditional
) {
$this->user_can_manage_wpseo_options_conditional = $user_can_manage_wpseo_options_conditional;
}
/**
* Returns whether or not this conditional is met.
*
* @return bool Whether or not the conditional is met.
*/
public function is_met() {
if ( ! $this->user_can_manage_wpseo_options_conditional->is_met() ) {
return false;
}
return true;
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace Yoast\WP\SEO\Conditionals;
use Yoast\WP\SEO\Helpers\Options_Helper;
/**
* Should_Index_Links_Conditional class.
*/
class Should_Index_Links_Conditional implements Conditional {
/**
* The options helper.
*
* @var Options_Helper
*/
protected $options_helper;
/**
* Should_Index_Links_Conditional constructor.
*
* @param Options_Helper $options_helper The options helper.
*/
public function __construct( Options_Helper $options_helper ) {
$this->options_helper = $options_helper;
}
/**
* Returns `true` when the links on this website should be indexed.
*
* @return bool `true` when the links on this website should be indexed.
*/
public function is_met() {
$should_index_links = $this->options_helper->get( 'enable_text_link_counter' );
/**
* Filter: 'wpseo_should_index_links' - Allows disabling of Yoast's links indexation.
*
* @api bool To disable the indexation, return false.
*/
return \apply_filters( 'wpseo_should_index_links', $should_index_links );
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace Yoast\WP\SEO\Conditionals;
/**
* Checks if the YOAST_SEO_TEXT_FORMALITY constant is set.
*/
class Text_Formality_Conditional extends Feature_Flag_Conditional {
/**
* Returns the name of the feature flag.
* 'YOAST_SEO_' is automatically prepended to it and it will be uppercased.
*
* @return string the name of the feature flag.
*/
public function get_feature_flag() {
return 'TEXT_FORMALITY';
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace Yoast\WP\SEO\Conditionals\Third_Party;
use Yoast\WP\SEO\Conditionals\Conditional;
/**
* Conditional that is met when the Elementor plugin is installed and activated.
*/
class Elementor_Activated_Conditional implements Conditional {
/**
* Checks if the Elementor plugins is installed and activated.
*
* @return bool `true` when the Elementor plugin is installed and activated.
*/
public function is_met() {
return \defined( 'ELEMENTOR__FILE__' );
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace Yoast\WP\SEO\Conditionals\Third_Party;
use Yoast\WP\SEO\Conditionals\Conditional;
/**
* Conditional that is only met when on an Elementor edit page or when the current
* request is an ajax request for saving our post meta data.
*/
class Elementor_Edit_Conditional implements Conditional {
/**
* Returns whether this conditional is met.
*
* @return bool Whether the conditional is met.
*/
public function is_met() {
global $pagenow;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reason: We are not processing form information.
if ( isset( $_GET['action'] ) && \is_string( $_GET['action'] ) ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Reason: We are not processing form information, We are only strictly comparing.
$get_action = \wp_unslash( $_GET['action'] );
if ( $pagenow === 'post.php' && $get_action === 'elementor' ) {
return true;
}
}
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Reason: We are not processing form information.
if ( isset( $_POST['action'] ) && \is_string( $_POST['action'] ) ) {
// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Reason: We are not processing form information, We are only strictly comparing.
$post_action = \wp_unslash( $_POST['action'] );
return \wp_doing_ajax() && $post_action === 'wpseo_elementor_save';
}
return false;
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace Yoast\WP\SEO\Conditionals\Third_Party;
use Yoast\WP\SEO\Conditionals\Conditional;
/**
* Conditional that is only met when Jetpack_Boost exists.
*/
class Jetpack_Boost_Active_Conditional implements Conditional {
/**
* Returns `true` when the Jetpack_Boost class exists within this WordPress installation.
*
* @return bool `true` when the Jetpack_Boost class exists within this WordPress installation.
*/
public function is_met() {
return \class_exists( '\Automattic\Jetpack_Boost\Jetpack_Boost', false );
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace Yoast\WP\SEO\Conditionals\Third_Party;
use Automattic\Jetpack_Boost\Lib\Premium_Features;
use Yoast\WP\SEO\Conditionals\Conditional;
/**
* Conditional that is met when Jetpack Boost is not installed, activated or premium.
*/
class Jetpack_Boost_Not_Premium_Conditional implements Conditional {
/**
* Whether Jetpack Boost is not premium.
*
* @return bool Whether Jetpack Boost is not premium.
*/
public function is_met() {
return ! $this->is_premium();
}
/**
* Retrieves, if available, if Jetpack Boost has priority feature available.
*
* @return bool Whether Jetpack Boost is premium.
*/
private function is_premium() {
if ( \class_exists( '\Automattic\Jetpack_Boost\Lib\Premium_Features', false ) ) {
return Premium_Features::has_feature(
Premium_Features::PRIORITY_SUPPORT
);
}
return false;
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace Yoast\WP\SEO\Conditionals\Third_Party;
use Yoast\WP\SEO\Conditionals\Conditional;
/**
* Conditional that is only met when the Polylang plugin is active.
*/
class Polylang_Conditional implements Conditional {
/**
* Checks whether the Polylang plugin is installed and active.
*
* @return bool Whether Polylang is installed and active.
*/
public function is_met() {
return \defined( 'POLYLANG_FILE' );
}
}

Some files were not shown because too many files have changed in this diff Show More