* @throws LocalizedException */ public function import( string $sourceDirectory, int $storeId, string $mediaSubdirectory, bool $dryRun, bool $allowMissingMedia, ?callable $progress = null ): array { $mediaSubdirectory = trim($mediaSubdirectory, '/'); if ($mediaSubdirectory === '' || str_contains($mediaSubdirectory, '..')) { throw new LocalizedException(__('The media subdirectory is invalid.')); } $source = $this->sourceReader->read($sourceDirectory); $categoryIds = $this->resolveCategoryIds(); $prepared = []; $missingMedia = []; $mediaToNormalize = []; $mediaRoot = $this->directoryList->getPath(DirectoryList::MEDIA) . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $mediaSubdirectory); foreach ($source['records'] as $record) { $urlKey = $this->translitUrl->filter(rawurldecode((string)$record['source_url_key'])); if ($urlKey === '') { throw new LocalizedException(__('URL key is empty after normalization: %1', $record['source_url'])); } if (isset($prepared[$urlKey]) && $prepared[$urlKey]['source_url'] !== $record['source_url']) { throw new LocalizedException(__('Two source URLs normalize to the same URL key: %1', $urlKey)); } $processed = $this->htmlProcessor->process($record['html_path'], $mediaSubdirectory); $contentBytes = strlen($processed['html']); if ($contentBytes > self::MEDIUMTEXT_MAX_BYTES) { throw new LocalizedException( __( 'Processed HTML for %1 is %2 bytes and exceeds the MEDIUMTEXT limit of %3 bytes.', $record['source_url'], $contentBytes, self::MEDIUMTEXT_MAX_BYTES ) ); } foreach ($processed['media'] as $relativePath) { $mediaPath = $mediaRoot . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relativePath); if (!is_file($mediaPath) || !is_readable($mediaPath)) { $missingMedia[$relativePath] = $relativePath; } elseif ($this->mediaNormalizer->needsNormalization($mediaPath)) { $mediaToNormalize[$mediaPath] = $mediaPath; } } if ($record['image_filename'] !== '') { $coverPath = $mediaRoot . DIRECTORY_SEPARATOR . $record['image_filename']; if (!is_file($coverPath) || !is_readable($coverPath)) { $missingMedia[$record['image_filename']] = $record['image_filename']; } elseif ($this->mediaNormalizer->needsNormalization($coverPath)) { $mediaToNormalize[$coverPath] = $coverPath; } } $record['url_key'] = $urlKey; $record['post_content'] = $processed['html']; $record['image'] = $this->buildPostImagePath($mediaSubdirectory, $record['image_filename']); if (strlen($record['image']) > 255) { throw new LocalizedException(__('Post image path exceeds 255 bytes for %1.', $record['source_url'])); } $record['category_ids'] = array_values(array_map( static fn(string $category): int => $categoryIds[$category], array_values($record['categories']) )); $prepared[$urlKey] = $record; } if ($missingMedia !== [] && !$allowMissingMedia) { $sample = implode(', ', array_slice(array_values($missingMedia), 0, 10)); throw new LocalizedException( __('Found %1 missing media file(s). First entries: %2', count($missingMedia), $sample) ); } $stats = [ 'json_records' => (int)$source['json_records'], 'unique_posts' => count($prepared), 'duplicate_records' => (int)$source['duplicate_records'], 'missing_media' => count($missingMedia), 'normalized_media' => count($mediaToNormalize), 'created' => 0, 'updated' => 0, 'unchanged' => 0, 'failed' => 0, ]; if (!$dryRun) { foreach ($mediaToNormalize as $mediaPath) { $this->mediaNormalizer->normalize($mediaPath); } } $resolvedPosts = []; foreach ($prepared as $urlKey => $record) { $resolvedPosts[$urlKey] = $this->loadPost($record); } if ($dryRun) { return $stats; } foreach ($prepared as $urlKey => $record) { $connection = $this->resourceConnection->getConnection(); $connection->beginTransaction(); try { [$post, $state] = $resolvedPosts[$urlKey]; $changed = $this->populatePost($post, $record, $storeId); if ($changed || $state === 'created') { $this->postResource->save($post); if ((string)$post->getUrlKey() !== $record['url_key']) { throw new LocalizedException( __('Mageplaza changed URL key %1 to %2.', $record['url_key'], $post->getUrlKey()) ); } $stats[$state]++; if ($progress !== null) { $progress(sprintf('%s %s', ucfirst($state), $record['url_key'])); } } else { $stats['unchanged']++; } $connection->commit(); } catch (\Throwable $exception) { $connection->rollBack(); $stats['failed']++; throw new LocalizedException( __('Failed importing %1: %2', $record['source_url'], $exception->getMessage()), $exception ); } } $this->cacheTypeList->invalidate('block_html'); $this->cacheTypeList->invalidate('full_page'); return $stats; } /** @return array */ private function resolveCategoryIds(): array { $result = []; foreach (['industry', 'activities', 'exhibition', 'news'] as $category) { $collection = $this->categoryCollectionFactory->create() ->addFieldToFilter('url_key', $category) ->setPageSize(2); if ((int)$collection->getSize() === 0) { $collection = $this->categoryCollectionFactory->create() ->addFieldToFilter('name', $category) ->setPageSize(2); } if ((int)$collection->getSize() !== 1) { throw new LocalizedException( __('Expected exactly one Mageplaza Blog category for "%1"; found %2.', $category, $collection->getSize()) ); } $result[$category] = (int)$collection->getFirstItem()->getId(); } return $result; } /** @return array{0: Post, 1: 'created'|'updated'} */ private function loadPost(array $record): array { /** @var Post $post */ $post = $this->postCollectionFactory->create() ->addFieldToFilter('import_source', $record['source_url']) ->setPageSize(1) ->getFirstItem(); if ($post->getId()) { $collision = $this->postCollectionFactory->create() ->addFieldToFilter('url_key', $record['url_key']) ->setPageSize(1) ->getFirstItem(); if ($collision->getId() && (int)$collision->getId() !== (int)$post->getId()) { throw new LocalizedException( __('URL key "%1" is already used by post ID %2.', $record['url_key'], $collision->getId()) ); } return [$post, 'updated']; } $collision = $this->postCollectionFactory->create() ->addFieldToFilter('url_key', $record['url_key']) ->setPageSize(1) ->getFirstItem(); if ($collision->getId()) { if (!$collision->getData('import_source') && trim((string)$collision->getName()) === trim((string)$record['title']) ) { return [$collision, 'updated']; } throw new LocalizedException( __('URL key "%1" is already used by post ID %2 with a different import source.', $record['url_key'], $collision->getId()) ); } return [$this->postFactory->create(), 'created']; } private function populatePost(Post $post, array $record, int $storeId): bool { $data = [ 'name' => $record['title'], 'short_description' => $record['short_description'], 'post_content' => $record['post_content'], 'image' => $record['image'], 'store_ids' => [$storeId], 'enabled' => 1, 'url_key' => $record['url_key'], 'in_rss' => 0, 'layout' => '2columns-left', 'allow_comment' => 0, 'meta_title' => $record['title'], 'publish_date' => $record['publish_date'] . ' 00:00:00', 'import_source' => $record['source_url'], 'categories_ids' => $record['category_ids'], ]; if (!$post->getId()) { $data['created_at'] = $record['publish_date'] . ' 00:00:00'; } $data['updated_at'] = gmdate('Y-m-d H:i:s'); $changed = !$post->getId(); foreach ($data as $key => $value) { $oldValue = $key === 'categories_ids' ? $post->getCategoryIds() : $post->getData($key); if ($key === 'store_ids' && is_string($oldValue)) { $oldValue = array_filter(explode(',', $oldValue), 'strlen'); } if ($key === 'updated_at') { continue; } if (is_array($value)) { $old = array_map('strval', (array)$oldValue); $new = array_map('strval', $value); sort($old); sort($new); $changed = $changed || $old !== $new; } else { $changed = $changed || (string)$oldValue !== (string)$value; } } if ($changed) { $post->addData($data); } return $changed; } private function buildPostImagePath(string $mediaSubdirectory, string $imageFilename): string { if ($imageFilename === '') { return ''; } if (str_starts_with($mediaSubdirectory . '/', self::MAGEPLAZA_POST_MEDIA_PREFIX)) { $mediaSubdirectory = substr($mediaSubdirectory, strlen(self::MAGEPLAZA_POST_MEDIA_PREFIX)); } return trim($mediaSubdirectory, '/') . '/' . $imageFilename; } }