602 lines
24 KiB
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);
|
|
}
|
|
}
|