' . PHP_EOL . '' . PHP_EOL . "\t" . '' . PHP_EOL; } /** * Returns the XML footer to be printed. * * @return string */ public static function get_xml_footer() { return "\t" . '' . PHP_EOL . ''; } /** * Returns the Item's XML for the given product. * * @param WC_Product $product The product to print the XML for. * @param string $location The location to print the XML for. * @return string XML string. */ public static function get_xml_item( $product, $location ) { if ( ! self::is_product_fit_for_feed( $product ) ) { return ''; } $xml = "\t\t" . PHP_EOL; /** * Filter that controls the attributes that will be added to the product XML file. * * @since 0.5.0 * @param array XML fields to add. * @param WC_Product Product for which the XML is being generated. */ foreach ( apply_filters( 'pinterest_for_woocommerce_feed_item_structure', self::$feed_item_structure, $product ) as $attribute ) { $method_name = 'get_property_' . str_replace( ':', '_', $attribute ); if ( method_exists( __CLASS__, $method_name ) ) { $att = call_user_func_array( array( __CLASS__, $method_name ), array( $product, $attribute ) ); $xml .= ! empty( $att ) ? "\t\t\t" . $att . PHP_EOL : ''; } } $xml .= self::get_attributes_xml( $product, "\t\t\t" ); $xml .= "\t\t" . PHP_EOL; /** * Filter XML output for product * * @since 1.0.10 * @param string XML content. * @param WC_Product Product for which the XML is being generated. */ return apply_filters( 'pinterest_for_woocommerce_feed_item_xml', $xml, $product ); } /** * Helper method to return if a product is fit for the feed profile. * * @param WC_Product $product The product. * * @return boolean */ private static function is_product_fit_for_feed( $product ) { // Decide if product is fit for the feed based on price. $price = self::get_product_regular_price( $product ); if ( empty( $price ) || empty( floatval( $price ) ) ) { return false; } return true; } /** * Get the XML for all the product attributes. * Will only return the attributes which have been set * or are available for the product type. * * @param WC_Product $product WooCommerce product. * @param string $indent Line indentation string. * @return string XML string. */ private static function get_attributes_xml( $product, $indent ) { $attribute_manager = AttributeManager::instance(); $attributes = $attribute_manager->get_all_values( $product ); $xml = ''; // Merge with parent's attributes if it's a variation product. if ( $product instanceof WC_Product_Variation ) { $parent_product = wc_get_product( $product->get_parent_id() ); if ( $parent_product instanceof WC_Product ) { $parent_attributes = $attribute_manager->get_all_values( $parent_product ); $attributes = array_merge( $parent_attributes, $attributes ); } } foreach ( $attributes as $name => $value ) { $property = "g:{$name}"; $value = esc_xml( $value ); $xml .= "{$indent}<{$property}>{$value}" . PHP_EOL; } return $xml; } /** * Returns the Product ID. * * @param WC_Product $product the product. * @param string $property The name of the property. * * @return string */ private static function get_property_g_id( $product, $property ) { return '<' . $property . '>' . $product->get_id() . ''; } /** * Returns the item_group_id (parent id for variations). * * @param WC_Product $product the product. * @param string $property The name of the property. * * @return string */ private static function get_property_item_group_id( $product, $property ) { if ( ! $product->get_parent_id() ) { return; } return '<' . $property . '>' . $product->get_parent_id() . ''; } /** * Returns the product title. * * @param WC_Product $product the product. * @param string $property The name of the property. * * @return string */ private static function get_property_title( $product, $property ) { $title = wp_strip_all_tags( $product->get_name() ); return "<$property>" . self::sanitize( '' ) . ""; } /** * Returns the product description. * * @param WC_Product $product the product. * @param string $property The name of the property. * * @return string */ private static function get_property_description( $product, $property ) { $description = $product->get_parent_id() ? $product->get_description() : $product->get_short_description(); if ( empty( $description ) ) { $description = get_the_excerpt( $product->get_id() ); } if ( empty( $description ) ) { return; } /** * Filters whether the shortcodes should be applied for product descriptions when generating the feed or be stripped out. * * @param bool $apply_shortcodes Shortcodes are applied if set to `true` and stripped out if set to `false`. * @param WC_Product $product WooCommerce product object. * * phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment */ $apply_shortcodes = apply_filters( 'pinterest_for_woocommerce_product_description_apply_shortcodes', false, $product ); $description = self::strip_tags_from_string( $description, $apply_shortcodes ); // Limit the number of characters in the description to 10000. if ( strlen( $description ) > self::DESCRIPTION_SIZE_CHARS_LIMIT ) { /* translators: %s product id */ Logger::log( sprintf( esc_html__( 'The product [%s] has a description longer than the allowed limit.', 'pinterest-for-woocommerce' ), $product->get_id() ) ); } $description = substr( $description, 0, self::DESCRIPTION_SIZE_CHARS_LIMIT ); return "<$property>" . self::sanitize( '' ) . ""; } /** * Returns the product taxonomies. * * @param WC_Product $product the product. * @param string $property The name of the property. * * @return string */ private static function get_property_g_product_type( $product, $property ) { $id = $product->get_parent_id() ? $product->get_parent_id() : $product->get_id(); $taxonomies = array_map( self::class . '::sanitize', self::get_taxonomies( $id ) ); if ( empty( $taxonomies ) ) { return; } // Limit to first 5 categories as per Pinterest requirements. $original_count = count( $taxonomies ); if ( $original_count > self::PRODUCT_TYPE_CATEGORIES_LIMIT ) { $taxonomies = array_slice( $taxonomies, 0, self::PRODUCT_TYPE_CATEGORIES_LIMIT ); Logger::log( sprintf( 'Product [%1$s] has %2$d categories, limiting to first %3$d as per Pinterest requirements.', $id, $original_count, self::PRODUCT_TYPE_CATEGORIES_LIMIT ) ); } // Build product_type string. $product_type = implode( ' > ', $taxonomies ); // Ensure product_type doesn't exceed 1000 character limit. if ( strlen( $product_type ) > self::PRODUCT_TYPE_CHARS_LIMIT ) { Logger::log( sprintf( 'Product [%1$s] product_type length is %2$d characters, truncating to %3$d characters as per Pinterest requirements.', $id, strlen( $product_type ), self::PRODUCT_TYPE_CHARS_LIMIT ) ); // Build product_type by adding taxonomies until we hit the character limit, we always include the first taxonomy. $product_type = ''; foreach ( $taxonomies as $index => $taxonomy ) { $separator = $index > 0 ? ' > ' : ''; $new_product_type = $product_type . $separator . $taxonomy; if ( strlen( $new_product_type ) > self::PRODUCT_TYPE_CHARS_LIMIT && $index > 0 ) { break; } $product_type = $new_product_type; } } return '<' . $property . '>' . $product_type . ''; } /** * Returns the permalink. * * @since 1.4.16 Url has UTM parameters used for tracking. * * @param WC_Product $product the product. * @param string $property The name of the property. * * @return string */ private static function get_property_link( $product, $property ) { $product_url = $product->get_permalink(); $product_url_with_utm = self::add_utm_parameters( $product_url ); return '<' . $property . '>'; } /** * Add UTM parameters to the product URL. * This is used to track the performance of the product pins. * The parameters are: * - utm_source: pinterest * - utm_medium: social * * @since 1.4.16 * * @param string $product_url The product URL. * @return string */ private static function add_utm_parameters( $product_url ) { $utm_params = array( 'utm_source' => 'pinterest', 'utm_medium' => 'social', ); return add_query_arg( $utm_params, $product_url ); } /** * Returns the URL of the main product image. * * @param WC_Product $product the product. * @param string $property The name of the property. * * @return string */ private static function get_property_g_image_link( $product, $property ) { $image_id = $product->get_image_id(); if ( ! $image_id ) { return ''; } /** * Get the image with a filter for default size. */ $image = wp_get_attachment_image_src( $image_id, apply_filters( 'pinterest_for_woocommerce_feed_image_size', 'full' ) ); if ( ! $image ) { return; } return '<' . $property . '>'; } /** * Returns the availability of the product. * * @param WC_Product $product the product. * @param string $property The name of the property. * * @return string */ private static function get_property_g_availability( $product, $property ) { switch ( $product->get_stock_status() ) { case 'instock': $stock_status = 'in stock'; break; case 'outofstock': $stock_status = 'out of stock'; break; case 'onbackorder': $stock_status = 'preorder'; break; default: $stock_status = $product->get_stock_status(); break; } return '<' . $property . '>' . $stock_status . ''; } /** * Returns the base price, or the min base price for a variable product. * * @param WC_Product $product the product. * @param string $property The name of the property. * * @return string */ private static function get_property_g_price( $product, $property ) { $price = self::get_product_regular_price( $product ); if ( empty( $price ) ) { return; } return '<' . $property . '>' . wc_format_decimal( $price, self::get_currency_decimals() ) . get_woocommerce_currency() . ''; } /** * Returns the sale price of the product, or the min sale price for a variable product. * * @param WC_Product $product the product. * @param string $property The name of the property. * * @return string */ private static function get_property_sale_price( $product, $property ) { if ( ! $product->get_parent_id() && method_exists( $product, 'get_variation_sale_price' ) ) { $regular_price = $product->get_variation_regular_price( 'min', true ); $sale_price = $product->get_variation_sale_price( 'min', true ); $price = $regular_price > $sale_price ? $sale_price : false; } else { $sale_price = $product->get_sale_price(); $price = $sale_price ? wc_get_price_to_display( $product, array( 'price' => $sale_price, ) ) : ''; } if ( empty( $price ) ) { return; } return '<' . $property . '>' . wc_format_decimal( $price, self::get_currency_decimals() ) . get_woocommerce_currency() . ''; } /** * Returns the SKU in order to populate the MPN field. * * @param WC_Product $product the product. * @param string $property The name of the property. * * @return string */ private static function get_property_g_mpn( $product, $property ) { return '<' . $property . '>' . self::sanitize( $product->get_sku() ) . ''; } /** * Returns the gallery images for the product. * * @param WC_Product $product the product. * @param string $property The name of the property. * * @return string */ private static function get_property_g_additional_image_link( $product, $property ) { $attachment_ids = $product->get_gallery_image_ids(); $images = array(); if ( $attachment_ids && $product->get_image_id() ) { foreach ( $attachment_ids as $attachment_id ) { /** * Get the image with a filter for default size. */ $image = wp_get_attachment_image_src( $attachment_id, apply_filters( 'pinterest_for_woocommerce_feed_image_size', 'full' ) ); $images[] = $image ? $image[0] : false; } } if ( empty( $images ) ) { return; } $images = array_slice( $images, 0, self::ADDITIONAL_IMAGES_LIMIT ); $images = implode( ',', $images ); return '<' . $property . '>'; } /** * Returns the product shipping information. * * @since 1.0.5 * * @param WC_Product $product The product. * @param string $property The name of the property. * @return string */ private static function get_property_g_shipping( $product, $property ) { $currency = get_woocommerce_currency(); $shipping = self::get_shipping(); $shipping_info = $shipping->prepare_shipping_info( $product ); if ( empty( $shipping_info ) ) { return ''; } $shipping_nodes = array(); /* * Entry is a one or multiple XML nodes in the following format: * * ... * ... * ... * ... * */ foreach ( $shipping_info as $info ) { $shipping_name = self::sanitize( $info['name'] ); $shipping_nodes[] = '' . PHP_EOL . "\t\t\t\t$info[country]" . PHP_EOL . ( $info['state'] ? "\t\t\t\t$info[state]" . PHP_EOL : '' ) . "\t\t\t\t$shipping_name" . PHP_EOL . "\t\t\t\t$info[cost] $currency" . PHP_EOL . "\t\t\t"; } return implode( PHP_EOL . "\t\t\t", $shipping_nodes ); } /** * Helper method to return the taxonomies of the product in a useful format. * * @param integer $product_id The product ID. * * @return array */ private static function get_taxonomies( $product_id ) { $terms = wc_get_object_terms( $product_id, 'product_cat' ); if ( empty( $terms ) ) { return array(); } return wp_list_pluck( $terms, 'name' ); } /** * Get locale currency decimals */ private static function get_currency_decimals() { $currencies = get_transient( PINTEREST_FOR_WOOCOMMERCE_PREFIX . '_currencies_list' ); if ( ! $currencies ) { $locale_info = include WC()->plugin_path() . '/i18n/locale-info.php'; $currencies = wp_list_pluck( $locale_info, 'num_decimals', 'currency_code' ); set_transient( PINTEREST_FOR_WOOCOMMERCE_PREFIX . '_currencies_list', $currencies, DAY_IN_SECONDS ); } return $currencies[ get_woocommerce_currency() ] ?? 2; } /** Fetch shipping object. * * @since 1.0.5 * * @return Shipping */ private static function get_shipping() { if ( null === self::$shipping ) { self::$shipping = new Shipping(); /** * When we start generating lets make sure that the cart is loaded. * Various shipping and tax functions are using elements of cart. */ wc_load_cart(); } return self::$shipping; } /** * Helper method to return the regular price of a product. * * @param WC_Product|WC_Product_Variable $product The product. * * @return string */ private static function get_product_regular_price( $product ) { if ( ! $product->get_parent_id() && method_exists( $product, 'get_variation_price' ) ) { $price = $product->get_variation_regular_price( 'min', true ); } else { $price = wc_get_price_to_display( $product, array( 'price' => $product->get_regular_price(), ) ); } return $price; } /** * Sanitize XML. * After this method the string should be a valid XML string to fit inside * a XML tag directly. If a CDATA markup is used it also needs to be passed * along the string. * * This operation consist of two steps: * * 1. First a standardized esc_xml WordPress method is used. * This escapes XML control characters inside the text block. * * 2. Remove all UTF-8 characters that are not part of the XML specification. * We search the whole string and remove the not-allowed chars. Since XML * does not understand them removing is the only operation that we can do * that will produce a valid XML. * For information about allowed UTF-8 characters please go to * https://www.w3.org/TR/xml/ documentation, section charsets. * * @since 1.0.9 * @param string $xml_fragment XML fragment for sanitization. * @return string Sanitized XML fragment. */ private static function sanitize( $xml_fragment ) { return esc_xml( preg_replace( '/[^\x{9}\x{A}\x{D}\x{20}-\x{D7FF}\x{E000}-\x{FFFD}\x{10000}-\x{10FFFF}]/u', ' ', $xml_fragment ) ); } }