Produkt-Uebersichtsseiten: Agentur-Texte aus den "Enriched"-Workbooks importiert
- neues CLI vitec:import-product-overview-workbooks (Dropbox-Lieferung 09.11., 15 Workbooks): Hero-Subline/CTA, Intro + Use Case, Why-VITEC-Band (Text- karten bzw. Text auf passende Icon-Karten), Range-Intro, Solution-Karten mit Link auf die Gruppenseite, CTA-Banner, SEO-Meta; Produktkarten bleiben (Record-Teaser), Vergleichstabelle nur mit --comparison - vitec:seed-product-features --attach-icons: gestagte Icons an vorhandene Karten ohne Icon haengen (PRISM 5/5, SFF PRISM 4/5 angehaengt) - Diagnose-Skripte check_overview_pages_20261110 / check_banners_20261110 - .gitignore: migrations/product_overviews_xlsx_in, product_images_pending Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
2aa8865b23
commit
f4f18e913a
5 files changed
+896
-46
No files matched your search
@@ -0,0 +1,681 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\Command;
|
||||
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
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;
|
||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||
use TYPO3\CMS\Core\DataHandling\DataHandler;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
|
||||
/**
|
||||
* Fills the product OVERVIEW pages (/products/<slug>, layout 9, built by
|
||||
* vitec:build-product-overviews on 2026-11-02) with the agency's editorial
|
||||
* copy from the "<Overview> - Enriched.xlsx" workbooks (Dropbox delivery
|
||||
* 2026-11-09, staged in migrations/product_overviews_xlsx_in/).
|
||||
*
|
||||
* Workbook shape: sheet "Content" with rows "Section | Content field |
|
||||
* Website copy / value | Production notes", sections 01 HERO, 02 INTRO,
|
||||
* 03 WHY VITEC, 04 COMPARISON, 05 PRODUCT RANGE, 06 SOLUTIONS, 07 CTA,
|
||||
* 08 SEO. The page is found by its title (= the workbook's H1).
|
||||
*
|
||||
* The pages already carry generated content (hero, optional "Why VITEC?"
|
||||
* icon band, "Product range" intro, product card grids, contact banner).
|
||||
* This importer UPDATES in place and only ADDS what is missing:
|
||||
*
|
||||
* 01 HERO -> hero bodytext (subline) + CTA label; title stays
|
||||
* 02 INTRO -> NEW introparagraph (H2 + introduction) at the top of
|
||||
* the main column, plus the use-case paragraph
|
||||
* 03 WHY VITEC -> page with icon band: benefit copy goes onto the card
|
||||
* whose title matches the benefit heading (theme match
|
||||
* like vitec:build-why-vitec-bands), unmatched benefits
|
||||
* are REPORTED only (the band is the agency's icon set);
|
||||
* page without band: NEW band of text_only cards
|
||||
* 04 COMPARISON -> skipped unless --comparison: the workbook holds no
|
||||
* specifications ("populate from approved datasheets"),
|
||||
* only the card descriptions again
|
||||
* 05 PRODUCT RANGE -> the "Product range" intro gets the H2 + intro copy;
|
||||
* product CARDS stay untouched (they show the record
|
||||
* teasers from the product workbooks - the overview
|
||||
* workbook texts are mostly boilerplate)
|
||||
* 06 SOLUTIONS -> NEW intro + one text_only card per solution, linked
|
||||
* to the solution group page under /solutions
|
||||
* 07 CTA -> contact banner header/body/button (link stays)
|
||||
* 08 SEO -> seo/og/twitter title + description, keywords, abstract
|
||||
*
|
||||
* Main-column order after apply: intro, use case, Why VITEC band,
|
||||
* [comparison], range intro, product cards (unchanged), solutions.
|
||||
* Idempotent: existing elements are found by header; nothing is duplicated.
|
||||
*
|
||||
* vendor/bin/typo3 vitec:import-product-overview-workbooks
|
||||
* vendor/bin/typo3 vitec:import-product-overview-workbooks --only=aetria --apply
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'vitec:import-product-overview-workbooks',
|
||||
description: 'Fill the product overview pages from the agency "Enriched" workbooks (dry-run by default)'
|
||||
)]
|
||||
final class ImportProductOverviewWorkbooksCommand extends Command
|
||||
{
|
||||
private const COLPOS_HERO = 1;
|
||||
private const COLPOS_MAIN = 0;
|
||||
private const COLPOS_PREFOOTER = 3;
|
||||
private const WHY_HEADER = 'Why VITEC?';
|
||||
private const RANGE_PLACEHOLDER = 'Product range';
|
||||
private const STOPWORDS = ['and', 'for', 'the', 'of', 'to', 'a', 'an', 'in', 'on', 'with'];
|
||||
|
||||
/** workbook H1 (post-norm) => page slug where the title match fails */
|
||||
private const TITLE_TO_SLUG = [
|
||||
'xplayerendpoints' => '/products/x-player-end-points',
|
||||
];
|
||||
|
||||
/** workbook solution names that differ from the group page titles beyond spelling (post-norm) */
|
||||
private const SOLUTION_ALIASES = [
|
||||
'videowallandvisualisationsolutions' => 'videowallanddatavisualisation',
|
||||
];
|
||||
|
||||
/** @var array<string,array<string|int,array<string,mixed>>> */
|
||||
private array $datamap = [];
|
||||
/** @var string[] */
|
||||
private array $report = [];
|
||||
private int $newCounter = 0;
|
||||
private bool $apply = false;
|
||||
private bool $withComparison = false;
|
||||
private int $touched = 0;
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('dir', null, InputOption::VALUE_REQUIRED, 'Workbook directory', 'migrations/product_overviews_xlsx_in');
|
||||
$this->addOption('only', null, InputOption::VALUE_REQUIRED, 'Filename substring filter', '');
|
||||
$this->addOption('comparison', null, InputOption::VALUE_NONE, 'Also create the comparison table (text element)');
|
||||
$this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
Bootstrap::initializeBackendAuthentication();
|
||||
$this->apply = (bool)$input->getOption('apply');
|
||||
$this->withComparison = (bool)$input->getOption('comparison');
|
||||
$only = mb_strtolower((string)$input->getOption('only'));
|
||||
$dir = rtrim((string)$input->getOption('dir'), '/');
|
||||
|
||||
$files = glob($dir . '/*.xlsx') ?: [];
|
||||
$files = array_values(array_filter($files, static fn(string $f): bool => !str_starts_with(basename($f), '~$')
|
||||
&& ($only === '' || str_contains(mb_strtolower(basename($f)), $only))));
|
||||
sort($files);
|
||||
$output->writeln(($this->apply ? 'APPLY' : 'DRY-RUN') . ' · ' . count($files) . ' workbook(s) in ' . $dir
|
||||
. ($this->withComparison ? ' · with comparison table' : ' · comparison table skipped'));
|
||||
|
||||
$ok = 0;
|
||||
foreach ($files as $file) {
|
||||
try {
|
||||
if ($this->importOne($file, $output)) {
|
||||
$ok++;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln('<error> ' . basename($file) . ': ' . $e->getMessage() . '</error>');
|
||||
}
|
||||
}
|
||||
$output->writeln(sprintf(
|
||||
"\n<info>%d of %d workbook(s) %s, %d element/page write(s).</info>%s",
|
||||
$ok, count($files), $this->apply ? 'applied' : 'planned', $this->touched,
|
||||
$this->apply ? ' Flush the cache, then vitec:ssr-purge --all + vitec:ssr-warm.' : ' DRY RUN - nothing written. Add --apply to write.'
|
||||
));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function importOne(string $file, OutputInterface $output): bool
|
||||
{
|
||||
$this->datamap = [];
|
||||
$this->report = [];
|
||||
$wb = $this->readWorkbook($file);
|
||||
$h1 = $wb['hero']['h1'] ?? '';
|
||||
if ($h1 === '') {
|
||||
$output->writeln('<comment> ' . basename($file) . ': no "01 · HERO / H1" row - skipped</comment>');
|
||||
return false;
|
||||
}
|
||||
$page = $this->pageByTitle($h1);
|
||||
if ($page === null) {
|
||||
$output->writeln('<error> ' . basename($file) . ': no overview page titled "' . $h1 . '" under /products - skipped'
|
||||
. ($this->report !== [] ? ' (' . implode('; ', $this->report) . ')' : '') . '</error>');
|
||||
return false;
|
||||
}
|
||||
$pageUid = (int)$page['uid'];
|
||||
$output->writeln(sprintf("\n<info>%s</info> -> %s (#%d)", basename($file), $page['slug'], $pageUid));
|
||||
|
||||
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
|
||||
$conn = $pool->getConnectionForTable('tt_content');
|
||||
$top = $conn->fetchAllAssociative(
|
||||
'SELECT uid, colPos, CType, header, bodytext, vitec_layout_variant, vitec_primary_cta_label, vitec_cta_label FROM tt_content
|
||||
WHERE deleted = 0 AND pid = ? AND sys_language_uid IN (0,-1)
|
||||
AND (tx_container_parent IS NULL OR tx_container_parent = 0)
|
||||
ORDER BY colPos, sorting, uid',
|
||||
[$pageUid]
|
||||
);
|
||||
$main = array_values(array_filter($top, static fn(array $r): bool => (int)$r['colPos'] === self::COLPOS_MAIN));
|
||||
$byUid = [];
|
||||
foreach ($main as $row) {
|
||||
$byUid[(string)$row['uid']] = $row;
|
||||
}
|
||||
|
||||
// --- 01 hero ------------------------------------------------------------------
|
||||
$hero = $this->first($top, self::COLPOS_HERO, 'vitec_herosection');
|
||||
if ($hero === null) {
|
||||
$this->report[] = 'hero: NO vitec_herosection in colPos 1 - subline/CTA not placed';
|
||||
} else {
|
||||
$this->update((string)$hero['uid'], $hero, [
|
||||
'bodytext' => $this->p($wb['hero']['subline'] ?? ''),
|
||||
'vitec_cta_label' => $wb['hero']['cta'] ?? '',
|
||||
], 'hero #' . $hero['uid']);
|
||||
}
|
||||
|
||||
// --- 02 intro + use case ----------------------------------------------------------
|
||||
$introH2 = ($wb['intro']['h2'] ?? '') !== '' ? $wb['intro']['h2'] : $h1;
|
||||
$introId = $this->ensureIntro($main, $byUid, $pageUid, $introH2, $wb['intro']['body'] ?? '', 'intro');
|
||||
$useId = null;
|
||||
if (($wb['intro']['usecase_body'] ?? '') !== '') {
|
||||
$useHeading = ($wb['intro']['usecase_heading'] ?? '') !== '' ? $wb['intro']['usecase_heading'] : 'Applications and workflows';
|
||||
$useId = $this->ensureIntro($main, $byUid, $pageUid, $useHeading, $wb['intro']['usecase_body'], 'use case');
|
||||
}
|
||||
|
||||
// --- 03 why vitec -----------------------------------------------------------------
|
||||
$whyIds = [];
|
||||
$whyIndex = $this->indexOf($main, 'vitec_introparagraph', self::WHY_HEADER);
|
||||
if ($whyIndex !== null) {
|
||||
$whyIds[] = (string)$main[$whyIndex]['uid'];
|
||||
$cards = [];
|
||||
for ($i = $whyIndex + 1; $i < count($main) && $main[$i]['CType'] === 'vitec_card'; $i++) {
|
||||
$cards[] = $main[$i];
|
||||
$whyIds[] = (string)$main[$i]['uid'];
|
||||
}
|
||||
$used = [];
|
||||
foreach ($wb['why']['benefits'] as $benefit) {
|
||||
$match = null;
|
||||
foreach ($cards as $card) {
|
||||
if (isset($used[(string)$card['uid']])) {
|
||||
continue;
|
||||
}
|
||||
if ($this->norm((string)$card['header']) === $this->norm($benefit['heading']) || $this->sameTheme($benefit['heading'], (string)$card['header'])) {
|
||||
$match = $card;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($match === null) {
|
||||
$this->report[] = 'why: benefit "' . $benefit['heading'] . '" has no matching icon card - NOT placed (band = agency icon set; editorial decision)';
|
||||
continue;
|
||||
}
|
||||
$used[(string)$match['uid']] = true;
|
||||
if (trim(strip_tags((string)$match['bodytext'])) !== '' && $this->norm(strip_tags((string)$match['bodytext'])) !== $this->norm($benefit['body'])) {
|
||||
$this->report[] = 'why: card "' . $match['header'] . '" already has own text - left as is';
|
||||
continue;
|
||||
}
|
||||
$this->update((string)$match['uid'], $match, ['bodytext' => $this->p($benefit['body'])], 'why card "' . $match['header'] . '" <- "' . $benefit['heading'] . '"');
|
||||
}
|
||||
} elseif ($wb['why']['benefits'] !== []) {
|
||||
$id = $this->create($pageUid, 'vitec_introparagraph', ['header' => self::WHY_HEADER, 'bodytext' => ''], 'why: NEW band intro');
|
||||
$whyIds[] = $id;
|
||||
foreach ($wb['why']['benefits'] as $benefit) {
|
||||
$whyIds[] = $this->create($pageUid, 'vitec_card', [
|
||||
'vitec_layout_variant' => 'text_only',
|
||||
'header' => $benefit['heading'],
|
||||
'bodytext' => $this->p($benefit['body']),
|
||||
'vitec_primary_cta_label' => '',
|
||||
], 'why: NEW card "' . $benefit['heading'] . '"');
|
||||
}
|
||||
}
|
||||
|
||||
// --- 04 comparison (opt-in) ------------------------------------------------------------
|
||||
$comparisonId = null;
|
||||
if ($this->withComparison && ($wb['comparison']['rows'] ?? []) !== []) {
|
||||
$h2 = $this->fixCase($wb['comparison']['h2'] ?? 'Compare ' . $h1, $h1);
|
||||
$body = $this->p($wb['comparison']['intro'] ?? '')
|
||||
. '<table class="vitec-comparison"><thead><tr><th>Product</th><th>Description</th></tr></thead><tbody>';
|
||||
foreach ($wb['comparison']['rows'] as $row) {
|
||||
$body .= '<tr><td>' . htmlspecialchars($row['name'], ENT_NOQUOTES | ENT_HTML5) . '</td><td>'
|
||||
. htmlspecialchars($row['text'], ENT_NOQUOTES | ENT_HTML5) . '</td></tr>';
|
||||
}
|
||||
$body .= '</tbody></table>';
|
||||
$idx = $this->indexOf($main, 'text', $h2);
|
||||
if ($idx !== null) {
|
||||
$comparisonId = (string)$main[$idx]['uid'];
|
||||
$this->update($comparisonId, $main[$idx], ['bodytext' => $body], 'comparison #' . $comparisonId);
|
||||
} else {
|
||||
$comparisonId = $this->create($pageUid, 'text', ['header' => $h2, 'bodytext' => $body], 'comparison: NEW text element (' . count($wb['comparison']['rows']) . ' rows)');
|
||||
}
|
||||
} elseif (($wb['comparison']['rows'] ?? []) !== []) {
|
||||
$this->report[] = 'comparison: ' . count($wb['comparison']['rows']) . ' row(s) skipped (no specifications in the workbook; --comparison to create)';
|
||||
}
|
||||
|
||||
// --- 05 product range intro ----------------------------------------------------------
|
||||
$rangeH2 = $this->fixCase(($wb['range']['h2'] ?? '') !== '' ? $wb['range']['h2'] : ('Explore the ' . $h1 . ' range'), $h1);
|
||||
$rangeIdx = $this->indexOf($main, 'vitec_introparagraph', $rangeH2) ?? $this->indexOf($main, 'vitec_introparagraph', self::RANGE_PLACEHOLDER);
|
||||
if ($rangeIdx !== null) {
|
||||
$rangeId = (string)$main[$rangeIdx]['uid'];
|
||||
$this->update($rangeId, $main[$rangeIdx], ['header' => $rangeH2, 'bodytext' => $this->p($wb['range']['intro'] ?? '')], 'range intro #' . $rangeId);
|
||||
} else {
|
||||
$rangeId = $this->create($pageUid, 'vitec_introparagraph', ['header' => $rangeH2, 'bodytext' => $this->p($wb['range']['intro'] ?? '')], 'range: NEW intro (grouped page)');
|
||||
}
|
||||
$names = array_map(static fn(array $p): string => $p['name'], $wb['range']['products'] ?? []);
|
||||
$cardsOnPage = (int)$conn->fetchOne(
|
||||
"SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = ? AND CType = 'vitec_card' AND colPos >= 200",
|
||||
[$pageUid]
|
||||
);
|
||||
$this->report[] = 'range: ' . count($names) . ' workbook product(s), ' . $cardsOnPage . ' card(s) in grids on the page - cards untouched (record teasers win)';
|
||||
|
||||
// --- 06 solutions ------------------------------------------------------------------------
|
||||
$solIds = [];
|
||||
if (($wb['solutions']['items'] ?? []) !== []) {
|
||||
$h2 = $this->fixCase(($wb['solutions']['h2'] ?? '') !== '' ? $wb['solutions']['h2'] : ('Solutions using ' . $h1), $h1);
|
||||
$idx = $this->indexOf($main, 'vitec_introparagraph', $h2);
|
||||
if ($idx !== null) {
|
||||
$this->report[] = 'solutions: intro "' . $h2 . '" exists - block left as is';
|
||||
} else {
|
||||
$solIds[] = $this->create($pageUid, 'vitec_introparagraph', ['header' => $h2, 'bodytext' => ''], 'solutions: NEW intro "' . $h2 . '"');
|
||||
foreach ($wb['solutions']['items'] as $item) {
|
||||
$fields = [
|
||||
'vitec_layout_variant' => 'text_only',
|
||||
'header' => $item['title'],
|
||||
'bodytext' => $this->p($item['text']),
|
||||
'vitec_primary_cta_label' => 'Learn more',
|
||||
];
|
||||
$target = $this->resolveSolutionPage($item['title']);
|
||||
if ($target > 0) {
|
||||
$fields['vitec_primary_cta'] = 't3://page?uid=' . $target;
|
||||
}
|
||||
$solIds[] = $this->create($pageUid, 'vitec_card', $fields, 'solutions: NEW card "' . $item['title'] . '" -> ' . ($target > 0 ? 'page #' . $target : 'NO solution page resolved (link empty)'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 07 cta banner -------------------------------------------------------------------------
|
||||
$cta = $this->first($top, self::COLPOS_PREFOOTER, 'vitec_ctabanner');
|
||||
if ($cta === null) {
|
||||
$this->report[] = 'cta: NO vitec_ctabanner in colPos 3 - not placed';
|
||||
} elseif (($wb['cta']['h2'] ?? '') !== '') {
|
||||
$this->update((string)$cta['uid'], $cta, [
|
||||
'header' => $this->fixCase($wb['cta']['h2'], $h1),
|
||||
'bodytext' => $this->p($wb['cta']['body'] ?? ''),
|
||||
'vitec_primary_cta_label' => ($wb['cta']['button'] ?? '') !== '' ? $wb['cta']['button'] : 'Contact us',
|
||||
], 'cta #' . $cta['uid']);
|
||||
}
|
||||
|
||||
// --- 08 seo / page properties -------------------------------------------------------------
|
||||
$seoTitle = ($wb['seo']['title'] ?? '') !== '' ? $wb['seo']['title'] : $h1 . ' | VITEC';
|
||||
$desc = $wb['seo']['description'] ?? '';
|
||||
$keywords = implode(', ', array_filter([$wb['seo']['primary'] ?? '', $wb['seo']['secondary'] ?? '']));
|
||||
$props = [
|
||||
'seo_title' => $seoTitle, 'og_title' => $seoTitle, 'twitter_title' => $seoTitle,
|
||||
'description' => $desc, 'og_description' => $desc, 'twitter_description' => $desc,
|
||||
'keywords' => $keywords,
|
||||
'abstract' => mb_substr(trim(($wb['intro']['body'] ?? '') !== '' ? $wb['intro']['body'] : $desc), 0, 300),
|
||||
];
|
||||
$changed = [];
|
||||
foreach ($props as $field => $value) {
|
||||
if (trim((string)($page[$field] ?? '')) !== trim($value)) {
|
||||
$changed[] = $field;
|
||||
$this->datamap['pages'][(string)$pageUid][$field] = $value;
|
||||
}
|
||||
}
|
||||
if ($changed !== []) {
|
||||
$this->touched++;
|
||||
$this->report[] = 'page meta: ' . implode(', ', $changed);
|
||||
} else {
|
||||
$this->report[] = 'page meta: unchanged';
|
||||
}
|
||||
|
||||
foreach ($this->report as $line) {
|
||||
$output->writeln(' ' . $line);
|
||||
}
|
||||
if (!$this->apply) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- write -----------------------------------------------------------------------------------
|
||||
$subst = [];
|
||||
if ($this->datamap !== []) {
|
||||
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$dataHandler->start($this->datamap, []);
|
||||
$dataHandler->process_datamap();
|
||||
if ($dataHandler->errorLog !== []) {
|
||||
throw new \RuntimeException('DataHandler: ' . implode(' | ', $dataHandler->errorLog));
|
||||
}
|
||||
$subst = $dataHandler->substNEWwithIDs;
|
||||
}
|
||||
|
||||
// final main-column order: intro, use case, why band, [comparison], range intro, the rest (cards/grids/group intros), solutions
|
||||
$order = array_merge([$introId], $useId !== null ? [$useId] : [], $whyIds, $comparisonId !== null ? [$comparisonId] : [], [$rangeId]);
|
||||
$placed = array_flip($order);
|
||||
foreach ($main as $row) {
|
||||
if (!isset($placed[(string)$row['uid']])) {
|
||||
$order[] = (string)$row['uid'];
|
||||
}
|
||||
}
|
||||
$order = array_merge($order, $solIds);
|
||||
$sorting = 0;
|
||||
foreach ($order as $id) {
|
||||
$uid = str_starts_with($id, 'NEW') ? (int)($subst[$id] ?? 0) : (int)$id;
|
||||
if ($uid > 0) {
|
||||
$sorting += 256;
|
||||
$conn->update('tt_content', ['sorting' => $sorting], ['uid' => $uid]);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- element helpers ---------------------------------------------------------------------------
|
||||
|
||||
/** @param array<int,array<string,mixed>> $rows */
|
||||
private function first(array $rows, int $colPos, string $ctype): ?array
|
||||
{
|
||||
foreach ($rows as $row) {
|
||||
if ((int)$row['colPos'] === $colPos && $row['CType'] === $ctype) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $main */
|
||||
private function indexOf(array $main, string $ctype, string $header): ?int
|
||||
{
|
||||
$needle = $this->norm($header);
|
||||
foreach ($main as $i => $row) {
|
||||
if ($row['CType'] === $ctype && $this->norm((string)$row['header']) === $needle) {
|
||||
return $i;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Existing introparagraph with this header (updated) or a new one.
|
||||
*
|
||||
* @param array<int,array<string,mixed>> $main
|
||||
* @param array<string,array<string,mixed>> $byUid
|
||||
*/
|
||||
private function ensureIntro(array $main, array $byUid, int $pageUid, string $header, string $body, string $label): string
|
||||
{
|
||||
$idx = $this->indexOf($main, 'vitec_introparagraph', $header);
|
||||
if ($idx !== null) {
|
||||
$id = (string)$main[$idx]['uid'];
|
||||
$this->update($id, $byUid[$id], ['header' => $header, 'bodytext' => $this->p($body)], $label . ' #' . $id);
|
||||
return $id;
|
||||
}
|
||||
return $this->create($pageUid, 'vitec_introparagraph', ['header' => $header, 'bodytext' => $this->p($body)], $label . ': NEW "' . $header . '"');
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $fields */
|
||||
private function create(int $pageUid, string $ctype, array $fields, string $label): string
|
||||
{
|
||||
$id = $this->newId();
|
||||
$this->datamap['tt_content'][$id] = ['pid' => $pageUid, 'colPos' => self::COLPOS_MAIN, 'CType' => $ctype] + $fields;
|
||||
$this->report[] = $label;
|
||||
$this->touched++;
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes only fields whose current value differs (RTE-tolerant on bodytext).
|
||||
*
|
||||
* @param array<string,mixed> $current
|
||||
* @param array<string,string> $fields
|
||||
*/
|
||||
private function update(string $uid, array $current, array $fields, string $label): void
|
||||
{
|
||||
$diff = [];
|
||||
foreach ($fields as $field => $value) {
|
||||
$old = (string)($current[$field] ?? '');
|
||||
$same = $field === 'bodytext'
|
||||
? $this->norm(strip_tags($old)) === $this->norm(strip_tags($value))
|
||||
: trim($old) === trim($value);
|
||||
if (!$same) {
|
||||
$diff[] = $field;
|
||||
$this->datamap['tt_content'][$uid][$field] = $value;
|
||||
}
|
||||
}
|
||||
if ($diff === []) {
|
||||
$this->report[] = $label . ': unchanged';
|
||||
return;
|
||||
}
|
||||
$this->touched++;
|
||||
$this->report[] = $label . ': ' . implode(', ', $diff);
|
||||
}
|
||||
|
||||
// ---- resolution --------------------------------------------------------------------------------
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
private function pageByTitle(string $title): ?array
|
||||
{
|
||||
$pages = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
|
||||
$needle = $this->norm($title);
|
||||
$rows = $pages->fetchAllAssociative(
|
||||
"SELECT uid, slug, title, nav_title, seo_title, og_title, twitter_title, description, og_description, twitter_description, keywords, abstract
|
||||
FROM pages WHERE deleted = 0 AND doktype = 1 AND sys_language_uid IN (0,-1) AND slug LIKE '/products/%'"
|
||||
);
|
||||
if (isset(self::TITLE_TO_SLUG[$needle])) {
|
||||
foreach ($rows as $row) {
|
||||
if ($row['slug'] === self::TITLE_TO_SLUG[$needle]) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
}
|
||||
$hits = array_values(array_filter($rows, fn(array $row): bool => $this->norm((string)$row['title']) === $needle));
|
||||
if (count($hits) === 1) {
|
||||
return $hits[0];
|
||||
}
|
||||
if (count($hits) > 1) {
|
||||
// prefer the flat overview slug (one segment below /products)
|
||||
$flat = array_values(array_filter($hits, static fn(array $r): bool => substr_count((string)$r['slug'], '/') === 2));
|
||||
if (count($flat) === 1) {
|
||||
return $flat[0];
|
||||
}
|
||||
$this->report[] = 'ambiguous title "' . $title . '": ' . implode(', ', array_map(static fn(array $r): string => $r['slug'], $hits));
|
||||
return null;
|
||||
}
|
||||
$hits = array_values(array_filter($rows, fn(array $row): bool => $this->norm((string)$row['nav_title']) === $needle));
|
||||
return count($hits) === 1 ? $hits[0] : null;
|
||||
}
|
||||
|
||||
private function resolveSolutionPage(string $title): int
|
||||
{
|
||||
$pages = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
|
||||
$root = $pages->fetchOne("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/solutions'");
|
||||
if (!$root) {
|
||||
return 0;
|
||||
}
|
||||
$needle = $this->norm($title);
|
||||
$needle = self::SOLUTION_ALIASES[$needle] ?? $needle;
|
||||
$strip = static fn(string $n): string => (string)preg_replace('/solutions$/', '', $n);
|
||||
$rows = $pages->fetchAllAssociative(
|
||||
'SELECT uid, title FROM pages WHERE deleted = 0 AND pid = ? AND sys_language_uid IN (0,-1) AND doktype = 1',
|
||||
[(int)$root]
|
||||
);
|
||||
foreach ($rows as $row) {
|
||||
if ($this->norm((string)$row['title']) === $needle) {
|
||||
return (int)$row['uid'];
|
||||
}
|
||||
}
|
||||
foreach ($rows as $row) {
|
||||
if ($strip($this->norm((string)$row['title'])) === $strip($needle)) {
|
||||
return (int)$row['uid'];
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---- workbook reading --------------------------------------------------------------------------
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function readWorkbook(string $file): array
|
||||
{
|
||||
$spreadsheet = IOFactory::load($file);
|
||||
$sheet = $spreadsheet->getSheetByName('Content') ?? $spreadsheet->getSheet($spreadsheet->getSheetCount() - 1);
|
||||
$rows = $sheet->toArray(null, false, false, false);
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
|
||||
$wb = ['hero' => [], 'intro' => [], 'why' => ['benefits' => []], 'comparison' => ['rows' => []],
|
||||
'range' => ['products' => []], 'solutions' => ['items' => []], 'cta' => [], 'seo' => []];
|
||||
$benefits = [];
|
||||
$products = [];
|
||||
$solutions = [];
|
||||
foreach ($rows as $row) {
|
||||
$section = trim((string)($row[0] ?? ''));
|
||||
$field = trim((string)($row[1] ?? ''));
|
||||
$value = trim((string)($row[2] ?? ''));
|
||||
if ($section === '' || $field === '' || !preg_match('/^(\d\d)/', $section, $m)) {
|
||||
continue;
|
||||
}
|
||||
$f = mb_strtolower($field);
|
||||
switch ($m[1]) {
|
||||
case '01':
|
||||
if ($f === 'h1') {
|
||||
$wb['hero']['h1'] = $value;
|
||||
} elseif (str_contains($f, 'subline')) {
|
||||
$wb['hero']['subline'] = $value;
|
||||
} elseif (str_contains($f, 'cta')) {
|
||||
$wb['hero']['cta'] = $value;
|
||||
}
|
||||
break;
|
||||
case '02':
|
||||
if ($f === 'h2') {
|
||||
$wb['intro']['h2'] = $value;
|
||||
} elseif ($f === 'introduction') {
|
||||
$wb['intro']['body'] = $value;
|
||||
} elseif (str_contains($f, 'use-case card heading')) {
|
||||
$wb['intro']['usecase_heading'] = $value;
|
||||
} elseif (str_contains($f, 'use-case description')) {
|
||||
$wb['intro']['usecase_body'] = $value;
|
||||
}
|
||||
break;
|
||||
case '03':
|
||||
if (preg_match('/^benefit\s*(\d+)\s*[—–-]+\s*(heading|body)$/u', $f, $b)) {
|
||||
$benefits[(int)$b[1]][$b[2]] = $value;
|
||||
} elseif (str_contains($f, 'h2')) {
|
||||
$wb['why']['h2'] = $value;
|
||||
}
|
||||
break;
|
||||
case '04':
|
||||
if ($f === 'h2') {
|
||||
$wb['comparison']['h2'] = $value;
|
||||
} elseif ($f === 'intro') {
|
||||
$wb['comparison']['intro'] = $value;
|
||||
} elseif (preg_match('/^product\s*\d+\s*:\s*(.+)$/iu', $field, $c)) {
|
||||
$wb['comparison']['rows'][] = ['name' => trim($c[1]), 'text' => $value];
|
||||
}
|
||||
break;
|
||||
case '05':
|
||||
if ($f === 'h2') {
|
||||
$wb['range']['h2'] = $value;
|
||||
} elseif ($f === 'intro') {
|
||||
$wb['range']['intro'] = $value;
|
||||
} elseif (preg_match('/^product\s*(\d+)\s*[—–-]+\s*(group|name|description|cta|target url)$/u', $f, $p)) {
|
||||
$products[(int)$p[1]][$p[2]] = $value;
|
||||
}
|
||||
break;
|
||||
case '06':
|
||||
if ($f === 'h2') {
|
||||
$wb['solutions']['h2'] = $value;
|
||||
} elseif (preg_match('/^solution\s*(\d+)\s*[—–-]+\s*(title|description|link)$/u', $f, $s)) {
|
||||
$solutions[(int)$s[1]][$s[2]] = $value;
|
||||
}
|
||||
break;
|
||||
case '07':
|
||||
if ($f === 'h2') {
|
||||
$wb['cta']['h2'] = $value;
|
||||
} elseif ($f === 'body') {
|
||||
$wb['cta']['body'] = $value;
|
||||
} elseif ($f === 'button') {
|
||||
$wb['cta']['button'] = $value;
|
||||
} elseif ($f === 'url') {
|
||||
$wb['cta']['url'] = $value;
|
||||
}
|
||||
break;
|
||||
case '08':
|
||||
if (str_contains($f, 'seo title')) {
|
||||
$wb['seo']['title'] = $value;
|
||||
} elseif (str_contains($f, 'meta description')) {
|
||||
$wb['seo']['description'] = $value;
|
||||
} elseif (str_contains($f, 'primary keyword')) {
|
||||
$wb['seo']['primary'] = $value;
|
||||
} elseif (str_contains($f, 'secondary keyword')) {
|
||||
$wb['seo']['secondary'] = $value;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
ksort($benefits);
|
||||
foreach ($benefits as $b) {
|
||||
if (($b['heading'] ?? '') !== '') {
|
||||
$wb['why']['benefits'][] = ['heading' => $b['heading'], 'body' => $b['body'] ?? ''];
|
||||
}
|
||||
}
|
||||
ksort($products);
|
||||
foreach ($products as $p) {
|
||||
if (($p['name'] ?? '') !== '') {
|
||||
$wb['range']['products'][] = ['group' => $p['group'] ?? '', 'name' => $p['name'], 'text' => $p['description'] ?? ''];
|
||||
}
|
||||
}
|
||||
ksort($solutions);
|
||||
foreach ($solutions as $s) {
|
||||
if (($s['title'] ?? '') !== '') {
|
||||
$wb['solutions']['items'][] = ['title' => $s['title'], 'text' => $s['description'] ?? '', 'link' => $s['link'] ?? ''];
|
||||
}
|
||||
}
|
||||
return $wb;
|
||||
}
|
||||
|
||||
// ---- text helpers --------------------------------------------------------------------------------
|
||||
|
||||
/** "Compare aetria" -> "Compare Aetria": the template lower-cased the product name inside the H2s */
|
||||
private function fixCase(string $text, string $h1): string
|
||||
{
|
||||
$lower = mb_strtolower($h1);
|
||||
if ($lower === '' || $lower === $h1) {
|
||||
return $text;
|
||||
}
|
||||
return (string)preg_replace('/' . preg_quote($lower, '/') . '/u', $h1, $text);
|
||||
}
|
||||
|
||||
private function sameTheme(string $a, string $b): bool
|
||||
{
|
||||
$words = fn(string $text): array => array_values(array_diff(
|
||||
preg_split('/[^a-z0-9]+/', mb_strtolower($text), -1, PREG_SPLIT_NO_EMPTY) ?: [],
|
||||
self::STOPWORDS
|
||||
));
|
||||
$x = $words($a);
|
||||
$y = $words($b);
|
||||
if ($x === [] || count($x) !== count($y)) {
|
||||
return false;
|
||||
}
|
||||
foreach ($x as $i => $word) {
|
||||
if (!str_starts_with($y[$i], $word) && !str_starts_with($word, $y[$i])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private function p(string $text): string
|
||||
{
|
||||
$text = trim($text);
|
||||
return $text === '' ? '' : '<p>' . htmlspecialchars($text, ENT_NOQUOTES | ENT_HTML5) . '</p>';
|
||||
}
|
||||
|
||||
private function norm(string $value): string
|
||||
{
|
||||
$value = mb_strtolower($value);
|
||||
$value = str_replace(['visualization', 'center'], ['visualisation', 'centre'], $value);
|
||||
return (string)preg_replace('/[^a-z0-9]+/', '', str_replace(['+', '&'], ['plus', 'and'], $value));
|
||||
}
|
||||
|
||||
private function newId(): string
|
||||
{
|
||||
return 'NEWOV' . str_pad((string)(++$this->newCounter), 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
}
|
||||
@@ -29,13 +29,17 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
* after the last "-"), stop words removed, Levenshtein <= 3 as typo
|
||||
* tolerance (the delivery contains e.g. "EncodeDecondeOnOneCard").
|
||||
* - a product that already HAS feature records is never touched - after
|
||||
* the seed the editors own the cards.
|
||||
* the seed the editors own the cards. EXCEPT with --attach-icons
|
||||
* (2026-11-10, later icon deliveries such as PRISM/SFF PRISM/Avedia
|
||||
* chassis): then its cards WITHOUT an icon get the matching staged icon
|
||||
* (same matcher); titles, texts and cards with an icon stay untouched.
|
||||
*
|
||||
* Unmatched icons and unmatched cards are reported so the editors can map
|
||||
* the leftovers by hand (0036-FE asks for exactly that list).
|
||||
*
|
||||
* vendor/bin/typo3 vitec:seed-product-features
|
||||
* vendor/bin/typo3 vitec:seed-product-features --only=activesqx2 --apply
|
||||
* vendor/bin/typo3 vitec:seed-product-features --only=prism --attach-icons --apply
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'vitec:seed-product-features',
|
||||
@@ -60,6 +64,7 @@ final class SeedProductFeaturesCommand extends Command
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('only', null, InputOption::VALUE_REQUIRED, 'Only product slugs containing this substring');
|
||||
$this->addOption('attach-icons', null, InputOption::VALUE_NONE, 'Products with existing cards: attach staged icons to cards without one');
|
||||
$this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)');
|
||||
}
|
||||
|
||||
@@ -67,6 +72,7 @@ final class SeedProductFeaturesCommand extends Command
|
||||
{
|
||||
Bootstrap::initializeBackendAuthentication();
|
||||
$apply = (bool)$input->getOption('apply');
|
||||
$attachIcons = (bool)$input->getOption('attach-icons');
|
||||
$only = mb_strtolower((string)($input->getOption('only') ?? ''));
|
||||
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
|
||||
|
||||
@@ -81,6 +87,7 @@ final class SeedProductFeaturesCommand extends Command
|
||||
$iconsMatched = 0;
|
||||
$skippedExisting = 0;
|
||||
$skippedEmpty = 0;
|
||||
$attached = 0;
|
||||
|
||||
foreach ($products as $product) {
|
||||
$uid = (int)$product['uid'];
|
||||
@@ -93,17 +100,11 @@ final class SeedProductFeaturesCommand extends Command
|
||||
'SELECT COUNT(*) FROM ' . self::TABLE . ' WHERE deleted = 0 AND product = ?',
|
||||
[$uid]
|
||||
);
|
||||
if ($existing > 0) {
|
||||
if ($existing > 0 && !$attachIcons) {
|
||||
$skippedExisting++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$pairs = $this->parseApplications((string)$product['applications']);
|
||||
if ($pairs === []) {
|
||||
$skippedEmpty++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// staged icons of this product, filename theme => identifier
|
||||
$context = self::ICON_DIRS[$slug] ?? $slug;
|
||||
$iconByTheme = [];
|
||||
@@ -119,48 +120,70 @@ final class SeedProductFeaturesCommand extends Command
|
||||
}
|
||||
}
|
||||
|
||||
$output->writeln(sprintf("\n<info>%s</info> (uid %d) - %d card(s), %d staged icon(s)", $slug, $uid, count($pairs), count($iconByTheme)));
|
||||
|
||||
// match icons onto the pairs
|
||||
$usedThemes = [];
|
||||
foreach ($pairs as $i => $pair) {
|
||||
$theme = $this->matchTheme($pair['title'], array_diff_key($iconByTheme, $usedThemes));
|
||||
if ($theme !== null) {
|
||||
$pairs[$i]['icon'] = $iconByTheme[$theme];
|
||||
$usedThemes[$theme] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// second pass: token containment, e.g. theme "ManagementFoundation"
|
||||
// inside the card title "Aetria management foundation"
|
||||
foreach ($pairs as $i => $pair) {
|
||||
if (isset($pair['icon'])) {
|
||||
if ($existing > 0) {
|
||||
// --attach-icons: existing cards without an icon get a staged one
|
||||
if ($iconByTheme === []) {
|
||||
$skippedExisting++;
|
||||
continue;
|
||||
}
|
||||
foreach (array_keys(array_diff_key($iconByTheme, $usedThemes)) as $theme) {
|
||||
$titleTokens = $this->tokens($pair['title']);
|
||||
$themeTokens = $this->tokens($theme);
|
||||
if ($titleTokens !== [] && $themeTokens !== []
|
||||
&& (array_diff($themeTokens, $titleTokens) === [] || array_diff($titleTokens, $themeTokens) === [])
|
||||
) {
|
||||
$pairs[$i]['icon'] = $iconByTheme[$theme];
|
||||
$usedThemes[$theme] = true;
|
||||
break;
|
||||
}
|
||||
$bare = $pool->getConnectionForTable(self::TABLE)->fetchAllAssociative(
|
||||
'SELECT f.uid, f.title FROM ' . self::TABLE . ' f WHERE f.deleted = 0 AND f.product = ?
|
||||
AND NOT EXISTS (SELECT 1 FROM sys_file_reference r WHERE r.deleted = 0 AND r.tablenames = ?
|
||||
AND r.fieldname = ? AND r.uid_foreign = f.uid)
|
||||
ORDER BY f.sorting',
|
||||
[$uid, self::TABLE, 'icon']
|
||||
);
|
||||
if ($bare === []) {
|
||||
$skippedExisting++;
|
||||
continue;
|
||||
}
|
||||
$pairs = array_map(static fn(array $r): array => ['uid' => (int)$r['uid'], 'title' => (string)$r['title'], 'text' => '-'], $bare);
|
||||
$output->writeln(sprintf("\n<info>%s</info> (uid %d) - ATTACH: %d existing card(s) without icon, %d staged icon(s)", $slug, $uid, count($pairs), count($iconByTheme)));
|
||||
$usedThemes = $this->matchIcons($pairs, $iconByTheme);
|
||||
foreach ($pairs as $pair) {
|
||||
$output->writeln(' "' . $pair['title'] . '"' . (isset($pair['icon']) ? ' + icon ' . basename((string)$pair['icon']) : ' <comment>- no icon</comment>'));
|
||||
}
|
||||
foreach (array_diff_key($iconByTheme, $usedThemes) as $identifier) {
|
||||
$output->writeln(' <comment>UNMATCHED icon: ' . basename($identifier) . '</comment>');
|
||||
}
|
||||
$iconsMatched += count($usedThemes);
|
||||
$attached += count($usedThemes);
|
||||
if (!$apply || $usedThemes === []) {
|
||||
continue;
|
||||
}
|
||||
$datamap = [];
|
||||
foreach ($pairs as $pair) {
|
||||
if (!isset($pair['icon'])) {
|
||||
continue;
|
||||
}
|
||||
$refId = $this->newId();
|
||||
$datamap['sys_file_reference'][$refId] = [
|
||||
'pid' => (int)$product['pid'],
|
||||
'table_local' => 'sys_file',
|
||||
'uid_local' => $storage->getFile((string)$pair['icon'])->getUid(),
|
||||
];
|
||||
$datamap[self::TABLE][(string)$pair['uid']] = ['icon' => $refId];
|
||||
}
|
||||
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$dataHandler->start($datamap, []);
|
||||
$dataHandler->process_datamap();
|
||||
if ($dataHandler->errorLog !== []) {
|
||||
$output->writeln(' <error>DataHandler: ' . implode(' | ', $dataHandler->errorLog) . '</error>');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// last pair standing: a complete set (#icons == #cards) with exactly
|
||||
// one icon and one card left over - they belong to each other
|
||||
if (count($iconByTheme) === count($pairs)) {
|
||||
$freeThemes = array_keys(array_diff_key($iconByTheme, $usedThemes));
|
||||
$freePairs = array_keys(array_filter($pairs, static fn(array $p): bool => !isset($p['icon'])));
|
||||
if (count($freeThemes) === 1 && count($freePairs) === 1) {
|
||||
$pairs[$freePairs[0]]['icon'] = $iconByTheme[$freeThemes[0]];
|
||||
$usedThemes[$freeThemes[0]] = true;
|
||||
}
|
||||
$pairs = $this->parseApplications((string)$product['applications']);
|
||||
if ($pairs === []) {
|
||||
$skippedEmpty++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$output->writeln(sprintf("\n<info>%s</info> (uid %d) - %d card(s), %d staged icon(s)", $slug, $uid, count($pairs), count($iconByTheme)));
|
||||
|
||||
$usedThemes = $this->matchIcons($pairs, $iconByTheme);
|
||||
|
||||
foreach ($pairs as $pair) {
|
||||
$output->writeln(sprintf(
|
||||
' "%s"%s%s',
|
||||
@@ -224,11 +247,11 @@ final class SeedProductFeaturesCommand extends Command
|
||||
}
|
||||
|
||||
$output->writeln(sprintf(
|
||||
"\n<info>%d product(s) seeded (%d cards, %d icons matched), %d with existing cards untouched, %d without applications pairs%s</info>",
|
||||
$seeded, $cards, $iconsMatched, $skippedExisting, $skippedEmpty,
|
||||
"\n<info>%d product(s) seeded (%d cards, %d icons matched%s), %d with existing cards untouched, %d without applications pairs%s</info>",
|
||||
$seeded, $cards, $iconsMatched, $attachIcons ? ', ' . $attached . ' attached to existing cards' : '', $skippedExisting, $skippedEmpty,
|
||||
$apply ? '' : ' (dry run - nothing written)'
|
||||
));
|
||||
if ($apply && $seeded > 0) {
|
||||
if ($apply && ($seeded > 0 || $attached > 0)) {
|
||||
$output->writeln('<info>Flush the cache: vendor/bin/typo3 cache:flush</info>');
|
||||
}
|
||||
return Command::SUCCESS;
|
||||
@@ -276,6 +299,58 @@ final class SeedProductFeaturesCommand extends Command
|
||||
return $pairs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches the staged icons onto the cards (three passes: normalised title
|
||||
* / Levenshtein, token containment, last pair standing). Sets 'icon' on
|
||||
* the matched pairs and returns the used themes.
|
||||
*
|
||||
* @param array<int,array{title:string,text:string,icon?:string,uid?:int}> $pairs
|
||||
* @param array<string,string> $iconByTheme theme => identifier
|
||||
* @return array<string,bool>
|
||||
*/
|
||||
private function matchIcons(array &$pairs, array $iconByTheme): array
|
||||
{
|
||||
$usedThemes = [];
|
||||
foreach ($pairs as $i => $pair) {
|
||||
$theme = $this->matchTheme($pair['title'], array_diff_key($iconByTheme, $usedThemes));
|
||||
if ($theme !== null) {
|
||||
$pairs[$i]['icon'] = $iconByTheme[$theme];
|
||||
$usedThemes[$theme] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// second pass: token containment, e.g. theme "ManagementFoundation"
|
||||
// inside the card title "Aetria management foundation"
|
||||
foreach ($pairs as $i => $pair) {
|
||||
if (isset($pair['icon'])) {
|
||||
continue;
|
||||
}
|
||||
foreach (array_keys(array_diff_key($iconByTheme, $usedThemes)) as $theme) {
|
||||
$titleTokens = $this->tokens($pair['title']);
|
||||
$themeTokens = $this->tokens($theme);
|
||||
if ($titleTokens !== [] && $themeTokens !== []
|
||||
&& (array_diff($themeTokens, $titleTokens) === [] || array_diff($titleTokens, $themeTokens) === [])
|
||||
) {
|
||||
$pairs[$i]['icon'] = $iconByTheme[$theme];
|
||||
$usedThemes[$theme] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// last pair standing: a complete set (#icons == #cards) with exactly
|
||||
// one icon and one card left over - they belong to each other
|
||||
if (count($iconByTheme) === count($pairs)) {
|
||||
$freeThemes = array_keys(array_diff_key($iconByTheme, $usedThemes));
|
||||
$freePairs = array_keys(array_filter($pairs, static fn(array $p): bool => !isset($p['icon'])));
|
||||
if (count($freeThemes) === 1 && count($freePairs) === 1) {
|
||||
$pairs[$freePairs[0]]['icon'] = $iconByTheme[$freeThemes[0]];
|
||||
$usedThemes[$freeThemes[0]] = true;
|
||||
}
|
||||
}
|
||||
return $usedThemes;
|
||||
}
|
||||
|
||||
/**
|
||||
* The icon theme that matches a card title: exact on the normalised
|
||||
* strings (stop words removed on both sides), else the closest theme
|
||||
|
||||
Reference in new issue
Block a user