- 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)
182 lines
7.1 KiB
PHP
182 lines
7.1 KiB
PHP
<?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);
|
|
}
|
|
}
|