Solution group pages: layout 22 + Template-06 workbook import
- new frontend layout 22 "Solution Group Page" (provider LAYOUT_MAP, TCA layout item, locallang_pages.xlf; zones hero=colPos1, main=colPos0 like layout 5 so the front end reuses its components) - new pipeline Import\SolutionGroupPageImporter + vitec:import-solution-group-workbooks (inbox migrations/solutiongroups_xlsx_in/, gitignored): reads the agency "Template 06" group workbooks by column-A label scan, resolves the group via "Hero H1" -> is_group record -> detail_page (pages must exist), cursor-based slotNext() allows repeated CTypes per column - element plan per page: markethero (+#capabilities CTA anchor) + hero intro paragraph; intro + supporting 50/50; challenges header + 4 text_only cards; capabilities header + 4 alternating horizontal cards (bullets as <ul>); usecaselist shipped HIDDEN (an empty selection would list ALL stories - editors pick, then unhide); marketinsights; prefooter ctabanner. Page properties as on the sub-solution pages, 8 curated keyword sets - 56 numbered grey placeholders for pages 459-466 (7 slots per page) - applied to all 8 group pages, live-verified on 459/465; the solutions branch is now content-complete (groups + 39 details) - spec v1.24 (revision row, head/footer) Found on the way: the "Engagament" typo lives in page 465's title AND slug; the record is spelled correctly. Fix needs coordination with the sub-solution workbook metas - own task, not done here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d115ffb144
commit
3e2ebf3823
7 files changed
+659
-3
No files matched your search
@@ -133,3 +133,4 @@ migrations/products_xlsx/*
|
|||||||
# Workbook inboxes: filled customer/agency workbooks live in the Nextcloud
|
# Workbook inboxes: filled customer/agency workbooks live in the Nextcloud
|
||||||
migrations/submarkets_xlsx_in/*
|
migrations/submarkets_xlsx_in/*
|
||||||
migrations/subsolutions_xlsx_in/*
|
migrations/subsolutions_xlsx_in/*
|
||||||
|
migrations/solutiongroups_xlsx_in/*
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Command;
|
||||||
|
|
||||||
|
use Evomedien\Vitec\Import\SolutionGroupPageImporter;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use TYPO3\CMS\Core\Core\Bootstrap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CLI shell around SolutionGroupPageImporter - imports Joe's "Template 06"
|
||||||
|
* group workbooks (Dropbox delivery 2026-10-23) into the eight solution
|
||||||
|
* group hero pages (layout 22). Behavior and lessons live on the importer.
|
||||||
|
*
|
||||||
|
* vendor/bin/typo3 vitec:import-solution-group-workbooks
|
||||||
|
* vendor/bin/typo3 vitec:import-solution-group-workbooks --only=control --apply
|
||||||
|
*/
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'vitec:import-solution-group-workbooks',
|
||||||
|
description: 'Import the agency Template-06 workbooks into the solution group pages (dry-run by default)'
|
||||||
|
)]
|
||||||
|
final class ImportSolutionGroupWorkbooksCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(private readonly SolutionGroupPageImporter $importer)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addOption('dir', null, InputOption::VALUE_REQUIRED, 'Directory with filled workbooks', 'migrations/solutiongroups_xlsx_in');
|
||||||
|
$this->addOption('only', null, InputOption::VALUE_REQUIRED, 'Only files whose name contains this substring');
|
||||||
|
$this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
Bootstrap::initializeBackendAuthentication();
|
||||||
|
$apply = (bool)$input->getOption('apply');
|
||||||
|
$only = mb_strtolower((string)($input->getOption('only') ?? ''));
|
||||||
|
$dir = rtrim((string)$input->getOption('dir'), '/');
|
||||||
|
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
$output->writeln('<error>Directory not found: ' . $dir . '</error>');
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
$files = [];
|
||||||
|
$iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir));
|
||||||
|
foreach ($iterator as $fileInfo) {
|
||||||
|
$name = $fileInfo->getFilename();
|
||||||
|
if ($fileInfo->isFile() && str_ends_with($name, '.xlsx') && !str_starts_with($name, '~$')) {
|
||||||
|
if ($only === '' || str_contains(mb_strtolower($name), $only)) {
|
||||||
|
$files[] = $fileInfo->getPathname();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort($files);
|
||||||
|
if ($files === []) {
|
||||||
|
$output->writeln('No workbooks found in ' . $dir);
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
$output->writeln(count($files) . ' workbook(s) · ' . ($apply ? 'APPLY' : 'DRY-RUN'));
|
||||||
|
|
||||||
|
$errors = 0;
|
||||||
|
foreach ($files as $file) {
|
||||||
|
try {
|
||||||
|
$result = $this->importer->importFile($file, $apply);
|
||||||
|
$output->writeln(sprintf("\n<info>%s</info> (group: %s)", $result['file'], $result['group']));
|
||||||
|
foreach ($result['report'] as $line) {
|
||||||
|
$output->writeln(' <comment>' . $line . '</comment>');
|
||||||
|
}
|
||||||
|
$output->writeln(sprintf(' page #%d · %d element(s) touched · %s',
|
||||||
|
$result['pageUid'], $result['elements'], $apply ? 'written.' : 'dry run'));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$errors++;
|
||||||
|
$output->writeln('<error>' . basename($file) . ': ' . $e->getMessage() . '</error>');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$apply) {
|
||||||
|
$output->writeln("\nDRY RUN - nothing written. Add --apply to write.");
|
||||||
|
}
|
||||||
|
return $errors > 0 ? Command::FAILURE : Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,558 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Import;
|
||||||
|
|
||||||
|
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||||
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||||
|
use TYPO3\CMS\Core\DataHandling\DataHandler;
|
||||||
|
use TYPO3\CMS\Core\Resource\StorageRepository;
|
||||||
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imports ONE agency "Template 06" workbook (Joe's Dropbox delivery,
|
||||||
|
* 2026-10-23) into its solution GROUP page - the eight hero pages below
|
||||||
|
* /solutions that the is_group=1 records point at via detail_page.
|
||||||
|
*
|
||||||
|
* DELIBERATE SIBLING of SubsolutionWorkbookImporter (same decision as
|
||||||
|
* markets vs. solutions, 2026-10-05): pipelines evolve independently.
|
||||||
|
*
|
||||||
|
* The workbook format differs from our generated templates: ONE sheet
|
||||||
|
* "Content" with label rows (column A = component, column B = copy, column
|
||||||
|
* C = editorial guidance we ignore), no Meta sheet, no named ranges. The
|
||||||
|
* group is identified by the "Hero H1" row (fallback: file name).
|
||||||
|
*
|
||||||
|
* Target template is the NEW layout 22 "Solution Group Page" (zones
|
||||||
|
* hero=1, main=0). Element plan, in page order:
|
||||||
|
*
|
||||||
|
* colPos 1: vitec_markethero (subline, CTA -> #capabilities, bg slot 01)
|
||||||
|
* vitec_introparagraph (hero introduction)
|
||||||
|
* colPos 0: vitec_marketintro (Intro H2/body, image slot 02)
|
||||||
|
* vitec_marketintro (Supporting 50/50 H2/copy)
|
||||||
|
* vitec_introparagraph (Common Challenges H2)
|
||||||
|
* vitec_card x4 (challenges, text_only)
|
||||||
|
* vitec_introparagraph (Capabilities H2 + section intro)
|
||||||
|
* vitec_card x4 (capabilities, horizontal alternating,
|
||||||
|
* bullets as <ul>, image slots 03-06)
|
||||||
|
* vitec_usecaselist (testimonials - created HIDDEN and
|
||||||
|
* empty: an empty selection would list
|
||||||
|
* ALL stories; editors pick & unhide)
|
||||||
|
* vitec_marketinsights (insights intro + CTA, cover slot 07)
|
||||||
|
* vitec_ctabanner (prefooter, two CTA labels)
|
||||||
|
*
|
||||||
|
* Shared lessons: content-idempotent file attach (attachFalFile), explicit
|
||||||
|
* tt_content.sorting after the DataHandler run, numbered grey placeholders,
|
||||||
|
* full page-properties set (og/twitter/canonical/abstract/keywords).
|
||||||
|
*/
|
||||||
|
final class SolutionGroupPageImporter
|
||||||
|
{
|
||||||
|
private const LAYOUT = 22;
|
||||||
|
private const COLPOS_HERO = 1;
|
||||||
|
private const COLPOS_MAIN = 0;
|
||||||
|
private const CHALLENGES = 4;
|
||||||
|
private const CAPABILITIES = 4;
|
||||||
|
|
||||||
|
/** Curated page keywords per group (Claude, 2026-10-23). Keys per norm(). */
|
||||||
|
private const PAGE_KEYWORDS = [
|
||||||
|
'controlroomcommandcentresolutions' => 'control room solutions, command centre technology, operations control rooms, video wall control, soc noc solutions',
|
||||||
|
'realtimemonitoringsituationalawareness' => 'real-time monitoring, situational awareness solutions, live video monitoring, operational visibility',
|
||||||
|
'videodistributionstreaming' => 'video distribution, enterprise video streaming, iptv solutions, av over ip, video delivery network',
|
||||||
|
'videocaptureencodingprocessing' => 'video capture, video encoding, video processing, encoders decoders, capture cards',
|
||||||
|
'videowalldatavisualisation' => 'video wall solutions, data visualisation, video wall controllers, multi-display visualisation',
|
||||||
|
'digitalsignagecontentdelivery' => 'digital signage solutions, content delivery, signage networks, public information displays',
|
||||||
|
'enterprisecommunicationsengagement' => 'enterprise communications, corporate iptv, employee engagement displays, internal broadcasting',
|
||||||
|
'securitysurveillancesolutions' => 'security surveillance solutions, cctv integration, security operations centre, surveillance monitoring',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @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 = [];
|
||||||
|
/** @var array<int,string[]> colPos => element ids in page order */
|
||||||
|
private array $sequence = [];
|
||||||
|
/** @var array<string,int[]> "colPos/CType" => existing uids, consumed by cursor */
|
||||||
|
private array $slotCursor = [];
|
||||||
|
private int $newCounter = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{file:string,group:string,pageUid:int,elements:int,applied:bool,report:string[]}
|
||||||
|
*/
|
||||||
|
public function importFile(string $file, bool $apply): array
|
||||||
|
{
|
||||||
|
$this->report = [];
|
||||||
|
$this->datamap = [];
|
||||||
|
$this->cmdmap = [];
|
||||||
|
$this->sequence = [];
|
||||||
|
$this->slotCursor = [];
|
||||||
|
|
||||||
|
$values = $this->readContentSheet($file);
|
||||||
|
$group = trim((string)($values['hero h1'] ?? ''));
|
||||||
|
if ($group === '') {
|
||||||
|
$group = trim((string)preg_replace('/\.xlsx$/i', '', basename($file)));
|
||||||
|
$this->report[] = 'no "Hero H1" row - group taken from the file name';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- resolve the group record and its page -----------------------------
|
||||||
|
$record = $this->groupRecord($group);
|
||||||
|
if ($record === null) {
|
||||||
|
throw new \RuntimeException('No is_group solution record matches "' . $group . '".');
|
||||||
|
}
|
||||||
|
$pageUid = (int)$record['detail_page'];
|
||||||
|
if ($pageUid <= 0 || !$this->pageExists($pageUid)) {
|
||||||
|
throw new \RuntimeException('Group record "' . $group . '" has no (living) detail_page - run vitec:create-solution-groups / fix editorially.');
|
||||||
|
}
|
||||||
|
$pageId = (string)$pageUid;
|
||||||
|
|
||||||
|
$pages = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
|
||||||
|
$pageRow = $pages->fetchAssociative('SELECT layout FROM pages WHERE uid = ?', [$pageUid]);
|
||||||
|
if ((int)($pageRow['layout'] ?? -1) !== self::LAYOUT) {
|
||||||
|
$this->datamap['pages'][$pageId]['layout'] = self::LAYOUT;
|
||||||
|
$this->datamap['pages'][$pageId]['backend_layout'] = 'vitec__' . self::LAYOUT;
|
||||||
|
$this->report[] = 'page: switch layout -> ' . self::LAYOUT . ' (was ' . (int)($pageRow['layout'] ?? 0) . ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SEO / page properties (same set as the sub-solution pages) --------
|
||||||
|
$seoTitle = trim((string)($values['seo title'] ?? ''));
|
||||||
|
$seoDescription = trim((string)($values['seo meta description'] ?? ''));
|
||||||
|
if ($seoTitle !== '') {
|
||||||
|
$this->setFields('pages', $pageId, [
|
||||||
|
'seo_title' => $seoTitle, 'og_title' => $seoTitle, 'twitter_title' => $seoTitle,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if ($seoDescription !== '') {
|
||||||
|
$this->setFields('pages', $pageId, [
|
||||||
|
'description' => $seoDescription, 'og_description' => $seoDescription, 'twitter_description' => $seoDescription,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
$this->datamap['pages'][$pageId]['canonical_link'] = 't3://page?uid=' . $pageId;
|
||||||
|
$abstract = $this->abstractFrom((string)($values['hero introduction'] ?? ''), $seoDescription);
|
||||||
|
if ($abstract !== '') {
|
||||||
|
$this->datamap['pages'][$pageId]['abstract'] = $abstract;
|
||||||
|
}
|
||||||
|
$keywords = self::PAGE_KEYWORDS[$this->norm($group)] ?? '';
|
||||||
|
if ($keywords !== '') {
|
||||||
|
$this->datamap['pages'][$pageId]['keywords'] = $keywords;
|
||||||
|
}
|
||||||
|
$this->report[] = 'page meta: og/twitter + canonical + abstract (' . mb_strlen($abstract) . ' chars) + keywords' . ($keywords === '' ? ' MISSING (no curated set for "' . $group . '")' : '');
|
||||||
|
|
||||||
|
// --- hero zone (colPos 1) ----------------------------------------------
|
||||||
|
$heroId = $this->slotNext($pageUid, self::COLPOS_HERO, 'vitec_markethero');
|
||||||
|
$this->setFields('tt_content', $heroId, [
|
||||||
|
'vitec_subline' => (string)($values['hero supporting headline'] ?? ''),
|
||||||
|
'vitec_cta_label' => (string)($values['hero cta'] ?? ''),
|
||||||
|
// the workbook routes the hero CTA to the capability section
|
||||||
|
'vitec_cta_link' => '#capabilities',
|
||||||
|
]);
|
||||||
|
$this->attachPlaceholder($heroId, 'vitec_bgimage', $apply, $pageUid, 1);
|
||||||
|
|
||||||
|
$heroIntroId = $this->slotNext($pageUid, self::COLPOS_HERO, 'vitec_introparagraph');
|
||||||
|
$this->setFields('tt_content', $heroIntroId, [
|
||||||
|
'bodytext' => $this->p((string)($values['hero introduction'] ?? '')),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// --- main: intro 50/50 + supporting 50/50 -------------------------------
|
||||||
|
$introId = $this->slotNext($pageUid, self::COLPOS_MAIN, 'vitec_marketintro');
|
||||||
|
$this->setFields('tt_content', $introId, [
|
||||||
|
'vitec_intro_headline' => (string)($values['intro h2'] ?? ''),
|
||||||
|
'vitec_intro_text' => $this->p((string)($values['intro body'] ?? '')),
|
||||||
|
'vitec_learnmore_label' => (string)($values['intro secondary cta'] ?? ''),
|
||||||
|
'vitec_show_portfolio' => 0,
|
||||||
|
]);
|
||||||
|
$this->attachPlaceholder($introId, 'vitec_image', $apply, $pageUid, 2);
|
||||||
|
if (trim((string)($values['intro secondary cta'] ?? '')) !== '') {
|
||||||
|
$this->report[] = 'intro CTA "' . trim((string)$values['intro secondary cta']) . '": set the link target editorially (contact page)';
|
||||||
|
}
|
||||||
|
|
||||||
|
$supportId = $this->slotNext($pageUid, self::COLPOS_MAIN, 'vitec_marketintro');
|
||||||
|
$this->setFields('tt_content', $supportId, [
|
||||||
|
'vitec_intro_headline' => (string)($values['supporting 50/50 h2'] ?? ''),
|
||||||
|
'vitec_intro_text' => $this->p((string)($values['supporting 50/50 copy'] ?? '')),
|
||||||
|
'vitec_show_portfolio' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// --- main: common challenges (header + 4 plain cards) -------------------
|
||||||
|
$chHeaderId = $this->slotNext($pageUid, self::COLPOS_MAIN, 'vitec_introparagraph');
|
||||||
|
$this->setFields('tt_content', $chHeaderId, [
|
||||||
|
'header' => (string)($values['common challenges h2'] ?? 'Common Challenges'),
|
||||||
|
]);
|
||||||
|
foreach (range(1, self::CHALLENGES) as $i) {
|
||||||
|
$title = trim((string)($values['challenge ' . $i . ' title'] ?? ''));
|
||||||
|
$copy = trim((string)($values['challenge ' . $i . ' copy'] ?? ''));
|
||||||
|
if ($title === '' && $copy === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$cardId = $this->slotNext($pageUid, self::COLPOS_MAIN, 'vitec_card');
|
||||||
|
$this->setFields('tt_content', $cardId, [
|
||||||
|
'vitec_layout_variant' => 'text_only',
|
||||||
|
'header' => $title,
|
||||||
|
'bodytext' => $this->p($copy),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- main: capabilities (header + 4 alternating 50/50 cards) ------------
|
||||||
|
$capHeaderId = $this->slotNext($pageUid, self::COLPOS_MAIN, 'vitec_introparagraph');
|
||||||
|
$this->setFields('tt_content', $capHeaderId, [
|
||||||
|
'header' => (string)($values['capabilities section h2'] ?? 'Capabilities'),
|
||||||
|
'bodytext' => $this->p((string)($values['capabilities section introduction'] ?? '')),
|
||||||
|
]);
|
||||||
|
foreach (range(1, self::CAPABILITIES) as $i) {
|
||||||
|
$title = trim((string)($values['capability ' . $i . ' h3'] ?? ''));
|
||||||
|
if ($title === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$body = $this->p((string)($values['capability ' . $i . ' body'] ?? ''))
|
||||||
|
. $this->bulletsToUl((string)($values['capability ' . $i . ' bullets'] ?? ''));
|
||||||
|
$capId = $this->slotNext($pageUid, self::COLPOS_MAIN, 'vitec_card');
|
||||||
|
$this->setFields('tt_content', $capId, [
|
||||||
|
'vitec_layout_variant' => ($i % 2 === 1) ? 'horizontal' : 'horizontal_reverse',
|
||||||
|
'header' => $title,
|
||||||
|
'subheader' => (string)($values['capability ' . $i . ' subheading'] ?? ''),
|
||||||
|
'bodytext' => $body,
|
||||||
|
'vitec_primary_cta_label' => (string)($values['capability ' . $i . ' cta'] ?? ''),
|
||||||
|
]);
|
||||||
|
$this->attachPlaceholder($capId, 'vitec_image', $apply, $pageUid, 2 + $i);
|
||||||
|
if (trim((string)($values['capability ' . $i . ' cta'] ?? '')) !== '') {
|
||||||
|
$this->report[] = 'capability ' . $i . ' CTA: route editorially (product/solution detail per workbook guidance)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- main: testimonials (hidden shell - selection is editorial) ---------
|
||||||
|
$testiExisting = $this->slotElements($pageUid, self::COLPOS_MAIN, 'vitec_usecaselist') !== [];
|
||||||
|
$testiId = $this->slotNext($pageUid, self::COLPOS_MAIN, 'vitec_usecaselist');
|
||||||
|
$fields = [
|
||||||
|
'header' => (string)($values['testimonials h2'] ?? 'Testimonials from the Field'),
|
||||||
|
];
|
||||||
|
if (!$testiExisting) {
|
||||||
|
// an EMPTY story selection would render ALL stories - ship the shell
|
||||||
|
// hidden, editors pick the case studies and unhide
|
||||||
|
$fields['hidden'] = 1;
|
||||||
|
$fields['pi_flexform'] = $this->flex([
|
||||||
|
'settings.layout' => 'testimonials',
|
||||||
|
'settings.usecases' => '',
|
||||||
|
'settings.showtoolbar' => '0',
|
||||||
|
]);
|
||||||
|
$this->report[] = 'testimonials: created HIDDEN - pick the case studies editorially, then unhide';
|
||||||
|
}
|
||||||
|
$this->setFields('tt_content', $testiId, $fields);
|
||||||
|
|
||||||
|
// --- main: industry insights ---------------------------------------------
|
||||||
|
$insightsId = $this->slotNext($pageUid, self::COLPOS_MAIN, 'vitec_marketinsights');
|
||||||
|
$this->setFields('tt_content', $insightsId, [
|
||||||
|
'header' => (string)($values['industry insights h2'] ?? 'Industry Insights'),
|
||||||
|
'vitec_text1' => (string)($values['industry insights intro'] ?? ''),
|
||||||
|
'vitec_cta_label' => (string)($values['industry insights cta'] ?? ''),
|
||||||
|
]);
|
||||||
|
$this->attachPlaceholder($insightsId, 'vitec_video_cover', $apply, $pageUid, 7);
|
||||||
|
if (trim((string)($values['industry insights cta'] ?? '')) !== '') {
|
||||||
|
$this->report[] = 'insights CTA: set the link target editorially (insights/news page)';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- main: prefooter CTA banner ------------------------------------------
|
||||||
|
$preId = $this->slotNext($pageUid, self::COLPOS_MAIN, 'vitec_ctabanner');
|
||||||
|
$this->setFields('tt_content', $preId, [
|
||||||
|
'header' => (string)($values['prefooter h2'] ?? ''),
|
||||||
|
'bodytext' => $this->p((string)($values['prefooter body'] ?? '')),
|
||||||
|
'vitec_primary_cta_label' => (string)($values['prefooter primary cta'] ?? ''),
|
||||||
|
'vitec_secondary_cta_label' => (string)($values['prefooter secondary cta'] ?? ''),
|
||||||
|
'vitec_layout_variant' => 'centered',
|
||||||
|
'vitec_background_variant' => 'orange',
|
||||||
|
]);
|
||||||
|
$this->report[] = 'prefooter CTAs: set the link targets editorially (contact / request a demo)';
|
||||||
|
|
||||||
|
$result = [
|
||||||
|
'file' => basename($file),
|
||||||
|
'group' => $group,
|
||||||
|
'pageUid' => $pageUid,
|
||||||
|
'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));
|
||||||
|
}
|
||||||
|
$this->applySequence($dataHandler->substNEWwithIDs);
|
||||||
|
$result['report'] = $this->report;
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- workbook reading ------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Label scan over the single "Content" sheet: column A component label
|
||||||
|
* (lower-cased, trailing asterisks stripped), column B copy. First
|
||||||
|
* occurrence wins; column C (editorial guidance) is ignored.
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function readContentSheet(string $file): array
|
||||||
|
{
|
||||||
|
$spreadsheet = IOFactory::load($file);
|
||||||
|
$content = $spreadsheet->getSheetByName('Content');
|
||||||
|
if ($content === null) {
|
||||||
|
$spreadsheet->disconnectWorksheets();
|
||||||
|
throw new \RuntimeException('No "Content" sheet - not a Template-06 group workbook?');
|
||||||
|
}
|
||||||
|
$values = [];
|
||||||
|
$highestRow = min(200, (int)$content->getHighestRow());
|
||||||
|
for ($rowNum = 1; $rowNum <= $highestRow; $rowNum++) {
|
||||||
|
$label = mb_strtolower(trim((string)$content->getCell('A' . $rowNum)->getValue()));
|
||||||
|
$label = rtrim($label, ' *');
|
||||||
|
if ($label === '' || $label === 'component') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!array_key_exists($label, $values)) {
|
||||||
|
$values[$label] = $content->getCell('B' . $rowNum)->getValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$spreadsheet->disconnectWorksheets();
|
||||||
|
return $values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "• a\n• b" (one cell, hard line breaks) -> <ul><li>a</li>... */
|
||||||
|
private function bulletsToUl(string $cell): string
|
||||||
|
{
|
||||||
|
$items = [];
|
||||||
|
foreach (preg_split('/\r\n|\r|\n/', $cell) ?: [] as $line) {
|
||||||
|
$line = trim((string)preg_replace('/^[\x{2022}\-\*]\s*/u', '', trim($line)));
|
||||||
|
if ($line !== '') {
|
||||||
|
$items[] = '<li>' . htmlspecialchars($line, ENT_QUOTES | ENT_HTML5) . '</li>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $items === [] ? '' : '<ul>' . implode('', $items) . '</ul>';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function p(string $text): string
|
||||||
|
{
|
||||||
|
$text = trim($text);
|
||||||
|
return $text === '' ? '' : '<p>' . htmlspecialchars($text, ENT_QUOTES | ENT_HTML5) . '</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- record / page resolution -----------------------------------------------
|
||||||
|
|
||||||
|
/** @return array<string,mixed>|null the is_group=1 solution record */
|
||||||
|
private function groupRecord(string $title): ?array
|
||||||
|
{
|
||||||
|
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tx_vitec_domain_model_solution');
|
||||||
|
foreach ($connection->fetchAllAssociative(
|
||||||
|
'SELECT * FROM tx_vitec_domain_model_solution WHERE deleted = 0 AND is_group = 1'
|
||||||
|
) as $row) {
|
||||||
|
if ($this->norm((string)$row['title']) === $this->norm($title)) {
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function pageExists(int $uid): bool
|
||||||
|
{
|
||||||
|
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
|
||||||
|
return (bool)$connection->fetchOne('SELECT uid FROM pages WHERE deleted = 0 AND uid = ?', [$uid]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- element plumbing (pattern of the sibling importers) ----------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Existing element for this (colPos, CType) in sorting order, consumed
|
||||||
|
* cursor-wise so a CType may appear several times per column (the two
|
||||||
|
* marketintros, the eight cards); falls back to a NEW element.
|
||||||
|
*/
|
||||||
|
private function slotNext(int $pageUid, int $colPos, string $ctype): string
|
||||||
|
{
|
||||||
|
$key = $colPos . '/' . $ctype;
|
||||||
|
if (!isset($this->slotCursor[$key])) {
|
||||||
|
$this->slotCursor[$key] = $this->slotElements($pageUid, $colPos, $ctype);
|
||||||
|
}
|
||||||
|
$uid = array_shift($this->slotCursor[$key]);
|
||||||
|
if ($uid !== null) {
|
||||||
|
$this->sequence[$colPos][] = (string)$uid;
|
||||||
|
return (string)$uid;
|
||||||
|
}
|
||||||
|
$id = $this->newId();
|
||||||
|
$this->datamap['tt_content'][$id] = [
|
||||||
|
'pid' => $pageUid,
|
||||||
|
'colPos' => $colPos,
|
||||||
|
'CType' => $ctype,
|
||||||
|
];
|
||||||
|
$this->sequence[$colPos][] = $id;
|
||||||
|
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]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes tt_content.sorting in page order (DataHandler puts NEW records
|
||||||
|
* with a positive pid at the TOP of their column - lesson 2026-10-07).
|
||||||
|
*
|
||||||
|
* @param array<string,int> $substNEWwithIDs
|
||||||
|
*/
|
||||||
|
private function applySequence(array $substNEWwithIDs): void
|
||||||
|
{
|
||||||
|
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content');
|
||||||
|
foreach ($this->sequence as $ids) {
|
||||||
|
$sorting = 0;
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
$uid = str_starts_with($id, 'NEW') ? (int)($substNEWwithIDs[$id] ?? 0) : (int)$id;
|
||||||
|
if ($uid > 0) {
|
||||||
|
$sorting += 256;
|
||||||
|
$connection->update('tt_content', ['sorting' => $sorting], ['uid' => $uid]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grey numbered stand-in for an image slot (same rules as the sibling
|
||||||
|
* importers): never over an existing reference; numbered file
|
||||||
|
* placeholders/pages/<pid>-NN.png preferred, generic grey as fallback.
|
||||||
|
*/
|
||||||
|
private function attachPlaceholder(string $elementId, string $fieldName, bool $apply, int $pid, int $slot): void
|
||||||
|
{
|
||||||
|
if (!str_starts_with($elementId, 'NEW') && $this->liveReferences((int)$elementId, $fieldName) !== []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
|
||||||
|
$identifier = null;
|
||||||
|
if ($slot > 0) {
|
||||||
|
$candidate = 'placeholders/pages/' . $pid . '-' . str_pad((string)$slot, 2, '0', STR_PAD_LEFT) . '.png';
|
||||||
|
if ($storage->hasFile($candidate)) {
|
||||||
|
$identifier = $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$identifier ??= 'placeholders/' . ($fieldName === 'vitec_bgimage' ? 'placeholder-grey-wide.png' : 'placeholder-grey.png');
|
||||||
|
if (!$storage->hasFile($identifier)) {
|
||||||
|
$this->report[] = $fieldName . ': empty in the workbook and fileadmin/' . $identifier . ' is missing - left empty';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!$apply) {
|
||||||
|
$this->report[] = $fieldName . ': empty in the workbook - would attach ' . $identifier;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($this->attachFalFile($elementId, $fieldName, $storage->getFile($identifier), $pid) !== 'unchanged') {
|
||||||
|
$this->report[] = $fieldName . ': placeholder ' . basename($identifier) . ' attached - swap in the real asset later';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach one FAL file, re-import-idempotent BY CONTENT (lesson
|
||||||
|
* 2026-10-02): reuse the first live reference, delete surplus duplicates.
|
||||||
|
*/
|
||||||
|
private function attachFalFile(string $elementId, string $fieldName, \TYPO3\CMS\Core\Resource\File $file, int|string $pid): string
|
||||||
|
{
|
||||||
|
if (!str_starts_with($elementId, 'NEW')) {
|
||||||
|
$existingRefs = $this->liveReferences((int)$elementId, $fieldName);
|
||||||
|
if ($existingRefs !== []) {
|
||||||
|
$first = array_shift($existingRefs);
|
||||||
|
$state = 'unchanged';
|
||||||
|
if ((int)$first['uid_local'] !== $file->getUid()) {
|
||||||
|
$this->datamap['sys_file_reference'][(int)$first['uid']] = ['uid_local' => $file->getUid()];
|
||||||
|
$state = 'swapped';
|
||||||
|
}
|
||||||
|
foreach ($existingRefs as $surplus) {
|
||||||
|
$this->cmdmap['sys_file_reference'][(int)$surplus['uid']]['delete'] = 1;
|
||||||
|
$this->report[] = $fieldName . ': removed duplicate reference #' . $surplus['uid'];
|
||||||
|
}
|
||||||
|
return $state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$refId = $this->newId();
|
||||||
|
// pid, tablenames and uid_foreign are REQUIRED - without them
|
||||||
|
// DataHandler silently rejects the reference (lesson 2026-10-02).
|
||||||
|
$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;
|
||||||
|
return 'attached';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @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]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page abstract: tags stripped, whitespace collapsed, word-boundary cut
|
||||||
|
* near 300 characters; SEO description as fallback.
|
||||||
|
*/
|
||||||
|
private function abstractFrom(string $introHtml, string $fallback): string
|
||||||
|
{
|
||||||
|
$plain = trim((string)preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($introHtml), ENT_QUOTES | ENT_HTML5)));
|
||||||
|
if ($plain === '') {
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
if (mb_strlen($plain) <= 300) {
|
||||||
|
return $plain;
|
||||||
|
}
|
||||||
|
$cut = mb_substr($plain, 0, 300);
|
||||||
|
$pos = mb_strrpos($cut, ' ');
|
||||||
|
return rtrim(mb_substr($cut, 0, $pos ?: 300), ' ,;:.') . "\u{2026}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,9 @@ final class BackendLayoutDataProvider implements DataProviderInterface
|
|||||||
19 => ['title' => 'Events Overview', 'zones' => ['hero', 'main']],
|
19 => ['title' => 'Events Overview', 'zones' => ['hero', 'main']],
|
||||||
20 => ['title' => 'Contact Page', 'zones' => ['hero', 'main', 'sidebar']],
|
20 => ['title' => 'Contact Page', 'zones' => ['hero', 'main', 'sidebar']],
|
||||||
21 => ['title' => 'Sub-Market Page', 'zones' => ['smHero', 'smIntro', 'smWorkflow', 'smTestimonials', 'smProducts', 'smInsights']],
|
21 => ['title' => 'Sub-Market Page', 'zones' => ['smHero', 'smIntro', 'smWorkflow', 'smTestimonials', 'smProducts', 'smInsights']],
|
||||||
|
// Solution group hero pages (Joe's Template 06, import 2026-10-23) -
|
||||||
|
// same hero/main zones as layout 5, so the front end reuses parts.
|
||||||
|
22 => ['title' => 'Solution Group Page', 'zones' => ['hero', 'main']],
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Display name for each zone. Shown as column header in the BE page module. */
|
/** Display name for each zone. Shown as column header in the BE page module. */
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ defined('TYPO3') or die();
|
|||||||
['label' => $l . 'layout.19', 'value' => '19'],
|
['label' => $l . 'layout.19', 'value' => '19'],
|
||||||
['label' => $l . 'layout.20', 'value' => '20'],
|
['label' => $l . 'layout.20', 'value' => '20'],
|
||||||
['label' => $l . 'layout.21', 'value' => '21'],
|
['label' => $l . 'layout.21', 'value' => '21'],
|
||||||
|
['label' => $l . 'layout.22', 'value' => '22'],
|
||||||
];
|
];
|
||||||
$GLOBALS['TCA']['pages']['columns']['layout']['config']['default'] = '0';
|
$GLOBALS['TCA']['pages']['columns']['layout']['config']['default'] = '0';
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Document identifier** | EVO‑VITEC‑HL‑001 |
|
| **Document identifier** | EVO‑VITEC‑HL‑001 |
|
||||||
| **Version** | 1.23 |
|
| **Version** | 1.24 |
|
||||||
| **Status** | Released |
|
| **Status** | Released |
|
||||||
| **Date** | 2026‑10‑09 |
|
| **Date** | 2026‑10‑23 |
|
||||||
| **Applies to** | `evomedien/vitec` on TYPO3 v14.3 (headless) |
|
| **Applies to** | `evomedien/vitec` on TYPO3 v14.3 (headless) |
|
||||||
| **Owner** | evomedien — VITEC relaunch |
|
| **Owner** | evomedien — VITEC relaunch |
|
||||||
|
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
| 1.21 | 2026‑10‑07 | **Interface change (additive) and corpus change.** Search results carry `image` (processed 640 px tile image, root-relative, `null` for types without one). Corpus: story documents now include their body (the inline `tt_content` children), page and story content include the text columns of the market/solution Content Blocks and the `vitec_items`/`vitec_faq_items` collection children; solutions are indexed. News rank by publication date (`datetime`; `crdate` is the import date) with an age-decay boost. EXT:solr upgraded from 14.0.0-RC1 to 14.0.2 final (TYPO3 and the Solr image). Editorial: 7.16 now states the `/_json` prefix the URL swap introduced, the `type` vocabulary lists `solution` (delivered since 1.14), and the footer had been stuck at v1.19. |
|
| 1.21 | 2026‑10‑07 | **Interface change (additive) and corpus change.** Search results carry `image` (processed 640 px tile image, root-relative, `null` for types without one). Corpus: story documents now include their body (the inline `tt_content` children), page and story content include the text columns of the market/solution Content Blocks and the `vitec_items`/`vitec_faq_items` collection children; solutions are indexed. News rank by publication date (`datetime`; `crdate` is the import date) with an age-decay boost. EXT:solr upgraded from 14.0.0-RC1 to 14.0.2 final (TYPO3 and the Solr image). Editorial: 7.16 now states the `/_json` prefix the URL swap introduced, the `type` vocabulary lists `solution` (delivered since 1.14), and the footer had been stuck at v1.19. |
|
||||||
| 1.22 | 2026‑10‑09 | **Interface change (additive) and defect fix.** Search cards ("best bets", 7.16): editorial `tx_vitec_domain_model_searchcard` records with keyword lists; the search payload gained `cards` (max. 3, priority-ordered, page 1 only, synonym-aware keyword match; record cards mirror the record’s indexed document, filled card fields override). Defect fix: a request without `q` returned an empty body instead of the documented empty shape (`stdClass` resolved inside the renderer namespace). |
|
| 1.22 | 2026‑10‑09 | **Interface change (additive) and defect fix.** Search cards ("best bets", 7.16): editorial `tx_vitec_domain_model_searchcard` records with keyword lists; the search payload gained `cards` (max. 3, priority-ordered, page 1 only, synonym-aware keyword match; record cards mirror the record’s indexed document, filled card fields override). Defect fix: a request without `q` returned an empty body instead of the documented empty shape (`stdClass` resolved inside the renderer namespace). |
|
||||||
| 1.23 | 2026‑10‑09 | **New interface (additive).** Product type-ahead for the megamenu catalog panel: lean page type `?type=7385&q=` (7.16) returning `suggestions` and up to six product cards via Solr, last term completed as a prefix. The megamenu gained the panel layout `catalog`, filled by the VITEC listener `ProvideProductCatalog` (main categories → subcategory groups → products with one-line teasers). |
|
| 1.23 | 2026‑10‑09 | **New interface (additive).** Product type-ahead for the megamenu catalog panel: lean page type `?type=7385&q=` (7.16) returning `suggestions` and up to six product cards via Solr, last term completed as a prefix. The megamenu gained the panel layout `catalog`, filled by the VITEC listener `ProvideProductCatalog` (main categories → subcategory groups → products with one-line teasers). |
|
||||||
|
| 1.24 | 2026‑10‑23 | **Interface change (additive).** New frontend layout **22 Solution Group Page** for the eight solution group hero pages (zones hero = `colPos1`, main = `colPos0`). Element order: `vitec_markethero` + `vitec_introparagraph` in the hero zone; `vitec_marketintro` 2, `vitec_introparagraph` + 4 `text_only` cards (challenges), `vitec_introparagraph` + 4 alternating `horizontal`/`horizontal_reverse` cards (capabilities, bullets as `<ul>`), `vitec_usecaselist` (testimonials, shipped hidden until stories are picked), `vitec_marketinsights`, `vitec_ctabanner` in main — imported from the agency Template‑06 workbooks by `vitec:import-solution-group-workbooks`; page properties as on the sub‑solution pages. |
|
||||||
|
|
||||||
This document is drafted in the style of, and adopts the terminology conventions of,
|
This document is drafted in the style of, and adopts the terminology conventions of,
|
||||||
ISO/IEC/IEEE 42010 (architecture description), ISO/IEC/IEEE 26514 (information for
|
ISO/IEC/IEEE 42010 (architecture description), ISO/IEC/IEEE 26514 (information for
|
||||||
@@ -1612,4 +1613,4 @@ remediation.
|
|||||||
- Header convention — the uniform header section across CEs, plugins and containers.
|
- Header convention — the uniform header section across CEs, plugins and containers.
|
||||||
- `Configuration/Sets/Vitecset/setup.typoscript` — the single TypoScript entry point.
|
- `Configuration/Sets/Vitecset/setup.typoscript` — the single TypoScript entry point.
|
||||||
|
|
||||||
*End of document EVO‑VITEC‑HL‑001 v1.23.*
|
*End of document EVO‑VITEC‑HL‑001 v1.24.*
|
||||||
@@ -69,6 +69,9 @@
|
|||||||
<trans-unit id="layout.21" resname="layout.21">
|
<trans-unit id="layout.21" resname="layout.21">
|
||||||
<source>Sub-Market Page</source>
|
<source>Sub-Market Page</source>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="layout.22" resname="layout.22">
|
||||||
|
<source>Solution Group Page</source>
|
||||||
|
</trans-unit>
|
||||||
</body>
|
</body>
|
||||||
</file>
|
</file>
|
||||||
</xliff>
|
</xliff>
|
||||||
Reference in new issue
Block a user