` inside the body are pulled * across as FAL files, because the source site goes away at go-live. * * No server-side state: the post list is fetched again on submit and filtered by * the ticked ids. Carrying ~900 KB of post JSON through a hidden form field - * the trick the CSV module uses - is not workable at this size. */ #[AsController] final class WordPressImportController { /** * Known sources. The URL field stays editable, so anything else can be typed * in; these two are just what VITEC actually has. */ private const PRESETS = [ ['label' => 'VITEC Blog', 'url' => 'https://www.vitec.com/blog'], ['label' => 'Datapath', 'url' => 'https://www.datapath.co.uk'], ]; public function __construct( private readonly ModuleTemplateFactory $moduleTemplateFactory, private readonly WordPressClient $client, private readonly WordPressImporter $importer, private readonly FlashMessageService $flashMessageService, private readonly PageRenderer $pageRenderer, private readonly UriBuilder $uriBuilder, ) {} public function indexAction(ServerRequestInterface $request): ResponseInterface { return $this->render($request, $this->state((array)$request->getQueryParams())); } /** * The form state, from query or body alike. Kept in one place so a re-render * after an import gives the editor the settings back instead of defaults. * * @param array $input * @return array */ private function state(array $input): array { return [ 'source' => trim((string)($input['source'] ?? '')), 'category' => (int)($input['category'] ?? 0), 'pid' => (int)($input['pid'] ?? 0), 'mode' => ($input['mode'] ?? 'article') === 'page' ? 'page' : 'article', 'pageParent' => (int)($input['pageParent'] ?? 0), 'pageLayout' => (int)($input['pageLayout'] ?? 14), 'dryRun' => !empty($input['dryRun']), ]; } public function importAction(ServerRequestInterface $request): ResponseInterface { $body = (array)$request->getParsedBody(); $state = $this->state($body); $selected = array_map('intval', (array)($body['posts'] ?? [])); // "Load" just re-renders with the chosen source; only "import" writes. if ((string)($body['op'] ?? '') !== 'import') { return $this->render($request, $state); } if ($selected === []) { $this->message('Nothing selected - no posts were imported.', ContextualFeedbackSeverity::WARNING); return $this->render($request, $state); } if ($state['pid'] <= 0) { $this->message('Pick a storage folder first.', ContextualFeedbackSeverity::ERROR); return $this->render($request, $state); } if ($state['mode'] === 'page' && $state['pageParent'] <= 0) { $this->message('Page mode needs a parent page for the generated pages.', ContextualFeedbackSeverity::ERROR); return $this->render($request, $state); } $fetched = $this->client->posts($state['source']); if ($fetched['error'] !== null) { $this->message($fetched['error'], ContextualFeedbackSeverity::ERROR); return $this->render($request, $state); } $result = $this->importer->import($fetched['posts'], $selected, [ 'categoryUid' => $state['category'], 'pid' => $state['pid'], 'source' => $this->sourceKey($state['source']), 'mode' => $state['mode'], 'pageParent' => $state['pageParent'], 'pageLayout' => $state['pageLayout'], 'dryRun' => $state['dryRun'], ]); $summary = sprintf( '%s%d created, %d updated, %d pages, %d images.', $state['dryRun'] ? 'DRY RUN - nothing was written. ' : '', $result['created'], $result['updated'], $result['pages'], $result['images'] ); if ($result['failed'] !== []) { $summary .= ' Failed: ' . count($result['failed']) . ' (' . implode(', ', array_keys($result['failed'])) . ').'; } $this->message( $summary, $result['failed'] !== [] ? ContextualFeedbackSeverity::WARNING : ($state['dryRun'] ? ContextualFeedbackSeverity::INFO : ContextualFeedbackSeverity::OK) ); return $this->render($request, $state, $result['log']); } /** * @param array $state * @param string[] $log */ private function render(ServerRequestInterface $request, array $state, array $log = []): ResponseInterface { $url = (string)$state['source']; $rows = []; $error = null; $sourceKey = ''; if ($url !== '') { $sourceKey = $this->sourceKey($url); $fetched = $this->client->posts($url); $error = $fetched['error']; if ($error === null) { $rows = $this->importer->rows($fetched['posts'], $this->client->categories($url), $sourceKey); } } $imported = 0; foreach ($rows as $row) { if ($row['existingUid'] > 0) { $imported++; } } // Select-all lives in a module: the backend CSP blocks inline handlers. $this->pageRenderer->loadJavaScriptModule('@evomedien/vitec/wp-import.js'); $view = $this->moduleTemplateFactory->create($request); $view->assignMultiple([ 'presets' => self::PRESETS, 'source' => $url, 'sourceKey' => $sourceKey, 'rows' => $rows, 'error' => $error, 'total' => count($rows), 'imported' => $imported, 'categories' => $this->categoryOptions(), 'category' => $state['category'], 'pid' => $state['pid'] > 0 ? $state['pid'] : $this->detectPid(), 'mode' => $state['mode'], 'pageParent' => $state['pageParent'], 'pageLayout' => $state['pageLayout'], 'dryRun' => $state['dryRun'], 'log' => $log, ]); return $view->renderResponse('WordPressImport/Index'); } /** * A stable, readable key per source: the host and path, reduced to safe * characters. It ends up in `import_source`, so it must not change between * runs - otherwise the second import would not recognise the first. */ private function sourceKey(string $url): string { $api = $this->client->apiBase($url) ?? $url; $host = (string)parse_url($api, PHP_URL_HOST); $path = trim((string)parse_url($api, PHP_URL_PATH), '/'); $path = str_replace('wp-json/wp/v2', '', $path); $key = 'wp-' . $host . '-' . $path; $key = strtolower(preg_replace('#[^A-Za-z0-9]+#', '-', $key) ?? $key); return trim($key, '-'); } /** @return array */ private function categoryOptions(): array { $qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_category'); $qb->getRestrictions()->removeAll(); $rows = $qb->select('uid', 'title')->from('sys_category') ->where($qb->expr()->eq('deleted', 0)) ->orderBy('title') ->executeQuery()->fetchAllAssociative(); return array_map( static fn(array $row): array => ['uid' => (int)$row['uid'], 'title' => (string)$row['title']], $rows ); } /** Most-used pid of the news already here, as a sensible default. */ private function detectPid(): int { $qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tx_news_domain_model_news'); $qb->getRestrictions()->removeAll(); $row = $qb->select('pid')->addSelectLiteral('COUNT(*) AS cnt')->from('tx_news_domain_model_news') ->where($qb->expr()->eq('deleted', 0)) ->groupBy('pid')->orderBy('cnt', 'DESC')->setMaxResults(1) ->executeQuery()->fetchAssociative(); return (int)($row['pid'] ?? 0); } private function message(string $text, ContextualFeedbackSeverity $severity): void { $message = GeneralUtility::makeInstance(FlashMessage::class, $text, '', $severity, true); $this->flashMessageService->getMessageQueueByIdentifier()->addMessage($message); } }