['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> $posts * @param array $wpCategories * @return array> */ 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> $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,log:array} */ 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( '' . '' . $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,log:array} $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('#]+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 $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 $options * @param array{created:int,updated:int,pages:int,images:int,failed:array,log:array} $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 $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 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); } }