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
@@ -4,40 +4,18 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Evomedien\Vitec\Command;
|
namespace Evomedien\Vitec\Command;
|
||||||
|
|
||||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
use Evomedien\Vitec\Import\SubmarketWorkbookImporter;
|
||||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
|
||||||
use Symfony\Component\Console\Attribute\AsCommand;
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
use Symfony\Component\Console\Input\InputOption;
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
use TYPO3\CMS\Core\Core\Bootstrap;
|
use TYPO3\CMS\Core\Core\Bootstrap;
|
||||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
|
||||||
use TYPO3\CMS\Core\DataHandling\DataHandler;
|
|
||||||
use TYPO3\CMS\Core\Resource\Enum\DuplicationBehavior;
|
|
||||||
use TYPO3\CMS\Core\Resource\StorageRepository;
|
|
||||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Imports the customer-filled sub-market workbooks (produced by
|
* CLI shell around SubmarketWorkbookImporter (the shared implementation also
|
||||||
* vitec:generate-submarket-workbooks) into layout-21 pages.
|
* used by the "VITEC Import" backend module since 2026-10-05 - behavior and
|
||||||
*
|
* lessons are documented THERE).
|
||||||
* Reads ONLY named ranges (hero_subline, testi_story_1, ...) plus the hidden
|
|
||||||
* Meta sheet - never cell addresses. Per workbook it
|
|
||||||
*
|
|
||||||
* 1. resolves the main-market page below /markets via the Meta group,
|
|
||||||
* 2. finds or CREATES the sub-market page (layout 21, HIDDEN on first
|
|
||||||
* creation - editorial review publishes it),
|
|
||||||
* 3. fills the six colPos slots (110-160) - existing elements are updated
|
|
||||||
* in place, missing ones created; nothing is deleted,
|
|
||||||
* 4. sets detail_page on the market record (Meta record_uid),
|
|
||||||
* 5. downloads asset URLs to fileadmin/submarket_import/<page>/ and
|
|
||||||
* attaches them (review/crop stays editorial - a failed download is a
|
|
||||||
* warning, never an abort).
|
|
||||||
*
|
|
||||||
* Dry run by default; --apply writes. Idempotent: re-importing an unchanged
|
|
||||||
* workbook updates fields to the same values. Flip-card icons are NOTED but
|
|
||||||
* not attached yet - the icon set is still open with the front-end team.
|
|
||||||
*
|
*
|
||||||
* vendor/bin/typo3 vitec:import-submarket-workbooks --dir=migrations/submarkets_xlsx_in
|
* vendor/bin/typo3 vitec:import-submarket-workbooks --dir=migrations/submarkets_xlsx_in
|
||||||
* vendor/bin/typo3 vitec:import-submarket-workbooks --dir=... --only=broadcast --apply
|
* vendor/bin/typo3 vitec:import-submarket-workbooks --dir=... --only=broadcast --apply
|
||||||
@@ -48,27 +26,10 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
|
|||||||
)]
|
)]
|
||||||
final class ImportSubmarketWorkbooksCommand extends Command
|
final class ImportSubmarketWorkbooksCommand extends Command
|
||||||
{
|
{
|
||||||
private const LAYOUT = 21;
|
public function __construct(private readonly SubmarketWorkbookImporter $importer)
|
||||||
private const COLPOS_HERO = 110;
|
{
|
||||||
private const COLPOS_INTRO = 120;
|
parent::__construct();
|
||||||
private const COLPOS_FLOW = 130;
|
}
|
||||||
private const COLPOS_TESTI = 140;
|
|
||||||
private const COLPOS_PROD = 150;
|
|
||||||
private const COLPOS_INSIGHTS = 160;
|
|
||||||
private const CARD_ROWS = 8;
|
|
||||||
private const STORY_ROWS = 6;
|
|
||||||
private const PRODUCT_ROWS = 9;
|
|
||||||
|
|
||||||
/** Story detail page; mirrors the site setting vitec.storyDetailPid. */
|
|
||||||
private const STORY_SINGLE_PID = 26;
|
|
||||||
|
|
||||||
/** @var string[] */
|
|
||||||
private array $report = [];
|
|
||||||
/** @var array<string,array<string|int,array<string,mixed>>> */
|
|
||||||
private array $datamap = [];
|
|
||||||
/** @var array<string,array<int,array<string,int>>> */
|
|
||||||
private array $cmdmap = [];
|
|
||||||
private int $newCounter = 0;
|
|
||||||
|
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
{
|
{
|
||||||
@@ -107,11 +68,16 @@ final class ImportSubmarketWorkbooksCommand extends Command
|
|||||||
|
|
||||||
$errors = 0;
|
$errors = 0;
|
||||||
foreach ($files as $file) {
|
foreach ($files as $file) {
|
||||||
$this->report = [];
|
|
||||||
$this->datamap = [];
|
|
||||||
$this->cmdmap = [];
|
|
||||||
try {
|
try {
|
||||||
$this->importOne($file, $apply, $output);
|
$result = $this->importer->importFile($file, $apply);
|
||||||
|
$output->writeln(sprintf("\n<info>%s</info> (%s / %s)", $result['file'], $result['group'], $result['submarket']));
|
||||||
|
foreach ($result['report'] as $line) {
|
||||||
|
$output->writeln(' <comment>' . $line . '</comment>');
|
||||||
|
}
|
||||||
|
$output->writeln(sprintf(' page %s · %d element(s) touched · %s',
|
||||||
|
$result['newPage'] && $result['pageUid'] === 0 ? 'NEW' : '#' . $result['pageUid'],
|
||||||
|
$result['elements'],
|
||||||
|
$apply ? 'written.' : 'dry run'));
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$errors++;
|
$errors++;
|
||||||
$output->writeln('<error>' . basename($file) . ': ' . $e->getMessage() . '</error>');
|
$output->writeln('<error>' . basename($file) . ': ' . $e->getMessage() . '</error>');
|
||||||
@@ -122,538 +88,4 @@ final class ImportSubmarketWorkbooksCommand extends Command
|
|||||||
}
|
}
|
||||||
return $errors > 0 ? Command::FAILURE : Command::SUCCESS;
|
return $errors > 0 ? Command::FAILURE : Command::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function importOne(string $file, bool $apply, OutputInterface $output): void
|
|
||||||
{
|
|
||||||
$spreadsheet = IOFactory::load($file);
|
|
||||||
$values = $this->readNamedValues($spreadsheet);
|
|
||||||
$submarket = trim((string)($values['meta_submarket'] ?? ''));
|
|
||||||
$group = trim((string)($values['meta_group'] ?? ''));
|
|
||||||
$recordUid = (int)($values['meta_record_uid'] ?? 0);
|
|
||||||
$spreadsheet->disconnectWorksheets();
|
|
||||||
if ($submarket === '' || $group === '') {
|
|
||||||
throw new \RuntimeException('Meta sheet missing - not a generated sub-market workbook?');
|
|
||||||
}
|
|
||||||
|
|
||||||
$output->writeln(sprintf("\n<info>%s</info> (%s / %s)", basename($file), $group, $submarket));
|
|
||||||
|
|
||||||
// --- resolve pages ---------------------------------------------------
|
|
||||||
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
|
|
||||||
$marketsRoot = $connection->fetchAssociative("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/markets'");
|
|
||||||
if (!$marketsRoot) {
|
|
||||||
throw new \RuntimeException('Page /markets not found.');
|
|
||||||
}
|
|
||||||
$mainPage = $this->childByTitle((int)$marketsRoot['uid'], $group);
|
|
||||||
if ($mainPage === null) {
|
|
||||||
throw new \RuntimeException('Main market page "' . $group . '" not found below /markets.');
|
|
||||||
}
|
|
||||||
$subPage = $this->childByTitle((int)$mainPage['uid'], $submarket);
|
|
||||||
|
|
||||||
$pageId = null;
|
|
||||||
if ($subPage === null) {
|
|
||||||
$pageId = $this->newId();
|
|
||||||
$this->datamap['pages'][$pageId] = [
|
|
||||||
'pid' => (int)$mainPage['uid'],
|
|
||||||
'title' => $submarket,
|
|
||||||
'doktype' => 1,
|
|
||||||
'layout' => self::LAYOUT,
|
|
||||||
'hidden' => 1,
|
|
||||||
'backend_layout' => 'vitec__' . self::LAYOUT,
|
|
||||||
];
|
|
||||||
$this->report[] = 'page: CREATE (hidden, review & publish editorially)';
|
|
||||||
} else {
|
|
||||||
$pageId = (string)(int)$subPage['uid'];
|
|
||||||
$updates = [];
|
|
||||||
if ((int)$subPage['layout'] !== self::LAYOUT) {
|
|
||||||
$updates['layout'] = self::LAYOUT;
|
|
||||||
$updates['backend_layout'] = 'vitec__' . self::LAYOUT;
|
|
||||||
$this->report[] = 'page: switch layout -> 21 (was ' . (int)$subPage['layout'] . ')';
|
|
||||||
}
|
|
||||||
if ($updates !== []) {
|
|
||||||
$this->datamap['pages'][$pageId] = $updates;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- SEO on the page --------------------------------------------------
|
|
||||||
$seoTitle = trim((string)($values['seo_title'] ?? ''));
|
|
||||||
$seoDescription = trim((string)($values['seo_description'] ?? ''));
|
|
||||||
if ($seoTitle !== '') {
|
|
||||||
$this->datamap['pages'][$pageId]['seo_title'] = $seoTitle;
|
|
||||||
}
|
|
||||||
if ($seoDescription !== '') {
|
|
||||||
$this->datamap['pages'][$pageId]['description'] = $seoDescription;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- slot 110: hero ---------------------------------------------------
|
|
||||||
$heroId = $this->slotElement($pageId, $subPage, self::COLPOS_HERO, 'vitec_markethero');
|
|
||||||
$this->setFields('tt_content', $heroId, [
|
|
||||||
'vitec_subline' => (string)($values['hero_subline'] ?? ''),
|
|
||||||
'vitec_cta_label' => (string)($values['hero_cta_label'] ?? ''),
|
|
||||||
'vitec_cta_link' => (string)($values['hero_cta_url'] ?? ''),
|
|
||||||
'vitec_video_title' => (string)($values['hero_video_title'] ?? ''),
|
|
||||||
'vitec_video_url' => (string)($values['hero_video_url'] ?? ''),
|
|
||||||
]);
|
|
||||||
$this->attachUrlFile($heroId, 'vitec_bgimage', (string)($values['hero_bgimage_url'] ?? ''), $submarket, $apply, $this->pidValue($pageId));
|
|
||||||
$this->attachUrlFile($heroId, 'vitec_video_cover', (string)($values['hero_video_cover_url'] ?? ''), $submarket, $apply, $this->pidValue($pageId));
|
|
||||||
|
|
||||||
// --- slot 120: intro ----------------------------------------------------
|
|
||||||
$introId = $this->slotElement($pageId, $subPage, self::COLPOS_INTRO, 'vitec_marketintro');
|
|
||||||
$this->setFields('tt_content', $introId, [
|
|
||||||
'vitec_intro_text' => (string)($values['intro_text'] ?? ''),
|
|
||||||
'vitec_learnmore_label' => (string)($values['intro_learnmore_label'] ?? ''),
|
|
||||||
'vitec_learnmore_target' => (string)($values['intro_learnmore_target'] ?? ''),
|
|
||||||
'vitec_show_portfolio' => strcasecmp(trim((string)($values['intro_portfolio'] ?? 'Show')), 'Hide') === 0 ? 0 : 1,
|
|
||||||
]);
|
|
||||||
$this->attachUrlFile($introId, 'vitec_image', (string)($values['intro_image_url'] ?? ''), $submarket, $apply, $this->pidValue($pageId));
|
|
||||||
|
|
||||||
// --- slot 130: workflow (intro-paragraph + flip cards) ------------------
|
|
||||||
$flowHeaderId = $this->slotElement($pageId, $subPage, self::COLPOS_FLOW, 'vitec_introparagraph');
|
|
||||||
$this->setFields('tt_content', $flowHeaderId, [
|
|
||||||
'header' => (string)($values['flow_headline'] ?? ''),
|
|
||||||
'bodytext' => (string)($values['flow_subtext'] ?? ''),
|
|
||||||
]);
|
|
||||||
$existingCards = $subPage ? $this->slotElements((int)$subPage['uid'], self::COLPOS_FLOW, 'vitec_card') : [];
|
|
||||||
$cardIndex = 0;
|
|
||||||
for ($i = 1; $i <= self::CARD_ROWS; $i++) {
|
|
||||||
$title = trim((string)($values['flow_card' . $i . '_title'] ?? ''));
|
|
||||||
$front = trim((string)($values['flow_card' . $i . '_front'] ?? ''));
|
|
||||||
$back = trim((string)($values['flow_card' . $i . '_back'] ?? ''));
|
|
||||||
$icon = trim((string)($values['flow_card' . $i . '_icon'] ?? ''));
|
|
||||||
if ($title === '' && $front === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$cardId = $existingCards[$cardIndex] ?? $this->newId();
|
|
||||||
if (!isset($existingCards[$cardIndex])) {
|
|
||||||
$this->datamap['tt_content'][$cardId]['pid'] = $this->pidValue($pageId);
|
|
||||||
$this->datamap['tt_content'][$cardId]['colPos'] = self::COLPOS_FLOW;
|
|
||||||
$this->datamap['tt_content'][$cardId]['CType'] = 'vitec_card';
|
|
||||||
}
|
|
||||||
$this->setFields('tt_content', (string)$cardId, [
|
|
||||||
'vitec_layout_variant' => 'flipcard',
|
|
||||||
'header' => $title,
|
|
||||||
'bodytext' => $front,
|
|
||||||
'vitec_back_headline' => $title,
|
|
||||||
'vitec_back_text' => $back,
|
|
||||||
]);
|
|
||||||
if ($icon !== '') {
|
|
||||||
$this->report[] = 'card ' . $i . ': icon "' . $icon . '" noted - icon set still pending, attach later';
|
|
||||||
}
|
|
||||||
$cardIndex++;
|
|
||||||
}
|
|
||||||
if (count($existingCards) > $cardIndex) {
|
|
||||||
$this->report[] = 'workflow: ' . (count($existingCards) - $cardIndex) . ' existing card(s) beyond the workbook - left untouched, review editorially';
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- slot 140: testimonials --------------------------------------------
|
|
||||||
$storyUids = [];
|
|
||||||
foreach (range(1, self::STORY_ROWS) as $i) {
|
|
||||||
$storyTitle = trim((string)($values['testi_story_' . $i] ?? ''));
|
|
||||||
if ($storyTitle === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$story = $this->recordByTitle('tx_vitec_domain_model_usecase', $storyTitle);
|
|
||||||
if ($story === null) {
|
|
||||||
$this->report[] = 'testimonial ' . $i . ': story "' . $storyTitle . '" NOT FOUND - skipped';
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (trim((string)$story['quote']) === '') {
|
|
||||||
$this->report[] = 'testimonial: "' . $storyTitle . '" has NO QUOTE yet - maintain on the story record';
|
|
||||||
}
|
|
||||||
$storyUids[] = (int)$story['uid'];
|
|
||||||
}
|
|
||||||
$testiId = $this->slotElement($pageId, $subPage, self::COLPOS_TESTI, 'vitec_usecaselist');
|
|
||||||
$this->setFields('tt_content', $testiId, [
|
|
||||||
'header' => (string)($values['testi_headline'] ?? ''),
|
|
||||||
'pi_flexform' => $this->flex([
|
|
||||||
'settings.layout' => 'testimonials',
|
|
||||||
'settings.usecases' => implode(',', $storyUids),
|
|
||||||
'settings.singlePid' => (string)self::STORY_SINGLE_PID,
|
|
||||||
'settings.showtoolbar' => '0',
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// --- slot 150: products --------------------------------------------------
|
|
||||||
$featuredTitle = trim((string)($values['prod_featured'] ?? ''));
|
|
||||||
$featuredUid = 0;
|
|
||||||
if ($featuredTitle !== '') {
|
|
||||||
$featured = $this->recordByTitle('tx_vitec_domain_model_product', $featuredTitle);
|
|
||||||
$featured === null
|
|
||||||
? $this->report[] = 'featured product "' . $featuredTitle . '" NOT FOUND'
|
|
||||||
: $featuredUid = (int)$featured['uid'];
|
|
||||||
}
|
|
||||||
$featureId = $this->slotElement($pageId, $subPage, self::COLPOS_PROD, 'vitec_productfeature');
|
|
||||||
$this->setFields('tt_content', $featureId, [
|
|
||||||
'pi_flexform' => $this->flex(['settings.product' => (string)$featuredUid]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$productUids = [];
|
|
||||||
foreach (range(1, self::PRODUCT_ROWS) as $i) {
|
|
||||||
$productTitle = trim((string)($values['prod_card_' . $i] ?? ''));
|
|
||||||
if ($productTitle === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$product = $this->recordByTitle('tx_vitec_domain_model_product', $productTitle);
|
|
||||||
$product === null
|
|
||||||
? $this->report[] = 'product card ' . $i . ': "' . $productTitle . '" NOT FOUND - skipped'
|
|
||||||
: $productUids[] = (int)$product['uid'];
|
|
||||||
}
|
|
||||||
$listId = $this->slotElement($pageId, $subPage, self::COLPOS_PROD, 'vitec_productlist');
|
|
||||||
$this->setFields('tt_content', $listId, [
|
|
||||||
'pi_flexform' => $this->flex([
|
|
||||||
'settings.selectedproducts' => implode(',', $productUids),
|
|
||||||
'settings.layout' => '0',
|
|
||||||
'settings.showtoolbar' => '0',
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// --- slot 160: insights ---------------------------------------------------
|
|
||||||
$insightsId = $this->slotElement($pageId, $subPage, self::COLPOS_INSIGHTS, 'vitec_marketinsights');
|
|
||||||
$this->setFields('tt_content', $insightsId, [
|
|
||||||
'vitec_text1' => (string)($values['insights_text1'] ?? ''),
|
|
||||||
'vitec_text2' => (string)($values['insights_text2'] ?? ''),
|
|
||||||
'vitec_cta_label' => (string)($values['insights_cta_label'] ?? ''),
|
|
||||||
'vitec_cta_target' => (string)($values['insights_cta_target'] ?? ''),
|
|
||||||
'vitec_video_person' => (string)($values['insights_video_person'] ?? ''),
|
|
||||||
'vitec_video_role' => (string)($values['insights_video_role'] ?? ''),
|
|
||||||
'vitec_video_title' => (string)($values['insights_video_title'] ?? ''),
|
|
||||||
'vitec_video_url' => (string)($values['insights_video_url'] ?? ''),
|
|
||||||
]);
|
|
||||||
$this->attachUrlFile($insightsId, 'vitec_video_cover', (string)($values['insights_video_cover_url'] ?? ''), $submarket, $apply, $this->pidValue($pageId));
|
|
||||||
|
|
||||||
// --- market record link -----------------------------------------------------
|
|
||||||
if ($recordUid > 0 && !str_starts_with($pageId, 'NEW')) {
|
|
||||||
$this->datamap['tx_vitec_domain_model_market'][$recordUid]['detail_page'] = (int)$pageId;
|
|
||||||
} elseif ($recordUid > 0) {
|
|
||||||
$this->datamap['tx_vitec_domain_model_market'][$recordUid]['detail_page'] = $pageId;
|
|
||||||
} else {
|
|
||||||
$this->report[] = 'no market record for this sub-market - detail_page not set';
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->report as $line) {
|
|
||||||
$output->writeln(' <comment>' . $line . '</comment>');
|
|
||||||
}
|
|
||||||
$elementCount = count($this->datamap['tt_content'] ?? []);
|
|
||||||
$output->writeln(sprintf(' page %s · %d element(s) touched · %s',
|
|
||||||
str_starts_with($pageId, 'NEW') ? 'NEW' : '#' . $pageId, $elementCount, $apply ? 'writing...' : 'dry run'));
|
|
||||||
|
|
||||||
if (!$apply) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
|
||||||
$dataHandler->start($this->datamap, $this->cmdmap);
|
|
||||||
$dataHandler->process_datamap();
|
|
||||||
if ($this->cmdmap !== []) {
|
|
||||||
$dataHandler->process_cmdmap();
|
|
||||||
}
|
|
||||||
if ($dataHandler->errorLog !== []) {
|
|
||||||
throw new \RuntimeException('DataHandler: ' . implode(' | ', $dataHandler->errorLog));
|
|
||||||
}
|
|
||||||
$output->writeln(' <info>written.</info>');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- helpers -------------------------------------------------------------
|
|
||||||
|
|
||||||
/** @return array<string,mixed> name => first-cell value */
|
|
||||||
private function readNamedValues(Spreadsheet $spreadsheet): array
|
|
||||||
{
|
|
||||||
$values = [];
|
|
||||||
foreach ($spreadsheet->getNamedRanges() as $namedRange) {
|
|
||||||
$name = $namedRange->getName();
|
|
||||||
$sheet = $namedRange->getWorksheet();
|
|
||||||
if ($sheet === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$range = str_replace('$', '', (string)$namedRange->getValue());
|
|
||||||
$range = str_contains($range, '!') ? substr($range, strpos($range, '!') + 1) : $range;
|
|
||||||
$cell = str_contains($range, ':') ? substr($range, 0, strpos($range, ':')) : $range;
|
|
||||||
$values[$name] = $sheet->getCell($cell)->getValue();
|
|
||||||
}
|
|
||||||
if (isset($values['meta_submarket'])) {
|
|
||||||
return $values;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Names are gone - scan by the LOCKED labels in column A instead.
|
|
||||||
// Fixed cell addresses were tried first and failed the same day: the
|
|
||||||
// Excel round-trip that strips the names may ALSO shift rows, so the
|
|
||||||
// only thing left to trust is the label text we generated ourselves.
|
|
||||||
$meta = $spreadsheet->getSheetByName('Meta');
|
|
||||||
$content = $spreadsheet->getSheetByName('Content');
|
|
||||||
if ($meta === null || $content === null) {
|
|
||||||
return $values; // caller reports "Meta sheet missing"
|
|
||||||
}
|
|
||||||
$this->report[] = 'NOTE: defined names were stripped (Excel save) - reading by column-A labels instead';
|
|
||||||
$values['meta_submarket'] = $meta->getCell('B1')->getValue();
|
|
||||||
$values['meta_group'] = $meta->getCell('B2')->getValue();
|
|
||||||
$values['meta_record_uid'] = $meta->getCell('B3')->getValue();
|
|
||||||
|
|
||||||
$section = 0;
|
|
||||||
$inCardTable = false;
|
|
||||||
$cardIndex = 0;
|
|
||||||
$highestRow = min(200, (int)$content->getHighestRow());
|
|
||||||
for ($rowNum = 1; $rowNum <= $highestRow; $rowNum++) {
|
|
||||||
$label = mb_strtolower(trim((string)$content->getCell('A' . $rowNum)->getValue()));
|
|
||||||
if (preg_match('/^section (\d)/', $label, $m)) {
|
|
||||||
$section = (int)$m[1];
|
|
||||||
$inCardTable = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Inside the card table every row is a card until the next
|
|
||||||
// section header - icons are optional, so rows with an empty
|
|
||||||
// column A still count when B-D carry content.
|
|
||||||
if ($inCardTable && $section === 3) {
|
|
||||||
$icon = trim((string)$content->getCell('A' . $rowNum)->getValue());
|
|
||||||
$title = trim((string)$content->getCell('B' . $rowNum)->getValue());
|
|
||||||
$front = trim((string)$content->getCell('C' . $rowNum)->getValue());
|
|
||||||
$back = trim((string)$content->getCell('D' . $rowNum)->getValue());
|
|
||||||
if ($icon === '' && $title === '' && $front === '' && $back === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$cardIndex++;
|
|
||||||
if ($cardIndex <= self::CARD_ROWS) {
|
|
||||||
$values['flow_card' . $cardIndex . '_icon'] = $icon;
|
|
||||||
$values['flow_card' . $cardIndex . '_title'] = $title;
|
|
||||||
$values['flow_card' . $cardIndex . '_front'] = $front;
|
|
||||||
$values['flow_card' . $cardIndex . '_back'] = $back;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($label === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($section === 3 && $label === 'icon') {
|
|
||||||
$inCardTable = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$label = rtrim($label, ' *');
|
|
||||||
$b = $content->getCell('B' . $rowNum)->getValue();
|
|
||||||
$name = match (true) {
|
|
||||||
$label === 'subline' => 'hero_subline',
|
|
||||||
$label === 'cta button label' => 'hero_cta_label',
|
|
||||||
$label === 'cta button link (url)' => 'hero_cta_url',
|
|
||||||
$label === 'background image url' => 'hero_bgimage_url',
|
|
||||||
$label === 'video teaser: title' => 'hero_video_title',
|
|
||||||
$label === 'video teaser: video url' => 'hero_video_url',
|
|
||||||
$label === 'video teaser: cover image url' => 'hero_video_cover_url',
|
|
||||||
$label === 'intro text' => 'intro_text',
|
|
||||||
$label === 'image url (right)' => 'intro_image_url',
|
|
||||||
$label === 'learn-more label' => 'intro_learnmore_label',
|
|
||||||
$label === 'learn-more target' => 'intro_learnmore_target',
|
|
||||||
str_starts_with($label, 'show "product portfolio"') => 'intro_portfolio',
|
|
||||||
$label === 'headline' && $section === 3 => 'flow_headline',
|
|
||||||
$label === 'subtext' => 'flow_subtext',
|
|
||||||
$label === 'headline' && $section === 4 => 'testi_headline',
|
|
||||||
str_starts_with($label, 'case study ') => 'testi_story_' . (int)substr($label, 11),
|
|
||||||
$label === 'featured product' => 'prod_featured',
|
|
||||||
str_starts_with($label, 'product card ') => 'prod_card_' . (int)substr($label, 13),
|
|
||||||
$label === 'paragraph 1' => 'insights_text1',
|
|
||||||
$label === 'paragraph 2' => 'insights_text2',
|
|
||||||
$label === 'cta label' => 'insights_cta_label',
|
|
||||||
$label === 'cta target' => 'insights_cta_target',
|
|
||||||
$label === 'video card: person' => 'insights_video_person',
|
|
||||||
$label === 'video card: role / title' => 'insights_video_role',
|
|
||||||
$label === 'video card: video title' => 'insights_video_title',
|
|
||||||
$label === 'video card: video url' => 'insights_video_url',
|
|
||||||
$label === 'video card: cover image url' => 'insights_video_cover_url',
|
|
||||||
$label === 'seo title' => 'seo_title',
|
|
||||||
$label === 'seo description' => 'seo_description',
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
if ($name !== null && !array_key_exists($name, $values)) {
|
|
||||||
$values[$name] = $b;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $values;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array{uid:int,layout:int}|null */
|
|
||||||
private function childByTitle(int $parentUid, string $title): ?array
|
|
||||||
{
|
|
||||||
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
|
|
||||||
foreach ($connection->fetchAllAssociative(
|
|
||||||
'SELECT uid, title, layout FROM pages WHERE deleted = 0 AND pid = ? AND sys_language_uid IN (0,-1)', [$parentUid]
|
|
||||||
) as $row) {
|
|
||||||
if ($this->norm((string)$row['title']) === $this->norm($title)) {
|
|
||||||
return ['uid' => (int)$row['uid'], 'layout' => (int)$row['layout']];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Existing element uid for a single-element slot, or a NEW id. */
|
|
||||||
private function slotElement(string $pageId, ?array $subPage, int $colPos, string $ctype): string
|
|
||||||
{
|
|
||||||
if ($subPage !== null) {
|
|
||||||
$existing = $this->slotElements((int)$subPage['uid'], $colPos, $ctype);
|
|
||||||
if ($existing !== []) {
|
|
||||||
return (string)$existing[0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$id = $this->newId();
|
|
||||||
$this->datamap['tt_content'][$id] = [
|
|
||||||
'pid' => $this->pidValue($pageId),
|
|
||||||
'colPos' => $colPos,
|
|
||||||
'CType' => $ctype,
|
|
||||||
];
|
|
||||||
return $id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return int[] uids in sorting order */
|
|
||||||
private function slotElements(int $pageUid, int $colPos, string $ctype): array
|
|
||||||
{
|
|
||||||
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content');
|
|
||||||
return array_map('intval', $connection->fetchFirstColumn(
|
|
||||||
'SELECT uid FROM tt_content WHERE deleted = 0 AND pid = ? AND colPos = ? AND CType = ? ORDER BY sorting',
|
|
||||||
[$pageUid, $colPos, $ctype]
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param array<string,mixed> $fields */
|
|
||||||
private function setFields(string $table, string $id, array $fields): void
|
|
||||||
{
|
|
||||||
foreach ($fields as $field => $value) {
|
|
||||||
$this->datamap[$table][$id][$field] = $value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** NEW page ids must stay strings in datamap pid references. */
|
|
||||||
private function pidValue(string $pageId): int|string
|
|
||||||
{
|
|
||||||
return str_starts_with($pageId, 'NEW') ? $pageId : (int)$pageId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array<string,mixed>|null */
|
|
||||||
private function recordByTitle(string $table, string $title): ?array
|
|
||||||
{
|
|
||||||
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table);
|
|
||||||
foreach ($connection->fetchAllAssociative(
|
|
||||||
'SELECT * FROM ' . $table . ' WHERE deleted = 0', []
|
|
||||||
) as $row) {
|
|
||||||
if ($this->norm((string)$row['title']) === $this->norm($title)) {
|
|
||||||
return $row;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Download a URL into fileadmin/submarket_import/<page>/ and attach it as
|
|
||||||
* the element's file field. Editorial review happens in the backend; a
|
|
||||||
* failed download is a warning. Skipped entirely in dry runs.
|
|
||||||
*/
|
|
||||||
private function attachUrlFile(string $elementId, string $fieldName, string $url, string $submarket, bool $apply, int|string $pid = 0): void
|
|
||||||
{
|
|
||||||
$url = trim($url);
|
|
||||||
if ($url === '' || !str_starts_with($url, 'http')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!$apply) {
|
|
||||||
$this->report[] = $fieldName . ': would download ' . $url;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
// RequestFactory instead of GeneralUtility::getUrl(): the latter
|
|
||||||
// swallows the failure reason, which cost a debugging round on
|
|
||||||
// 2026-10-02 (the server could not fetch the URL, the report
|
|
||||||
// could not say why).
|
|
||||||
$response = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Http\RequestFactory::class)
|
|
||||||
->request($url, 'GET', ['timeout' => 20]);
|
|
||||||
if ($response->getStatusCode() !== 200) {
|
|
||||||
$this->report[] = $fieldName . ': DOWNLOAD FAILED (HTTP ' . $response->getStatusCode() . ' for ' . $url . ') - attach editorially';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$content = (string)$response->getBody();
|
|
||||||
if ($content === '') {
|
|
||||||
$this->report[] = $fieldName . ': DOWNLOAD FAILED (empty response from ' . $url . ') - attach editorially';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
|
|
||||||
$folderPath = 'submarket_import/' . mb_strtolower((string)preg_replace('/[^A-Za-z0-9]+/', '-', $submarket));
|
|
||||||
$folder = $storage->hasFolder($folderPath) ? $storage->getFolder($folderPath) : $storage->createFolder($folderPath);
|
|
||||||
$fileName = basename(parse_url($url, PHP_URL_PATH) ?: '') ?: ($fieldName . '.bin');
|
|
||||||
$temp = GeneralUtility::tempnam('submarket_');
|
|
||||||
file_put_contents($temp, $content);
|
|
||||||
$file = $storage->addFile($temp, $folder, $fileName, DuplicationBehavior::REPLACE);
|
|
||||||
|
|
||||||
// Re-import idempotency BY CONTENT (lesson 2026-10-02: passing a
|
|
||||||
// fresh NEW reference every run made DataHandler STACK live
|
|
||||||
// references - two visible images after two runs). An existing
|
|
||||||
// element therefore reuses its first live reference; surplus
|
|
||||||
// live references on the same field - our own earlier duplicates -
|
|
||||||
// are deleted via cmdmap.
|
|
||||||
if (!str_starts_with($elementId, 'NEW')) {
|
|
||||||
$existingRefs = $this->liveReferences((int)$elementId, $fieldName);
|
|
||||||
if ($existingRefs !== []) {
|
|
||||||
$first = array_shift($existingRefs);
|
|
||||||
if ((int)$first['uid_local'] === $file->getUid()) {
|
|
||||||
$this->report[] = $fieldName . ': ' . $fileName . ' already attached - unchanged';
|
|
||||||
} else {
|
|
||||||
$this->datamap['sys_file_reference'][(int)$first['uid']] = ['uid_local' => $file->getUid()];
|
|
||||||
$this->report[] = $fieldName . ': ' . $fileName . ' swapped onto the existing reference';
|
|
||||||
}
|
|
||||||
foreach ($existingRefs as $surplus) {
|
|
||||||
$this->cmdmap['sys_file_reference'][(int)$surplus['uid']]['delete'] = 1;
|
|
||||||
$this->report[] = $fieldName . ': removed duplicate reference #' . $surplus['uid'];
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$refId = $this->newId();
|
|
||||||
// pid, tablenames and uid_foreign are REQUIRED - without them
|
|
||||||
// DataHandler rejects the reference (found 2026-10-02: downloads
|
|
||||||
// succeeded, attachments silently did not).
|
|
||||||
$this->datamap['sys_file_reference'][$refId] = [
|
|
||||||
'pid' => $pid,
|
|
||||||
'table_local' => 'sys_file',
|
|
||||||
'uid_local' => $file->getUid(),
|
|
||||||
'tablenames' => 'tt_content',
|
|
||||||
'fieldname' => $fieldName,
|
|
||||||
'uid_foreign' => str_starts_with($elementId, 'NEW') ? $elementId : (int)$elementId,
|
|
||||||
];
|
|
||||||
$this->datamap['tt_content'][$elementId][$fieldName] = $refId;
|
|
||||||
$this->report[] = $fieldName . ': downloaded ' . $fileName . ' - review editorially';
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$this->report[] = $fieldName . ': DOWNLOAD FAILED (' . $e->getMessage() . ') - attach editorially';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array<int,array{uid:int,uid_local:int}> live refs on one field, oldest first */
|
|
||||||
private function liveReferences(int $elementUid, string $fieldName): array
|
|
||||||
{
|
|
||||||
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_file_reference');
|
|
||||||
return array_map(
|
|
||||||
static fn(array $r): array => ['uid' => (int)$r['uid'], 'uid_local' => (int)$r['uid_local']],
|
|
||||||
$connection->fetchAllAssociative(
|
|
||||||
"SELECT uid, uid_local FROM sys_file_reference
|
|
||||||
WHERE deleted = 0 AND hidden = 0 AND tablenames = 'tt_content'
|
|
||||||
AND fieldname = ? AND uid_foreign = ? ORDER BY uid",
|
|
||||||
[$fieldName, $elementUid]
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DataHandler flexform array for sheet sDEF.
|
|
||||||
*
|
|
||||||
* @param array<string,string> $settings
|
|
||||||
* @return array<string,mixed>
|
|
||||||
*/
|
|
||||||
private function flex(array $settings): array
|
|
||||||
{
|
|
||||||
$fields = [];
|
|
||||||
foreach ($settings as $key => $value) {
|
|
||||||
$fields[$key] = ['vDEF' => $value];
|
|
||||||
}
|
|
||||||
return ['data' => ['sDEF' => ['lDEF' => $fields]]];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function newId(): string
|
|
||||||
{
|
|
||||||
return 'NEW' . str_pad((string)(++$this->newCounter), 6, '0', STR_PAD_LEFT) . substr(md5(uniqid('', true)), 0, 6);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function norm(string $value): string
|
|
||||||
{
|
|
||||||
return (string)preg_replace('/[^a-z0-9]+/', '', str_replace('+', 'plus', mb_strtolower($value)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,612 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Import;
|
||||||
|
|
||||||
|
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||||
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||||
|
use TYPO3\CMS\Core\DataHandling\DataHandler;
|
||||||
|
use TYPO3\CMS\Core\Resource\Enum\DuplicationBehavior;
|
||||||
|
use TYPO3\CMS\Core\Resource\StorageRepository;
|
||||||
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imports ONE customer-filled sub-market workbook (produced by
|
||||||
|
* vitec:generate-submarket-workbooks) into its layout-21 page.
|
||||||
|
*
|
||||||
|
* Extracted 2026-10-05 from ImportSubmarketWorkbooksCommand so the CLI and
|
||||||
|
* the "VITEC Import" backend module share one implementation. All behavior
|
||||||
|
* and the hard-won lessons (label-scan fallback, content-idempotent file
|
||||||
|
* attach, required sys_file_reference fields) live HERE now; the command and
|
||||||
|
* the controller are thin shells around importFile().
|
||||||
|
*
|
||||||
|
* Per workbook it
|
||||||
|
* 1. resolves the main-market page below /markets via the Meta group,
|
||||||
|
* 2. finds or CREATES the sub-market page (layout 21, HIDDEN on first
|
||||||
|
* creation - editorial review publishes it),
|
||||||
|
* 3. fills the six colPos slots (110-160) - existing elements are updated
|
||||||
|
* in place, missing ones created; nothing is deleted,
|
||||||
|
* 4. sets detail_page on the market record (Meta record_uid),
|
||||||
|
* 5. downloads asset URLs to fileadmin/submarket_import/<page>/ and
|
||||||
|
* attaches them (review/crop stays editorial - a failed download is a
|
||||||
|
* warning, never an abort).
|
||||||
|
*
|
||||||
|
* Idempotent: re-importing an unchanged workbook updates fields to the same
|
||||||
|
* values. Flip-card icons are NOTED but not attached yet - the icon set is
|
||||||
|
* still open with the front-end team.
|
||||||
|
*/
|
||||||
|
final class SubmarketWorkbookImporter
|
||||||
|
{
|
||||||
|
private const LAYOUT = 21;
|
||||||
|
private const COLPOS_HERO = 110;
|
||||||
|
private const COLPOS_INTRO = 120;
|
||||||
|
private const COLPOS_FLOW = 130;
|
||||||
|
private const COLPOS_TESTI = 140;
|
||||||
|
private const COLPOS_PROD = 150;
|
||||||
|
private const COLPOS_INSIGHTS = 160;
|
||||||
|
private const CARD_ROWS = 8;
|
||||||
|
private const STORY_ROWS = 6;
|
||||||
|
private const PRODUCT_ROWS = 9;
|
||||||
|
|
||||||
|
/** Story detail page; mirrors the site setting vitec.storyDetailPid. */
|
||||||
|
private const STORY_SINGLE_PID = 26;
|
||||||
|
|
||||||
|
/** @var string[] */
|
||||||
|
private array $report = [];
|
||||||
|
/** @var array<string,array<string|int,array<string,mixed>>> */
|
||||||
|
private array $datamap = [];
|
||||||
|
/** @var array<string,array<int,array<string,int>>> */
|
||||||
|
private array $cmdmap = [];
|
||||||
|
private int $newCounter = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{file:string,group:string,submarket:string,newPage:bool,pageUid:int,elements:int,applied:bool,report:string[]}
|
||||||
|
* @throws \RuntimeException on fatal problems (unreadable, meta missing, main market page missing, DataHandler errors)
|
||||||
|
*/
|
||||||
|
public function importFile(string $file, bool $apply): array
|
||||||
|
{
|
||||||
|
$this->report = [];
|
||||||
|
$this->datamap = [];
|
||||||
|
$this->cmdmap = [];
|
||||||
|
|
||||||
|
$spreadsheet = IOFactory::load($file);
|
||||||
|
$values = $this->readNamedValues($spreadsheet);
|
||||||
|
$submarket = trim((string)($values['meta_submarket'] ?? ''));
|
||||||
|
$group = trim((string)($values['meta_group'] ?? ''));
|
||||||
|
$recordUid = (int)($values['meta_record_uid'] ?? 0);
|
||||||
|
$spreadsheet->disconnectWorksheets();
|
||||||
|
if ($submarket === '' || $group === '') {
|
||||||
|
throw new \RuntimeException('Meta sheet missing - not a generated sub-market workbook?');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- resolve pages ---------------------------------------------------
|
||||||
|
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
|
||||||
|
$marketsRoot = $connection->fetchAssociative("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/markets'");
|
||||||
|
if (!$marketsRoot) {
|
||||||
|
throw new \RuntimeException('Page /markets not found.');
|
||||||
|
}
|
||||||
|
$mainPage = $this->childByTitle((int)$marketsRoot['uid'], $group);
|
||||||
|
if ($mainPage === null) {
|
||||||
|
throw new \RuntimeException('Main market page "' . $group . '" not found below /markets.');
|
||||||
|
}
|
||||||
|
$subPage = $this->childByTitle((int)$mainPage['uid'], $submarket);
|
||||||
|
|
||||||
|
if ($subPage === null) {
|
||||||
|
$pageId = $this->newId();
|
||||||
|
$this->datamap['pages'][$pageId] = [
|
||||||
|
'pid' => (int)$mainPage['uid'],
|
||||||
|
'title' => $submarket,
|
||||||
|
'doktype' => 1,
|
||||||
|
'layout' => self::LAYOUT,
|
||||||
|
'hidden' => 1,
|
||||||
|
'backend_layout' => 'vitec__' . self::LAYOUT,
|
||||||
|
];
|
||||||
|
$this->report[] = 'page: CREATE (hidden, review & publish editorially)';
|
||||||
|
} else {
|
||||||
|
$pageId = (string)(int)$subPage['uid'];
|
||||||
|
$updates = [];
|
||||||
|
if ((int)$subPage['layout'] !== self::LAYOUT) {
|
||||||
|
$updates['layout'] = self::LAYOUT;
|
||||||
|
$updates['backend_layout'] = 'vitec__' . self::LAYOUT;
|
||||||
|
$this->report[] = 'page: switch layout -> 21 (was ' . (int)$subPage['layout'] . ')';
|
||||||
|
}
|
||||||
|
if ($updates !== []) {
|
||||||
|
$this->datamap['pages'][$pageId] = $updates;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SEO on the page --------------------------------------------------
|
||||||
|
$seoTitle = trim((string)($values['seo_title'] ?? ''));
|
||||||
|
$seoDescription = trim((string)($values['seo_description'] ?? ''));
|
||||||
|
if ($seoTitle !== '') {
|
||||||
|
$this->datamap['pages'][$pageId]['seo_title'] = $seoTitle;
|
||||||
|
}
|
||||||
|
if ($seoDescription !== '') {
|
||||||
|
$this->datamap['pages'][$pageId]['description'] = $seoDescription;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- slot 110: hero ---------------------------------------------------
|
||||||
|
$heroId = $this->slotElement($pageId, $subPage, self::COLPOS_HERO, 'vitec_markethero');
|
||||||
|
$this->setFields('tt_content', $heroId, [
|
||||||
|
'vitec_subline' => (string)($values['hero_subline'] ?? ''),
|
||||||
|
'vitec_cta_label' => (string)($values['hero_cta_label'] ?? ''),
|
||||||
|
'vitec_cta_link' => (string)($values['hero_cta_url'] ?? ''),
|
||||||
|
'vitec_video_title' => (string)($values['hero_video_title'] ?? ''),
|
||||||
|
'vitec_video_url' => (string)($values['hero_video_url'] ?? ''),
|
||||||
|
]);
|
||||||
|
$this->attachUrlFile($heroId, 'vitec_bgimage', (string)($values['hero_bgimage_url'] ?? ''), $submarket, $apply, $this->pidValue($pageId));
|
||||||
|
$this->attachUrlFile($heroId, 'vitec_video_cover', (string)($values['hero_video_cover_url'] ?? ''), $submarket, $apply, $this->pidValue($pageId));
|
||||||
|
|
||||||
|
// --- slot 120: intro ----------------------------------------------------
|
||||||
|
$introId = $this->slotElement($pageId, $subPage, self::COLPOS_INTRO, 'vitec_marketintro');
|
||||||
|
$this->setFields('tt_content', $introId, [
|
||||||
|
'vitec_intro_text' => (string)($values['intro_text'] ?? ''),
|
||||||
|
'vitec_learnmore_label' => (string)($values['intro_learnmore_label'] ?? ''),
|
||||||
|
'vitec_learnmore_target' => (string)($values['intro_learnmore_target'] ?? ''),
|
||||||
|
'vitec_show_portfolio' => strcasecmp(trim((string)($values['intro_portfolio'] ?? 'Show')), 'Hide') === 0 ? 0 : 1,
|
||||||
|
]);
|
||||||
|
$this->attachUrlFile($introId, 'vitec_image', (string)($values['intro_image_url'] ?? ''), $submarket, $apply, $this->pidValue($pageId));
|
||||||
|
|
||||||
|
// --- slot 130: workflow (intro-paragraph + flip cards) ------------------
|
||||||
|
$flowHeaderId = $this->slotElement($pageId, $subPage, self::COLPOS_FLOW, 'vitec_introparagraph');
|
||||||
|
$this->setFields('tt_content', $flowHeaderId, [
|
||||||
|
'header' => (string)($values['flow_headline'] ?? ''),
|
||||||
|
'bodytext' => (string)($values['flow_subtext'] ?? ''),
|
||||||
|
]);
|
||||||
|
$existingCards = $subPage ? $this->slotElements((int)$subPage['uid'], self::COLPOS_FLOW, 'vitec_card') : [];
|
||||||
|
$cardIndex = 0;
|
||||||
|
for ($i = 1; $i <= self::CARD_ROWS; $i++) {
|
||||||
|
$title = trim((string)($values['flow_card' . $i . '_title'] ?? ''));
|
||||||
|
$front = trim((string)($values['flow_card' . $i . '_front'] ?? ''));
|
||||||
|
$back = trim((string)($values['flow_card' . $i . '_back'] ?? ''));
|
||||||
|
$icon = trim((string)($values['flow_card' . $i . '_icon'] ?? ''));
|
||||||
|
if ($title === '' && $front === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$cardId = $existingCards[$cardIndex] ?? $this->newId();
|
||||||
|
if (!isset($existingCards[$cardIndex])) {
|
||||||
|
$this->datamap['tt_content'][$cardId]['pid'] = $this->pidValue($pageId);
|
||||||
|
$this->datamap['tt_content'][$cardId]['colPos'] = self::COLPOS_FLOW;
|
||||||
|
$this->datamap['tt_content'][$cardId]['CType'] = 'vitec_card';
|
||||||
|
}
|
||||||
|
$this->setFields('tt_content', (string)$cardId, [
|
||||||
|
'vitec_layout_variant' => 'flipcard',
|
||||||
|
'header' => $title,
|
||||||
|
'bodytext' => $front,
|
||||||
|
'vitec_back_headline' => $title,
|
||||||
|
'vitec_back_text' => $back,
|
||||||
|
]);
|
||||||
|
if ($icon !== '') {
|
||||||
|
$this->report[] = 'card ' . $i . ': icon "' . $icon . '" noted - icon set still pending, attach later';
|
||||||
|
}
|
||||||
|
$cardIndex++;
|
||||||
|
}
|
||||||
|
if (count($existingCards) > $cardIndex) {
|
||||||
|
$this->report[] = 'workflow: ' . (count($existingCards) - $cardIndex) . ' existing card(s) beyond the workbook - left untouched, review editorially';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- slot 140: testimonials --------------------------------------------
|
||||||
|
$storyUids = [];
|
||||||
|
foreach (range(1, self::STORY_ROWS) as $i) {
|
||||||
|
$storyTitle = trim((string)($values['testi_story_' . $i] ?? ''));
|
||||||
|
if ($storyTitle === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$story = $this->recordByTitle('tx_vitec_domain_model_usecase', $storyTitle);
|
||||||
|
if ($story === null) {
|
||||||
|
$this->report[] = 'testimonial ' . $i . ': story "' . $storyTitle . '" NOT FOUND - skipped';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (trim((string)$story['quote']) === '') {
|
||||||
|
$this->report[] = 'testimonial: "' . $storyTitle . '" has NO QUOTE yet - maintain on the story record';
|
||||||
|
}
|
||||||
|
$storyUids[] = (int)$story['uid'];
|
||||||
|
}
|
||||||
|
$testiId = $this->slotElement($pageId, $subPage, self::COLPOS_TESTI, 'vitec_usecaselist');
|
||||||
|
$this->setFields('tt_content', $testiId, [
|
||||||
|
'header' => (string)($values['testi_headline'] ?? ''),
|
||||||
|
'pi_flexform' => $this->flex([
|
||||||
|
'settings.layout' => 'testimonials',
|
||||||
|
'settings.usecases' => implode(',', $storyUids),
|
||||||
|
'settings.singlePid' => (string)self::STORY_SINGLE_PID,
|
||||||
|
'settings.showtoolbar' => '0',
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// --- slot 150: products --------------------------------------------------
|
||||||
|
$featuredTitle = trim((string)($values['prod_featured'] ?? ''));
|
||||||
|
$featuredUid = 0;
|
||||||
|
if ($featuredTitle !== '') {
|
||||||
|
$featured = $this->recordByTitle('tx_vitec_domain_model_product', $featuredTitle);
|
||||||
|
$featured === null
|
||||||
|
? $this->report[] = 'featured product "' . $featuredTitle . '" NOT FOUND'
|
||||||
|
: $featuredUid = (int)$featured['uid'];
|
||||||
|
}
|
||||||
|
$featureId = $this->slotElement($pageId, $subPage, self::COLPOS_PROD, 'vitec_productfeature');
|
||||||
|
$this->setFields('tt_content', $featureId, [
|
||||||
|
'pi_flexform' => $this->flex(['settings.product' => (string)$featuredUid]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$productUids = [];
|
||||||
|
foreach (range(1, self::PRODUCT_ROWS) as $i) {
|
||||||
|
$productTitle = trim((string)($values['prod_card_' . $i] ?? ''));
|
||||||
|
if ($productTitle === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$product = $this->recordByTitle('tx_vitec_domain_model_product', $productTitle);
|
||||||
|
$product === null
|
||||||
|
? $this->report[] = 'product card ' . $i . ': "' . $productTitle . '" NOT FOUND - skipped'
|
||||||
|
: $productUids[] = (int)$product['uid'];
|
||||||
|
}
|
||||||
|
$listId = $this->slotElement($pageId, $subPage, self::COLPOS_PROD, 'vitec_productlist');
|
||||||
|
$this->setFields('tt_content', $listId, [
|
||||||
|
'pi_flexform' => $this->flex([
|
||||||
|
'settings.selectedproducts' => implode(',', $productUids),
|
||||||
|
'settings.layout' => '0',
|
||||||
|
'settings.showtoolbar' => '0',
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// --- slot 160: insights ---------------------------------------------------
|
||||||
|
$insightsId = $this->slotElement($pageId, $subPage, self::COLPOS_INSIGHTS, 'vitec_marketinsights');
|
||||||
|
$this->setFields('tt_content', $insightsId, [
|
||||||
|
'vitec_text1' => (string)($values['insights_text1'] ?? ''),
|
||||||
|
'vitec_text2' => (string)($values['insights_text2'] ?? ''),
|
||||||
|
'vitec_cta_label' => (string)($values['insights_cta_label'] ?? ''),
|
||||||
|
'vitec_cta_target' => (string)($values['insights_cta_target'] ?? ''),
|
||||||
|
'vitec_video_person' => (string)($values['insights_video_person'] ?? ''),
|
||||||
|
'vitec_video_role' => (string)($values['insights_video_role'] ?? ''),
|
||||||
|
'vitec_video_title' => (string)($values['insights_video_title'] ?? ''),
|
||||||
|
'vitec_video_url' => (string)($values['insights_video_url'] ?? ''),
|
||||||
|
]);
|
||||||
|
$this->attachUrlFile($insightsId, 'vitec_video_cover', (string)($values['insights_video_cover_url'] ?? ''), $submarket, $apply, $this->pidValue($pageId));
|
||||||
|
|
||||||
|
// --- market record link -----------------------------------------------------
|
||||||
|
if ($recordUid > 0 && !str_starts_with($pageId, 'NEW')) {
|
||||||
|
$this->datamap['tx_vitec_domain_model_market'][$recordUid]['detail_page'] = (int)$pageId;
|
||||||
|
} elseif ($recordUid > 0) {
|
||||||
|
$this->datamap['tx_vitec_domain_model_market'][$recordUid]['detail_page'] = $pageId;
|
||||||
|
} else {
|
||||||
|
$this->report[] = 'no market record for this sub-market - detail_page not set';
|
||||||
|
}
|
||||||
|
|
||||||
|
$isNewPage = str_starts_with($pageId, 'NEW');
|
||||||
|
$result = [
|
||||||
|
'file' => basename($file),
|
||||||
|
'group' => $group,
|
||||||
|
'submarket' => $submarket,
|
||||||
|
'newPage' => $isNewPage,
|
||||||
|
'pageUid' => $isNewPage ? 0 : (int)$pageId,
|
||||||
|
'elements' => count($this->datamap['tt_content'] ?? []),
|
||||||
|
'applied' => $apply,
|
||||||
|
'report' => $this->report,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!$apply) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||||
|
$dataHandler->start($this->datamap, $this->cmdmap);
|
||||||
|
$dataHandler->process_datamap();
|
||||||
|
if ($this->cmdmap !== []) {
|
||||||
|
$dataHandler->process_cmdmap();
|
||||||
|
}
|
||||||
|
if ($dataHandler->errorLog !== []) {
|
||||||
|
throw new \RuntimeException('DataHandler: ' . implode(' | ', $dataHandler->errorLog));
|
||||||
|
}
|
||||||
|
if ($isNewPage && isset($dataHandler->substNEWwithIDs[$pageId])) {
|
||||||
|
$result['pageUid'] = (int)$dataHandler->substNEWwithIDs[$pageId];
|
||||||
|
}
|
||||||
|
$result['report'] = $this->report;
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers -------------------------------------------------------------
|
||||||
|
|
||||||
|
/** @return array<string,mixed> name => first-cell value */
|
||||||
|
private function readNamedValues(Spreadsheet $spreadsheet): array
|
||||||
|
{
|
||||||
|
$values = [];
|
||||||
|
foreach ($spreadsheet->getNamedRanges() as $namedRange) {
|
||||||
|
$name = $namedRange->getName();
|
||||||
|
$sheet = $namedRange->getWorksheet();
|
||||||
|
if ($sheet === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$range = str_replace('$', '', (string)$namedRange->getValue());
|
||||||
|
$range = str_contains($range, '!') ? substr($range, strpos($range, '!') + 1) : $range;
|
||||||
|
$cell = str_contains($range, ':') ? substr($range, 0, strpos($range, ':')) : $range;
|
||||||
|
$values[$name] = $sheet->getCell($cell)->getValue();
|
||||||
|
}
|
||||||
|
if (isset($values['meta_submarket'])) {
|
||||||
|
return $values;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Names are gone - scan by the LOCKED labels in column A instead.
|
||||||
|
// Fixed cell addresses were tried first and failed the same day: the
|
||||||
|
// Excel round-trip that strips the names may ALSO shift rows, so the
|
||||||
|
// only thing left to trust is the label text we generated ourselves.
|
||||||
|
$meta = $spreadsheet->getSheetByName('Meta');
|
||||||
|
$content = $spreadsheet->getSheetByName('Content');
|
||||||
|
if ($meta === null || $content === null) {
|
||||||
|
return $values; // caller reports "Meta sheet missing"
|
||||||
|
}
|
||||||
|
$this->report[] = 'NOTE: defined names were stripped (Excel save) - reading by column-A labels instead';
|
||||||
|
$values['meta_submarket'] = $meta->getCell('B1')->getValue();
|
||||||
|
$values['meta_group'] = $meta->getCell('B2')->getValue();
|
||||||
|
$values['meta_record_uid'] = $meta->getCell('B3')->getValue();
|
||||||
|
|
||||||
|
$section = 0;
|
||||||
|
$inCardTable = false;
|
||||||
|
$cardIndex = 0;
|
||||||
|
$highestRow = min(200, (int)$content->getHighestRow());
|
||||||
|
for ($rowNum = 1; $rowNum <= $highestRow; $rowNum++) {
|
||||||
|
$label = mb_strtolower(trim((string)$content->getCell('A' . $rowNum)->getValue()));
|
||||||
|
if (preg_match('/^section (\d)/', $label, $m)) {
|
||||||
|
$section = (int)$m[1];
|
||||||
|
$inCardTable = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Inside the card table every row is a card until the next
|
||||||
|
// section header - icons are optional, so rows with an empty
|
||||||
|
// column A still count when B-D carry content.
|
||||||
|
if ($inCardTable && $section === 3) {
|
||||||
|
$icon = trim((string)$content->getCell('A' . $rowNum)->getValue());
|
||||||
|
$title = trim((string)$content->getCell('B' . $rowNum)->getValue());
|
||||||
|
$front = trim((string)$content->getCell('C' . $rowNum)->getValue());
|
||||||
|
$back = trim((string)$content->getCell('D' . $rowNum)->getValue());
|
||||||
|
if ($icon === '' && $title === '' && $front === '' && $back === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$cardIndex++;
|
||||||
|
if ($cardIndex <= self::CARD_ROWS) {
|
||||||
|
$values['flow_card' . $cardIndex . '_icon'] = $icon;
|
||||||
|
$values['flow_card' . $cardIndex . '_title'] = $title;
|
||||||
|
$values['flow_card' . $cardIndex . '_front'] = $front;
|
||||||
|
$values['flow_card' . $cardIndex . '_back'] = $back;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($label === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($section === 3 && $label === 'icon') {
|
||||||
|
$inCardTable = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$label = rtrim($label, ' *');
|
||||||
|
$b = $content->getCell('B' . $rowNum)->getValue();
|
||||||
|
$name = match (true) {
|
||||||
|
$label === 'subline' => 'hero_subline',
|
||||||
|
$label === 'cta button label' => 'hero_cta_label',
|
||||||
|
$label === 'cta button link (url)' => 'hero_cta_url',
|
||||||
|
$label === 'background image url' => 'hero_bgimage_url',
|
||||||
|
$label === 'video teaser: title' => 'hero_video_title',
|
||||||
|
$label === 'video teaser: video url' => 'hero_video_url',
|
||||||
|
$label === 'video teaser: cover image url' => 'hero_video_cover_url',
|
||||||
|
$label === 'intro text' => 'intro_text',
|
||||||
|
$label === 'image url (right)' => 'intro_image_url',
|
||||||
|
$label === 'learn-more label' => 'intro_learnmore_label',
|
||||||
|
$label === 'learn-more target' => 'intro_learnmore_target',
|
||||||
|
str_starts_with($label, 'show "product portfolio"') => 'intro_portfolio',
|
||||||
|
$label === 'headline' && $section === 3 => 'flow_headline',
|
||||||
|
$label === 'subtext' => 'flow_subtext',
|
||||||
|
$label === 'headline' && $section === 4 => 'testi_headline',
|
||||||
|
str_starts_with($label, 'case study ') => 'testi_story_' . (int)substr($label, 11),
|
||||||
|
$label === 'featured product' => 'prod_featured',
|
||||||
|
str_starts_with($label, 'product card ') => 'prod_card_' . (int)substr($label, 13),
|
||||||
|
$label === 'paragraph 1' => 'insights_text1',
|
||||||
|
$label === 'paragraph 2' => 'insights_text2',
|
||||||
|
$label === 'cta label' => 'insights_cta_label',
|
||||||
|
$label === 'cta target' => 'insights_cta_target',
|
||||||
|
$label === 'video card: person' => 'insights_video_person',
|
||||||
|
$label === 'video card: role / title' => 'insights_video_role',
|
||||||
|
$label === 'video card: video title' => 'insights_video_title',
|
||||||
|
$label === 'video card: video url' => 'insights_video_url',
|
||||||
|
$label === 'video card: cover image url' => 'insights_video_cover_url',
|
||||||
|
$label === 'seo title' => 'seo_title',
|
||||||
|
$label === 'seo description' => 'seo_description',
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
if ($name !== null && !array_key_exists($name, $values)) {
|
||||||
|
$values[$name] = $b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{uid:int,layout:int}|null */
|
||||||
|
private function childByTitle(int $parentUid, string $title): ?array
|
||||||
|
{
|
||||||
|
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
|
||||||
|
foreach ($connection->fetchAllAssociative(
|
||||||
|
'SELECT uid, title, layout FROM pages WHERE deleted = 0 AND pid = ? AND sys_language_uid IN (0,-1)', [$parentUid]
|
||||||
|
) as $row) {
|
||||||
|
if ($this->norm((string)$row['title']) === $this->norm($title)) {
|
||||||
|
return ['uid' => (int)$row['uid'], 'layout' => (int)$row['layout']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Existing element uid for a single-element slot, or a NEW id. */
|
||||||
|
private function slotElement(string $pageId, ?array $subPage, int $colPos, string $ctype): string
|
||||||
|
{
|
||||||
|
if ($subPage !== null) {
|
||||||
|
$existing = $this->slotElements((int)$subPage['uid'], $colPos, $ctype);
|
||||||
|
if ($existing !== []) {
|
||||||
|
return (string)$existing[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$id = $this->newId();
|
||||||
|
$this->datamap['tt_content'][$id] = [
|
||||||
|
'pid' => $this->pidValue($pageId),
|
||||||
|
'colPos' => $colPos,
|
||||||
|
'CType' => $ctype,
|
||||||
|
];
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return int[] uids in sorting order */
|
||||||
|
private function slotElements(int $pageUid, int $colPos, string $ctype): array
|
||||||
|
{
|
||||||
|
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content');
|
||||||
|
return array_map('intval', $connection->fetchFirstColumn(
|
||||||
|
'SELECT uid FROM tt_content WHERE deleted = 0 AND pid = ? AND colPos = ? AND CType = ? ORDER BY sorting',
|
||||||
|
[$pageUid, $colPos, $ctype]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string,mixed> $fields */
|
||||||
|
private function setFields(string $table, string $id, array $fields): void
|
||||||
|
{
|
||||||
|
foreach ($fields as $field => $value) {
|
||||||
|
$this->datamap[$table][$id][$field] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** NEW page ids must stay strings in datamap pid references. */
|
||||||
|
private function pidValue(string $pageId): int|string
|
||||||
|
{
|
||||||
|
return str_starts_with($pageId, 'NEW') ? $pageId : (int)$pageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string,mixed>|null */
|
||||||
|
private function recordByTitle(string $table, string $title): ?array
|
||||||
|
{
|
||||||
|
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table);
|
||||||
|
foreach ($connection->fetchAllAssociative(
|
||||||
|
'SELECT * FROM ' . $table . ' WHERE deleted = 0', []
|
||||||
|
) as $row) {
|
||||||
|
if ($this->norm((string)$row['title']) === $this->norm($title)) {
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a URL into fileadmin/submarket_import/<page>/ and attach it as
|
||||||
|
* the element's file field. Editorial review happens in the backend; a
|
||||||
|
* failed download is a warning. Skipped entirely in dry runs.
|
||||||
|
*/
|
||||||
|
private function attachUrlFile(string $elementId, string $fieldName, string $url, string $submarket, bool $apply, int|string $pid = 0): void
|
||||||
|
{
|
||||||
|
$url = trim($url);
|
||||||
|
if ($url === '' || !str_starts_with($url, 'http')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!$apply) {
|
||||||
|
$this->report[] = $fieldName . ': would download ' . $url;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// RequestFactory instead of GeneralUtility::getUrl(): the latter
|
||||||
|
// swallows the failure reason, which cost a debugging round on
|
||||||
|
// 2026-10-02 (the server could not fetch the URL, the report
|
||||||
|
// could not say why).
|
||||||
|
$response = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Http\RequestFactory::class)
|
||||||
|
->request($url, 'GET', ['timeout' => 20]);
|
||||||
|
if ($response->getStatusCode() !== 200) {
|
||||||
|
$this->report[] = $fieldName . ': DOWNLOAD FAILED (HTTP ' . $response->getStatusCode() . ' for ' . $url . ') - attach editorially';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$content = (string)$response->getBody();
|
||||||
|
if ($content === '') {
|
||||||
|
$this->report[] = $fieldName . ': DOWNLOAD FAILED (empty response from ' . $url . ') - attach editorially';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
|
||||||
|
$folderPath = 'submarket_import/' . mb_strtolower((string)preg_replace('/[^A-Za-z0-9]+/', '-', $submarket));
|
||||||
|
$folder = $storage->hasFolder($folderPath) ? $storage->getFolder($folderPath) : $storage->createFolder($folderPath);
|
||||||
|
$fileName = basename(parse_url($url, PHP_URL_PATH) ?: '') ?: ($fieldName . '.bin');
|
||||||
|
$temp = GeneralUtility::tempnam('submarket_');
|
||||||
|
file_put_contents($temp, $content);
|
||||||
|
$file = $storage->addFile($temp, $folder, $fileName, DuplicationBehavior::REPLACE);
|
||||||
|
|
||||||
|
// Re-import idempotency BY CONTENT (lesson 2026-10-02: passing a
|
||||||
|
// fresh NEW reference every run made DataHandler STACK live
|
||||||
|
// references - two visible images after two runs). An existing
|
||||||
|
// element therefore reuses its first live reference; surplus
|
||||||
|
// live references on the same field - our own earlier duplicates -
|
||||||
|
// are deleted via cmdmap.
|
||||||
|
if (!str_starts_with($elementId, 'NEW')) {
|
||||||
|
$existingRefs = $this->liveReferences((int)$elementId, $fieldName);
|
||||||
|
if ($existingRefs !== []) {
|
||||||
|
$first = array_shift($existingRefs);
|
||||||
|
if ((int)$first['uid_local'] === $file->getUid()) {
|
||||||
|
$this->report[] = $fieldName . ': ' . $fileName . ' already attached - unchanged';
|
||||||
|
} else {
|
||||||
|
$this->datamap['sys_file_reference'][(int)$first['uid']] = ['uid_local' => $file->getUid()];
|
||||||
|
$this->report[] = $fieldName . ': ' . $fileName . ' swapped onto the existing reference';
|
||||||
|
}
|
||||||
|
foreach ($existingRefs as $surplus) {
|
||||||
|
$this->cmdmap['sys_file_reference'][(int)$surplus['uid']]['delete'] = 1;
|
||||||
|
$this->report[] = $fieldName . ': removed duplicate reference #' . $surplus['uid'];
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$refId = $this->newId();
|
||||||
|
// pid, tablenames and uid_foreign are REQUIRED - without them
|
||||||
|
// DataHandler rejects the reference (found 2026-10-02: downloads
|
||||||
|
// succeeded, attachments silently did not).
|
||||||
|
$this->datamap['sys_file_reference'][$refId] = [
|
||||||
|
'pid' => $pid,
|
||||||
|
'table_local' => 'sys_file',
|
||||||
|
'uid_local' => $file->getUid(),
|
||||||
|
'tablenames' => 'tt_content',
|
||||||
|
'fieldname' => $fieldName,
|
||||||
|
'uid_foreign' => str_starts_with($elementId, 'NEW') ? $elementId : (int)$elementId,
|
||||||
|
];
|
||||||
|
$this->datamap['tt_content'][$elementId][$fieldName] = $refId;
|
||||||
|
$this->report[] = $fieldName . ': downloaded ' . $fileName . ' - review editorially';
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->report[] = $fieldName . ': DOWNLOAD FAILED (' . $e->getMessage() . ') - attach editorially';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int,array{uid:int,uid_local:int}> live refs on one field, oldest first */
|
||||||
|
private function liveReferences(int $elementUid, string $fieldName): array
|
||||||
|
{
|
||||||
|
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_file_reference');
|
||||||
|
return array_map(
|
||||||
|
static fn(array $r): array => ['uid' => (int)$r['uid'], 'uid_local' => (int)$r['uid_local']],
|
||||||
|
$connection->fetchAllAssociative(
|
||||||
|
"SELECT uid, uid_local FROM sys_file_reference
|
||||||
|
WHERE deleted = 0 AND hidden = 0 AND tablenames = 'tt_content'
|
||||||
|
AND fieldname = ? AND uid_foreign = ? ORDER BY uid",
|
||||||
|
[$fieldName, $elementUid]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DataHandler flexform array for sheet sDEF.
|
||||||
|
*
|
||||||
|
* @param array<string,string> $settings
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function flex(array $settings): array
|
||||||
|
{
|
||||||
|
$fields = [];
|
||||||
|
foreach ($settings as $key => $value) {
|
||||||
|
$fields[$key] = ['vDEF' => $value];
|
||||||
|
}
|
||||||
|
return ['data' => ['sDEF' => ['lDEF' => $fields]]];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function newId(): string
|
||||||
|
{
|
||||||
|
return 'NEW' . str_pad((string)(++$this->newCounter), 6, '0', STR_PAD_LEFT) . substr(md5(uniqid('', true)), 0, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function norm(string $value): string
|
||||||
|
{
|
||||||
|
return (string)preg_replace('/[^a-z0-9]+/', '', str_replace('+', 'plus', mb_strtolower($value)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
use Evomedien\Vitec\Controller\Backend\ImportController;
|
use Evomedien\Vitec\Controller\Backend\ImportController;
|
||||||
use Evomedien\Vitec\Controller\Backend\ProductTextImportController;
|
use Evomedien\Vitec\Controller\Backend\ProductTextImportController;
|
||||||
|
use Evomedien\Vitec\Controller\Backend\SubmarketImportController;
|
||||||
use Evomedien\Vitec\Controller\Backend\WordPressImportController;
|
use Evomedien\Vitec\Controller\Backend\WordPressImportController;
|
||||||
use Evomedien\Vitec\Controller\OgImageController;
|
use Evomedien\Vitec\Controller\OgImageController;
|
||||||
|
|
||||||
@@ -53,6 +54,17 @@ return [
|
|||||||
'target' => ProductTextImportController::class . '::productApplyAction',
|
'target' => ProductTextImportController::class . '::productApplyAction',
|
||||||
'methods' => ['POST'],
|
'methods' => ['POST'],
|
||||||
],
|
],
|
||||||
|
'submarkets' => [
|
||||||
|
'target' => SubmarketImportController::class . '::submarketsAction',
|
||||||
|
],
|
||||||
|
'submarket_upload' => [
|
||||||
|
'target' => SubmarketImportController::class . '::submarketUploadAction',
|
||||||
|
'methods' => ['POST'],
|
||||||
|
],
|
||||||
|
'submarket_process' => [
|
||||||
|
'target' => SubmarketImportController::class . '::submarketProcessAction',
|
||||||
|
'methods' => ['POST'],
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
'web_vitecwpimport' => [
|
'web_vitecwpimport' => [
|
||||||
|
|||||||
@@ -26,6 +26,9 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.products')}">Products</a>
|
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.products')}">Products</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.submarkets')}">Sub-Markets</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.seo')}">SEO Research</a>
|
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.seo')}">SEO Research</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -23,6 +23,9 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link active" href="{f:be.uri(route: 'web_vitecimport.products')}">Products</a>
|
<a class="nav-link active" href="{f:be.uri(route: 'web_vitecimport.products')}">Products</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.submarkets')}">Sub-Markets</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.seo')}">SEO Research</a>
|
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.seo')}">SEO Research</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -18,6 +18,9 @@
|
|||||||
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport', parameters: {model: m.key})}">{m.label}</a>
|
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport', parameters: {model: m.key})}">{m.label}</a>
|
||||||
</li>
|
</li>
|
||||||
</f:for>
|
</f:for>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.submarkets')}">Sub-Markets</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link active" href="{f:be.uri(route: 'web_vitecimport.seo')}">SEO Research</a>
|
<a class="nav-link active" href="{f:be.uri(route: 'web_vitecimport.seo')}">SEO Research</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<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">VITEC Import — Sub-Markets</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="nav nav-tabs" style="margin-bottom:1rem;">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport')}">CSV Import</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.products')}">Products</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" href="{f:be.uri(route: 'web_vitecimport.submarkets')}">Sub-Markets</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.seo')}">SEO Research</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Upload: drops Joe's filled workbooks into the shared inbox directory -->
|
||||||
|
<div class="card" style="margin-bottom:1rem;">
|
||||||
|
<div class="card-header"><strong>Upload filled workbooks</strong></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form action="{f:be.uri(route: 'web_vitecimport.submarket_upload')}" method="post" enctype="multipart/form-data" class="row row-cols-auto align-items-center g-2">
|
||||||
|
<div class="col">
|
||||||
|
<input type="file" name="workbooks[]" accept=".xlsx" multiple="multiple" class="form-control" required="required" />
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<button type="submit" class="btn btn-primary">Upload</button>
|
||||||
|
</div>
|
||||||
|
<div class="col form-text">
|
||||||
|
Multiple files allowed. A file with the same name replaces the stored one ({inbox}/).
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Inbox + dry-run / apply -->
|
||||||
|
<div class="card" style="margin-bottom:1rem;">
|
||||||
|
<div class="card-header"><strong>Workbooks in the inbox</strong></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<f:if condition="{workbooks}">
|
||||||
|
<f:then>
|
||||||
|
<form action="{f:be.uri(route: 'web_vitecimport.submarket_process')}" method="post">
|
||||||
|
<table class="table table-striped table-sm" style="max-width:900px;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:30px;"></th>
|
||||||
|
<th>File</th>
|
||||||
|
<th>Size</th>
|
||||||
|
<th>Uploaded / changed</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<f:for each="{workbooks}" as="w">
|
||||||
|
<tr>
|
||||||
|
<td><input type="checkbox" name="files[]" value="{w.name}" checked="checked" /></td>
|
||||||
|
<td>{w.name}</td>
|
||||||
|
<td>{w.size}</td>
|
||||||
|
<td>{w.modified}</td>
|
||||||
|
</tr>
|
||||||
|
</f:for>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<button type="submit" name="op" value="dry" class="btn btn-default">Dry run (no changes)</button>
|
||||||
|
<button type="submit" name="op" value="apply" class="btn btn-warning">Apply — write pages</button>
|
||||||
|
<span class="form-text" style="display:inline-block;margin-left:.75rem;">
|
||||||
|
Always dry-run first. Apply updates existing elements in place, creates missing ones, never deletes.
|
||||||
|
New pages are created HIDDEN for editorial review.
|
||||||
|
</span>
|
||||||
|
</form>
|
||||||
|
</f:then>
|
||||||
|
<f:else>
|
||||||
|
<p>No workbooks yet — upload Joe's filled .xlsx files above.</p>
|
||||||
|
</f:else>
|
||||||
|
</f:if>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results -->
|
||||||
|
<f:if condition="{hasResults}">
|
||||||
|
<f:for each="{results}" as="r">
|
||||||
|
<div class="card" style="margin-bottom:1rem;">
|
||||||
|
<div class="card-header">
|
||||||
|
<strong>{r.file}</strong>
|
||||||
|
<f:if condition="{r.error}">
|
||||||
|
<f:then>
|
||||||
|
<span class="vitec-badge vitec-badge-danger">ERROR</span>
|
||||||
|
</f:then>
|
||||||
|
<f:else>
|
||||||
|
— {r.group} / {r.submarket}
|
||||||
|
<f:if condition="{r.applied}">
|
||||||
|
<f:then><span class="vitec-badge vitec-badge-ok">written</span></f:then>
|
||||||
|
<f:else><span class="vitec-badge vitec-badge-info">dry run</span></f:else>
|
||||||
|
</f:if>
|
||||||
|
</f:else>
|
||||||
|
</f:if>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<f:if condition="{r.error}">
|
||||||
|
<f:then>
|
||||||
|
<p class="text-danger">{r.error}</p>
|
||||||
|
</f:then>
|
||||||
|
<f:else>
|
||||||
|
<p>
|
||||||
|
Page:
|
||||||
|
<f:if condition="{r.pageUid}">
|
||||||
|
<f:then>
|
||||||
|
<a href="{f:be.uri(route: 'web_layout', parameters: {id: r.pageUid})}">#{r.pageUid} — open in page module</a>
|
||||||
|
</f:then>
|
||||||
|
<f:else>
|
||||||
|
NEW (would be created hidden)
|
||||||
|
</f:else>
|
||||||
|
</f:if>
|
||||||
|
· {r.elements} element(s) touched
|
||||||
|
</p>
|
||||||
|
<f:if condition="{r.report}">
|
||||||
|
<ul style="margin-bottom:0;">
|
||||||
|
<f:for each="{r.report}" as="line">
|
||||||
|
<li>{line}</li>
|
||||||
|
</f:for>
|
||||||
|
</ul>
|
||||||
|
</f:if>
|
||||||
|
</f:else>
|
||||||
|
</f:if>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</f:for>
|
||||||
|
</f:if>
|
||||||
|
|
||||||
|
</f:section>
|
||||||
|
</html>
|
||||||
@@ -95,6 +95,14 @@
|
|||||||
background-color: #313131;
|
background-color: #313131;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
.vitec-administration .vitec-badge-ok {
|
||||||
|
background-color: #2f9e44;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.vitec-administration .vitec-badge-danger {
|
||||||
|
background-color: #c0392b;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- form focus (frontend uses a 3px orange outline) ---------------- */
|
/* --- form focus (frontend uses a 3px orange outline) ---------------- */
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user