'esun_industry_details.json', 'activities' => 'esun_activities_details.json', 'exhibition' => 'esun_exhibition_details.json', 'news' => 'esun_news_details.json', ]; private const LIST_FILES = [ 'industry' => 'esun_industry_all.json', 'activities' => 'esun_activities_all.json', 'exhibition' => 'esun_exhibition_all.json', 'news' => 'esun_news_all.json', ]; public function __construct(private readonly Json $json) { } /** * @return array{records: array>, json_records: int, duplicate_records: int} * @throws LocalizedException */ public function read(string $sourceDirectory): array { $sourceRoot = realpath($sourceDirectory); if ($sourceRoot === false || !is_dir($sourceRoot) || !is_readable($sourceRoot)) { throw new LocalizedException(__('Import source is not a readable directory: %1', $sourceDirectory)); } $records = []; $jsonRecords = 0; foreach (self::SOURCE_FILES as $category => $filename) { $listItems = $this->readListItems($sourceRoot, $category); $jsonPath = $sourceRoot . DIRECTORY_SEPARATOR . $filename; if (!is_file($jsonPath) || !is_readable($jsonPath)) { throw new LocalizedException(__('Required JSON file is missing or unreadable: %1', $jsonPath)); } try { $data = $this->json->unserialize((string)file_get_contents($jsonPath)); } catch (\InvalidArgumentException $exception) { throw new LocalizedException(__('Invalid JSON in %1: %2', $filename, $exception->getMessage())); } if (!is_array($data) || !isset($data['details']) || !is_array($data['details'])) { throw new LocalizedException(__('The details array is missing from %1.', $filename)); } if (isset($data['total_count']) && (int)$data['total_count'] !== count($data['details'])) { throw new LocalizedException( __('The total_count in %1 does not match its details count.', $filename) ); } foreach ($data['details'] as $index => $item) { $jsonRecords++; if (!is_array($item)) { throw new LocalizedException(__('Invalid item %1 in %2.', $index + 1, $filename)); } $this->validateItem($item, $index, $filename); $sourceUrl = trim((string)$item['sourceUrl']); if (!isset($listItems[$sourceUrl])) { throw new LocalizedException( __('No matching list item was found for %1 in %2.', $sourceUrl, self::LIST_FILES[$category]) ); } $listItem = $listItems[$sourceUrl]; $path = (string)parse_url($sourceUrl, PHP_URL_PATH); $pathSegments = array_values(array_filter(explode('/', trim($path, '/')), 'strlen')); $sourceUrlKey = $pathSegments ? (string)end($pathSegments) : ''; if ($sourceUrlKey === '') { throw new LocalizedException( __('Cannot derive a URL key from item %1 in %2.', $index + 1, $filename) ); } $htmlPath = $this->resolveHtmlPath($sourceRoot, (string)$item['localHtmlFile']); if (isset($records[$sourceUrl])) { $existing = $records[$sourceUrl]; if ($existing['title'] !== trim((string)$item['title']) || $existing['publish_date'] !== (string)$item['publishDate'] ) { throw new LocalizedException( __('Conflicting duplicate post data for source URL %1.', $sourceUrl) ); } if ($records[$sourceUrl]['short_description'] !== '' && $listItem['summary'] !== '' && $records[$sourceUrl]['short_description'] !== $listItem['summary'] ) { throw new LocalizedException(__('Conflicting summaries for source URL %1.', $sourceUrl)); } if ($records[$sourceUrl]['image_filename'] !== '' && $listItem['image_filename'] !== '' && $records[$sourceUrl]['image_filename'] !== $listItem['image_filename'] ) { throw new LocalizedException(__('Conflicting list images for source URL %1.', $sourceUrl)); } if ($records[$sourceUrl]['short_description'] === '') { $records[$sourceUrl]['short_description'] = $listItem['summary']; } if ($records[$sourceUrl]['image_filename'] === '') { $records[$sourceUrl]['image_filename'] = $listItem['image_filename']; } $records[$sourceUrl]['categories'][$category] = $category; continue; } $records[$sourceUrl] = [ 'title' => trim((string)$item['title']), 'source_url' => $sourceUrl, 'source_url_key' => $sourceUrlKey, 'publish_date' => (string)$item['publishDate'], 'html_path' => $htmlPath, 'short_description' => $listItem['summary'], 'image_filename' => $listItem['image_filename'], 'categories' => [$category => $category], ]; } } return [ 'records' => $records, 'json_records' => $jsonRecords, 'duplicate_records' => $jsonRecords - count($records), ]; } /** * @return array * @throws LocalizedException */ private function readListItems(string $sourceRoot, string $category): array { $filename = self::LIST_FILES[$category]; $jsonPath = $sourceRoot . DIRECTORY_SEPARATOR . $filename; if (!is_file($jsonPath) || !is_readable($jsonPath)) { throw new LocalizedException(__('Required list JSON file is missing or unreadable: %1', $jsonPath)); } try { $data = $this->json->unserialize((string)file_get_contents($jsonPath)); } catch (\InvalidArgumentException $exception) { throw new LocalizedException(__('Invalid JSON in %1: %2', $filename, $exception->getMessage())); } if (!is_array($data) || !isset($data['news']) || !is_array($data['news'])) { throw new LocalizedException(__('The news array is missing from %1.', $filename)); } if (isset($data['total_count']) && (int)$data['total_count'] !== count($data['news'])) { throw new LocalizedException(__('The total_count in %1 does not match its news count.', $filename)); } $result = []; foreach ($data['news'] as $index => $item) { if (!is_array($item) || !isset($item['link']) || !is_string($item['link']) || trim($item['link']) === '') { throw new LocalizedException(__('Missing or invalid link in item %1 of %2.', $index + 1, $filename)); } $link = trim($item['link']); if (isset($result[$link])) { throw new LocalizedException(__('Duplicate link %1 in %2.', $link, $filename)); } $summary = isset($item['summary']) && is_string($item['summary']) ? trim($item['summary']) : ''; $imageUrl = isset($item['imageUrl']) && is_string($item['imageUrl']) ? trim($item['imageUrl']) : ''; $result[$link] = [ 'summary' => $summary, 'image_filename' => $this->extractImageFilename($imageUrl, $index, $filename), ]; } return $result; } /** @throws LocalizedException */ private function extractImageFilename(string $imageUrl, int $index, string $filename): string { if ($imageUrl === '') { return ''; } $path = (string)preg_split('/[?#]/', $imageUrl, 2)[0]; $imageFilename = rawurldecode(basename($path)); if ($imageFilename === '' || $imageFilename === '.' || $imageFilename === '..') { throw new LocalizedException(__('Invalid imageUrl in item %1 of %2.', $index + 1, $filename)); } return $imageFilename; } /** @throws LocalizedException */ private function validateItem(array $item, int $index, string $filename): void { foreach (['title', 'sourceUrl', 'publishDate', 'localHtmlFile'] as $field) { if (!isset($item[$field]) || !is_string($item[$field]) || trim($item[$field]) === '') { throw new LocalizedException( __('Missing or invalid %1 in item %2 of %3.', $field, $index + 1, $filename) ); } } $sourceUrl = (string)$item['sourceUrl']; $parts = parse_url($sourceUrl); if (!is_array($parts) || ($parts['scheme'] ?? '') !== 'https' || strtolower((string)($parts['host'] ?? '')) !== 'www.esun3d.com' || !str_starts_with((string)($parts['path'] ?? ''), '/news/') ) { throw new LocalizedException(__('Invalid sourceUrl in item %1 of %2.', $index + 1, $filename)); } $date = \DateTimeImmutable::createFromFormat('!Y-m-d', (string)$item['publishDate']); if ($date === false || $date->format('Y-m-d') !== $item['publishDate']) { throw new LocalizedException(__('Invalid publishDate in item %1 of %2.', $index + 1, $filename)); } } /** @throws LocalizedException */ private function resolveHtmlPath(string $sourceRoot, string $relativePath): string { if ($relativePath === '' || str_contains($relativePath, "\0") || str_starts_with($relativePath, '/')) { throw new LocalizedException(__('Invalid localHtmlFile path: %1', $relativePath)); } $htmlPath = realpath($sourceRoot . DIRECTORY_SEPARATOR . $relativePath); $rootPrefix = rtrim($sourceRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; if ($htmlPath === false || !is_file($htmlPath) || !is_readable($htmlPath) || !str_starts_with($htmlPath, $rootPrefix) ) { throw new LocalizedException(__('HTML file is missing, unreadable, or outside the source: %1', $relativePath)); } return $htmlPath; } }