oont-contents/plugins/google-listings-and-ads/src/Ads/AssetSuggestionsService.php
2025-02-08 15:10:23 +01:00

795 lines
24 KiB
PHP

<?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\Ads;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Utility\ArrayUtil;
use Automattic\WooCommerce\GoogleListingsAndAds\Utility\ImageUtility;
use Automattic\WooCommerce\GoogleListingsAndAds\Utility\DimensionUtility;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AdsAssetGroupAsset;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AssetFieldType;
use Exception;
use WP_Query;
use wpdb;
use DOMDocument;
/**
* Class AssetSuggestionsService
*
* Suggest assets and possible final URLs.
*
* @since 2.4.0
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Ads
*/
class AssetSuggestionsService implements Service {
/**
* WP Proxy
*
* @var WP
*/
protected WP $wp;
/**
* WC Proxy
*
* @var WC
*/
protected WC $wc;
/**
* Image utilities.
*
* @var ImageUtility
*/
protected ImageUtility $image_utility;
/**
* The AdsAssetGroupAsset class.
*
* @var AdsAssetGroupAsset
*/
protected $asset_group_asset;
/**
* WordPress database access abstraction class.
*
* @var wpdb
*/
protected $wpdb;
/**
* Image requirements.
*/
protected const IMAGE_REQUIREMENTS = [
self::MARKETING_IMAGE_KEY => [
'minimum' => [ 600, 314 ],
'recommended' => [ 1200, 628 ],
'max_qty' => 8,
],
self::SQUARE_MARKETING_IMAGE_KEY => [
'minimum' => [ 300, 300 ],
'recommended' => [ 1200, 1200 ],
'max_qty' => 8,
],
self::PORTRAIT_MARKETING_IMAGE_KEY => [
'minimum' => [ 480, 600 ],
'recommended' => [ 960, 1200 ],
'max_qty' => 4,
],
self::LOGO_IMAGE_KEY => [
'minimum' => [ 128, 128 ],
'recommended' => [ 1200, 1200 ],
'max_qty' => 20,
],
];
/**
* Default maximum marketing images.
*/
protected const DEFAULT_MAXIMUM_MARKETING_IMAGES = 20;
/**
* The subsize key for the square marketing image.
*/
protected const SQUARE_MARKETING_IMAGE_KEY = 'gla_square_marketing_asset';
/**
* The subsize key for the marketing image.
*/
protected const MARKETING_IMAGE_KEY = 'gla_marketing_asset';
/**
* The subsize key for the portrait marketing image.
*/
protected const PORTRAIT_MARKETING_IMAGE_KEY = 'gla_portrait_marketing_asset';
/**
* The subsize key for the logo image.
*/
protected const LOGO_IMAGE_KEY = 'gla_logo_asset';
/**
* The homepage key ID.
*/
protected const HOMEPAGE_KEY_ID = 0;
/**
* AssetSuggestionsService constructor.
*
* @param WP $wp WP Proxy.
* @param WC $wc WC Proxy.
* @param ImageUtility $image_utility Image utility.
* @param wpdb $wpdb WordPress database access abstraction class.
* @param AdsAssetGroupAsset $asset_group_asset The AdsAssetGroupAsset class.
*/
public function __construct( WP $wp, WC $wc, ImageUtility $image_utility, wpdb $wpdb, AdsAssetGroupAsset $asset_group_asset ) {
$this->wp = $wp;
$this->wc = $wc;
$this->wpdb = $wpdb;
$this->image_utility = $image_utility;
$this->asset_group_asset = $asset_group_asset;
}
/**
* Get WP and other campaigns' assets from the specific post or term.
*
* @param int $id Post ID, Term ID or self::HOMEPAGE_KEY_ID if it's the homepage.
* @param string $type Only possible values are post, term and homepage.
*/
public function get_assets_suggestions( int $id, string $type ): array {
$asset_group_assets = $this->get_asset_group_asset_suggestions( $id, $type );
if ( ! empty( $asset_group_assets ) ) {
return $asset_group_assets;
}
return $this->get_wp_assets( $id, $type );
}
/**
* Get URL for a specific post or term.
*
* @param int $id Post ID, Term ID or self::HOMEPAGE_KEY_ID
* @param string $type Only possible values are post, term and homepage.
*
* @return string The URL.
* @throws Exception If the ID is invalid.
*/
protected function get_url( int $id, string $type ): string {
if ( $type === 'post' ) {
$url = get_permalink( $id );
} elseif ( $type === 'term' ) {
$url = get_term_link( $id );
} else {
$url = get_bloginfo( 'url' );
}
if ( is_wp_error( $url ) || empty( $url ) ) {
throw new Exception(
/* translators: 1: is an integer representing an unknown Term ID */
sprintf( __( 'Invalid Term ID or Post ID or site url %1$d', 'google-listings-and-ads' ), $id )
);
}
return $url;
}
/**
* Get other campaigns' assets from the specific url.
*
* @param int $id Post or Term ID.
* @param string $type Only possible values are post or term.
*/
protected function get_asset_group_asset_suggestions( int $id, string $type ): array {
$final_url = $this->get_url( $id, $type );
// Suggest the assets from the first asset group if exists.
$asset_group_assets = $this->asset_group_asset->get_assets_by_final_url( $final_url, true );
if ( empty( $asset_group_assets ) ) {
return [];
}
return array_merge( $this->get_suggestions_common_fields( [] ), [ 'final_url' => $final_url ], $asset_group_assets );
}
/**
* Get assets from specific post or term.
*
* @param int $id Post or Term ID, or self::HOMEPAGE_KEY_ID.
* @param string $type Only possible values are post or term.
*
* @return array All assets available for specific term, post or homepage.
* @throws Exception If the ID is invalid.
*/
protected function get_wp_assets( int $id, string $type ): array {
if ( $type === 'post' ) {
return $this->get_post_assets( $id );
} elseif ( $type === 'term' ) {
return $this->get_term_assets( $id );
} else {
return $this->get_homepage_assets();
}
}
/**
* Get assets from the homepage.
*
* @return array Assets available for the homepage.
* @throws Exception If the homepage id is invalid.
*/
protected function get_homepage_assets(): array {
$home_page = $this->wp->get_static_homepage();
// Static homepage.
if ( $home_page ) {
return $this->get_post_assets( $home_page->ID );
}
// Get images from the latest posts.
$posts = $this->wp->get_posts( [] );
$inserted_images_ids = array_map( [ $this, 'get_html_inserted_images' ], array_column( $posts, 'post_content' ) );
$ids = array_merge( $this->get_post_image_attachments( [ 'post_parent__in' => array_column( $posts, 'ID' ) ] ), ...$inserted_images_ids );
$marketing_images = $this->get_url_attachments_by_ids( $ids );
// Non static homepage.
return array_merge(
[
AssetFieldType::HEADLINE => [ __( 'Homepage', 'google-listings-and-ads' ) ],
AssetFieldType::LONG_HEADLINE => [ get_bloginfo( 'name' ) . ': ' . __( 'Homepage', 'google-listings-and-ads' ) ],
AssetFieldType::DESCRIPTION => ArrayUtil::remove_empty_values( [ __( 'Homepage', 'google-listings-and-ads' ), get_bloginfo( 'description' ) ] ),
'display_url_path' => [],
'final_url' => get_bloginfo( 'url' ),
],
$this->get_suggestions_common_fields( $marketing_images )
);
}
/**
* Get assets from specific post.
*
* @param int $id Post ID.
*
* @return array All assets for specific post.
* @throws Exception If the Post ID is invalid.
*/
protected function get_post_assets( int $id ): array {
$post = get_post( $id );
if ( ! $post || $post->post_status === 'trash' ) {
throw new Exception(
/* translators: 1: is an integer representing an unknown Post ID */
sprintf( __( 'Invalid Post ID %1$d', 'google-listings-and-ads' ), $id )
);
}
$attachments_ids = $this->get_post_image_attachments(
[
'post_parent' => $id,
]
);
if ( $id === wc_get_page_id( 'shop' ) ) {
$attachments_ids = [ ...$attachments_ids, ...$this->get_shop_attachments() ];
}
if ( $post->post_type === 'product' || $post->post_type === 'product_variation' ) {
$product = $this->wc->maybe_get_product( $id );
$attachments_ids = [ ...$attachments_ids, ...$product->get_gallery_image_ids() ];
}
$attachments_ids = [ ...$attachments_ids, ...$this->get_gallery_images_ids( $id ), ...$this->get_html_inserted_images( $post->post_content ), get_post_thumbnail_id( $id ) ];
$marketing_images = $this->get_url_attachments_by_ids( $attachments_ids );
$long_headline = get_bloginfo( 'name' ) . ': ' . $post->post_title;
return array_merge(
[
AssetFieldType::HEADLINE => [ $post->post_title ],
AssetFieldType::LONG_HEADLINE => [ $long_headline ],
AssetFieldType::DESCRIPTION => ArrayUtil::remove_empty_values( [ $post->post_excerpt, get_bloginfo( 'description' ) ] ),
'display_url_path' => [ $post->post_name ],
'final_url' => get_permalink( $id ),
],
$this->get_suggestions_common_fields( $marketing_images )
);
}
/**
* Get assets from specific term.
*
* @param int $id Term ID.
*
* @return array All assets for specific term.
* @throws Exception If the Term ID is invalid.
*/
protected function get_term_assets( int $id ): array {
$term = get_term( $id );
if ( ! $term ) {
throw new Exception(
/* translators: 1: is an integer representing an unknown Term ID */
sprintf( __( 'Invalid Term ID %1$d', 'google-listings-and-ads' ), $id )
);
}
$posts_assigned_to_term = $this->get_posts_assigned_to_a_term( $term->term_id, $term->taxonomy );
$posts_ids_assigned_to_term = [];
$attachments_ids = [];
foreach ( $posts_assigned_to_term as $post ) {
$attachments_ids[] = get_post_thumbnail_id( $post->ID );
$posts_ids_assigned_to_term[] = $post->ID;
}
if ( count( $posts_assigned_to_term ) ) {
$attachments_ids = [ ...$this->get_post_image_attachments( [ 'post_parent__in' => $posts_ids_assigned_to_term ] ), ...$attachments_ids ];
}
$marketing_images = $this->get_url_attachments_by_ids( $attachments_ids );
return array_merge(
[
AssetFieldType::HEADLINE => [ $term->name ],
AssetFieldType::LONG_HEADLINE => [ get_bloginfo( 'name' ) . ': ' . $term->name ],
AssetFieldType::DESCRIPTION => ArrayUtil::remove_empty_values( [ wp_strip_all_tags( $term->description ), get_bloginfo( 'description' ) ] ),
'display_url_path' => [ $term->slug ],
'final_url' => get_term_link( $term->term_id ),
],
$this->get_suggestions_common_fields( $marketing_images )
);
}
/**
* Get inserted images from HTML.
*
* @param string $html HTML string.
*
* @return array Array of image IDs.
*/
protected function get_html_inserted_images( string $html ): array {
if ( empty( $html ) ) {
return [];
}
// Malformed HTML can cause DOMDocument to throw warnings. With the below line, we can suppress them and work only with the HTML that has been parsed.
libxml_use_internal_errors( true );
$dom = new DOMDocument();
if ( $dom->loadHTML( $html ) ) {
$images = $dom->getElementsByTagName( 'img' );
$images_ids = [];
$pattern = '/-\d+x\d+\.(jpg|jpeg|png)$/i';
foreach ( $images as $image ) {
$url_unscaled = preg_replace(
$pattern,
'.${1}',
$image->getAttribute( 'src' ),
);
$image_id = attachment_url_to_postid( $url_unscaled );
// Look for scaled image if the original image is not found.
if ( $image_id === 0 ) {
$url_scaled = preg_replace(
$pattern,
'-scaled.${1}',
$image->getAttribute( 'src' ),
);
$image_id = attachment_url_to_postid( $url_scaled );
}
if ( $image_id > 0 ) {
$images_ids[] = $image_id;
}
}
}
return $images_ids;
}
/**
* Get logo images urls.
*
* @return array Logo images urls.
*/
protected function get_logo_images(): array {
$logo_images = $this->get_url_attachments_by_ids( [ get_theme_mod( 'custom_logo' ) ], [ self::LOGO_IMAGE_KEY ] );
return $logo_images[ self::LOGO_IMAGE_KEY ] ?? [];
}
/**
* Get posts linked to a specific term.
*
* @param int $term_id Term ID.
* @param string $taxonomy_name Taxonomy name.
*
* @return array List of posts assigned to the term.
*/
protected function get_posts_assigned_to_a_term( int $term_id, string $taxonomy_name ): array {
$args = [
'post_type' => 'any',
'numberposts' => self::DEFAULT_MAXIMUM_MARKETING_IMAGES,
'tax_query' => [
[
'taxonomy' => $taxonomy_name,
'terms' => $term_id,
'field' => 'term_id',
'include_children' => false,
],
],
];
return $this->wp->get_posts( $args );
}
/**
* Get attachments related to the shop page.
*
* @return array Shop attachments.
*/
protected function get_shop_attachments(): array {
return $this->get_post_image_attachments(
[
'post_parent__in' => $this->get_shop_products(),
]
);
}
/**
*
* Get products that will be use to offer image assets.
*
* @param array $args See WP_Query::parse_query() for all available arguments.
* @return array Shop products.
*/
protected function get_shop_products( array $args = [] ): array {
$defaults = [
'post_type' => 'product',
'numberposts' => self::DEFAULT_MAXIMUM_MARKETING_IMAGES,
'fields' => 'ids',
];
$args = wp_parse_args( $args, $defaults );
return $this->wp->get_posts( $args );
}
/**
* Get gallery images ids.
*
* @param int $post_id Post ID that contains the gallery.
*
* @return array List of gallery images ids.
*/
protected function get_gallery_images_ids( int $post_id ): array {
$gallery = get_post_gallery( $post_id, false );
if ( ! $gallery || ! isset( $gallery['ids'] ) ) {
return [];
}
return explode( ',', $gallery['ids'] );
}
/**
* Get unique attachments ids converted to int values.
*
* @param array $ids Attachments ids.
* @param int $maximum_images Maximum number of images to return.
*
* @return array List of unique attachments ids converted to int values.
*/
protected function prepare_image_ids( array $ids, int $maximum_images = self::DEFAULT_MAXIMUM_MARKETING_IMAGES ): array {
$ids = array_unique( ArrayUtil::remove_empty_values( $ids ) );
$ids = array_map( 'intval', $ids );
return array_slice( $ids, 0, $maximum_images );
}
/**
* Get URL for each attachment using an array of attachment ids and a list of subsizes.
*
* @param array $ids Attachments ids.
* @param array $size_keys Image subsize keys.
* @param int $maximum_images Maximum number of images to return.
*
* @return array A list of attachments urls.
*/
protected function get_url_attachments_by_ids( array $ids, array $size_keys = [ self::SQUARE_MARKETING_IMAGE_KEY, self::MARKETING_IMAGE_KEY, self::PORTRAIT_MARKETING_IMAGE_KEY ], $maximum_images = self::DEFAULT_MAXIMUM_MARKETING_IMAGES ): array {
$ids = $this->prepare_image_ids( $ids, $maximum_images );
$marketing_images = [];
foreach ( $ids as $id ) {
$metadata = wp_get_attachment_metadata( $id );
if ( ! $metadata ) {
continue;
}
foreach ( $size_keys as $size_key ) {
if ( count( $marketing_images[ $size_key ] ?? [] ) >= self::IMAGE_REQUIREMENTS[ $size_key ]['max_qty'] ) {
continue;
}
$minimum_size = new DimensionUtility( ...self::IMAGE_REQUIREMENTS[ $size_key ]['minimum'] );
$recommended_size = new DimensionUtility( ...self::IMAGE_REQUIREMENTS[ $size_key ]['recommended'] );
$image_size = new DimensionUtility( $metadata['width'], $metadata['height'] );
$suggested_size = $this->image_utility->recommend_size( $image_size, $recommended_size, $minimum_size );
// If the original size matches the suggested size with a precision of +-1px.
if ( $suggested_size && $suggested_size->equals( $image_size ) ) {
$marketing_images[ $size_key ][] = wp_get_attachment_url( $id );
} elseif ( isset( $metadata['sizes'][ $size_key ] ) ) {
// use the sub size.
$marketing_images[ $size_key ][] = wp_get_attachment_image_url( $id, $size_key );
} elseif ( $suggested_size && $this->image_utility->maybe_add_subsize_image( $id, $size_key, $suggested_size ) ) {
// use the resized image.
$marketing_images[ $size_key ][] = wp_get_attachment_image_url( $id, $size_key );
}
}
}
return $marketing_images;
}
/**
* Get Attachmets for specific posts.
*
* @param array $args See WP_Query::parse_query() for all available arguments.
*
* @return array List of attachments
*/
protected function get_post_image_attachments( array $args = [] ): array {
$defaults = [
'post_type' => 'attachment',
'post_mime_type' => [ 'image/jpeg', 'image/png', 'image/jpg' ],
'fields' => 'ids',
'numberposts' => self::DEFAULT_MAXIMUM_MARKETING_IMAGES,
];
$args = wp_parse_args( $args, $defaults );
return $this->wp->get_posts( $args );
}
/**
* Get posts that can be used to suggest assets
*
* @param string $search The search query.
* @param int $per_page Number of items per page.
* @param int $offset Used in the get_posts query.
*
* @return array formatted post suggestions
*/
protected function get_post_suggestions( string $search, int $per_page, int $offset = 0 ): array {
if ( $per_page <= 0 ) {
return [];
}
$post_suggestions = [];
$excluded_post_types = [ 'attachment' ];
$post_types = $this->wp->get_post_types(
[
'exclude_from_search' => false,
'public' => true,
]
);
// Exclude attachment post_type
$filtered_post_types = array_diff( $post_types, $excluded_post_types );
$args = [
'post_type' => $filtered_post_types,
'posts_per_page' => $per_page,
'post_status' => 'publish',
'search_title' => $search,
'offset' => $offset,
'suppress_filters' => false,
];
add_filter( 'posts_where', [ $this, 'title_filter' ], 10, 2 );
$posts = $this->wp->get_posts( $args );
remove_filter( 'posts_where', [ $this, 'title_filter' ] );
foreach ( $posts as $post ) {
$post_suggestions[] = $this->format_final_url_response( $post->ID, 'post', $post->post_title, get_permalink( $post->ID ) );
}
return $post_suggestions;
}
/**
* Filter for the posts_where hook, adds WHERE clause to search
* for the 'search_title' parameter in the post titles (when present).
*
* @param string $where The WHERE clause of the query.
* @param WP_Query $wp_query The WP_Query instance (passed by reference).
*
* @return string The updated WHERE clause.
*/
public function title_filter( string $where, WP_Query $wp_query ): string {
$search_title = $wp_query->get( 'search_title' );
if ( $search_title ) {
$title_search = '%' . $this->wpdb->esc_like( $search_title ) . '%';
$where .= $this->wpdb->prepare( " AND `{$this->wpdb->posts}`.`post_title` LIKE %s", $title_search ); // phpcs:ignore WordPress.DB.PreparedSQL
}
return $where;
}
/**
* Get terms that can be used to suggest assets
*
* @param string $search The search query
* @param int $per_page Number of items per page
*
* @return array formatted terms suggestions
*/
protected function get_terms_suggestions( string $search, int $per_page ): array {
$terms_suggestions = [];
// get_terms evaluates $per_page_terms = 0 as a falsy, therefore it will not add the LIMIT clausure returning all the results.
// See: https://github.com/WordPress/WordPress/blob/abe134c2090e84080adc46187884201a4badd649/wp-includes/class-wp-term-query.php#L868
if ( $per_page <= 0 ) {
return [];
}
// Get all taxonomies that are public, show_in_menu = true helps to exclude taxonomies such as "product_shipping_class".
$taxonomies = $this->wp->get_taxonomies(
[
'public' => true,
'show_in_menu' => true,
],
);
$terms = $this->wp->get_terms(
[
'taxonomy' => $taxonomies,
'hide_empty' => false,
'number' => $per_page,
'name__like' => $search,
]
);
foreach ( $terms as $term ) {
$terms_suggestions[] = $this->format_final_url_response( $term->term_id, 'term', $term->name, get_term_link( $term->term_id, $term->taxonomy ) );
}
return $terms_suggestions;
}
/**
* Return a list of final urls that can be used to suggest assets.
*
* @param string $search The search query
* @param int $per_page Number of items per page
* @param string $order_by Order by: type, title, url
*
* @return array final urls with their title, id & type.
*/
public function get_final_url_suggestions( string $search = '', int $per_page = 30, string $order_by = 'title' ): array {
if ( empty( $search ) ) {
return $this->get_defaults_final_url_suggestions();
}
$homepage = [];
// If the search query contains the word "homepage" add the homepage to the results.
if ( strpos( 'homepage', strtolower( $search ) ) !== false ) {
$homepage[] = $this->get_homepage_final_url();
--$per_page;
}
// Split possible results between posts and terms.
$per_page_posts = (int) ceil( $per_page / 2 );
$posts = $this->get_post_suggestions( $search, $per_page_posts );
// Try to get more results using the terms
$per_page_terms = $per_page - count( $posts );
$terms = $this->get_terms_suggestions( $search, $per_page_terms );
$pending_results = $per_page - count( $posts ) - count( $terms );
$more_results = [];
// Try to get more results using posts
if ( $pending_results > 0 && count( $posts ) === $per_page_posts ) {
$more_results = $this->get_post_suggestions( $search, $pending_results, $per_page_posts );
}
$result = array_merge( $homepage, $posts, $terms, $more_results );
return $this->sort_results( $result, $order_by );
}
/**
* Get the final url for the homepage.
*
* @return array final url for the homepage.
*/
protected function get_homepage_final_url(): array {
return $this->format_final_url_response( self::HOMEPAGE_KEY_ID, 'homepage', __( 'Homepage', 'google-listings-and-ads' ), get_bloginfo( 'url' ) );
}
/**
* Get defaults final urls suggestions.
*
* @return array default final urls.
*/
protected function get_defaults_final_url_suggestions(): array {
$defaults = [ $this->get_homepage_final_url() ];
$shop_page = $this->wp->get_shop_page();
if ( $shop_page ) {
$defaults[] = $this->format_final_url_response( $shop_page->ID, 'post', $shop_page->post_title, get_permalink( $shop_page->ID ) );
}
return $defaults;
}
/**
* Order suggestions alphabetically
*
* @param array $results Results as an associative array
* @param string $field Sort by a specific field
*
* @return array response sorted alphabetically
*/
protected function sort_results( array $results, string $field ): array {
usort(
$results,
function ( $a, $b ) use ( $field ) {
return strcmp( strtolower( (string) $a[ $field ] ), strtolower( (string) $b[ $field ] ) );
}
);
return $results;
}
/**
* Return an assotiave array with the page suggestion response format.
*
* @param int $id post id, term id or self::HOMEPAGE_KEY_ID.
* @param string $type post|term
* @param string $title page|term title
* @param string $url page|term url
*
* @return array response formated.
*/
protected function format_final_url_response( int $id, string $type, string $title, string $url ): array {
return [
'id' => $id,
'type' => $type,
'title' => $title,
'url' => $url,
];
}
/**
* Get the suggested common fieds.
*
* @param array $marketing_images Marketing images.
*
* @return array Suggested common fields.
*/
protected function get_suggestions_common_fields( array $marketing_images ): array {
return [
AssetFieldType::LOGO => $this->get_logo_images(),
AssetFieldType::BUSINESS_NAME => get_bloginfo( 'name' ),
AssetFieldType::SQUARE_MARKETING_IMAGE => $marketing_images[ self::SQUARE_MARKETING_IMAGE_KEY ] ?? [],
AssetFieldType::MARKETING_IMAGE => $marketing_images [ self::MARKETING_IMAGE_KEY ] ?? [],
AssetFieldType::PORTRAIT_MARKETING_IMAGE => $marketing_images [ self::PORTRAIT_MARKETING_IMAGE_KEY ] ?? [],
AssetFieldType::CALL_TO_ACTION_SELECTION => null,
];
}
}