Files
2026-09-15 10:35:56 +08:00

308 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace Esun\BlogImport\Model;
use Magento\Framework\App\Cache\TypeListInterface;
use Magento\Framework\App\Filesystem\DirectoryList;
use Magento\Framework\App\ResourceConnection;
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\Filter\TranslitUrl;
use Mageplaza\Blog\Model\Post;
use Mageplaza\Blog\Model\PostFactory;
use Mageplaza\Blog\Model\ResourceModel\Category\CollectionFactory as CategoryCollectionFactory;
use Mageplaza\Blog\Model\ResourceModel\Post\CollectionFactory as PostCollectionFactory;
use Mageplaza\Blog\Model\ResourceModel\Post as PostResource;
class BlogImporter
{
private const MEDIUMTEXT_MAX_BYTES = 16777215;
private const MAGEPLAZA_POST_MEDIA_PREFIX = 'mageplaza/blog/post/';
public function __construct(
private readonly SourceReader $sourceReader,
private readonly HtmlProcessor $htmlProcessor,
private readonly MediaNormalizer $mediaNormalizer,
private readonly DirectoryList $directoryList,
private readonly TranslitUrl $translitUrl,
private readonly CategoryCollectionFactory $categoryCollectionFactory,
private readonly PostCollectionFactory $postCollectionFactory,
private readonly PostFactory $postFactory,
private readonly PostResource $postResource,
private readonly ResourceConnection $resourceConnection,
private readonly TypeListInterface $cacheTypeList
) {
}
/**
* @param callable(string): void|null $progress
* @return array<string, int>
* @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<string, int> */
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;
}
}