Files
VITEC-website/packages/vitec/Classes/Import/WordPressImporter.php

602 lines
24 KiB
PHP

<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Import;
use Doctrine\DBAL\ParameterType;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Http\RequestFactory;
use TYPO3\CMS\Core\Resource\Enum\DuplicationBehavior;
use TYPO3\CMS\Core\Resource\File;
use TYPO3\CMS\Core\Resource\Folder;
use TYPO3\CMS\Core\Resource\ResourceStorage;
use TYPO3\CMS\Core\Resource\StorageRepository;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Turns WordPress posts into EXT:news records.
*
* Identity is `import_source` + `import_id` - the two fields EXT:news carries
* for exactly this - so a second run recognises what is already there instead of
* duplicating it, even after an editor has changed a slug on this side.
*
* Two shapes, chosen per run:
*
* article news record with the body in `bodytext` (type 0)
* page a TYPO3 page carrying the body, plus a news record of type 1
* pointing at it - the "page as news" pattern the migrated press
* releases already use. Costs one page per post and buys the freedom
* to rework single posts with Content Blocks later.
*
* Be aware what the page mode does NOT buy: the VITEC blog is built with Divi,
* whose output has no block boundaries (no `wp-block-*`, no Gutenberg comments,
* just ~21 nested `et_pb_*` divs per post). The body therefore arrives as ONE
* content element either way. The page mode is a starting point for later
* editorial work, not an automatic layout.
*
* Images are pulled across rather than linked: the source site goes away at
* go-live, so every remote URL would turn into a hole.
*/
final class WordPressImporter
{
private const TABLE = 'tx_news_domain_model_news';
private const FOLDER = 'blog-import';
private const TIMEOUT = 120;
/** Responsive attributes point at generated sizes on the old host. */
private const DROP_ATTRIBUTES = ['srcset', 'sizes'];
/**
* Tags that survive normalisation. Everything else is unwrapped - its
* children are kept, the element itself disappears. That is what turns
* Divi's div nesting into readable markup without losing a word.
*/
private const ALLOWED_TAGS = [
'p', 'br', 'strong', 'b', 'em', 'i', 'u', 'sub', 'sup',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'ul', 'ol', 'li', 'blockquote', 'a', 'img', 'iframe',
'table', 'thead', 'tbody', 'tr', 'th', 'td', 'figure', 'figcaption',
];
/** Attributes kept per tag; everything else (class, style, id, data-*) goes. */
private const ALLOWED_ATTRIBUTES = [
'a' => ['href', 'target', 'rel', 'title'],
'img' => ['src', 'alt', 'width', 'height', 'title'],
'iframe' => ['src', 'width', 'height', 'title', 'allow', 'allowfullscreen'],
'td' => ['colspan', 'rowspan'],
'th' => ['colspan', 'rowspan'],
];
/**
* One display row per post.
*
* @param array<int,array<string,mixed>> $posts
* @param array<int,string> $wpCategories
* @return array<int,array<string,mixed>>
*/
public function rows(array $posts, array $wpCategories, string $source): array
{
$existing = $this->existingImportIds($source);
$rows = [];
foreach ($posts as $post) {
$wpId = (int)($post['id'] ?? 0);
if ($wpId === 0) {
continue;
}
$names = [];
foreach ((array)($post['categories'] ?? []) as $catId) {
$names[] = $wpCategories[(int)$catId] ?? ('#' . $catId);
}
$body = (string)($post['content']['rendered'] ?? '');
$rows[] = [
'wpId' => $wpId,
'title' => $this->plain((string)($post['title']['rendered'] ?? '')),
'date' => substr((string)($post['date'] ?? ''), 0, 10),
'slug' => (string)($post['slug'] ?? ''),
'categories' => implode(', ', $names),
'image' => $this->featuredImageUrl($post),
'bodyLength' => strlen($body),
'inlineImages' => count($this->inlineImageUrls($body)),
// Divi shortcodes that were never rendered - the post would
// import as unreadable soup, so it is flagged in the list.
'rawShortcodes' => str_contains($body, '[et_pb_'),
'existingUid' => $existing[$wpId] ?? 0,
];
}
return $rows;
}
/**
* @param array<int,array<string,mixed>> $posts all posts from the source
* @param int[] $selected WordPress post ids
* @param array{categoryUid:int,pid:int,source:string,mode:string,pageParent:int,pageLayout:int,dryRun:bool} $options
* @return array{created:int,updated:int,pages:int,images:int,failed:array<int,string>,log:array<int,string>}
*/
public function import(array $posts, array $selected, array $options): array
{
$wanted = array_flip(array_map('intval', $selected));
$source = (string)$options['source'];
$dryRun = (bool)($options['dryRun'] ?? false);
$mode = ($options['mode'] ?? 'article') === 'page' ? 'page' : 'article';
$existing = $this->existingImportIds($source);
$result = ['created' => 0, 'updated' => 0, 'pages' => 0, 'images' => 0, 'failed' => [], 'log' => []];
foreach ($posts as $post) {
$wpId = (int)($post['id'] ?? 0);
if ($wpId === 0 || !isset($wanted[$wpId])) {
continue;
}
try {
$existingUid = $existing[$wpId] ?? 0;
$title = $this->plain((string)($post['title']['rendered'] ?? ''));
$slug = (string)($post['slug'] ?? '');
$body = $this->normaliseHtml((string)($post['content']['rendered'] ?? ''));
$body = $this->localiseInlineImages($body, $source, $dryRun, $result);
$data = [
'pid' => (int)$options['pid'],
'title' => $title,
'teaser' => $this->plain((string)($post['excerpt']['rendered'] ?? '')),
'datetime' => strtotime((string)($post['date'] ?? '')) ?: time(),
'path_segment' => $slug,
'import_source' => $source,
'import_id' => $wpId,
];
if ((int)$options['categoryUid'] > 0) {
$data['categories'] = (string)$options['categoryUid'];
}
if ($mode === 'page') {
$pageUid = $dryRun ? 0 : $this->pageFor($existingUid, $title, $slug, $body, $options, $result);
$data['type'] = 1;
$data['bodytext'] = '';
if (!$dryRun) {
if ($pageUid === 0) {
$result['failed'][$wpId] = 'page could not be created';
continue;
}
$data['internalurl'] = 't3://page?uid=' . $pageUid;
}
$result['log'][] = sprintf('%s "%s" -> page %s', $existingUid > 0 ? 'update' : 'create', $title, $dryRun ? '(new)' : (string)$pageUid);
} else {
$data['type'] = 0;
$data['bodytext'] = $body;
$result['log'][] = sprintf('%s "%s" -> article, %d chars', $existingUid > 0 ? 'update' : 'create', $title, strlen($body));
}
if ($dryRun) {
$existingUid > 0 ? $result['updated']++ : $result['created']++;
continue;
}
$newsUid = $this->write($existingUid, $data);
if ($newsUid === 0) {
$result['failed'][$wpId] = 'record could not be written';
continue;
}
$existingUid > 0 ? $result['updated']++ : $result['created']++;
$imageUrl = $this->featuredImageUrl($post);
if ($imageUrl !== '' && !$this->hasFalMedia($newsUid)) {
$file = $this->fetchIntoFal($imageUrl, $source, false);
if ($file !== null) {
$this->attachFalMedia($newsUid, (int)$options['pid'], $file);
$result['images']++;
}
}
} catch (\Throwable $e) {
$result['failed'][$wpId] = $e->getMessage();
}
}
return $result;
}
// ------------------------------------------------------------------- HTML
/**
* Reduces the source markup to plain semantic HTML.
*
* The blog is a Divi site: the body arrives wrapped in ~21 nested
* `et_pb_*` divs whose classes mean nothing here, and occasionally with the
* shortcodes leaking through unrendered. Unwrapping everything outside
* ALLOWED_TAGS keeps every word and every image while dropping the scaffold.
*
* DOM rather than regular expressions on purpose - nesting this deep is
* exactly where a regex silently eats content.
*/
public function normaliseHtml(string $html): string
{
$html = trim($html);
if ($html === '') {
return '';
}
// Unrendered Divi shortcodes are noise in any case.
$html = preg_replace('#\[/?et_pb_[^\]]*\]#u', '', $html) ?? $html;
$doc = new \DOMDocument();
$previous = libxml_use_internal_errors(true);
// Wrapped in html/body, and deliberately WITHOUT LIBXML_HTML_NOIMPLIED:
// with several top-level nodes and no implied structure libxml keeps
// only the FIRST one, which quietly threw the whole body away. The
// encoding hint has to come as a processing instruction, otherwise
// libxml reads the UTF-8 bytes as Latin-1.
$loaded = $doc->loadHTML(
'<?xml encoding="UTF-8">' . '<html><body>' . $html . '</body></html>',
LIBXML_NOERROR | LIBXML_NOWARNING
);
libxml_clear_errors();
libxml_use_internal_errors($previous);
if ($loaded === false) {
return $html;
}
$body = $doc->getElementsByTagName('body')->item(0);
if ($body === null) {
return $html;
}
$this->cleanNode($body, $doc);
$out = '';
foreach (iterator_to_array($body->childNodes) as $child) {
$out .= $doc->saveHTML($child);
}
// Collapse the blank lines left behind by the unwrapped scaffold.
$out = preg_replace('#(\s*\n){2,}#', "\n", $out) ?? $out;
$out = trim($out);
// Never hand back nothing for something: if the cleaning swallowed the
// content, the raw markup is still better than an empty record.
return $out !== '' ? $out : $html;
}
/** Recursively unwraps disallowed elements and strips stray attributes. */
private function cleanNode(\DOMNode $node, \DOMDocument $doc): void
{
foreach (iterator_to_array($node->childNodes ?? []) as $child) {
if ($child instanceof \DOMComment) {
$child->parentNode?->removeChild($child);
continue;
}
if (!$child instanceof \DOMElement) {
continue;
}
$this->cleanNode($child, $doc);
$tag = strtolower($child->nodeName);
if (in_array($tag, self::ALLOWED_TAGS, true)) {
$keep = self::ALLOWED_ATTRIBUTES[$tag] ?? [];
foreach (iterator_to_array($child->attributes ?? []) as $attribute) {
if (!in_array(strtolower($attribute->nodeName), $keep, true)) {
$child->removeAttribute($attribute->nodeName);
}
}
continue;
}
// Not allowed: hand the children to the parent, drop the element.
$parent = $child->parentNode;
if ($parent === null) {
continue;
}
while ($child->firstChild !== null) {
$parent->insertBefore($child->firstChild, $child);
}
$parent->removeChild($child);
}
}
// ----------------------------------------------------------------- images
/**
* @param array{created:int,updated:int,pages:int,images:int,failed:array<int,string>,log:array<int,string>} $result
*/
private function localiseInlineImages(string $html, string $source, bool $dryRun, array &$result): string
{
foreach ($this->inlineImageUrls($html) as $url) {
$file = $this->fetchIntoFal($url, $source, $dryRun);
$result['images']++;
if ($file === null) {
continue;
}
$html = str_replace($url, (string)$file->getPublicUrl(), $html);
}
foreach (self::DROP_ATTRIBUTES as $attribute) {
$html = preg_replace('#\s' . $attribute . '="[^"]*"#i', '', $html) ?? $html;
}
return $html;
}
/** @return string[] */
private function inlineImageUrls(string $html): array
{
if ($html === '') {
return [];
}
preg_match_all('#<img[^>]+src="([^"]+)"#i', $html, $matches);
return array_values(array_filter(
array_unique($matches[1] ?? []),
static fn(string $url): bool => preg_match('#^https?://#i', $url) === 1
));
}
/** @param array<string,mixed> $post */
private function featuredImageUrl(array $post): string
{
$media = $post['_embedded']['wp:featuredmedia'][0]['source_url'] ?? '';
return is_string($media) ? $media : '';
}
private function fetchIntoFal(string $url, string $source, bool $dryRun): ?File
{
if ($dryRun) {
return null;
}
$name = $this->sanitiseFileName($url);
if ($name === '') {
return null;
}
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
if ($storage === null) {
return null;
}
$folder = $this->ensureFolder($storage, self::FOLDER . '/' . $source);
if ($folder->hasFile($name)) {
$file = $storage->getFileInFolder($name, $folder);
return $file instanceof File ? $file : null;
}
$tmp = GeneralUtility::tempnam('wpimport_');
try {
$response = GeneralUtility::makeInstance(RequestFactory::class)
->request($url, 'GET', ['timeout' => self::TIMEOUT]);
if ($response->getStatusCode() !== 200) {
return null;
}
file_put_contents($tmp, (string)$response->getBody());
if (filesize($tmp) === 0) {
return null;
}
$file = $storage->addFile($tmp, $folder, $name, DuplicationBehavior::RENAME);
return $file instanceof File ? $file : null;
} catch (\Throwable $e) {
return null;
} finally {
if (file_exists($tmp)) {
@unlink($tmp);
}
}
}
/**
* Walks the path one segment at a time instead of handing a slashed name to
* createFolder(): that would reach folderExistsInFolder() with a slash in
* the name, and the drivers are not consistent about what they do with it.
*/
private function ensureFolder(ResourceStorage $storage, string $path): Folder
{
$current = $storage->getRootLevelFolder();
foreach (explode('/', trim($path, '/')) as $segment) {
$segment = preg_replace('#[^A-Za-z0-9._-]#', '-', $segment) ?? '';
if ($segment === '') {
continue;
}
$current = $current->hasFolder($segment)
? $current->getSubfolder($segment)
: $storage->createFolder($segment, $current);
}
return $current;
}
private function sanitiseFileName(string $url): string
{
$path = (string)parse_url($url, PHP_URL_PATH);
$name = preg_replace('#[^A-Za-z0-9._-]#', '-', basename($path)) ?? '';
return trim($name, '-');
}
// ------------------------------------------------------------------ pages
/**
* The page carrying a post's body, created on first import and reused after
* that. Reuse goes through the news record's `internalurl` rather than a
* slug guess, so an editor renaming the page does not cause a duplicate.
*
* @param array<string,mixed> $options
* @param array{created:int,updated:int,pages:int,images:int,failed:array<int,string>,log:array<int,string>} $result
*/
private function pageFor(int $existingNewsUid, string $title, string $slug, string $body, array $options, array &$result): int
{
$pageUid = $existingNewsUid > 0 ? $this->pageOfNews($existingNewsUid) : 0;
if ($pageUid === 0) {
$parent = (int)($options['pageParent'] ?? 0);
if ($parent <= 0) {
return 0;
}
$layout = (int)($options['pageLayout'] ?? 0);
$page = [
'pid' => $parent,
'title' => $title,
'doktype' => 1,
'hidden' => 0,
];
if ($slug !== '') {
// Full path, so the WordPress segment survives the move.
$page['slug'] = rtrim($this->slugOfPage($parent), '/') . '/' . trim($slug, '/');
}
if ($layout > 0) {
$page['layout'] = $layout;
$page['backend_layout'] = 'vitec__' . $layout;
$page['backend_layout_next_level'] = 'vitec__' . $layout;
}
$id = 'NEWwppage' . substr(md5($slug . $title), 0, 10);
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start(['pages' => [$id => $page]], []);
$dataHandler->process_datamap();
if ($dataHandler->errorLog !== []) {
return 0;
}
$pageUid = (int)($dataHandler->substNEWwithIDs[$id] ?? 0);
if ($pageUid === 0) {
return 0;
}
$result['pages']++;
}
$this->writeBodyElement($pageUid, $title, $body);
return $pageUid;
}
/**
* One `html` element holding the whole body. Divi gives no block boundaries
* to split on, so this is deliberately a single element - a starting point
* for editors, not a pretend layout.
*/
private function writeBodyElement(int $pageUid, string $title, string $body): void
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tt_content');
$qb->getRestrictions()->removeAll();
$existing = (int)($qb->select('uid')->from('tt_content')
->where(
$qb->expr()->eq('pid', $qb->createNamedParameter($pageUid, ParameterType::INTEGER)),
$qb->expr()->eq('CType', $qb->createNamedParameter('html')),
$qb->expr()->eq('deleted', 0)
)
->orderBy('sorting')->setMaxResults(1)
->executeQuery()->fetchOne() ?: 0);
$id = $existing > 0 ? (string)$existing : 'NEWwpce' . substr(md5((string)$pageUid), 0, 10);
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start([
'tt_content' => [
$id => [
'pid' => $pageUid,
'CType' => 'html',
'colPos' => 0,
'header' => $title,
'header_layout' => 100,
'bodytext' => $body,
],
],
], []);
$dataHandler->process_datamap();
}
private function pageOfNews(int $newsUid): int
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE);
$qb->getRestrictions()->removeAll();
$url = (string)($qb->select('internalurl')->from(self::TABLE)
->where($qb->expr()->eq('uid', $qb->createNamedParameter($newsUid, ParameterType::INTEGER)))
->executeQuery()->fetchOne() ?: '');
if (preg_match('#t3://page\?uid=(\d+)#i', $url, $m)) {
$uid = (int)$m[1];
return $this->pageExists($uid) ? $uid : 0;
}
return ctype_digit(trim($url)) && $this->pageExists((int)$url) ? (int)$url : 0;
}
private function pageExists(int $uid): bool
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages');
$qb->getRestrictions()->removeAll();
return (int)($qb->count('uid')->from('pages')
->where(
$qb->expr()->eq('uid', $qb->createNamedParameter($uid, ParameterType::INTEGER)),
$qb->expr()->eq('deleted', 0)
)->executeQuery()->fetchOne()) > 0;
}
private function slugOfPage(int $uid): string
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages');
$qb->getRestrictions()->removeAll();
return (string)($qb->select('slug')->from('pages')
->where($qb->expr()->eq('uid', $qb->createNamedParameter($uid, ParameterType::INTEGER)))
->executeQuery()->fetchOne() ?: '');
}
// ---------------------------------------------------------------- records
private function attachFalMedia(int $newsUid, int $pid, File $file): void
{
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start([
'sys_file_reference' => [
'NEWblogmedia' => ['uid_local' => $file->getUid(), 'pid' => $pid],
],
self::TABLE => [
(string)$newsUid => ['fal_media' => 'NEWblogmedia'],
],
], []);
$dataHandler->process_datamap();
}
private function hasFalMedia(int $newsUid): bool
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_file_reference');
$qb->getRestrictions()->removeAll();
return (int)($qb->count('uid')->from('sys_file_reference')
->where(
$qb->expr()->eq('tablenames', $qb->createNamedParameter(self::TABLE)),
$qb->expr()->eq('fieldname', $qb->createNamedParameter('fal_media')),
$qb->expr()->eq('uid_foreign', $qb->createNamedParameter($newsUid, ParameterType::INTEGER)),
$qb->expr()->eq('deleted', 0)
)->executeQuery()->fetchOne()) > 0;
}
/** @param array<string,mixed> $data */
private function write(int $existingUid, array $data): int
{
$id = $existingUid > 0 ? (string)$existingUid : 'NEWwp' . substr(md5((string)($data['import_id'] ?? '')), 0, 10);
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start([self::TABLE => [$id => $data]], []);
$dataHandler->process_datamap();
if ($dataHandler->errorLog !== []) {
return 0;
}
return $existingUid > 0 ? $existingUid : (int)($dataHandler->substNEWwithIDs[$id] ?? 0);
}
/** @return array<int,int> WordPress post id => news uid */
private function existingImportIds(string $source): array
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE);
$qb->getRestrictions()->removeAll();
$rows = $qb->select('uid', 'import_id')->from(self::TABLE)
->where(
$qb->expr()->eq('import_source', $qb->createNamedParameter($source)),
$qb->expr()->eq('deleted', 0)
)->executeQuery()->fetchAllAssociative();
$map = [];
foreach ($rows as $row) {
$map[(int)$row['import_id']] = (int)$row['uid'];
}
return $map;
}
private function plain(string $html): string
{
$text = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8');
return trim(preg_replace('#\s+#u', ' ', $text) ?? $text);
}
}