*/ private const DEFAULT_ALLOWED_PROVIDERS = array( 'amazon', 'rakuten', 'yahoo_shopping', 'mercari', ); /** * Required CSV headers. * * @var list */ private const REQUIRED_HEADERS = array( 'provider', 'provider_item_id', 'title', 'detail_url', 'lifecycle_state', ); /** * Product repository. */ private ProductRepository $products; /** * Manual category/tag helper. */ private ProductManualTaxonomy $taxonomy; /** * Allowed provider keys. * * @var list */ private array $allowedProviders; /** * @param ProductRepository $products Product repository. * @param ProductManualTaxonomy $taxonomy Manual taxonomy helper. * @param list $allowedProviders Allowed provider keys. */ public function __construct( ProductRepository $products, ProductManualTaxonomy $taxonomy, array $allowedProviders = self::DEFAULT_ALLOWED_PROVIDERS ) { $this->products = $products; $this->taxonomy = $taxonomy; $this->allowedProviders = self::normalizeAllowedProviders( $allowedProviders ); } /** * Preview an import without mutating saved products. * * @param string $csv CSV payload. */ public function preview( string $csv ): ProductCsvImportResult { $analysis = $this->analyze( $csv ); return $analysis['result']; } /** * Apply an import after the caller has shown a preview. * * @param string $csv CSV payload. * * @throws InvalidArgumentException When the CSV is invalid. */ public function apply( string $csv ): ProductCsvImportResult { $analysis = $this->analyze( $csv ); $result = $analysis['result']; if ( $result->hasErrors() ) { throw new InvalidArgumentException( 'CSV import contains validation errors.' ); } $created = 0; $updated = 0; foreach ( $analysis['rows'] as $row ) { $existing = $this->products->findByProviderItem( $row['provider'], $row['provider_item_id'] ); $saved = $this->saveRow( $row, $existing ); $id = $saved->id(); if ( $id !== null ) { $this->taxonomy->saveCategoriesForProduct( $id, $row['categories'] ); $this->taxonomy->saveTagsForProduct( $id, $row['tags'] ); } if ( $existing === null ) { ++$created; } else { ++$updated; } } return new ProductCsvImportResult( $result->rows(), array(), $created, $updated ); } /** * Parse and validate CSV. * * @param string $csv CSV payload. * * @return array{result:ProductCsvImportResult,rows:list>} */ private function analyze( string $csv ): array { $parsed = self::parseCsv( $csv ); if ( $parsed['errors'] !== array() ) { return array( 'result' => new ProductCsvImportResult( array(), $parsed['errors'] ), 'rows' => array(), ); } $headerErrors = self::validateHeaders( $parsed['headers'] ); if ( $headerErrors !== array() ) { return array( 'result' => new ProductCsvImportResult( array(), $headerErrors ), 'rows' => array(), ); } $previewRows = array(); $validRows = array(); $seenPairs = array(); $rowNumber = 1; foreach ( $parsed['rows'] as $csvRow ) { ++$rowNumber; $validated = $this->validateRow( $csvRow, $parsed['headers'], $rowNumber, $seenPairs ); $previewRows[] = $validated['preview']; if ( $validated['preview']->isValid() ) { $validRows[] = $validated['data']; } } if ( $previewRows === array() ) { return array( 'result' => new ProductCsvImportResult( array(), array( 'CSV contains no product rows.' ) ), 'rows' => array(), ); } return array( 'result' => new ProductCsvImportResult( $previewRows ), 'rows' => $validRows, ); } /** * Validate one row. * * @param array $csvRow CSV row cells. * @param array $headers Header map. * @param int $rowNumber Physical row number. * @param array $seenPairs Provider/item pairs already seen. * * @return array{preview:ProductCsvImportRow,data:array} */ private function validateRow( array $csvRow, array $headers, int $rowNumber, array &$seenPairs ): array { $errors = array(); $provider = self::cell( $csvRow, $headers, 'provider' ); $providerItemId = self::cell( $csvRow, $headers, 'provider_item_id' ); $title = self::cell( $csvRow, $headers, 'title' ); $lifecycleState = self::cell( $csvRow, $headers, 'lifecycle_state' ); $syncState = self::cell( $csvRow, $headers, 'sync_state' ); $detailUrl = self::cell( $csvRow, $headers, 'detail_url' ); $imageUrl = self::cell( $csvRow, $headers, 'image_primary_url' ); $affiliateUrl = self::cell( $csvRow, $headers, 'affiliate_url' ); $categories = ProductManualTaxonomy::termsFromInput( self::cell( $csvRow, $headers, 'categories' ) ); $tags = ProductManualTaxonomy::termsFromInput( self::cell( $csvRow, $headers, 'tags' ) ); if ( $provider === '' ) { $errors[] = 'Provider is required.'; } elseif ( ! in_array( $provider, $this->allowedProviders, true ) ) { $errors[] = sprintf( 'Provider "%s" is not supported.', $provider ); } if ( $providerItemId === '' ) { $errors[] = 'Provider item id is required.'; } if ( $title === '' ) { $errors[] = 'Title is required.'; } if ( ! in_array( $lifecycleState, ProductListQuery::validLifecycleStates(), true ) ) { $errors[] = 'Lifecycle state is invalid.'; } if ( $syncState === '' ) { $syncState = ProductRecord::DEFAULT_SYNC_STATE; } elseif ( ! in_array( $syncState, ProductListQuery::validSyncStates(), true ) ) { $errors[] = 'Sync state is invalid.'; } if ( ! self::isHttpUrl( $detailUrl ) ) { $errors[] = 'Detail URL must be an HTTP(S) URL.'; } if ( $imageUrl !== '' && ! self::isHttpUrl( $imageUrl ) ) { $errors[] = 'Image URL must be an HTTP(S) URL.'; } if ( $affiliateUrl !== '' && ! self::isHttpUrl( $affiliateUrl ) ) { $errors[] = 'Affiliate URL must be an HTTP(S) URL.'; } $pairKey = $provider . "\0" . $providerItemId; if ( $provider !== '' && $providerItemId !== '' ) { if ( array_key_exists( $pairKey, $seenPairs ) ) { $errors[] = 'Duplicate provider/item pair in CSV.'; } $seenPairs[ $pairKey ] = true; } $existing = null; if ( $provider !== '' && $providerItemId !== '' ) { $existing = $this->products->findByProviderItem( $provider, $providerItemId ); } $action = $existing === null ? ProductCsvImportRow::ACTION_CREATE : ProductCsvImportRow::ACTION_UPDATE; $data = array( 'provider' => $provider, 'provider_item_id' => $providerItemId, 'canonical_hash' => self::optionalCell( $csvRow, $headers, 'canonical_hash' ), 'title' => $title, 'brand' => self::optionalCell( $csvRow, $headers, 'brand' ), 'description' => self::optionalCell( $csvRow, $headers, 'description' ), 'image_primary_url' => $imageUrl === '' ? null : $imageUrl, 'detail_url' => $detailUrl, 'affiliate_url' => $affiliateUrl === '' ? null : $affiliateUrl, 'price_amount' => self::optionalCell( $csvRow, $headers, 'price_amount' ), 'price_currency' => self::optionalCell( $csvRow, $headers, 'price_currency' ), 'price_label' => self::optionalCell( $csvRow, $headers, 'price_label' ), 'availability_status' => self::optionalCell( $csvRow, $headers, 'availability_status' ), 'search_keywords' => self::optionalCell( $csvRow, $headers, 'search_keywords' ), 'search_keywords_source' => self::optionalCell( $csvRow, $headers, 'search_keywords_source' ), 'sync_state' => $syncState, 'lifecycle_state' => $lifecycleState, 'categories' => $categories, 'tags' => $tags, ); return array( 'preview' => new ProductCsvImportRow( $rowNumber, $action, $provider, $providerItemId, $title, $lifecycleState, $categories, $tags, $errors ), 'data' => $data, ); } /** * Save one normalized import row. * * @param array $row Row data. * @param ProductRecord|null $existing Existing record. */ private function saveRow( array $row, ?ProductRecord $existing ): ProductRecord { $now = new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ); $archivedAt = $row['lifecycle_state'] === ProductListQuery::LIFECYCLE_STATE_ACTIVE ? null : $now; $hash = is_string( $row['canonical_hash'] ) && $row['canonical_hash'] !== '' ? $row['canonical_hash'] : self::fallbackCanonicalHash( $row ); if ( $existing === null ) { return $this->products->save( ProductRecord::createNew( $row['provider'], $row['provider_item_id'], $hash, $row['title'], $row['detail_url'], $now, array( 'brand' => $row['brand'], 'description' => $row['description'], 'imagePrimaryUrl' => $row['image_primary_url'], 'priceAmount' => $row['price_amount'], 'priceCurrency' => $row['price_currency'], 'priceLabel' => $row['price_label'], 'availabilityStatus' => $row['availability_status'], 'affiliateUrl' => $row['affiliate_url'], 'syncState' => $row['sync_state'], 'lifecycleState' => $row['lifecycle_state'], 'archivedAt' => $archivedAt, 'searchKeywords' => $row['search_keywords'], 'searchKeywordsSource' => $row['search_keywords_source'], ) ) ); } $recordRow = $existing->toArray(); $recordRow['canonical_hash'] = $hash; $recordRow['title'] = $row['title']; $recordRow['brand'] = $row['brand']; $recordRow['description'] = $row['description']; $recordRow['image_primary_url'] = $row['image_primary_url']; $recordRow['detail_url'] = $row['detail_url']; $recordRow['affiliate_url'] = $row['affiliate_url']; $recordRow['price_amount'] = $row['price_amount']; $recordRow['price_currency'] = $row['price_currency']; $recordRow['price_label'] = $row['price_label']; $recordRow['availability_status'] = $row['availability_status']; $recordRow['search_keywords'] = $row['search_keywords']; $recordRow['search_keywords_source'] = $row['search_keywords_source']; $recordRow['sync_state'] = $row['sync_state']; $recordRow['lifecycle_state'] = $row['lifecycle_state']; $recordRow['archived_at'] = $archivedAt; $recordRow['updated_at'] = $now; return $this->products->save( ProductRecord::fromArray( $recordRow ) ); } /** * Parse CSV into a header map and data rows. * * @param string $csv CSV payload. * * @return array{headers:array,rows:list>,errors:list} */ private static function parseCsv( string $csv ): array { $csv = trim( str_replace( "\0", '', $csv ) ); if ( $csv === '' ) { return array( 'headers' => array(), 'rows' => array(), 'errors' => array( 'CSV content is empty.' ), ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Uses in-memory php://temp for pure CSV parsing; no filesystem path is touched. $handle = fopen( 'php://temp', 'r+' ); if ( $handle === false ) { return array( 'headers' => array(), 'rows' => array(), 'errors' => array( 'Unable to open CSV parser.' ), ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Writes caller-provided CSV to in-memory php://temp for fgetcsv parsing. fwrite( $handle, $csv ); rewind( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fgetcsv -- Parses in-memory CSV content. $headerRow = fgetcsv( $handle, 0, ',', '"', '' ); if ( ! is_array( $headerRow ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closes in-memory php://temp parser handle. fclose( $handle ); return array( 'headers' => array(), 'rows' => array(), 'errors' => array( 'CSV header row is missing.' ), ); } $headers = array(); foreach ( $headerRow as $index => $header ) { $key = self::normalizeHeader( is_scalar( $header ) ? (string) $header : '' ); if ( $key !== '' && ! array_key_exists( $key, $headers ) ) { $headers[ $key ] = (int) $index; } } $rows = array(); while ( true ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fgetcsv -- Parses in-memory CSV content. $row = fgetcsv( $handle, 0, ',', '"', '' ); if ( $row === false ) { break; } if ( ! is_array( $row ) || self::isEmptyCsvRow( $row ) ) { continue; } $rows[] = array_map( static fn ( $value ): string => is_scalar( $value ) ? (string) $value : '', $row ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closes in-memory php://temp parser handle. fclose( $handle ); return array( 'headers' => $headers, 'rows' => $rows, 'errors' => array(), ); } /** * Validate required headers. * * @param array $headers Header map. * * @return list */ private static function validateHeaders( array $headers ): array { $errors = array(); foreach ( self::REQUIRED_HEADERS as $header ) { if ( ! array_key_exists( $header, $headers ) ) { $errors[] = sprintf( 'Required CSV header "%s" is missing.', $header ); } } return $errors; } /** * Read one normalized cell. * * @param array $row CSV row. * @param array $headers Header map. * @param string $key Header key. */ private static function cell( array $row, array $headers, string $key ): string { if ( ! array_key_exists( $key, $headers ) ) { return ''; } $index = $headers[ $key ]; $value = $row[ $index ] ?? ''; $value = trim( str_replace( "\0", '', $value ) ); $value = preg_replace( '/^\xEF\xBB\xBF/u', '', $value ); $value = is_string( $value ) ? $value : ''; return self::unescapeFormulaCell( $value ); } /** * Read one optional normalized cell. * * @param array $row CSV row. * @param array $headers Header map. * @param string $key Header key. */ private static function optionalCell( array $row, array $headers, string $key ): ?string { $value = self::cell( $row, $headers, $key ); return $value === '' ? null : $value; } /** * Normalize a header key. * * @param string $header Raw header. */ private static function normalizeHeader( string $header ): string { $header = preg_replace( '/^\xEF\xBB\xBF/u', '', $header ); $header = is_string( $header ) ? $header : ''; return strtolower( trim( $header ) ); } /** * Whether a parsed row has no meaningful cells. * * @param array $row Row. */ private static function isEmptyCsvRow( array $row ): bool { foreach ( $row as $cell ) { if ( is_scalar( $cell ) && trim( (string) $cell ) !== '' ) { return false; } } return true; } /** * Reverse the exporter's spreadsheet formula prefix. * * @param string $value Cell value. */ private static function unescapeFormulaCell( string $value ): string { if ( strlen( $value ) >= 2 && $value[0] === "'" && self::isFormulaTrigger( $value[1] ) ) { return substr( $value, 1 ); } return $value; } /** * Whether a value starts with a spreadsheet formula trigger. * * @param string $char First character. */ private static function isFormulaTrigger( string $char ): bool { return in_array( $char, array( '=', '+', '-', '@', "\t", "\r", "\n" ), true ); } /** * Validate HTTP(S) URL. * * @param string $value URL. */ private static function isHttpUrl( string $value ): bool { if ( $value === '' ) { return false; } // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- Pure application validation also runs outside WordPress. $parts = parse_url( $value ); if ( ! is_array( $parts ) ) { return false; } $scheme = strtolower( (string) ( $parts['scheme'] ?? '' ) ); $host = (string) ( $parts['host'] ?? '' ); return in_array( $scheme, array( 'http', 'https' ), true ) && $host !== ''; } /** * Deterministic fallback canonical hash for imported rows. * * @param array $row Import row. */ private static function fallbackCanonicalHash( array $row ): string { $payload = strtolower( (string) $row['provider'] . "\0" . (string) $row['provider_item_id'] . "\0" . (string) $row['title'] . "\0" . (string) $row['detail_url'] ); return hash( 'sha256', $payload ); } /** * Normalize allowed providers. * * @param list $providers Providers. * * @return list */ private static function normalizeAllowedProviders( array $providers ): array { $normalized = array(); foreach ( $providers as $provider ) { $provider = trim( $provider ); if ( $provider !== '' && ! in_array( $provider, $normalized, true ) ) { $normalized[] = $provider; } } return $normalized === array() ? self::DEFAULT_ALLOWED_PROVIDERS : $normalized; } }