Sub-market workbook import as a backend module tab
- Extract the complete import logic (label-scan reading, page find-or-create, slot filling, content-idempotent image attach) from ImportSubmarketWorkbooksCommand into the shared service SubmarketWorkbookImporter; the CLI command and the new controller are thin shells around importFile(), so both entry points cannot drift. CLI regression verified: identical dry-run report for the reference workbook (12 elements, page 443) - New "Sub-Markets" tab in the VITEC Import module (SubmarketImportController + Import/Submarkets.html): upload multiple filled workbooks into the SAME inbox the CLI uses (migrations/submarkets_xlsx_in/), per-file checkboxes, dry-run and apply buttons, result cards with the familiar report and a direct link into the page module; backend dry-run verified against the CLI - Submitted filenames are whitelisted against the actual directory listing (no path traversal); uploads restricted to sanitized .xlsx basenames - Routes submarkets/submarket_upload/submarket_process in Modules.php, Sub-Markets tab link in the sibling module views, ok/danger badge colors in backend-import.css - Docs section 5y (incl. operations note: apply in small batches in the module, mass runs via CLI - image downloads run inside the web request)
This commit is contained in:
1 parent
d496877a53
commit
3d5459b8d2
9 files changed
+980
-585
No files matched your search
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\Controller\Backend;
|
||||
|
||||
use Evomedien\Vitec\Import\SubmarketWorkbookImporter;
|
||||
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-Markets tab of the "VITEC Import" module: upload the customer-filled
|
||||
* sub-market workbooks (Joe's XLSX files), dry-run them with the same report
|
||||
* the CLI prints, then apply per file or all at once.
|
||||
*
|
||||
* Files live in migrations/submarkets_xlsx_in/ - the SAME directory the CLI
|
||||
* uses, so both entry points always see the same inbox. The import itself is
|
||||
* SubmarketWorkbookImporter (shared with vitec:import-submarket-workbooks);
|
||||
* this controller only lists, uploads and renders results.
|
||||
*/
|
||||
#[AsController]
|
||||
final class SubmarketImportController
|
||||
{
|
||||
private const INBOX = 'migrations/submarkets_xlsx_in';
|
||||
|
||||
public function __construct(
|
||||
private readonly ModuleTemplateFactory $moduleTemplateFactory,
|
||||
private readonly SubmarketWorkbookImporter $importer,
|
||||
private readonly FlashMessageService $flashMessageService,
|
||||
private readonly PageRenderer $pageRenderer,
|
||||
private readonly UriBuilder $uriBuilder,
|
||||
) {}
|
||||
|
||||
public function submarketsAction(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
return $this->render($request, []);
|
||||
}
|
||||
|
||||
public function submarketUploadAction(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.submarkets'), 303);
|
||||
}
|
||||
|
||||
public function submarketProcessAction(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/Submarkets');
|
||||
}
|
||||
|
||||
/** @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