247 lines
9.7 KiB
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);
|
|
}
|
|
}
|