Source: classes/RegisteredDataHandler.php

<?php
/**
 * Class for process registered data.
 *
 * @package  distributor
 */

namespace Distributor;

/**
 * This class is responsible for processing the registered data for the post content and post meta.
 *
 * @since 2.2.0
 */
class RegisteredDataHandler {
	/**
	 * The Connection data array.
	 *
	 * @since 2.2.0
	 * @var array
	 */
	public $connection_data = array();

	/**
	 * Constructor for the RegisteredDataHandler class.
	 *
	 * @since 2.2.0
	 *
	 * @param array $connection_data The connection data array.
	 */
	public function __construct( $connection_data = array() ) {
		$this->connection_data = $connection_data;
	}

	/**
	 * Search and replace inner content of a block with the provided replacements.
	 *
	 * @since 2.2.0
	 *
	 * @param array $block               The block to search and replace inner content.
	 * @param array $replacement_strings Array of search and replace strings for inner content.
	 * @return array The block with inner content replaced.
	 */
	public function search_replace_block_inner_content( $block, $replacement_strings ) {
		if ( empty( $replacement_strings ) ) {
			return $block;
		}

		foreach ( $replacement_strings as $replacement_string ) {
			$block['innerHTML']       = str_replace( $replacement_string['search'], $replacement_string['replace'], $block['innerHTML'] );
			$block['innerContent'][0] = str_replace( $replacement_string['search'], $replacement_string['replace'], $block['innerContent'][0] );
		}
		return $block;
	}

	/**
	 * Recursively process blocks for the registered data.
	 *
	 * Processes the blocks data recursively and calls the callback function provided in the registered data.
	 *
	 * @since 2.2.0
	 *
	 * @param array $blocks          Array of blocks.
	 * @param array $registered_data Array of registered data.
	 * @param array $extra_data      Array of extra data provided by source for the registered data.
	 * @param array $post_data       Array of post data.
	 * @param int   $index           Index of the extra data.
	 * @return array Array with 'blocks' (processed blocks) and 'modified' (bool).
	 */
	public function process_blocks_data_recursive( $blocks, $registered_data, $extra_data, $post_data, $index = 0 ) {
		$callback_fn     = $registered_data['post_distribute_cb'] ?? null;
		$attributes      = $registered_data['attributes'] ?? array();
		$block_name      = $attributes['block_name'] ?? '';
		$block_attribute = $attributes['block_attribute'] ?? '';
		$modified        = false;

		// Skip if the callback function is not provided or not callable.
		if ( empty( $callback_fn ) || ! is_callable( $callback_fn ) ) {
			return array(
				'blocks'   => $blocks,
				'modified' => $modified,
			);
		}

		foreach ( $blocks as &$block ) {
			if ( isset( $block['blockName'] ) && $block_name === $block['blockName'] ) {
				if ( is_array( $block_attribute ) ) {
					$source_data = array();
					foreach ( $block_attribute as $attribute ) {
						if ( isset( $block['attrs'][ $attribute ] ) ) {
							$source_data[ $attribute ] = $block['attrs'][ $attribute ];
						}
					}
					$current_extra_data = $extra_data[ $index ] ?? array();
					$replacement        = call_user_func_array( $callback_fn, array( $current_extra_data, $source_data, $post_data, $this->connection_data ) );
					if ( ! empty( $replacement ) ) {
						foreach ( $block_attribute as $attribute ) {
							if ( isset( $replacement[ $attribute ] ) ) {
								$block['attrs'][ $attribute ] = $replacement[ $attribute ];
							}
						}

						// Do replacement for innerHTML if it's set.
						if ( ! empty( $replacement['inner_content_replacements'] ) ) {
							$block = $this->search_replace_block_inner_content( $block, $replacement['inner_content_replacements'] );
						}

						$modified = true;
					}
					$index++;
				} elseif ( isset( $block['attrs'][ $block_attribute ] ) ) {
					$source_data        = $block['attrs'][ $block_attribute ];
					$current_extra_data = $extra_data[ $index ] ?? array();
					$replacement        = call_user_func_array( $callback_fn, array( $current_extra_data, $source_data, $post_data, $this->connection_data ) );
					if ( ! empty( $replacement ) ) {
						$block['attrs'][ $block_attribute ] = $replacement;

						// Handle inner content replacements for media blocks.
						$type = $registered_data['type'] ?? '';
						if ( 'media' === $type && ! empty( $replacement ) ) {
							$from_url = $current_extra_data['url'] ?? '';
							$to_url   = wp_get_attachment_url( $replacement );

							if ( ! empty( $from_url ) && ! empty( $to_url ) ) {
								$replacements = array(
									array(
										'search'  => $from_url,
										'replace' => $to_url,
									),
									array(
										'search'  => 'wp-image-' . $source_data,
										'replace' => 'wp-image-' . $replacement,
									),
								);
								// Try replacing the guid as well, due to the media url could be different based on from where it's being pulled.
								if ( ! empty( $current_extra_data['guid'] ) ) {
									$replacements[] = array(
										'search'  => $current_extra_data['guid'],
										'replace' => $to_url,
									);
								}
								$block = $this->search_replace_block_inner_content( $block, $replacements );
							}
						}

						$modified = true;
					}
					$index++;
				}
			}

			if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
				$inner_result = $this->process_blocks_data_recursive( $block['innerBlocks'], $registered_data, $extra_data, $post_data, $index );
				if ( $inner_result['modified'] ) {
					$block['innerBlocks'] = $inner_result['blocks'];
					$modified             = true;
				}
			}
		}

