Sub-solution workbook pipeline on layout 5, fix element order in both importers
- New, deliberately separate pipeline for sub-solutions (markets and solutions must never break each other): generator vitec:generate-subsolution-workbooks (list from the page tree below /solutions, 39 workbooks, every sub-solution matched to its record), SubsolutionWorkbookImporter + CLI vitec:import-subsolution-workbooks, and a "Sub-Solutions" tab in the VITEC Import module with its own inbox migrations/subsolutions_xlsx_in/ - Targets the existing template 5 "Solution Detail" without changing it: hero element in colPos 1, the remaining sections in fixed order in colPos 0, found again by CType on re-import; existing empty sub-solution pages are switched to layout 5, missing ones created hidden; detail_page set on the solution record - Workbook per the Figma "07 Solution Detail page": hero without video teaser, intro with headline and without learn-more/portfolio, extended icon list, own page-layout reference image - Shared content elements: market hero/intro/insights relabelled "Market/Solution", market intro gains the optional intro_headline field (new column, requires extension:setup) - Fix: DataHandler puts every NEW record with a positive pid at the top of its column, so pages built in one run came out reversed. Both importers now track the page order per colPos and write tt_content.sorting explicitly after the DataHandler run; re-applying repairs pages imported earlier (verified on the control-room sub-solution and sub-market page 443) - migrations/fill_test_subsolution.php: realistic test content from vitec.com copy and images for the control-room sub-solution - Docs section 5z
This commit is contained in:
1 parent
3d5459b8d2
commit
8e67f5db23
84 files changed
+1772
-35
No files matched your search
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\Controller\Backend;
|
||||
|
||||
use Evomedien\Vitec\Import\SubsolutionWorkbookImporter;
|
||||
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\Core\Environment;
|
||||
use TYPO3\CMS\Core\Http\RedirectResponse;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Sub-Solutions tab of the "VITEC Import" module - the deliberate sibling
|
||||
* of SubmarketImportController (decision 2026-10-05: dedicated importers,
|
||||
* markets and solutions must never break each other).
|
||||
*
|
||||
* Files live in migrations/subsolutions_xlsx_in/ - the SAME directory the
|
||||
* CLI vitec:import-subsolution-workbooks uses.
|
||||
*/
|
||||
#[AsController]
|
||||
final class SubsolutionImportController
|
||||
{
|
||||
private const INBOX = 'migrations/subsolutions_xlsx_in';
|
||||
|
||||
public function __construct(
|
||||
private readonly ModuleTemplateFactory $moduleTemplateFactory,
|
||||
private readonly SubsolutionWorkbookImporter $importer,
|
||||
private readonly FlashMessageService $flashMessageService,
|
||||
private readonly PageRenderer $pageRenderer,
|
||||
private readonly UriBuilder $uriBuilder,
|
||||
) {}
|
||||
|
||||
public function subsolutionsAction(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
return $this->render($request, []);
|
||||
}
|
||||
|
||||
public function subsolutionUploadAction(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
$uploads = $request->getUploadedFiles()['workbooks'] ?? [];
|
||||
if (!is_array($uploads)) {
|
||||
$uploads = [$uploads];
|
||||
}
|
||||
$dir = $this->inboxPath();
|
||||
if (!is_dir($dir)) {
|
||||
GeneralUtility::mkdir_deep($dir);
|
||||
}
|
||||
$stored = [];
|
||||
foreach ($uploads as $upload) {
|
||||
if ($upload === null || $upload->getError() !== UPLOAD_ERR_OK) {
|
||||
continue;
|
||||
}
|
||||
$name = $this->safeName((string)$upload->getClientFilename());
|
||||
if ($name === '') {
|
||||
$this->message('Skipped a file that is not an .xlsx workbook.', ContextualFeedbackSeverity::WARNING);
|
||||
continue;
|
||||
}
|
||||
$upload->moveTo($dir . '/' . $name);
|
||||
GeneralUtility::fixPermissions($dir . '/' . $name);
|
||||
$stored[] = $name;
|
||||
}
|
||||
if ($stored !== []) {
|
||||
$this->message(count($stored) . ' workbook(s) stored: ' . implode(', ', $stored), ContextualFeedbackSeverity::OK);
|
||||
} else {
|
||||
$this->message('No workbook uploaded.', ContextualFeedbackSeverity::WARNING);
|
||||
}
|
||||
return new RedirectResponse((string)$this->uriBuilder->buildUriFromRoute('web_vitecimport.subsolutions'), 303);
|
||||
}
|
||||
|
||||
public function subsolutionProcessAction(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
$body = (array)$request->getParsedBody();
|
||||
$apply = (string)($body['op'] ?? 'dry') === 'apply';
|
||||
$selected = array_map('strval', (array)($body['files'] ?? []));
|
||||
if ($selected === []) {
|
||||
$this->message('No workbook selected.', ContextualFeedbackSeverity::WARNING);
|
||||
return $this->render($request, []);
|
||||
}
|
||||
|
||||
$available = array_flip(array_column($this->workbooks(), 'name'));
|
||||
$results = [];
|
||||
foreach ($selected as $name) {
|
||||
// Whitelist against the actual directory listing - the submitted
|
||||
// name never touches the filesystem on its own (no traversal).
|
||||
if (!isset($available[$name])) {
|
||||
$results[] = ['file' => $name, 'error' => 'File not found in the inbox.'];
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$results[] = $this->importer->importFile($this->inboxPath() . '/' . $name, $apply);
|
||||
} catch (\Throwable $e) {
|
||||
$results[] = ['file' => $name, 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
$errors = count(array_filter($results, static fn(array $r): bool => isset($r['error'])));
|
||||
$this->message(
|
||||
sprintf('%d workbook(s) processed (%s)%s.',
|
||||
count($results),
|
||||
$apply ? 'APPLY' : 'dry run',
|
||||
$errors > 0 ? ', ' . $errors . ' with errors' : ''
|
||||
),
|
||||
$errors > 0 ? ContextualFeedbackSeverity::WARNING : ContextualFeedbackSeverity::OK
|
||||
);
|
||||
return $this->render($request, $results);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ internals
|
||||
|
||||
/** @param array<int,array<string,mixed>> $results */
|
||||
private function render(ServerRequestInterface $request, array $results): ResponseInterface
|
||||
{
|
||||
$view = $this->moduleTemplateFactory->create($request);
|
||||
$this->pageRenderer->addCssFile('EXT:vitec/Resources/Public/Css/backend-import.css');
|
||||
$view->assignMultiple([
|
||||
'workbooks' => $this->workbooks(),
|
||||
'inbox' => self::INBOX,
|
||||
'results' => $results,
|
||||
'hasResults' => $results !== [],
|
||||
]);
|
||||
return $view->renderResponse('Import/Subsolutions');
|
||||
}
|
||||
|
||||
/** @return array<int,array{name:string,size:string,modified:string}> */
|
||||
private function workbooks(): array
|
||||
{
|
||||
$dir = $this->inboxPath();
|
||||
if (!is_dir($dir)) {
|
||||
return [];
|
||||
}
|
||||
$rows = [];
|
||||
foreach (scandir($dir) ?: [] as $name) {
|
||||
if (!str_ends_with($name, '.xlsx') || str_starts_with($name, '~$')) {
|
||||
continue;
|
||||
}
|
||||
$path = $dir . '/' . $name;
|
||||
if (!is_file($path)) {
|
||||
continue;
|
||||
}
|
||||
$rows[] = [
|
||||
'name' => $name,
|
||||
'size' => sprintf('%.0f KB', filesize($path) / 1024),
|
||||
'modified' => date('Y-m-d H:i', (int)filemtime($path)),
|
||||
];
|
||||
}
|
||||
usort($rows, static fn(array $a, array $b): int => strcasecmp($a['name'], $b['name']));
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function inboxPath(): string
|
||||
{
|
||||
return Environment::getProjectPath() . '/' . self::INBOX;
|
||||
}
|
||||
|
||||
/** Client filename reduced to a safe basename; '' when it is no .xlsx. */
|
||||
private function safeName(string $clientFilename): string
|
||||
{
|
||||
$name = basename(str_replace('\\', '/', trim($clientFilename)));
|
||||
if (!str_ends_with(mb_strtolower($name), '.xlsx') || str_starts_with($name, '~$') || str_starts_with($name, '.')) {
|
||||
return '';
|
||||
}
|
||||
return (string)preg_replace('/[^\w.\-() ]+/u', '_', $name);
|
||||
}
|
||||
|
||||
private function message(string $text, ContextualFeedbackSeverity $severity): void
|
||||
{
|
||||
$message = GeneralUtility::makeInstance(FlashMessage::class, $text, '', $severity, true);
|
||||
$this->flashMessageService->getMessageQueueByIdentifier()->addMessage($message);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user