From 4ef3ad00261ced6e7d7a2f65f28afcc7ebb2d5e6 Mon Sep 17 00:00:00 2001 From: Oliver Rasche Date: Fri, 14 Aug 2026 13:26:40 +0200 Subject: [PATCH] Backend Modul Worpress Import added --- .../Backend/WordPressImportController.php | 246 +++++++ .../vitec/Classes/Import/WordPressClient.php | 123 ++++ .../Classes/Import/WordPressImporter.php | 601 ++++++++++++++++++ .../vitec/Configuration/Backend/Modules.php | 23 + packages/vitec/Configuration/Icons.php | 4 + .../Templates/WordPressImport/Index.html | 179 ++++++ .../Resources/Public/Icons/vitec-wpimport.svg | 9 + .../Resources/Public/Javascript/wp-import.js | 45 ++ public/_frontend/.htaccess | 54 +- public/_frontend/index.html | 4 +- 10 files changed, 1259 insertions(+), 29 deletions(-) create mode 100644 packages/vitec/Classes/Controller/Backend/WordPressImportController.php create mode 100644 packages/vitec/Classes/Import/WordPressClient.php create mode 100644 packages/vitec/Classes/Import/WordPressImporter.php create mode 100644 packages/vitec/Resources/Private/Templates/WordPressImport/Index.html create mode 100644 packages/vitec/Resources/Public/Icons/vitec-wpimport.svg create mode 100644 packages/vitec/Resources/Public/Javascript/wp-import.js diff --git a/packages/vitec/Classes/Controller/Backend/WordPressImportController.php b/packages/vitec/Classes/Controller/Backend/WordPressImportController.php new file mode 100644 index 0000000..fa9cb72 --- /dev/null +++ b/packages/vitec/Classes/Controller/Backend/WordPressImportController.php @@ -0,0 +1,246 @@ +` 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 $input + * @return array + */ + 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 $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 */ + 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); + } +} diff --git a/packages/vitec/Classes/Import/WordPressClient.php b/packages/vitec/Classes/Import/WordPressClient.php new file mode 100644 index 0000000..87167fb --- /dev/null +++ b/packages/vitec/Classes/Import/WordPressClient.php @@ -0,0 +1,123 @@ +>, error:?string} + */ + public function posts(string $baseUrl): array + { + $api = $this->apiBase($baseUrl); + if ($api === null) { + return ['posts' => [], 'error' => 'No usable URL.']; + } + + $posts = []; + for ($page = 1; $page <= self::MAX_PAGES; $page++) { + $url = $api . '/posts?per_page=' . self::PER_PAGE . '&page=' . $page . '&_embed=wp:featuredmedia,wp:term'; + $batch = $this->get($url); + if ($batch === null) { + // Page 1 failing is a real error; later pages simply mean the end + // (WordPress answers 400 rest_post_invalid_page_number past it). + return $page === 1 + ? ['posts' => [], 'error' => 'The source did not answer with a post list. Check the URL.'] + : ['posts' => $posts, 'error' => null]; + } + $posts = array_merge($posts, $batch); + if (count($batch) < self::PER_PAGE) { + break; + } + } + + return ['posts' => $posts, 'error' => null]; + } + + /** + * Category id => name, for showing what a post was filed under over there. + * + * @return array + */ + public function categories(string $baseUrl): array + { + $api = $this->apiBase($baseUrl); + if ($api === null) { + return []; + } + $rows = $this->get($api . '/categories?per_page=' . self::PER_PAGE) ?? []; + + $map = []; + foreach ($rows as $row) { + $map[(int)($row['id'] ?? 0)] = html_entity_decode((string)($row['name'] ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } + return $map; + } + + /** + * Turns whatever the editor typed into the v2 API base. + * "vitec.com/blog", "https://vitec.com/blog/" and a full ".../wp-json/wp/v2" + * all end up the same. + */ + public function apiBase(string $baseUrl): ?string + { + $baseUrl = trim($baseUrl); + if ($baseUrl === '') { + return null; + } + if (!preg_match('#^https?://#i', $baseUrl)) { + $baseUrl = 'https://' . $baseUrl; + } + $baseUrl = rtrim($baseUrl, '/'); + if (str_contains($baseUrl, '/wp-json')) { + return $baseUrl; + } + return $baseUrl . '/wp-json/wp/v2'; + } + + /** @return array>|null */ + private function get(string $url): ?array + { + try { + $response = GeneralUtility::makeInstance(RequestFactory::class) + ->request($url, 'GET', ['timeout' => self::TIMEOUT, 'headers' => ['Accept' => 'application/json']]); + if ($response->getStatusCode() !== 200) { + return null; + } + $decoded = json_decode((string)$response->getBody(), true); + return is_array($decoded) ? $decoded : null; + } catch (\Throwable $e) { + return null; + } + } +} diff --git a/packages/vitec/Classes/Import/WordPressImporter.php b/packages/vitec/Classes/Import/WordPressImporter.php new file mode 100644 index 0000000..54e798d --- /dev/null +++ b/packages/vitec/Classes/Import/WordPressImporter.php @@ -0,0 +1,601 @@ + ['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); + } +} diff --git a/packages/vitec/Configuration/Backend/Modules.php b/packages/vitec/Configuration/Backend/Modules.php index db63fc6..2d67d72 100644 --- a/packages/vitec/Configuration/Backend/Modules.php +++ b/packages/vitec/Configuration/Backend/Modules.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Evomedien\Vitec\Controller\Backend\ImportController; +use Evomedien\Vitec\Controller\Backend\WordPressImportController; use Evomedien\Vitec\Controller\OgImageController; return [ @@ -35,6 +36,28 @@ return [ ], ], ], + 'web_vitecwpimport' => [ + 'parent' => 'web', + 'position' => ['after' => 'web_vitecimport'], + 'access' => 'user', + 'workspaces' => 'live', + 'path' => '/module/web/vitec-wp-import', + 'labels' => [ + 'title' => 'WordPress Import', + 'shortDescription' => 'Imports WordPress blog posts as news records', + ], + 'extensionName' => 'Vitec', + 'iconIdentifier' => 'vitec-wpimport', + 'routes' => [ + '_default' => [ + 'target' => WordPressImportController::class . '::indexAction', + ], + 'import' => [ + 'target' => WordPressImportController::class . '::importAction', + 'methods' => ['POST'], + ], + ], + ], 'web_vitecogimage' => [ 'parent' => 'web', 'position' => ['after' => 'web_info'], diff --git a/packages/vitec/Configuration/Icons.php b/packages/vitec/Configuration/Icons.php index 1491eed..729c1bb 100755 --- a/packages/vitec/Configuration/Icons.php +++ b/packages/vitec/Configuration/Icons.php @@ -88,6 +88,10 @@ return [ 'provider' => SvgIconProvider::class, 'source' => 'EXT:vitec/Resources/Public/Icons/vitec-import.svg', ], + 'vitec-wpimport' => [ + 'provider' => SvgIconProvider::class, + 'source' => 'EXT:vitec/Resources/Public/Icons/vitec-wpimport.svg', + ], 'vitec-plugin-customerlogos' => [ 'provider' => SvgIconProvider::class, 'source' => 'EXT:vitec/Resources/Public/Icons/vitec-plugin-customerlogos.svg', diff --git a/packages/vitec/Resources/Private/Templates/WordPressImport/Index.html b/packages/vitec/Resources/Private/Templates/WordPressImport/Index.html new file mode 100644 index 0000000..3bc2827 --- /dev/null +++ b/packages/vitec/Resources/Private/Templates/WordPressImport/Index.html @@ -0,0 +1,179 @@ + + + + + + +
+
+

WordPress Import

+
+
+ + +
{error}
+
+ +
+ + +
+
+
+
+ + + + + +
+ Read through the WordPress REST API — only the address is needed, no credentials. +
Import key: {sourceKey}
+
+
+
+ +
+
+
+
+ + + +
+
+
+
+ + +
+
+ + +
+
+ +
+ +
+
+ +
+ + +
+
+ + +
+
+ The blog is built with Divi and has no block boundaries, so the body arrives as + one content element either way. Page mode is a starting point for reworking + single posts later, not an automatic layout. +
+
+
+ + +
Only used in page mode.
+
+
+ + +
14 = News Detail V2
+
+
+ +
+ +
+
+
+ + +
+
+
+ +
+
+
+
+ + +
+
What happened
+
+
{line}
+
+
+
+ +

+ {total} posts found, + {imported} already imported. + Ticking one that is already there updates it. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
TitleDateWP categoriesBodyImagesStatus
+ {row.title} +
{row.slug} +
{row.date}{row.categories}{row.bodyLength} chars + 1 + + {row.inlineImages} + + + imported (uid {row.existingUid}) + new + + +
shortcodes +
+
+
+ +
+ +
+ diff --git a/packages/vitec/Resources/Public/Icons/vitec-wpimport.svg b/packages/vitec/Resources/Public/Icons/vitec-wpimport.svg new file mode 100644 index 0000000..3470bb4 --- /dev/null +++ b/packages/vitec/Resources/Public/Icons/vitec-wpimport.svg @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/packages/vitec/Resources/Public/Javascript/wp-import.js b/packages/vitec/Resources/Public/Javascript/wp-import.js new file mode 100644 index 0000000..e1f42f8 --- /dev/null +++ b/packages/vitec/Resources/Public/Javascript/wp-import.js @@ -0,0 +1,45 @@ +/** + * Select-all for the WordPress import list. + * + * Lives in a module rather than in an onclick attribute: the backend enforces a + * Content Security Policy that blocks inline event handlers, so the attribute + * version simply did nothing. + * + * Also keeps the header box honest - it shows indeterminate while only some + * rows are ticked, which matters on a list of a hundred posts. + */ + +function boxes() { + return Array.from(document.querySelectorAll('[data-vitec-post]')); +} + +function sync(toggle) { + const all = boxes(); + const checked = all.filter((box) => box.checked).length; + toggle.checked = all.length > 0 && checked === all.length; + toggle.indeterminate = checked > 0 && checked < all.length; +} + +function init() { + const toggle = document.querySelector('[data-vitec-toggle-all]'); + if (toggle === null) { + return; + } + + toggle.addEventListener('change', () => { + const state = toggle.checked; + boxes().forEach((box) => { + box.checked = state; + }); + toggle.indeterminate = false; + }); + + boxes().forEach((box) => box.addEventListener('change', () => sync(toggle))); + sync(toggle); +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); +} else { + init(); +} diff --git a/public/_frontend/.htaccess b/public/_frontend/.htaccess index 15135c6..52d7f05 100644 --- a/public/_frontend/.htaccess +++ b/public/_frontend/.htaccess @@ -1,27 +1,27 @@ -RewriteEngine On -RewriteBase /_frontend/ - -RewriteRule ^index\.html$ - [L] - -RewriteCond %{REQUEST_FILENAME} -f [OR] -RewriteCond %{REQUEST_FILENAME} -d -RewriteRule ^ - [L] - -RewriteRule ^ index.html [L] - -# ── Cache headers ──────────────────────────────────────────────────────────── - -# index.html: never cache — always fetch fresh so the browser picks up -# the latest hashed JS/CSS filenames after a deploy. - - Header set Cache-Control "no-store, no-cache, must-revalidate" - Header set Pragma "no-cache" - Header set Expires "0" - - -# Hashed assets (JS, CSS, fonts, images inside /assets/): -# Vite appends a content hash to every filename, so these are immutable. -# Cache them for 1 year — a new deploy produces new filenames automatically. - - Header set Cache-Control "public, max-age=31536000, immutable" - +RewriteEngine On +RewriteBase /_frontend/ + +RewriteRule ^index\.html$ - [L] + +RewriteCond %{REQUEST_FILENAME} -f [OR] +RewriteCond %{REQUEST_FILENAME} -d +RewriteRule ^ - [L] + +RewriteRule ^ index.html [L] + +# ── Cache headers ──────────────────────────────────────────────────────────── + +# index.html: never cache — always fetch fresh so the browser picks up +# the latest hashed JS/CSS filenames after a deploy. + + Header set Cache-Control "no-store, no-cache, must-revalidate" + Header set Pragma "no-cache" + Header set Expires "0" + + +# Hashed assets (JS, CSS, fonts, images inside /assets/): +# Vite appends a content hash to every filename, so these are immutable. +# Cache them for 1 year — a new deploy produces new filenames automatically. + + Header set Cache-Control "public, max-age=31536000, immutable" + diff --git a/public/_frontend/index.html b/public/_frontend/index.html index d919d2c..51a66a8 100644 --- a/public/_frontend/index.html +++ b/public/_frontend/index.html @@ -25,8 +25,8 @@ VITEC - - + +