diff --git a/migrations/pagecheck_setup_20261113.sh b/migrations/pagecheck_setup_20261113.sh new file mode 100644 index 0000000..318e1ca --- /dev/null +++ b/migrations/pagecheck_setup_20261113.sh @@ -0,0 +1,14 @@ +#!/bin/sh +# vitec_pagecheck (2026-11-13): permissions, syntax check, cache flush, page setup (dry-run unless --apply). +set -e +cd /usr/www/users/vitecevo/live +EXT=packages/vitec +CFG=$EXT/Configuration +chmod 644 $EXT/Classes/Command/CreateCheckPagesCommand.php $EXT/Classes/Service/PageCheckService.php $EXT/Classes/UserFunc/PageCheckJsonRenderer.php \ + $CFG/FlexForms/Pagecheck.xml $CFG/Services.yaml $CFG/Sets/Vitecset/setup.typoscript $CFG/TCA/Overrides/tt_content.php $CFG/page.tsconfig migrations/pagecheck_setup_20261113.sh +php -l $EXT/Classes/Service/PageCheckService.php +php -l $EXT/Classes/UserFunc/PageCheckJsonRenderer.php +php -l $EXT/Classes/Command/CreateCheckPagesCommand.php +php -l $CFG/TCA/Overrides/tt_content.php +vendor/bin/typo3 cache:flush +vendor/bin/typo3 vitec:pagecheck-pages "$@" diff --git a/packages/vitec/Classes/Command/CreateCheckPagesCommand.php b/packages/vitec/Classes/Command/CreateCheckPagesCommand.php new file mode 100644 index 0000000..4858255 --- /dev/null +++ b/packages/vitec/Classes/Command/CreateCheckPagesCommand.php @@ -0,0 +1,142 @@ + '/markets', 'title' => 'Market Check', 'slug' => '/markets/market-check', 'header' => 'Market Content Check', 'scope' => 'markets'], + ['parentSlug' => '/solutions', 'title' => 'Solution Check', 'slug' => '/solutions/solution-check', 'header' => 'Solution Content Check', 'scope' => 'solutions'], + ]; + + protected function configure(): void + { + $this->addOption('apply', null, InputOption::VALUE_NONE, 'Create the pages (default: dry run)'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + Bootstrap::initializeBackendAuthentication(); + $apply = (bool)$input->getOption('apply'); + $db = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages'); + + $template = $db->fetchAssociative( + 'SELECT uid, doktype, backend_layout, backend_layout_next_level, no_index, no_follow, nav_hide, no_search, seo_title, description FROM pages WHERE deleted = 0 AND slug = ?', + [self::TEMPLATE_SLUG] + ); + if ($template === false) { + $output->writeln('Template page ' . self::TEMPLATE_SLUG . ' not found.'); + return Command::FAILURE; + } + $output->writeln(sprintf( + 'Template %s (uid %d): doktype %d, layout "%s", no_index %d, nav_hide %d, no_search %d', + self::TEMPLATE_SLUG, $template['uid'], $template['doktype'], $template['backend_layout'], $template['no_index'], $template['nav_hide'], $template['no_search'] + )); + $output->writeln($apply ? 'APPLY' : 'DRY RUN - nothing is written (--apply to create)'); + + $created = 0; + foreach (self::PAGES as $def) { + $existing = $db->fetchAssociative('SELECT uid FROM pages WHERE deleted = 0 AND slug = ?', [$def['slug']]); + if ($existing !== false) { + $output->writeln(sprintf(' %s exists (uid %d) - skipped', $def['slug'], $existing['uid'])); + continue; + } + $parent = $db->fetchAssociative('SELECT uid FROM pages WHERE deleted = 0 AND sys_language_uid = 0 AND slug = ?', [$def['parentSlug']]); + if ($parent === false) { + $output->writeln(sprintf(' parent %s not found - %s skipped', $def['parentSlug'], $def['slug'])); + continue; + } + $output->writeln(sprintf(' create %s "%s" under %s (pid %d) + vitec_pagecheck "%s" (scope %s)', $def['slug'], $def['title'], $def['parentSlug'], $parent['uid'], $def['header'], $def['scope'])); + $created++; + if (!$apply) { + continue; + } + $pageId = 'NEWpage' . $def['scope']; + $datamap = [ + 'pages' => [ + $pageId => [ + 'pid' => -(int)$this->lastChild((int)$parent['uid'], (int)$parent['uid']), + 'title' => $def['title'], + 'slug' => $def['slug'], + 'doktype' => (int)$template['doktype'], + 'backend_layout' => (string)$template['backend_layout'], + 'backend_layout_next_level' => (string)$template['backend_layout_next_level'], + 'no_index' => (int)$template['no_index'], + 'no_follow' => (int)$template['no_follow'], + 'nav_hide' => (int)$template['nav_hide'], + 'no_search' => (int)$template['no_search'], + 'hidden' => 0, + ], + ], + 'tt_content' => [ + 'NEWce' . $def['scope'] => [ + 'pid' => $pageId, + 'colPos' => 0, + 'sys_language_uid' => 0, + 'CType' => 'vitec_pagecheck', + 'header' => $def['header'], + 'pi_flexform' => ['data' => ['sDEF' => ['lDEF' => ['settings.scope' => ['vDEF' => $def['scope']]]]]], + ], + ], + ]; + $dataHandler = GeneralUtility::makeInstance(DataHandler::class); + $dataHandler->start($datamap, []); + $dataHandler->process_datamap(); + if ($dataHandler->errorLog !== []) { + $output->writeln(' DataHandler: ' . implode(' | ', $dataHandler->errorLog) . ''); + return Command::FAILURE; + } + foreach ($dataHandler->substNEWwithIDs as $new => $uid) { + $output->writeln(sprintf(' written: %s -> #%d', $new, $uid)); + } + } + $output->writeln(sprintf('%s: %d page(s).', $apply ? 'DONE' : 'DRY RUN - would create', $created)); + if ($apply && $created > 0) { + $output->writeln('Afterwards: vendor/bin/typo3 cache:flush'); + } + return Command::SUCCESS; + } + + /** uid of the last live child page (to append after it), or the parent itself when there is none */ + private function lastChild(int $parentUid, int $fallback): int + { + $uid = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages')->fetchOne( + 'SELECT uid FROM pages WHERE deleted = 0 AND sys_language_uid = 0 AND pid = ? ORDER BY sorting DESC LIMIT 1', + [$parentUid] + ); + return $uid === false ? $fallback : (int)$uid; + } +} diff --git a/packages/vitec/Classes/Service/PageCheckService.php b/packages/vitec/Classes/Service/PageCheckService.php new file mode 100644 index 0000000..3445c37 --- /dev/null +++ b/packages/vitec/Classes/Service/PageCheckService.php @@ -0,0 +1,624 @@ + [ + 'label' => 'Markets', + 'rootSlug' => '/markets', + 'types' => ['market', 'submarket'], + 'recordTable' => 'tx_vitec_domain_model_market', + ], + 'solutions' => [ + 'label' => 'Solutions', + 'rootSlug' => '/solutions', + 'types' => ['solution', 'subsolution'], + 'recordTable' => 'tx_vitec_domain_model_solution', + ], + ]; + + private const ALL = ['market', 'submarket', 'solution', 'subsolution']; + + /** + * Columns in display order. kind as in the product check (images / video / + * text / features / list), applies = page types that have the column. + */ + private const FIELDS = [ + ['key' => 'hero', 'label' => 'Hero image', 'kind' => 'images', 'applies' => self::ALL], + ['key' => 'subline', 'label' => 'Hero subline', 'kind' => 'text', 'applies' => self::ALL], + ['key' => 'lead', 'label' => 'Lead', 'kind' => 'text', 'applies' => ['solution', 'subsolution']], + ['key' => 'intro', 'label' => 'Intro text', 'kind' => 'text', 'applies' => self::ALL], + ['key' => 'introImage', 'label' => 'Intro image', 'kind' => 'images', 'applies' => self::ALL], + ['key' => 'introCta', 'label' => 'Intro CTA', 'kind' => 'text', 'applies' => self::ALL], + ['key' => 'cards', 'label' => 'Cards', 'kind' => 'features', 'applies' => self::ALL], + ['key' => 'stories', 'label' => 'Testimonials', 'kind' => 'list', 'applies' => self::ALL], + ['key' => 'products', 'label' => 'Products', 'kind' => 'list', 'applies' => ['submarket', 'subsolution']], + ['key' => 'insights', 'label' => 'Insights text', 'kind' => 'text', 'applies' => ['submarket', 'solution', 'subsolution']], + ['key' => 'insightsCover', 'label' => 'Insights cover', 'kind' => 'images', 'applies' => ['submarket', 'solution', 'subsolution']], + ['key' => 'insightsCta', 'label' => 'Insights CTA', 'kind' => 'text', 'applies' => ['submarket', 'solution', 'subsolution']], + ['key' => 'logos', 'label' => 'Logos', 'kind' => 'list', 'applies' => ['market']], + ['key' => 'cta', 'label' => 'CTA banner', 'kind' => 'text', 'applies' => ['market', 'solution']], + ['key' => 'seo', 'label' => 'Meta description', 'kind' => 'text', 'applies' => self::ALL], + ['key' => 'og', 'label' => 'OG image', 'kind' => 'images', 'applies' => self::ALL], + ['key' => 'media', 'label' => 'Page media', 'kind' => 'images', 'applies' => self::ALL], + ['key' => 'record', 'label' => 'Record', 'kind' => 'list', 'applies' => self::ALL], + ]; + + private const HERO_COLPOS = [1, 110]; + private const CONTAINER_PREFIXES = ['vitec_cols_', 'vitec_container', 'vitec_cards_carousel']; + + /** Text fields per element type for the detail pane: column => label. */ + private const ELEMENT_TEXTS = [ + 'vitec_markethero' => ['vitec_subline' => 'Subline', 'vitec_cta_label' => 'CTA label', 'vitec_video_title' => 'Video title'], + 'vitec_herosection' => ['header' => 'Header', 'subheader' => 'Subheader', 'bodytext' => 'Text', 'vitec_cta_label' => 'CTA label'], + 'vitec_introparagraph' => ['header' => 'Header', 'subheader' => 'Subheader', 'bodytext' => 'Text', 'vitec_button_text' => 'Button'], + 'vitec_marketintro' => ['vitec_intro_headline' => 'Headline', 'vitec_intro_text' => 'Text', 'vitec_learnmore_label' => 'Button'], + 'vitec_card' => ['vitec_eyebrow' => 'Eyebrow', 'header' => 'Header', 'subheader' => 'Subheader', 'bodytext' => 'Text', 'vitec_back_headline' => 'Back headline', 'vitec_back_text' => 'Back text', 'vitec_primary_cta_label' => 'Button'], + 'vitec_usecaselist' => ['header' => 'Header'], + 'vitec_customerlogos' => ['header' => 'Header'], + 'vitec_marketinsights' => ['vitec_text1' => 'Text 1', 'vitec_text2' => 'Text 2', 'vitec_cta_label' => 'CTA label', 'vitec_video_person' => 'Video person', 'vitec_video_role' => 'Video role', 'vitec_video_title' => 'Video title'], + 'vitec_ctabanner' => ['header' => 'Header', 'bodytext' => 'Text', 'vitec_primary_cta_label' => 'Primary button', 'vitec_secondary_cta_label' => 'Secondary button'], + 'vitec_productfeature' => ['header' => 'Header'], + 'vitec_productlist' => ['header' => 'Header'], + ]; + + /** Link fields per element type: column => label. */ + private const ELEMENT_LINKS = [ + 'vitec_markethero' => ['vitec_cta_link' => 'CTA link', 'vitec_video_url' => 'Video URL'], + 'vitec_herosection' => ['vitec_cta' => 'CTA link'], + 'vitec_introparagraph' => ['vitec_button_link' => 'Button link'], + 'vitec_marketintro' => ['vitec_learnmore_target' => 'Button target'], + 'vitec_card' => ['vitec_primary_cta' => 'Button link', 'vitec_card_link' => 'Card link'], + 'vitec_marketinsights' => ['vitec_cta_target' => 'CTA target', 'vitec_video_url' => 'Video URL'], + 'vitec_ctabanner' => ['vitec_primary_cta' => 'Primary link', 'vitec_secondary_cta' => 'Secondary link'], + ]; + + private const ELEMENT_LABELS = [ + 'vitec_markethero' => 'Hero', + 'vitec_herosection' => 'Hero section', + 'vitec_introparagraph' => 'Intro paragraph', + 'vitec_marketintro' => 'Intro (text | image)', + 'vitec_card' => 'Card', + 'vitec_usecaselist' => 'Testimonials', + 'vitec_customerlogos' => 'Customer logos', + 'vitec_marketinsights' => 'Insights', + 'vitec_ctabanner' => 'CTA banner', + 'vitec_productfeature' => 'Featured product', + 'vitec_productlist' => 'Product list', + ]; + + public function __construct( + private readonly ConnectionPool $connectionPool, + ) {} + + /** @return array */ + public function overview(string $scope): array + { + $cfg = self::SCOPES[$scope] ?? throw new \InvalidArgumentException('Unknown scope ' . $scope); + $pages = $this->pages($cfg); + $pageUids = array_map(static fn(array $p): int => (int)$p['uid'], $pages); + $elementsByPage = $this->elementsByPage($pageUids); + $allElementUids = []; + foreach ($elementsByPage as $els) { + foreach ($els as $el) { + $allElementUids[] = (int)$el['uid']; + } + } + $elementFiles = $this->referenceCounts('tt_content', $allElementUids); + $pageFiles = $this->referenceCounts('pages', $pageUids); + $records = $this->records($cfg['recordTable'], $pageUids); + $recordFiles = $this->referenceCounts($cfg['recordTable'], array_map(static fn(array $r): int => (int)$r['uid'], $records)); + // Empty selection = what the testimonial band shows: quoted stories, + // at most twelve (UsecaseListJsonRenderer::testimonialRows). + $allStories = min(12, $this->countAll('tx_vitec_domain_model_usecase', "deleted = 0 AND hidden = 0 AND hideonwebsite = 0 AND TRIM(COALESCE(quote, '')) <> ''")); + $allCustomers = $this->countAll('tx_vitec_domain_model_customer', 'deleted = 0 AND hidden = 0'); + + $keys = array_column(self::FIELDS, 'key'); + $filled = array_fill_keys($keys, 0); + $applicable = array_fill_keys($keys, 0); + $byType = []; + $rows = []; + foreach ($pages as $p) { + $uid = (int)$p['uid']; + $type = (string)$p['_type']; + $byType[$type] = ($byType[$type] ?? 0) + 1; + $els = $elementsByPage[$uid] ?? []; + $visible = array_values(array_filter($els, static fn(array $e): bool => (int)$e['hidden'] === 0)); + $hiddenCount = count($els) - count($visible); + $record = $records[$uid] ?? null; + $raw = $this->pageChecks($type, $visible, $elementFiles, $pageFiles[$uid] ?? [], $p, $record, $record ? ($recordFiles[(int)$record['uid']] ?? []) : [], $allStories, $allCustomers); + + $checks = []; + foreach (self::FIELDS as $field) { + $key = $field['key']; + if (!in_array($type, $field['applies'], true)) { + $checks[$key] = ['ok' => null, 'count' => 0]; + continue; + } + $checks[$key] = $raw[$key]; + $applicable[$key]++; + if ($raw[$key]['ok'] === true) { + $filled[$key]++; + } + } + $rows[] = $this->base($p, $type, $record, $hiddenCount) + ['checks' => $checks]; + } + + return [ + 'scope' => $scope, + 'scopeLabel' => $cfg['label'], + 'generatedAt' => date(\DATE_ATOM), + 'fields' => self::FIELDS, + 'summary' => ['pages' => count($rows), 'byType' => $byType, 'filled' => $filled, 'applicable' => $applicable], + 'pages' => $rows, + ]; + } + + /** @return array|null null when the page is not part of any scope */ + public function detail(int $uid): ?array + { + foreach (self::SCOPES as $scope => $cfg) { + foreach ($this->pages($cfg) as $p) { + if ((int)$p['uid'] !== $uid) { + continue; + } + $type = (string)$p['_type']; + $records = $this->records($cfg['recordTable'], [$uid]); + $record = $records[$uid] ?? null; + $els = $this->elementsByPage([$uid])[$uid] ?? []; + $hiddenCount = count(array_filter($els, static fn(array $e): bool => (int)$e['hidden'] === 1)); + $flexFormService = GeneralUtility::makeInstance(FlexFormService::class); + + $elements = []; + foreach ($els as $el) { + $cType = (string)$el['CType']; + $texts = []; + foreach (self::ELEMENT_TEXTS[$cType] ?? ['header' => 'Header', 'bodytext' => 'Text'] as $column => $label) { + $value = (string)($el[$column] ?? ''); + $texts[] = ['key' => $column, 'label' => $label, 'ok' => $this->filled($value), 'html' => $value]; + } + $links = []; + foreach (self::ELEMENT_LINKS[$cType] ?? [] as $column => $label) { + $value = trim((string)($el[$column] ?? '')); + if ($value !== '') { + $links[] = ['key' => $column, 'label' => $label, 'target' => $value]; + } + } + $settings = []; + if (trim((string)($el['pi_flexform'] ?? '')) !== '') { + $settings = $flexFormService->convertFlexFormContentToArray((string)$el['pi_flexform'])['settings'] ?? []; + $settings = array_filter($settings, static fn($v): bool => is_scalar($v) && (string)$v !== '' && (string)$v !== '0'); + $settings = array_map(static fn($v): string => (string)$v, $settings); + } + $elements[] = [ + 'uid' => (int)$el['uid'], + 'type' => $cType, + 'label' => self::ELEMENT_LABELS[$cType] ?? $cType, + 'colPos' => (int)$el['colPos'], + 'slot' => in_array((int)$el['colPos'], self::HERO_COLPOS, true) ? 'hero' : 'main', + 'hidden' => (int)$el['hidden'] === 1, + 'header' => (string)($el['header'] ?? ''), + 'layoutVariant' => (string)($el['vitec_layout_variant'] ?? ''), + 'texts' => $texts, + 'links' => $links, + 'images' => $this->files('tt_content', (int)$el['uid']), + 'settings' => $settings, + ]; + } + + $children = []; + if ((int)$p['_level'] === 1) { + foreach ($this->pages($cfg) as $c) { + if ((int)$c['pid'] === $uid) { + $children[] = ['uid' => (int)$c['uid'], 'title' => (string)$c['title'], 'url' => (string)$c['slug'], 'hidden' => (int)$c['hidden'] === 1]; + } + } + } + + return $this->base($p, $type, $record, $hiddenCount) + [ + 'seo' => [ + 'title' => (string)$p['title'], + 'seoTitle' => (string)($p['seo_title'] ?? ''), + 'description' => (string)($p['description'] ?? ''), + 'og' => $this->files('pages', $uid, ['og_image']), + ], + 'media' => $this->files('pages', $uid, ['media']), + 'record' => $record === null ? null : [ + 'uid' => (int)$record['uid'], + 'title' => (string)$record['title'], + 'subtitle' => (string)($record['subtitle'] ?? ''), + 'teaser' => (string)($record['teaser'] ?? ''), + 'description' => (string)($record['description'] ?? ''), + 'image' => $this->files($cfg['recordTable'], (int)$record['uid'], ['image']), + ], + 'elements' => $elements, + 'children' => $children, + ]; + } + } + return null; + } + + // ---- checks ------------------------------------------------------------- + + /** + * @param array> $visible visible elements in page order + * @param array> $elementFiles [elementUid][fieldname] = count + * @param array $pageFiles [fieldname] = count + * @param array $page + * @param array|null $record + * @param array $recordFiles + * @return array + */ + private function pageChecks(string $type, array $visible, array $elementFiles, array $pageFiles, array $page, ?array $record, array $recordFiles, int $allStories, int $allCustomers): array + { + $hero = $this->firstOf($visible, 'vitec_markethero') ?? $this->firstOf($visible, 'vitec_herosection'); + $heroFiles = $hero ? ($elementFiles[(int)$hero['uid']] ?? []) : []; + $lead = null; + foreach ($visible as $el) { + if ($el['CType'] === 'vitec_introparagraph' && in_array((int)$el['colPos'], self::HERO_COLPOS, true)) { + $lead = $el; + break; + } + } + $intro = $this->firstOf($visible, 'vitec_marketintro'); + $introFiles = $intro ? ($elementFiles[(int)$intro['uid']] ?? []) : []; + + $cards = array_values(array_filter($visible, static fn(array $e): bool => $e['CType'] === 'vitec_card')); + $cardVisuals = 0; + foreach ($cards as $c) { + $f = $elementFiles[(int)$c['uid']] ?? []; + if (($f['vitec_icon'] ?? 0) > 0 || ($f['vitec_image'] ?? 0) > 0) { + $cardVisuals++; + } + } + + $stories = $this->firstOf($visible, 'vitec_usecaselist'); + $storyCount = 0; + if ($stories) { + $sel = $this->flexSettings((string)$stories['pi_flexform']); + $selected = GeneralUtility::intExplode(',', (string)($sel['usecases'] ?? ''), true); + $storyCount = $selected === [] ? $allStories : count($selected); + } + + $productCount = 0; + $hasProducts = false; + foreach ($visible as $el) { + if ($el['CType'] === 'vitec_productfeature') { + $hasProducts = true; + $productCount += (int)(($this->flexSettings((string)$el['pi_flexform'])['product'] ?? 0) > 0); + } elseif ($el['CType'] === 'vitec_productlist') { + $hasProducts = true; + $sel = $this->flexSettings((string)$el['pi_flexform']); + $productCount += count(GeneralUtility::intExplode(',', (string)($sel['selectedproducts'] ?? ''), true)); + } + } + + $insights = $this->firstOf($visible, 'vitec_marketinsights'); + $insightsFiles = $insights ? ($elementFiles[(int)$insights['uid']] ?? []) : []; + + $logos = $this->firstOf($visible, 'vitec_customerlogos'); + $logoCount = 0; + if ($logos) { + $sel = $this->flexSettings((string)$logos['pi_flexform']); + $selected = GeneralUtility::intExplode(',', (string)($sel['customers'] ?? ''), true); + $logoCount = $selected === [] || ($sel['onlySelected'] ?? '0') !== '1' ? $allCustomers : count($selected); + } + + $cta = $this->firstOf($visible, 'vitec_ctabanner'); + + $recordFilled = 0; + if ($record !== null) { + $recordFilled += (($recordFiles['image'] ?? 0) > 0) ? 1 : 0; + $recordFilled += $this->filled($record['teaser'] ?? '') ? 1 : 0; + $recordFilled += $this->filled($record['description'] ?? '') ? 1 : 0; + } + + return [ + 'hero' => $this->check(($heroFiles['vitec_bgimage'] ?? 0) + ($heroFiles['vitec_hero_bgimage'] ?? 0) + ($heroFiles['vitec_hero_image'] ?? 0)), + 'subline' => $this->check($hero && ($this->filled($hero['vitec_subline'] ?? '') || $this->filled($hero['subheader'] ?? '')) ? 1 : 0), + 'lead' => $this->check($lead && $this->filled($lead['bodytext'] ?? '') ? 1 : 0), + 'intro' => $this->check($intro && $this->filled($intro['vitec_intro_text'] ?? '') ? 1 : 0), + 'introImage' => $this->check($introFiles['vitec_image'] ?? 0), + 'introCta' => $this->check($intro && $this->filled($intro['vitec_learnmore_label'] ?? '') && $this->filled($intro['vitec_learnmore_target'] ?? '') ? 1 : 0), + 'cards' => $this->check(count($cards)) + ['icons' => $cardVisuals], + 'stories' => ['ok' => $stories !== null, 'count' => $storyCount], + 'products' => ['ok' => $hasProducts, 'count' => $productCount], + 'insights' => $this->check($insights && $this->filled($insights['vitec_text1'] ?? '') ? 1 : 0), + 'insightsCover' => $this->check($insightsFiles['vitec_video_cover'] ?? 0), + 'insightsCta' => $this->check($insights && $this->filled($insights['vitec_cta_label'] ?? '') && $this->filled($insights['vitec_cta_target'] ?? '') ? 1 : 0), + 'logos' => ['ok' => $logos !== null, 'count' => $logoCount], + 'cta' => $this->check($cta && $this->filled($cta['vitec_primary_cta_label'] ?? '') ? 1 : 0), + 'seo' => $this->check($this->filled($page['description'] ?? '') ? 1 : 0), + 'og' => $this->check($pageFiles['og_image'] ?? 0), + 'media' => $this->check($pageFiles['media'] ?? 0), + 'record' => ['ok' => $record !== null && ($recordFiles['image'] ?? 0) > 0 && $this->filled($record['teaser'] ?? ''), 'count' => $recordFilled], + ]; + } + + // ---- building blocks ---------------------------------------------------- + + /** + * @param array $p + * @param array|null $record + */ + private function base(array $p, string $type, ?array $record, int $hiddenElements): array + { + $flags = []; + if ((int)$p['hidden'] === 1) { + $flags[] = 'hidden'; + } + if ((int)$p['nav_hide'] === 1) { + $flags[] = 'navHide'; + } + if ((int)$p['no_index'] === 1) { + $flags[] = 'noindex'; + } + if ($record === null) { + $flags[] = 'noRecord'; + } + if ($hiddenElements > 0) { + $flags[] = 'hiddenElements:' . $hiddenElements; + } + return [ + 'uid' => (int)$p['uid'], + 'title' => (string)$p['title'], + 'slug' => trim((string)$p['slug'], '/'), + 'url' => (string)$p['slug'], + 'type' => $type, + 'level' => (int)$p['_level'], + 'parent' => (int)$p['pid'], + 'flags' => $flags, + ]; + } + + /** @return array{ok: bool, count: int} */ + private function check(int $count): array + { + return ['ok' => $count > 0, 'count' => $count]; + } + + private function filled(mixed $value): bool + { + return trim(strip_tags((string)$value)) !== ''; + } + + /** @param array> $elements */ + private function firstOf(array $elements, string $cType): ?array + { + foreach ($elements as $el) { + if ($el['CType'] === $cType) { + return $el; + } + } + return null; + } + + /** @return array */ + private function flexSettings(string $xml): array + { + if (trim($xml) === '') { + return []; + } + $settings = GeneralUtility::makeInstance(FlexFormService::class)->convertFlexFormContentToArray($xml)['settings'] ?? []; + return is_array($settings) ? array_map(static fn($v): string => is_scalar($v) ? (string)$v : '', $settings) : []; + } + + /** + * The scope's pages: level 1 = children of the root page, level 2 = their + * children. Standard pages only, in tree order; a page carrying the check + * element itself is left out. + * + * @param array{rootSlug: string, types: array} $cfg + * @return array> rows with _level and _type + */ + private function pages(array $cfg): array + { + $conn = $this->connection('pages'); + $root = $conn->fetchAssociative('SELECT uid FROM pages WHERE deleted = 0 AND sys_language_uid = 0 AND slug = ?', [$cfg['rootSlug']]); + if ($root === false) { + return []; + } + $select = 'SELECT uid, pid, title, slug, hidden, nav_hide, no_index, description, seo_title, sorting FROM pages + WHERE deleted = 0 AND sys_language_uid = 0 AND doktype = 1 AND pid IN (%s) ORDER BY pid, sorting'; + $level1 = $conn->fetchAllAssociative(sprintf($select, (int)$root['uid'])); + $checkPages = $this->pagesWithCheckElement(); + $out = []; + foreach ($level1 as $p1) { + if (isset($checkPages[(int)$p1['uid']])) { + continue; + } + $out[] = $p1 + ['_level' => 1, '_type' => $cfg['types'][0]]; + $level2 = $conn->fetchAllAssociative(sprintf($select, (int)$p1['uid'])); + foreach ($level2 as $p2) { + if (isset($checkPages[(int)$p2['uid']])) { + continue; + } + $out[] = $p2 + ['_level' => 2, '_type' => $cfg['types'][1]]; + } + } + return $out; + } + + /** @return array page uid => true for pages that hold a check element */ + private function pagesWithCheckElement(): array + { + $out = []; + foreach ($this->connection('tt_content')->fetchFirstColumn( + "SELECT DISTINCT pid FROM tt_content WHERE deleted = 0 AND CType IN ('vitec_pagecheck', 'vitec_productcheck')" + ) as $pid) { + $out[(int)$pid] = true; + } + return $out; + } + + /** + * All non-deleted default-language elements of the pages, page order, + * containers themselves left out (their children count as page content). + * + * @param int[] $pageUids + * @return array>> [pageUid] = rows + */ + private function elementsByPage(array $pageUids): array + { + if ($pageUids === []) { + return []; + } + $rows = $this->connection('tt_content')->fetchAllAssociative( + 'SELECT * FROM tt_content WHERE deleted = 0 AND sys_language_uid = 0 AND pid IN (' + . implode(',', array_map('intval', $pageUids)) . ') ORDER BY pid, colPos, sorting, uid' + ); + // page order: hero slot first, then the main column(s) by colPos and sorting + usort($rows, static function (array $a, array $b): int { + $heroA = in_array((int)$a['colPos'], self::HERO_COLPOS, true) ? 0 : 1; + $heroB = in_array((int)$b['colPos'], self::HERO_COLPOS, true) ? 0 : 1; + return [(int)$a['pid'], $heroA, (int)$a['colPos'], (int)$a['sorting'], (int)$a['uid']] + <=> [(int)$b['pid'], $heroB, (int)$b['colPos'], (int)$b['sorting'], (int)$b['uid']]; + }); + $out = []; + foreach ($rows as $row) { + $cType = (string)$row['CType']; + foreach (self::CONTAINER_PREFIXES as $prefix) { + if (str_starts_with($cType, $prefix)) { + continue 2; + } + } + $out[(int)$row['pid']][] = $row; + } + return $out; + } + + /** + * Visible file references per record and field. + * + * @param int[] $uids + * @return array> [uid][fieldname] = count + */ + private function referenceCounts(string $table, array $uids): array + { + if ($uids === []) { + return []; + } + $rows = $this->connection('sys_file_reference')->fetchAllAssociative( + 'SELECT uid_foreign, fieldname, COUNT(*) AS n FROM sys_file_reference + WHERE deleted = 0 AND hidden = 0 AND tablenames = ? AND uid_foreign IN (' . implode(',', array_map('intval', $uids)) . ') + GROUP BY uid_foreign, fieldname', + [$table] + ); + $out = []; + foreach ($rows as $r) { + $out[(int)$r['uid_foreign']][(string)$r['fieldname']] = (int)$r['n']; + } + return $out; + } + + /** + * Files of one record with original URL and a ~480 px thumbnail. + * + * @param string[]|null $fieldnames null = all fields + * @return array + */ + private function files(string $table, int $uid, ?array $fieldnames = null): array + { + $sql = "SELECT r.fieldname, r.uid_local, f.identifier, f.name, f.mime_type + FROM sys_file_reference r JOIN sys_file f ON f.uid = r.uid_local + WHERE r.deleted = 0 AND r.hidden = 0 AND r.tablenames = ? AND r.uid_foreign = ?"; + $params = [$table, $uid]; + if ($fieldnames !== null) { + $sql .= ' AND r.fieldname IN (' . implode(',', array_fill(0, count($fieldnames), '?')) . ')'; + $params = array_merge($params, $fieldnames); + } + $sql .= ' ORDER BY r.fieldname, r.sorting_foreign'; + $out = []; + foreach ($this->connection('sys_file_reference')->fetchAllAssociative($sql, $params) as $r) { + $url = '/fileadmin' . $r['identifier']; + $out[] = [ + 'field' => (string)preg_replace('/^vitec_/', '', (string)$r['fieldname']), + 'url' => $url, + 'thumb' => $this->thumbnail((int)$r['uid_local'], (string)$r['mime_type'], $url), + 'name' => (string)$r['name'], + 'mime' => (string)$r['mime_type'], + ]; + } + return $out; + } + + private function thumbnail(int $fileUid, string $mime, string $fallback): string + { + if (!str_starts_with($mime, 'image/') || $mime === 'image/svg+xml') { + return $fallback; + } + try { + $file = GeneralUtility::makeInstance(ResourceFactory::class)->getFileObject($fileUid); + $imageService = GeneralUtility::makeInstance(ImageService::class); + $processed = $imageService->applyProcessingInstructions($file, ['maxWidth' => 480, 'maxHeight' => 480]); + return $imageService->getImageUri($processed); + } catch (\Throwable) { + return $fallback; + } + } + + /** + * Records pointing to the pages via detail_page (first live record per page). + * + * @param int[] $pageUids + * @return array> [pageUid] = record row + */ + private function records(string $table, array $pageUids): array + { + if ($pageUids === []) { + return []; + } + $rows = $this->connection($table)->fetchAllAssociative( + 'SELECT uid, title, subtitle, teaser, description, detail_page FROM ' . $table . ' + WHERE deleted = 0 AND detail_page IN (' . implode(',', array_map('intval', $pageUids)) . ') ORDER BY hidden, uid' + ); + $out = []; + foreach ($rows as $r) { + $out[(int)$r['detail_page']] ??= $r; + } + return $out; + } + + private function countAll(string $table, string $where): int + { + return (int)$this->connection($table)->fetchOne("SELECT COUNT(*) FROM $table WHERE $where"); + } + + private function connection(string $table): Connection + { + return $this->connectionPool->getConnectionForTable($table); + } +} diff --git a/packages/vitec/Classes/UserFunc/PageCheckJsonRenderer.php b/packages/vitec/Classes/UserFunc/PageCheckJsonRenderer.php new file mode 100644 index 0000000..264f090 --- /dev/null +++ b/packages/vitec/Classes/UserFunc/PageCheckJsonRenderer.php @@ -0,0 +1,68 @@ + {mode: "detail", page: {…} | null} + * on failure {mode: "error", error} + * + * The scope (markets | solutions) comes from the element's FlexForm + * (settings.scope). Always USER_INT - the audit shows the database as it is + * right now; `check` is excluded from the cHash (ext_localconf.php, shared + * with the product check). The front end loads the detail variant + * client-side from /_json, one request per opened row. + */ +final class PageCheckJsonRenderer +{ + protected ?ContentObjectRenderer $cObj = null; + + public function setContentObjectRenderer(ContentObjectRenderer $cObj): void + { + $this->cObj = $cObj; + } + + #[AsAllowedCallable] + public function render(string $content, array $conf): string + { + if ($this->cObj !== null && $this->cObj->getUserObjectType() === ContentObjectRenderer::OBJECTTYPE_USER) { + $this->cObj->convertToUserIntObject(); + return ''; + } + try { + $scope = 'markets'; + $flex = (string)($this->cObj?->data['pi_flexform'] ?? ''); + if (trim($flex) !== '') { + $settings = GeneralUtility::makeInstance(FlexFormService::class)->convertFlexFormContentToArray($flex)['settings'] ?? []; + if (isset(PageCheckService::SCOPES[(string)($settings['scope'] ?? '')])) { + $scope = (string)$settings['scope']; + } + } + $service = GeneralUtility::makeInstance(PageCheckService::class); + $queryParams = $GLOBALS['TYPO3_REQUEST']?->getQueryParams() ?? []; + $check = (int)($queryParams['check'] ?? 0); + if ($check > 0) { + $payload = ['mode' => 'detail', 'scope' => $scope, 'page' => $service->detail($check)]; + } else { + $payload = ['mode' => 'overview'] + $service->overview($scope); + } + } catch (\Throwable $e) { + $payload = ['mode' => 'error', 'error' => $e->getMessage()]; + } + return (string)json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); + } +} diff --git a/packages/vitec/Configuration/FlexForms/Pagecheck.xml b/packages/vitec/Configuration/FlexForms/Pagecheck.xml new file mode 100644 index 0000000..c7d98f4 --- /dev/null +++ b/packages/vitec/Configuration/FlexForms/Pagecheck.xml @@ -0,0 +1,35 @@ + + + + 1 + + + + + Page Content Check + array + + + + Markets = the 11 markets and their sub-markets below /markets. Solutions = the 8 solution groups and their sub-solutions below /solutions. + + select + selectSingle + + + + markets + + + + solutions + + + markets + + + + + + + diff --git a/packages/vitec/Configuration/Services.yaml b/packages/vitec/Configuration/Services.yaml index b031bae..b2a2e66 100644 --- a/packages/vitec/Configuration/Services.yaml +++ b/packages/vitec/Configuration/Services.yaml @@ -32,6 +32,8 @@ services: # ProductCheckJsonRenderer (TypoScript userFunc, no DI there). Evomedien\Vitec\Service\ProductCheckService: public: true + Evomedien\Vitec\Service\PageCheckService: + public: true # SSR dashboard tiles (the WidgetConfigurationInterface argument is # injected by the dashboard compiler pass via the tag). diff --git a/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript b/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript index 18b8607..0c0bf95 100755 --- a/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript +++ b/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript @@ -114,6 +114,21 @@ tt_content { } } + # Page Content Check - the market / solution page audit as data (pages + # /markets/market-check, /solutions/solution-check). Always USER_INT, see PageCheckJsonRenderer. + vitec_pagecheck < lib.contentElementWithHeader + vitec_pagecheck { + fields { + content { + fields { + pagecheck = USER + pagecheck.userFunc = Evomedien\Vitec\UserFunc\PageCheckJsonRenderer->render + } + } + } + } + + # EXT:solr search results as JSON - the search endpoint for the React frontend solr_pi_results < lib.contentElementWithHeader solr_pi_results { diff --git a/packages/vitec/Configuration/TCA/Overrides/tt_content.php b/packages/vitec/Configuration/TCA/Overrides/tt_content.php index f224fd9..131bd08 100644 --- a/packages/vitec/Configuration/TCA/Overrides/tt_content.php +++ b/packages/vitec/Configuration/TCA/Overrides/tt_content.php @@ -37,6 +37,10 @@ use TYPO3\CMS\Extbase\Utility\ExtensionUtility; 'plugins', 'Live audit of every product record: one row per product, one check per content field, details per row. Place once on /products/product-check.' ); + // Page content audit for the market and solution trees (2026-11-13): + // scope (markets | solutions) via FlexForm, data from PageCheckService. + $registerPluginWithFlexForm('Pagecheck', 'Page Content Check (markets / solutions audit)', 'FILE:EXT:vitec/Configuration/FlexForms/Pagecheck.xml', 'vitec-plugin-productlist'); + $registerPluginWithFlexForm('Simplecard', 'Simple Card', 'FILE:EXT:vitec/Configuration/FlexForms/Simplecard.xml', 'vitec-plugin-simplecard'); $registerPluginWithFlexForm('Productshow', 'Show Single Product', 'FILE:EXT:vitec/Configuration/FlexForms/Productshow.xml', 'vitec-plugin-productshow'); $registerPluginWithFlexForm('Usecaseshow', 'Single Success Story', 'FILE:EXT:vitec/Configuration/FlexForms/Usecase.xml', 'vitec-plugin-usecaseshow'); diff --git a/packages/vitec/Configuration/page.tsconfig b/packages/vitec/Configuration/page.tsconfig index aef489e..30320e0 100755 --- a/packages/vitec/Configuration/page.tsconfig +++ b/packages/vitec/Configuration/page.tsconfig @@ -40,6 +40,15 @@ mod { CType = vitec_productcheck } } + pagecheck { + iconIdentifier = vitec-plugin-productlist + title = Page Content Check (markets / solutions audit) + description = Live audit of the market or solution page tree - one row per page, one check per content slot. Internal pages /markets/market-check and /solutions/solution-check. + tt_content_defValues { + CType = vitec_pagecheck + } + } + productshow { iconIdentifier = vitec-plugin-productshow title = Show single VITEC Product