channel = $channel; $this->limits = $channel->limits(); $this->tree = $channel->taxonomy()->index(); $this->settings = $settings; $this->attribute_map = $attribute_map; $this->price_filter = $price_filter; } /** * Builds the advert. * * @param array $item Product data: product_id, variation_id, product_name, sku, description_html, short_description_html, * price (shop currency), images (upload-relative paths, in order), attribute_summary (list of texts), * attribute_table (label => text), remote_category (marketplace category ID, int or null), ask (attribute code => value). * @param callable $values callable( AttributeRule $rule ): string[] — the product's values for an attribute rule. */ public function build( array $item, callable $values ): BuildResult { $result = new BuildResult(); $label = $this->channel->label(); $category = (int) ( $item['remote_category'] ?? 0 ); if ( ! $category ) { $result->error( 'no_category', Text::channel( 'Not mapped: this product\'s shop category has no {channel} category. Set it in the category mapping.', $label ) ); } elseif ( $this->tree->count() > 0 && ! $this->tree->has( $category ) ) { $result->error( 'no_category', Text::channel( 'The mapped {channel} category does not exist (any more). Choose another one in the category mapping.', $label ) ); } elseif ( $this->tree->count() > 0 && ! $this->tree->is_leaf( $category ) ) { $result->error( 'category_not_leaf', Text::channel( '{channel} only takes adverts in the last level of its categories. Choose a more specific category.', $label ) ); } $others = array_values( array_filter( array_map( 'intval', (array) ( $item['category_alternatives'] ?? array() ) ) ) ); if ( $category && $others ) { $name = function ( int $id ): string { return $this->tree->has( $id ) ? $this->tree->path( $id ) : '#' . $id; }; $result->warn( 'category_ambiguous', Text::channel( 'The shop categories of this product lead to more than one {channel} category, so one was picked. Check that it is the right one.', $label ), array( $name( $category ), implode( '; ', array_map( $name, $others ) ) ) ); } $block = (string) ( $item['listing_block'] ?? '' ); if ( 'out_of_stock' === $block ) { $result->error( 'out_of_stock', 'This product is out of stock, so it cannot be advertised. Put it back in stock in the shop first.' ); } elseif ( '' !== $block ) { $result->error( 'not_for_sale', 'This product is not published in the shop, so it cannot be advertised.' ); } $title = $this->title( $item, $result ); $description = $this->description( $item, $result ); // Price. $currency = $this->channel->currency(); $shop_price = $this->channel->convert_price( (float) ( $item['price'] ?? 0 ) ); $price = $this->channel->adjust_price( $shop_price, $category ); if ( null !== $this->price_filter ) { $price = (float) call_user_func( $this->price_filter, $price, $item, $category ); } if ( $price <= 0 ) { $result->error( 'no_price', 'The product has no price.' ); } // Photos. $limit = $category && $this->tree->photos_limit( $category ) > 0 ? $this->tree->photos_limit( $category ) : $this->limits->photos_default; $images = $this->images( (array) ( $item['images'] ?? array() ), $limit, $result ); if ( ! empty( $item['images_from_parent'] ) && $images ) { $result->warn( 'photos_from_parent', 'This variation has no own photos; the product photos may show other options.' ); } // Contact. $contact = array( 'name' => trim( (string) ( $this->settings['contact_name'] ?? '' ) ) ); $phone = preg_replace( '/[^\d+]/', '', (string) ( $this->settings['contact_phone'] ?? '' ) ); if ( '' !== $phone ) { $contact['phone'] = $phone; } if ( '' === $contact['name'] ) { $result->error( 'no_contact', 'Contact name is not set (Settings → Advert contact).' ); } // Attributes. $attributes = array(); $definitions = array(); if ( $category ) { $definitions = (array) $this->channel->taxonomy()->attributes( $category ); if ( ! $definitions ) { $result->warn( 'no_attribute_data', Text::channel( 'The attributes of this {channel} category could not be read, so none are sent. Connect {channel} and try again.', $label ) ); } $resolved = AttributeResolver::resolve( $definitions, $this->attribute_map->rules_for( $category ), $values, (array) ( $item['ask'] ?? array() ), $this->channel->managed_attributes(), $label ); $attributes = $resolved['attributes']; foreach ( $resolved['errors'] as $error ) { $result->error( $error['code'], $error['text'], (array) ( $error['args'] ?? array() ) ); } foreach ( $resolved['warnings'] as $warning ) { $result->warn( $warning['code'], $warning['text'], (array) ( $warning['args'] ?? array() ) ); } } $listing = new Listing( array( 'title' => $title, 'description' => $description, 'price' => $price, 'currency' => $currency, 'category_id' => (string) $category, 'attributes' => $attributes, 'images' => array_column( $images, 'url' ), 'contact' => $contact, 'external_id' => (string) ( ! empty( $item['variation_id'] ) ? (int) $item['variation_id'] : (int) ( $item['product_id'] ?? 0 ) ), ) ); $this->channel->complete_listing( $listing, $result, $definitions ); $result->listing = $listing; $result->info = array( 'category' => $category && $this->tree->has( $category ) ? $this->tree->path( $category ) : ( $category ? '#' . $category : '' ), 'shop_price' => (string) $shop_price, 'currency' => $currency, 'converted' => (string) ( $result->info['converted'] ?? '' ), ); return $result; } /** * Advert title from the template, cleaned and cut to the marketplace's length. * * @param array $item Product data. * @param BuildResult $result Result (for errors). */ private function title( array $item, BuildResult $result ): string { $template = trim( (string) ( $this->settings['title_template'] ?? '' ) ); $template = '' !== $template ? $template : self::DEFAULT_TITLE; $values = array( 'product_name' => '', 'attribute_summary' => implode( ', ', array_map( 'strval', (array) ( $item['attribute_summary'] ?? array() ) ) ), 'sku' => (string) ( $item['sku'] ?? '' ), ); $name = TextRules::clean( trim( (string) ( $item['product_name'] ?? '' ) ), $this->limits->forbids_contacts ); $probe = Template::render( $template, array_merge( $values, array( 'product_name' => 'X' ) ) ); $room = $this->limits->title_max - ( TextRules::length( $probe ) - 1 ); $values['product_name'] = TextRules::shorten( $name, max( 20, $room ) ); $title = $this->tidy_title( Template::render( $template, $values ) ); if ( TextRules::length( $title ) > $this->limits->title_max ) { $title = TextRules::shorten( $title, $this->limits->title_max ); } $length = TextRules::length( $title ); if ( $length < $this->limits->title_min || $length > $this->limits->title_max ) { $result->error( 'title_length', sprintf( 'Title must be %1$d–%2$d characters (now %3$d). Use a longer product name or add attributes to the title template.', $this->limits->title_min, $this->limits->title_max, $length ) ); } return $title; } /** * Removes dangling separators left by empty placeholders ("Shirt – "). * * @param string $title Title. */ private function tidy_title( string $title ): string { $title = (string) preg_replace( '/\s+/u', ' ', $title ); $title = (string) preg_replace( '/(\s[-–—:|,]\s*)+$/u', '', $title ); $title = (string) preg_replace( '/^(\s*[-–—:|,]\s)+/u', '', $title ); return trim( $title ); } /** * Advert description from the template (plain text, no contact details). * * @param array $item Product data. * @param BuildResult $result Result. */ private function description( array $item, BuildResult $result ): string { $template = trim( (string) ( $this->settings['description_template'] ?? '' ) ); $template = '' !== $template ? $template : self::DEFAULT_DESCRIPTION; $raw = TextRules::html_to_text( (string) ( $item['description_html'] ?? '' ) ); $description = TextRules::clean( $raw, $this->limits->forbids_contacts ); if ( $this->limits->forbids_contacts && TextRules::has_contacts( $raw ) ) { $result->warn( 'contacts_removed', Text::channel( 'A web address, e-mail or phone number was removed from the product text ({channel} does not allow them).', $this->channel->label() ) ); } $short = TextRules::clean( TextRules::html_to_text( (string) ( $item['short_description_html'] ?? '' ) ), $this->limits->forbids_contacts ); $table = array(); foreach ( (array) ( $item['attribute_table'] ?? array() ) as $label => $text ) { if ( '' !== trim( (string) $text ) ) { $table[] = trim( (string) $label ) . ': ' . trim( (string) $text ); } } $footer = TextRules::clean( (string) ( $this->settings['shop_footer'] ?? '' ), $this->limits->forbids_contacts ); $values = array( 'description' => '', 'short_description' => $short, 'attributes_table' => implode( "\n", $table ), 'measurements' => trim( (string) ( $item['measurements'] ?? '' ) ), // Filled by the Pro add-on (filter tidelist_measurements). 'shop_footer' => $footer, 'product_name' => (string) ( $item['product_name'] ?? '' ), 'sku' => (string) ( $item['sku'] ?? '' ), ); if ( '' === $description && '' !== $short && false === strpos( $template, '{short_description}' ) ) { $description = $short; } $base = $this->tidy_text( Template::render( $template, $values ) ); $room = $this->limits->description_max - TextRules::length( $base ) - 2; $values['description'] = TextRules::shorten( $description, max( 0, $room ) ); $text = $this->tidy_text( Template::render( $template, $values ) ); $length = TextRules::length( $text ); if ( $length < $this->limits->description_min || $length > $this->limits->description_max ) { $result->error( 'description_length', sprintf( 'Description must be %1$d–%2$d characters (now %3$d). Add product text or a footer text in the settings.', $this->limits->description_min, $this->limits->description_max, $length ) ); } return $text; } /** * Collapses the empty lines left by empty placeholders. * * @param string $text Text. */ private function tidy_text( string $text ): string { $text = (string) preg_replace( '/[ \t]+\n/u', "\n", $text ); $text = (string) preg_replace( '/\n{3,}/u', "\n\n", $text ); return trim( $text ); } /** * Photo list: [{"url": "..."}], deduplicated and limited to the category limit. * * @param array $paths Upload-relative file paths. * @param int $limit Category photo limit. * @param BuildResult $result Result. */ private function images( array $paths, int $limit, BuildResult $result ): array { $base = (string) ( $this->settings['image_base_url'] ?? '' ); $paths = array_values( array_unique( array_filter( array_map( 'strval', $paths ) ) ) ); if ( ! $paths ) { $result->error( 'no_photos', 'The product has no photos.' ); return array(); } if ( count( $paths ) > $limit ) { $result->warn( 'photos_trimmed', sprintf( 'Only the first %1$d of %2$d photos are used (%3$s limit).', $limit, count( $paths ), $this->channel->label() ) ); $paths = array_slice( $paths, 0, $limit ); } // Photos a marketplace cannot take as they are (for example .webp) are sent as a JPG copy that the plugin makes. $converted = array(); foreach ( $paths as $path ) { if ( ! PhotoPaths::is_native( $path ) ) { $converted[] = $path; } } $result->info['converted'] = implode( "\n", $converted ); return array_map( static function ( $path ) use ( $base ) { return array( 'url' => PhotoPaths::url( $base, $path ) ); }, $paths ); } }