		return array(
			'blocks'   => $blocks,
			'modified' => $modified,
		);
	}

	/**
	 * Processes the registered data for the post content and post meta.
	 *
	 * Calls the callback function provided in the registered data and updates the post data.
	 *
	 * @since 2.2.0
	 *
	 * @param array $post_data The post data.
	 * @param bool  $is_rest   Whether the post data is from the REST API.
	 * @return array $post_data The processed post data.
	 */
	public function process_registered_data( $post_data, $is_rest = false ) {
		$unprocessed_post_data = $post_data;
		// Filter is documented in includes/classes/DistributorPost.php
		if ( ! apply_filters( 'dt_process_extra_data', true, $post_data ) ) {
			return $post_data;
		}

		// Get the registered data.
		$registered_data = distributor_get_registered_data();

		// Skip if no registered data is found.
		if ( empty( $registered_data ) ) {
			return $post_data;
		}

		foreach ( $registered_data as $data_key => $data ) {
			$location    = $data['location'];
			$attributes  = $data['attributes'];
			$callback_fn = $data['post_distribute_cb'] ?? null;

			// Skip if the callback function is not provided or not callable.
			if ( empty( $callback_fn ) || ! is_callable( $callback_fn ) ) {
				continue;
			}

			$extra_data = $post_data['distributor_extra_data'][ $data_key ] ?? array();

			if ( 'post_meta' === $location ) {
				$metadata_key = 'distributor_meta';
				if ( isset( $post_data['meta'] ) && ! empty( $post_data['meta'] ) ) {
					$metadata_key = 'meta';
				}

				$post_meta = $post_data[ $metadata_key ] ?? array();

				if ( empty( $post_meta ) ) {
					continue;
				}

				$post_data[ $metadata_key ] = $this->process_registered_post_meta_data( $post_meta, $data, $extra_data, $post_data );

			} elseif ( 'post_content' === $location ) {
				$content_key = 'post_content';
				if ( $is_rest ) {
					$content_key = 'content';
					if ( isset( $post_data['distributor_raw_content'] ) ) {
						$content_key = 'distributor_raw_content';
					}
				}

				$post_content = $post_data[ $content_key ] ?? '';
				$block_name   = $attributes['block_name'] ?? '';
				$shortcode    = $attributes['shortcode'] ?? '';

				if ( ( empty( $block_name ) && empty( $shortcode ) ) || empty( $post_content ) ) {
					continue;
				}

				if ( ! empty( $block_name ) && has_blocks( $post_content ) ) {
					$post_data[ $content_key ] = $this->process_registered_block_data( $post_content, $data, $extra_data, $post_data );
					$post_content              = $post_data[ $content_key ];
				}

				// Process the shortcode if shortcode is provided.
				if ( ! empty( $shortcode ) ) {
					$post_data[ $content_key ] = $this->process_registered_shortcode_data( $post_content, $data, $extra_data, $post_data );
				}
			}
		}

		/**
		 * Filter the post data after processing the registered data.
		 *
		 * @since 2.2.0
		 * @hook dt_after_registered_data_processed
		 *
		 * @param {array} $post_data             The post data after processing the registered data.
		 * @param {array} $registered_data       The distributor registered data.
		 * @param {array} $extra_data            The extra data for the given registered data.
		 * @param {array} $unprocessed_post_data The post data before processing the registered data.
		 * @return {array} $post_data The updated post data.
		 */
		$post_data = apply_filters( 'dt_after_registered_data_processed', $post_data, $registered_data, $post_data['distributor_extra_data'] ?? array(), $unprocessed_post_data );

		return $post_data;
	}

	/**
	 * Processes the registered data for the post meta.
	 *
	 * Calls the callback function provided in the registered data and updates the post meta data.
	 *
	 * @since 2.2.0
	 *
	 * @param array $post_meta       The post meta data.
	 * @param array $registered_data The distributor registered data.
	 * @param array $extra_data      The extra data for the given registered data.
	 * @param array $post_data       The post data.
	 * @return array $post_data The processed post data.
	 */
	public function process_registered_post_meta_data( $post_meta, $registered_data, $extra_data, $post_data ) {
		$attributes            = $registered_data['attributes'] ?? array();
		$callback_fn           = $registered_data['post_distribute_cb'] ?? null;
		$meta_key              = $attributes['meta_key'] ?? '';
		$unprocessed_post_meta = $post_meta;

		// Skip if the callback function is not provided or not callable.
		if ( empty( $callback_fn ) || ! is_callable( $callback_fn ) || empty( $meta_key ) ) {
			return $post_meta;
		}

		// Handle multiple meta keys.
		if ( is_array( $meta_key ) ) {
			$original_data = array();
			foreach ( $meta_key as $key ) {
				if ( isset( $post_meta[ $key ] ) ) {
					if ( is_array( $post_meta[ $key ] ) && 1 === count( $post_meta[ $key ] ) ) {
						$original_data[ $key ] = $post_meta[ $key ][0];
					} else {
						$original_data[ $key ] = $post_meta[ $key ];
					}
				}
			}
			$updated_meta = call_user_func_array( $callback_fn, array( $extra_data, $original_data, $post_data, $this->connection_data ) );

			if ( ! empty( $updated_meta ) ) {
				foreach ( $updated_meta as $key => $value ) {
					if ( is_array( $post_meta[ $key ] ) && 1 === count( $post_meta[ $key ] ) ) {
						$post_meta[ $key ] = array( $value );
					} else {
						$post_meta[ $key ] = $value;
					}
				}
			}
		} else {
			$original_data = isset( $post_meta[ $meta_key ] ) ? $post_meta[ $meta_key ] : '';
			if ( is_array( $original_data ) && 1 === count( $original_data ) ) {
				$original_data = $original_data[0];
			}
			$updated_meta = call_user_func_array( $callback_fn, array( $extra_data, $original_data, $post_data, $this->connection_data ) );

			if ( ! empty( $updated_meta ) ) {
				if ( is_array( $post_meta[ $meta_key ] ) && 1 === count( $post_meta[ $meta_key ] ) ) {
					$post_meta[ $meta_key ] = array( $updated_meta );
				} else {
					$post_meta[ $meta_key ] = $updated_meta;
				}
			}
		}

		/**
		 * Filter the post meta data after processing the registered data.
		 *
		 * @since 2.2.0
		 * @hook dt_after_registered_post_meta_processed
		 *
		 * @param {array} $post_meta             The post meta data.
		 * @param {array} $registered_data       The distributor registered data.
		 * @param {array} $extra_data            The extra data for the given registered data.
		 * @param {array} $post_data             The post data.
		 * @param {array} $unprocessed_post_meta The post meta data before processing the registered data.
		 * @return {array} $post_meta The updated post meta data.
		 */
		return apply_filters( 'dt_after_registered_post_meta_processed', $post_meta, $registered_data, $extra_data, $post_data, $unprocessed_post_meta );
	}

	/**
	 * Process the registered block data for the post content.
	 *
	 * @since 2.2.0
	 *
	 * @param string $post_content    The post content.
	 * @param array  $registered_data The distributor registered data.
	 * @param array  $extra_data      The extra data for the given registered data.
	 * @param array  $post_data       The post data.
	 * @return string $post_content The updated post content.
	 */
	public function process_registered_block_data( $post_content, $registered_data, $extra_data, $post_data ) {
		$attributes               = $registered_data['attributes'] ?? array();
		$block_name               = $attributes['block_name'] ?? '';
		$block_attribute          = $attributes['block_attribute'] ?? '';
		$unprocessed_post_content = $post_content;

		if ( ! empty( $block_attribute ) && has_block( $block_name, $post_content ) ) {
			$blocks = parse_blocks( $post_content );
			$result = $this->process_blocks_data_recursive( $blocks, $registered_data, $extra_data, $post_data );

			if ( $result['modified'] ) {
				$post_content = serialize_blocks( $result['blocks'] );
			};
		}

		/**
		 * Filter the post content blocks after processing the registered data.
		 *
		 * @since 2.2.0
		 * @hook dt_after_registered_block_data_processed
		 *
		 * @param {array} $post_content             The post content.
		 * @param {array} $registered_data          The distributor registered data.
		 * @param {array} $extra_data               The extra data for the given registered data.
		 * @param {array} $post_data                The post data.
		 * @param {array} $unprocessed_post_content The post content before processing the registered data.
		 * @return {array} $post_content The updated post content.
		 */
		return apply_filters( 'dt_after_registered_block_data_processed', $post_content, $registered_data, $extra_data, $post_data, $unprocessed_post_content );
	}

	/**
	 * Process the registered shortcode data for the post content.
	 *
	 * @since 2.2.0
	 *
	 * @param string $post_content    The post content.
	 * @param array  $registered_data The distributor registered data.
	 * @param array  $extra_data      The extra data for the given registered data.
	 * @param array  $post_data       The post data.
	 * @return string $post_content The updated post content.
	 */
	public function process_registered_shortcode_data( $post_content, $registered_data, $extra_data, $post_data ) {
		$attributes               = $registered_data['attributes'] ?? array();
		$shortcode                = $attributes['shortcode'] ?? '';
		$shortcode_attribute      = $attributes['shortcode_attribute'] ?? '';
		$callback_fn              = $registered_data['post_distribute_cb'] ?? null;
		$unprocessed_post_content = $post_content;

		if ( ! empty( $shortcode_attribute ) && has_shortcode( $post_content, $shortcode ) ) {
			$index        = 0;
			$pattern      = get_shortcode_regex( array( $shortcode ) );
			$post_content = preg_replace_callback(
				"/$pattern/",
				function ( $matches ) use ( &$index, $shortcode, $shortcode_attribute, $callback_fn, $extra_data, $post_data ) {
					if ( $matches[2] === $shortcode ) {
						$attrs = shortcode_parse_atts( $matches[3] );
						$i     = $index;
						$index++;

						if ( is_array( $shortcode_attribute ) ) {
							$source_data = array();
							foreach ( $shortcode_attribute as $key ) {
								if ( isset( $attrs[ $key ] ) ) {
									$source_data[ $key ] = $attrs[ $key ];
								}
							}
							$current_extra_data = $extra_data[ $i ] ?? array();
							$replacement        = call_user_func_array( $callback_fn, array( $current_extra_data, $source_data, $post_data, $this->connection_data ) );
							if ( ! empty( $replacement ) ) {
								foreach ( $shortcode_attribute as $key ) {
									if ( isset( $replacement[ $key ] ) ) {
										$attrs[ $key ] = $replacement[ $key ];
									}
								}
								$attrs_str = '';
								foreach ( $attrs as $key => $val ) {
									$attrs_str .= sprintf( ' %s="%s"', $key, esc_attr( $val ) );
								}
								return str_replace( $matches[3], $attrs_str, $matches[0] );
							}
						} elseif ( isset( $attrs[ $shortcode_attribute ] ) ) {
							$source_data        = $attrs[ $shortcode_attribute ];
							$current_extra_data = $extra_data[ $i ] ?? array();
							$replacement        = call_user_func_array( $callback_fn, array( $current_extra_data, $source_data, $post_data, $this->connection_data ) );
							if ( ! empty( $replacement ) ) {
								// Replace with the new target ID.
								$attrs[ $shortcode_attribute ] = $replacement;
								$attrs_str                     = '';
								foreach ( $attrs as $key => $val ) {
									$attrs_str .= sprintf( ' %s="%s"', $key, esc_attr( $val ) );
								}
								return str_replace( $matches[3], $attrs_str, $matches[0] );
							}
						}
						$index++;
					}
					return $matches[0];
				},
				$post_content
			);
		}

		/**
		 * Filter the post content shortcodes after processing the registered data.
		 *
		 * @since 2.2.0
		 * @hook dt_after_registered_shortcode_data_processed
		 *
		 * @param {array} $post_content             The post content.
		 * @param {array} $registered_data          The distributor registered data.
		 * @param {array} $extra_data               The extra data for the given registered data.
		 * @param {array} $post_data                The post data.
		 * @param {array} $unprocessed_post_content The post content before processing the registered data.
		 * @return {array} $post_content The updated post content.
		 */
		return apply_filters( 'dt_after_registered_shortcode_data_processed', $post_content, $registered_data, $extra_data, $post_data, $unprocessed_post_content );
	}

	/**
	 * Prepare the term extra data to be sent to the target site.
	 *
	 * @since 2.2.0
	 *
	 * @param int  $term_id     The term ID.
	 * @param bool $with_parent Whether to include the parent term data.
	 * @return array|int The term extra data.
	 */
	public function prepare_registered_data_term( $term_id, $with_parent = false ) {
		$term = get_term( $term_id );
		if ( ! $term || is_wp_error( $term ) ) {
			return 0;
		}

		$term_data = array(
			'term_id'     => $term->term_id,
			'name'        => $term->name,
			'slug'        => $term->slug,
			'description' => $term->description,
			'taxonomy'    => $term->taxonomy,
		);

		if ( ! empty( $term->parent ) && $with_parent && is_taxonomy_hierarchical( $term->taxonomy ) ) {
			$term_data['parent'] = $this->prepare_registered_data_term( $term->parent, $with_parent );
		}

		return $term_data;
	}

	/**
	 * Process the registered data for the term.
	 *
	 * @since 2.2.0
	 *
	 * @param array $term_data        The term data to be processed.
	 * @param bool  $process_parent   Whether to process the parent term.
	 * @param bool  $update_hierarchy Whether to update the term hierarchy.
	 * @return int The term ID of the processed term.
	 */
	public function process_registered_data_term( $term_data, $process_parent = false, $update_hierarchy = false ) {
		if ( empty( $term_data ) ) {
			return 0;
		}

		if ( ! is_array( $term_data ) ) {
			$term_data = (array) $term_data;
		}

		$process_parent = $process_parent && is_taxonomy_hierarchical( $term_data['taxonomy'] );
		$parent_term_id = 0;
		if ( $process_parent && ! empty( $term_data['parent'] ) ) {
			if ( ! empty( $term_data['parent']['term_id'] ) ) {
				$parent_term_id = $this->process_registered_data_term( $term_data['parent'], $process_parent, $update_hierarchy );
			}
		}

		// Check if the term exists on the target site already and return the term ID if it does.
		$taxonomy = $term_data['taxonomy'] ?? '';
		$term     = get_term_by( 'slug', $term_data['slug'], $taxonomy );
		if ( ! empty( $term ) && ! empty( $term->term_id ) ) {
			if ( $update_hierarchy && $process_parent && ! empty( $parent_term_id ) && $term->parent !== $parent_term_id ) {
				wp_update_term( $term->term_id, $taxonomy, array( 'parent' => $parent_term_id ) );
			}

			return $term->term_id;
		}

		$args = array(
			'slug'        => $term_data['slug'],
			'description' => $term_data['description'],
		);

		if ( $process_parent && ! empty( $parent_term_id ) ) {
			$args['parent'] = $parent_term_id;
		}

		$term = wp_insert_term(
			$term_data['name'],
			$taxonomy,
			$args
		);

		if ( is_wp_error( $term ) || empty( $term['term_id'] ) ) {
			return 0;
		}

		return $term['term_id'];
	}

	/**
	 * Pre-process registered "post" type data for external connections.
	 *
	 * Handles "post" type data when pushing to an external connection.
	 *
	 * Since the target site cannot pull the post, we push it to the target site,
	 * retrieve the remote post ID, and include it in the extra data sent to the target site.
	 *
	 * @param array                           $post_data The post data to process for the external connection.
	 * @param \Distributor\ExternalConnection $connection The connection object.
	 * @return array The processed extra data.
	 */
	public function pre_process_registered_data_post( $post_data, $connection ) {
		$extra_data = $post_data['distributor_extra_data'] ?? array();
		if ( empty( $extra_data ) || ! is_array( $extra_data ) || ! apply_filters( 'dt_process_extra_data', true, $post_data ) ) {
			return $post_data;
		}

		try {
			$registered_data      = distributor_get_registered_data();
			$registered_post_data = array_filter(
				$registered_data,
				function( $arr ) {
					return 'post' === $arr['type'];
				}
			);

			if ( ! empty( $registered_post_data ) && ! empty( $extra_data ) ) {
				$prevent_processing = function() {
					return false;
				};
				// Disable the process_extra_data filter to prevent infinite loop.
				add_filter( 'dt_process_extra_data', $prevent_processing, 9999 );

				foreach ( $registered_post_data as $key => $data ) {
					if ( empty( $extra_data[ $key ] ) || ! is_array( $extra_data[ $key ] ) ) {
						continue;
					}

					foreach ( $extra_data[ $key ] as $index => $current_extra_data ) {
						if ( ! empty( $current_extra_data['source_post_id'] ) ) {
							$source_post_id = absint( wp_unslash( $current_extra_data['source_post_id'] ) );
							$source_post    = get_post( $source_post_id );
							if ( empty( $source_post ) ) {
								continue;
							}

							// Push the source post ID to the remote site.
							$connection_map = get_post_meta( $source_post_id, 'dt_connection_map', true );
							if ( empty( $connection_map ) ) {
								$connection_map             = array();
								$connection_map['external'] = array();
							} else {
								$external_connections = $connection_map['external'] ?? array();
								if ( ! empty( $external_connections[ $connection->id ] ) && ! empty( $external_connections[ $connection->id ]['post_id'] ) ) {
									// If the post is already pushed to the remote site, skip it.
									$extra_data[ $key ][ $index ]['remote_post_id'] = (int) $external_connections[ $connection->id ]['post_id'];
									continue;
								}
							}

							$remote_post = $connection->push( $source_post_id, array( 'post_status' => $post_data['post_status'] ?? 'publish' ) );
							if ( ! is_wp_error( $remote_post ) && ! empty( $remote_post['id'] ) ) {
								$connection_map['external'][ (int) $connection->id ] = array(
									'post_id' => (int) $remote_post['id'],
									'time'    => time(),
								);

								$connection->log_sync( array( (int) $remote_post['id'] => $source_post_id ) );
								update_post_meta( $source_post_id, 'dt_connection_map', $connection_map );

								// Update the extra data with the remote post ID.
								$extra_data[ $key ][ $index ]['remote_post_id'] = (int) $remote_post['id'];
							}
						}
					}
				}

				// Remove the filter.
				remove_filter( 'dt_process_extra_data', $prevent_processing, 9999 );
			}
		} catch ( \Exception $e ) {
			// Ignore.
		}
		$post_data['distributor_extra_data'] = $extra_data;

		return $post_data;
	}
}