Backend Modul Worpress Import added
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\Controller\Backend;
|
||||
|
||||
use Doctrine\DBAL\ParameterType;
|
||||
use Evomedien\Vitec\Import\WordPressClient;
|
||||
use Evomedien\Vitec\Import\WordPressImporter;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use TYPO3\CMS\Backend\Attribute\AsController;
|
||||
use TYPO3\CMS\Backend\Routing\UriBuilder;
|
||||
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
|
||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||
use TYPO3\CMS\Core\Messaging\FlashMessage;
|
||||
use TYPO3\CMS\Core\Messaging\FlashMessageService;
|
||||
use TYPO3\CMS\Core\Page\PageRenderer;
|
||||
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
|
||||
/**
|
||||
* Backend module: imports WordPress blog posts as EXT:news records.
|
||||
*
|
||||
* Reads the source through its REST API rather than its database. The VITEC blog
|
||||
* happens to sit on the same server, but datapath.co.uk does not and its MySQL is
|
||||
* not reachable from here - one code path serves both, and no database password
|
||||
* has to be stored anywhere.
|
||||
*
|
||||
* Workflow: pick a source, look at the list (each row shows whether it has been
|
||||
* imported before), tick what you want, choose one sys_category and a storage
|
||||
* folder, import. Featured images and every `<img>` inside the body are pulled
|
||||
* across as FAL files, because the source site goes away at go-live.
|
||||
*
|
||||
* No server-side state: the post list is fetched again on submit and filtered by
|
||||
* the ticked ids. Carrying ~900 KB of post JSON through a hidden form field -
|
||||
* the trick the CSV module uses - is not workable at this size.
|
||||
*/
|
||||
#[AsController]
|
||||
final class WordPressImportController
|
||||
{
|
||||
/**
|
||||
* Known sources. The URL field stays editable, so anything else can be typed
|
||||
* in; these two are just what VITEC actually has.
|
||||
*/
|
||||
private const PRESETS = [
|
||||
['label' => 'VITEC Blog', 'url' => 'https://www.vitec.com/blog'],
|
||||
['label' => 'Datapath', 'url' => 'https://www.datapath.co.uk'],
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly ModuleTemplateFactory $moduleTemplateFactory,
|
||||
private readonly WordPressClient $client,
|
||||
private readonly WordPressImporter $importer,
|
||||
private readonly FlashMessageService $flashMessageService,
|
||||
private readonly PageRenderer $pageRenderer,
|
||||
private readonly UriBuilder $uriBuilder,
|
||||
) {}
|
||||
|
||||
public function indexAction(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
return $this->render($request, $this->state((array)$request->getQueryParams()));
|
||||
}
|
||||
|
||||
/**
|
||||
* The form state, from query or body alike. Kept in one place so a re-render
|
||||
* after an import gives the editor the settings back instead of defaults.
|
||||
*
|
||||
* @param array<string,mixed> $input
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function state(array $input): array
|
||||
{
|
||||
return [
|
||||
'source' => trim((string)($input['source'] ?? '')),
|
||||
'category' => (int)($input['category'] ?? 0),
|
||||
'pid' => (int)($input['pid'] ?? 0),
|
||||
'mode' => ($input['mode'] ?? 'article') === 'page' ? 'page' : 'article',
|
||||
'pageParent' => (int)($input['pageParent'] ?? 0),
|
||||
'pageLayout' => (int)($input['pageLayout'] ?? 14),
|
||||
'dryRun' => !empty($input['dryRun']),
|
||||
];
|
||||
}
|
||||
|
||||
public function importAction(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
$body = (array)$request->getParsedBody();
|
||||
$state = $this->state($body);
|
||||
$selected = array_map('intval', (array)($body['posts'] ?? []));
|
||||
|
||||
// "Load" just re-renders with the chosen source; only "import" writes.
|
||||
if ((string)($body['op'] ?? '') !== 'import') {
|
||||
return $this->render($request, $state);
|
||||
}
|
||||
|
||||
if ($selected === []) {
|
||||
$this->message('Nothing selected - no posts were imported.', ContextualFeedbackSeverity::WARNING);
|
||||
return $this->render($request, $state);
|
||||
}
|
||||
if ($state['pid'] <= 0) {
|
||||
$this->message('Pick a storage folder first.', ContextualFeedbackSeverity::ERROR);
|
||||
return $this->render($request, $state);
|
||||
}
|
||||
if ($state['mode'] === 'page' && $state['pageParent'] <= 0) {
|
||||
$this->message('Page mode needs a parent page for the generated pages.', ContextualFeedbackSeverity::ERROR);
|
||||
return $this->render($request, $state);
|
||||
}
|
||||
|
||||
$fetched = $this->client->posts($state['source']);
|
||||
if ($fetched['error'] !== null) {
|
||||
$this->message($fetched['error'], ContextualFeedbackSeverity::ERROR);
|
||||
return $this->render($request, $state);
|
||||
}
|
||||
|
||||
$result = $this->importer->import($fetched['posts'], $selected, [
|
||||
'categoryUid' => $state['category'],
|
||||
'pid' => $state['pid'],
|
||||
'source' => $this->sourceKey($state['source']),
|
||||
'mode' => $state['mode'],
|
||||
'pageParent' => $state['pageParent'],
|
||||
'pageLayout' => $state['pageLayout'],
|
||||
'dryRun' => $state['dryRun'],
|
||||
]);
|
||||
|
||||
$summary = sprintf(
|
||||
'%s%d created, %d updated, %d pages, %d images.',
|
||||
$state['dryRun'] ? 'DRY RUN - nothing was written. ' : '',
|
||||
$result['created'],
|
||||
$result['updated'],
|
||||
$result['pages'],
|
||||
$result['images']
|
||||
);
|
||||
if ($result['failed'] !== []) {
|
||||
$summary .= ' Failed: ' . count($result['failed']) . ' (' . implode(', ', array_keys($result['failed'])) . ').';
|
||||
}
|
||||
$this->message(
|
||||
$summary,
|
||||
$result['failed'] !== [] ? ContextualFeedbackSeverity::WARNING
|
||||
: ($state['dryRun'] ? ContextualFeedbackSeverity::INFO : ContextualFeedbackSeverity::OK)
|
||||
);
|
||||
|
||||
return $this->render($request, $state, $result['log']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $state
|
||||
* @param string[] $log
|
||||
*/
|
||||
private function render(ServerRequestInterface $request, array $state, array $log = []): ResponseInterface
|
||||
{
|
||||
$url = (string)$state['source'];
|
||||
$rows = [];
|
||||
$error = null;
|
||||
$sourceKey = '';
|
||||
|
||||
if ($url !== '') {
|
||||
$sourceKey = $this->sourceKey($url);
|
||||
$fetched = $this->client->posts($url);
|
||||
$error = $fetched['error'];
|
||||
if ($error === null) {
|
||||
$rows = $this->importer->rows($fetched['posts'], $this->client->categories($url), $sourceKey);
|
||||
}
|
||||
}
|
||||
|
||||
$imported = 0;
|
||||
foreach ($rows as $row) {
|
||||
if ($row['existingUid'] > 0) {
|
||||
$imported++;
|
||||
}
|
||||
}
|
||||
|
||||
// Select-all lives in a module: the backend CSP blocks inline handlers.
|
||||
$this->pageRenderer->loadJavaScriptModule('@evomedien/vitec/wp-import.js');
|
||||
|
||||
$view = $this->moduleTemplateFactory->create($request);
|
||||
$view->assignMultiple([
|
||||
'presets' => self::PRESETS,
|
||||
'source' => $url,
|
||||
'sourceKey' => $sourceKey,
|
||||
'rows' => $rows,
|
||||
'error' => $error,
|
||||
'total' => count($rows),
|
||||
'imported' => $imported,
|
||||
'categories' => $this->categoryOptions(),
|
||||
'category' => $state['category'],
|
||||
'pid' => $state['pid'] > 0 ? $state['pid'] : $this->detectPid(),
|
||||
'mode' => $state['mode'],
|
||||
'pageParent' => $state['pageParent'],
|
||||
'pageLayout' => $state['pageLayout'],
|
||||
'dryRun' => $state['dryRun'],
|
||||
'log' => $log,
|
||||
]);
|
||||
|
||||
return $view->renderResponse('WordPressImport/Index');
|
||||
}
|
||||
|
||||
/**
|
||||
* A stable, readable key per source: the host and path, reduced to safe
|
||||
* characters. It ends up in `import_source`, so it must not change between
|
||||
* runs - otherwise the second import would not recognise the first.
|
||||
*/
|
||||
private function sourceKey(string $url): string
|
||||
{
|
||||
$api = $this->client->apiBase($url) ?? $url;
|
||||
$host = (string)parse_url($api, PHP_URL_HOST);
|
||||
$path = trim((string)parse_url($api, PHP_URL_PATH), '/');
|
||||
$path = str_replace('wp-json/wp/v2', '', $path);
|
||||
$key = 'wp-' . $host . '-' . $path;
|
||||
$key = strtolower(preg_replace('#[^A-Za-z0-9]+#', '-', $key) ?? $key);
|
||||
return trim($key, '-');
|
||||
}
|
||||
|
||||
/** @return array<int,array{uid:int,title:string}> */
|
||||
private function categoryOptions(): array
|
||||
{
|
||||
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_category');
|
||||
$qb->getRestrictions()->removeAll();
|
||||
$rows = $qb->select('uid', 'title')->from('sys_category')
|
||||
->where($qb->expr()->eq('deleted', 0))
|
||||
->orderBy('title')
|
||||
->executeQuery()->fetchAllAssociative();
|
||||
|
||||
return array_map(
|
||||
static fn(array $row): array => ['uid' => (int)$row['uid'], 'title' => (string)$row['title']],
|
||||
$rows
|
||||
);
|
||||
}
|
||||
|
||||
/** Most-used pid of the news already here, as a sensible default. */
|
||||
private function detectPid(): int
|
||||
{
|
||||
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tx_news_domain_model_news');
|
||||
$qb->getRestrictions()->removeAll();
|
||||
$row = $qb->select('pid')->addSelectLiteral('COUNT(*) AS cnt')->from('tx_news_domain_model_news')
|
||||
->where($qb->expr()->eq('deleted', 0))
|
||||
->groupBy('pid')->orderBy('cnt', 'DESC')->setMaxResults(1)
|
||||
->executeQuery()->fetchAssociative();
|
||||
return (int)($row['pid'] ?? 0);
|
||||
}
|
||||
|
||||
private function message(string $text, ContextualFeedbackSeverity $severity): void
|
||||
{
|
||||
$message = GeneralUtility::makeInstance(FlashMessage::class, $text, '', $severity, true);
|
||||
$this->flashMessageService->getMessageQueueByIdentifier()->addMessage($message);
|
||||
}
|
||||
}
|
||||
123
packages/vitec/Classes/Import/WordPressClient.php
Normal file
123
packages/vitec/Classes/Import/WordPressClient.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\Import;
|
||||
|
||||
use TYPO3\CMS\Core\Http\RequestFactory;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
|
||||
/**
|
||||
* Reads posts and categories from a WordPress site through its REST API.
|
||||
*
|
||||
* The REST route was chosen over direct database access on purpose: the VITEC
|
||||
* blog happens to sit on the same server, but datapath.co.uk does not and its
|
||||
* MySQL is not reachable from here. One code path serves both, and nothing has
|
||||
* to store a database password.
|
||||
*
|
||||
* The trade-off is that `/wp/v2/posts` only exposes published posts. That is
|
||||
* what a content migration wants; drafts would need an application password.
|
||||
*
|
||||
* Read-only throughout - this class never writes to the source.
|
||||
*/
|
||||
final class WordPressClient
|
||||
{
|
||||
/** WordPress caps per_page at 100. */
|
||||
private const PER_PAGE = 100;
|
||||
|
||||
/** Generous: 358 posts with embedded media is a lot of JSON. */
|
||||
private const TIMEOUT = 120;
|
||||
|
||||
/** Guard against a misconfigured source paginating forever. */
|
||||
private const MAX_PAGES = 50;
|
||||
|
||||
/**
|
||||
* All published posts, newest first, with the featured image embedded so the
|
||||
* list does not need one extra request per post.
|
||||
*
|
||||
* @return array{posts:array<int,array<string,mixed>>, 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<int,string>
|
||||
*/
|
||||
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<int,array<string,mixed>>|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;
|
||||
}
|
||||
}
|
||||
}
|
||||
601
packages/vitec/Classes/Import/WordPressImporter.php
Normal file
601
packages/vitec/Classes/Import/WordPressImporter.php
Normal file
@@ -0,0 +1,601 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
|
||||
data-namespace-typo3-fluid="true">
|
||||
|
||||
<f:layout name="Backend/Default" />
|
||||
|
||||
<f:section name="main">
|
||||
|
||||
<div class="module-docheader-bar module-docheader-bar-navigation">
|
||||
<div class="module-docheader-bar-column-left">
|
||||
<h2 class="t3js-title-inlineedit">WordPress Import</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<f:if condition="{error}">
|
||||
<div class="callout callout-danger"><div class="callout-body">{error}</div></div>
|
||||
</f:if>
|
||||
|
||||
<form action="{f:be.uri(route: 'web_vitecwpimport.import')}" method="post">
|
||||
|
||||
<!-- Source -->
|
||||
<div class="card" style="margin-bottom:1rem;">
|
||||
<div class="card-body">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="wpsource">Source (WordPress site)</label>
|
||||
<input type="text" class="form-control" id="wpsource" name="source" value="{source}"
|
||||
list="wppresets" placeholder="https://www.vitec.com/blog" />
|
||||
<datalist id="wppresets">
|
||||
<f:for each="{presets}" as="p"><option value="{p.url}">{p.label}</option></f:for>
|
||||
</datalist>
|
||||
<div class="form-text">
|
||||
Read through the WordPress REST API — only the address is needed, no credentials.
|
||||
<f:if condition="{sourceKey}"><br />Import key: <code>{sourceKey}</code></f:if>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-auto">
|
||||
<button type="submit" name="op" value="load" class="btn btn-default">Load posts</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<f:if condition="{rows}">
|
||||
<!-- Import settings -->
|
||||
<div class="card" style="margin-bottom:1rem;">
|
||||
<div class="card-body">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label" for="wpcategory">Category for the imported posts</label>
|
||||
<select class="form-select" id="wpcategory" name="category">
|
||||
<option value="0">— none —</option>
|
||||
<f:for each="{categories}" as="c">
|
||||
<option value="{c.uid}" {f:if(condition: '{c.uid} == {category}', then: 'selected="selected"')}>{c.title} ({c.uid})</option>
|
||||
</f:for>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label" for="wppid">Storage folder (pid) for the news records</label>
|
||||
<input type="number" class="form-control" id="wppid" name="pid" value="{pid}" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label">Import as</label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="mode" id="modeArticle" value="article"
|
||||
{f:if(condition: '{mode} != \'page\'', then: 'checked="checked"')} />
|
||||
<label class="form-check-label" for="modeArticle">
|
||||
<strong>Article</strong> — body in the news record
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="mode" id="modePage" value="page"
|
||||
{f:if(condition: '{mode} == \'page\'', then: 'checked="checked"')} />
|
||||
<label class="form-check-label" for="modePage">
|
||||
<strong>Page + news</strong> — one page per post, news of type 1 pointing at it
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
The blog is built with Divi and has no block boundaries, so the body arrives as
|
||||
<em>one</em> content element either way. Page mode is a starting point for reworking
|
||||
single posts later, not an automatic layout.
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label" for="wppageparent">Parent page for generated pages</label>
|
||||
<input type="number" class="form-control" id="wppageparent" name="pageParent" value="{pageParent}" />
|
||||
<div class="form-text">Only used in page mode.</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label" for="wppagelayout">Page layout</label>
|
||||
<input type="number" class="form-control" id="wppagelayout" name="pageLayout" value="{pageLayout}" />
|
||||
<div class="form-text">14 = News Detail V2</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col-md-auto">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="dryRun" id="wpdryrun" value="1"
|
||||
{f:if(condition: dryRun, then: 'checked="checked"')} />
|
||||
<label class="form-check-label" for="wpdryrun">
|
||||
<strong>Dry run</strong> — report only, write nothing
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-auto">
|
||||
<button type="submit" name="op" value="import" class="btn btn-primary">Import selected</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<f:if condition="{log}">
|
||||
<div class="card" style="margin-bottom:1rem;">
|
||||
<div class="card-header">What happened</div>
|
||||
<div class="card-body" style="max-height:18rem;overflow:auto;">
|
||||
<f:for each="{log}" as="line"><div><code>{line}</code></div></f:for>
|
||||
</div>
|
||||
</div>
|
||||
</f:if>
|
||||
|
||||
<p>
|
||||
<strong>{total}</strong> posts found,
|
||||
<strong>{imported}</strong> already imported.
|
||||
Ticking one that is already there updates it.
|
||||
</p>
|
||||
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:2rem;"><input type="checkbox" data-vitec-toggle-all="1" title="Select all" /></th>
|
||||
<th>Title</th>
|
||||
<th style="width:7rem;">Date</th>
|
||||
<th>WP categories</th>
|
||||
<th style="width:6rem;">Body</th>
|
||||
<th style="width:5rem;">Images</th>
|
||||
<th style="width:8rem;">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<f:for each="{rows}" as="row">
|
||||
<tr>
|
||||
<td><input type="checkbox" name="posts[]" value="{row.wpId}" data-vitec-post="1" /></td>
|
||||
<td>
|
||||
{row.title}
|
||||
<br /><small class="text-muted">{row.slug}</small>
|
||||
</td>
|
||||
<td>{row.date}</td>
|
||||
<td>{row.categories}</td>
|
||||
<td>{row.bodyLength} chars</td>
|
||||
<td>
|
||||
<f:if condition="{row.image}"><span title="featured image">1</span></f:if>
|
||||
<f:if condition="{row.inlineImages}"> + {row.inlineImages}</f:if>
|
||||
</td>
|
||||
<td>
|
||||
<f:if condition="{row.existingUid}">
|
||||
<f:then><span class="badge bg-info">imported (uid {row.existingUid})</span></f:then>
|
||||
<f:else><span class="badge bg-success">new</span></f:else>
|
||||
</f:if>
|
||||
<f:if condition="{row.rawShortcodes}">
|
||||
<br /><span class="badge bg-warning" title="Divi shortcodes were never rendered - this post needs a look by hand">shortcodes</span>
|
||||
</f:if>
|
||||
</td>
|
||||
</tr>
|
||||
</f:for>
|
||||
</tbody>
|
||||
</table>
|
||||
</f:if>
|
||||
|
||||
</form>
|
||||
|
||||
</f:section>
|
||||
</html>
|
||||
9
packages/vitec/Resources/Public/Icons/vitec-wpimport.svg
Normal file
9
packages/vitec/Resources/Public/Icons/vitec-wpimport.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
|
||||
stroke="#f47937" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="8.5" r="6"/>
|
||||
<path d="M6.8 5.6 9.4 12.2 11 8.1"/>
|
||||
<path d="M12.2 5.1 14.8 12.2 17.2 5.6"/>
|
||||
<path d="M12 15.5v6"/>
|
||||
<path d="m9 18.5 3 3 3-3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 379 B |
45
packages/vitec/Resources/Public/Javascript/wp-import.js
Normal file
45
packages/vitec/Resources/Public/Javascript/wp-import.js
Normal file
@@ -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();
|
||||
}
|
||||
@@ -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.
|
||||
<Files "index.html">
|
||||
Header set Cache-Control "no-store, no-cache, must-revalidate"
|
||||
Header set Pragma "no-cache"
|
||||
Header set Expires "0"
|
||||
</Files>
|
||||
|
||||
# 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.
|
||||
<FilesMatch "\.(js|css|woff2?|ttf|eot|otf)$">
|
||||
Header set Cache-Control "public, max-age=31536000, immutable"
|
||||
</FilesMatch>
|
||||
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.
|
||||
<Files "index.html">
|
||||
Header set Cache-Control "no-store, no-cache, must-revalidate"
|
||||
Header set Pragma "no-cache"
|
||||
Header set Expires "0"
|
||||
</Files>
|
||||
|
||||
# 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.
|
||||
<FilesMatch "\.(js|css|woff2?|ttf|eot|otf)$">
|
||||
Header set Cache-Control "public, max-age=31536000, immutable"
|
||||
</FilesMatch>
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<title>VITEC</title>
|
||||
<script type="module" crossorigin src="/_frontend/assets/index-CEaA2pPG.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/_frontend/assets/index-AGbYYdQ8.css">
|
||||
<script type="module" crossorigin src="/_frontend/assets/index-CA5rSLMo.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/_frontend/assets/index-C7oUhBog.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="page"></div>
|
||||
|
||||
Reference in New Issue
Block a user