Files
VITEC-website/packages/vitec/Classes/Controller/Backend/WordPressImportController.php

247 lines
9.7 KiB
PHP

<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Controller\Backend;
use Doctrine\DBAL\ParameterType;
use Evomedien\Vitec\Import\WordPressClient;
use Evomedien\Vitec\Import\WordPressImporter;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Messaging\FlashMessageService;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Backend module: imports WordPress blog posts as EXT:news records.
*
* Reads the source through its REST API rather than its database. The VITEC blog
* happens to sit on the same server, but datapath.co.uk does not and its MySQL is
* not reachable from here - one code path serves both, and no database password
* has to be stored anywhere.
*
* Workflow: pick a source, look at the list (each row shows whether it has been
* imported before), tick what you want, choose one sys_category and a storage
* folder, import. Featured images and every `<img>` 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<string,mixed> $input
* @return array<string,mixed>
*/
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<string,mixed> $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<int,array{uid:int,title:string}> */
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);
}
}