diff --git a/migrations/check_dup_solution_pages.php b/migrations/check_dup_solution_pages.php new file mode 100644 index 0000000..9ea74b9 --- /dev/null +++ b/migrations/check_dup_solution_pages.php @@ -0,0 +1,23 @@ + PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]); +$uids = [481,482,483,484,485,486,487,488,489,490,491,492,493,494,495,496,497,498,499,500]; +$rows = $pdo->query('SELECT uid, pid, title, slug, hidden, crdate, sorting FROM pages WHERE uid IN (' . implode(',', $uids) . ') ORDER BY pid, sorting')->fetchAll(); +$parents = []; +foreach ($rows as $r) { $parents[(int)$r['pid']] = true; } +foreach (array_keys($parents) as $pid) { + $p = $pdo->query("SELECT uid, pid, title, slug, hidden, crdate FROM pages WHERE uid = $pid")->fetch(); + printf("ELTERN %d | pid %d | %s | /%s | %s| angelegt %s\n", $p['uid'], $p['pid'], $p['title'], trim((string)$p['slug'],'/'), $p['hidden'] ? 'HIDDEN ' : '', date('Y-m-d H:i', (int)$p['crdate'])); + // Kinderzahl + Inhalt vorhanden? + foreach ($rows as $r) { + if ((int)$r['pid'] !== (int)$p['uid']) { continue; } + $ce = (int)$pdo->query('SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = ' . (int)$r['uid'])->fetchColumn(); + printf(" %d | %-42s | /%s | %s| %d CE | angelegt %s\n", $r['uid'], $r['title'], trim((string)$r['slug'],'/'), $r['hidden'] ? 'HIDDEN ' : '', $ce, date('Y-m-d H:i', (int)$r['crdate'])); + } +} diff --git a/migrations/check_solutions.php b/migrations/check_solutions.php new file mode 100644 index 0000000..93ea6b5 --- /dev/null +++ b/migrations/check_solutions.php @@ -0,0 +1,50 @@ + PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC] +); + +$rows = $pdo->query( + "SELECT uid, title, slug, hidden, detail_page, teaser <> '' AS has_teaser, description <> '' AS has_description + FROM tx_vitec_domain_model_solution WHERE deleted = 0 ORDER BY title" +)->fetchAll(); + +echo "=== solutions (uid | title | slug | flags) ===\n"; +foreach ($rows as $row) { + printf("%3d | %-55s | %-45s | %s%s%s%s\n", + $row['uid'], $row['title'], $row['slug'], + $row['hidden'] ? 'HIDDEN ' : '', + $row['detail_page'] ? ('detail_page=' . $row['detail_page'] . ' ') : '', + $row['has_teaser'] ? 'teaser ' : '', + $row['has_description'] ? 'desc' : '' + ); +} +echo count($rows) . " solutions total\n"; diff --git a/packages/vitec/Classes/Command/AlignSolutionsCommand.php b/packages/vitec/Classes/Command/AlignSolutionsCommand.php new file mode 100644 index 0000000..2fa2f56 --- /dev/null +++ b/packages/vitec/Classes/Command/AlignSolutionsCommand.php @@ -0,0 +1,300 @@ + "(CCTV, IoT, sensors)" + * "Passenger Information Displays (Digital Signage)" -> "Passenger / Public Information Displays" + * (the latter keeps the only editorial teaser/description in the table) + * 3. creates missing target solutions (title + slug) + * 4. sets each target's categories to exactly its group (market categories + * are stripped from solutions - they stay in place for other models) + * 5. regenerates empty/stale slugs from the (possibly new) title + * 6. soft-DELETES every solution record whose title is not a target + * (DataHandler delete - restorable via recycler) + * + * vendor/bin/typo3 vitec:align-solutions --dry-run + * vendor/bin/typo3 vitec:align-solutions + */ +#[AsCommand( + name: 'vitec:align-solutions', + description: 'Align solution records to EXACTLY the 39 sitemap Generic Solutions in 8 groups (prunes the rest)' +)] +final class AlignSolutionsCommand extends Command +{ + private const TABLE = 'tx_vitec_domain_model_solution'; + + /** Binding target: group => solutions, verbatim from the sheet (typo fixed). */ + private const TARGET = [ + 'Control Room & Command Centre Solutions' => [ + 'Control Room Platforms (SOC / NOC / Command Centres)', + 'Command & Control Visualisation', + 'Operations Management Environments', + 'Crisis & Incident Management Centres', + ], + 'Real-time Monitoring & Situational Awareness' => [ + 'Multi-source Video Monitoring', + 'Real-time Data Monitoring (CCTV, IoT, sensors)', + 'Situational Awareness Platforms', + 'Surveillance & Operational Visibility', + 'Incident Detection & Response', + ], + 'Video Distribution & Streaming' => [ + 'IPTV Distribution', + 'AV-over-IP Distribution', + 'Secure Video Streaming (low latency)', + 'Multi-site Video Delivery', + 'Internal & External Broadcast Distribution', + ], + 'Video Capture, Encoding & Processing' => [ + 'Video Encoding / Decoding', + 'Live Video Capture', + 'Signal Processing & Conversion', + 'Transcoding & Optimisation', + 'Contribution & Remote Production', + ], + 'Video Wall & Data Visualisation' => [ + 'Video Wall Platforms', + 'Multi-display Visualisation', + 'Real-time Dashboards', + 'Data Aggregation & Display', + 'Operational Intelligence Displays', + ], + 'Digital Signage & Content Delivery' => [ + 'Digital Signage Networks', + 'Passenger / Public Information Displays', + 'Wayfinding & Messaging', + 'Advertising & Sponsorship Displays', + 'Targeted Content Delivery', + ], + 'Enterprise Communications & Engagement' => [ + 'Internal Communications (IPTV)', + 'Staff Messaging Systems', + 'Corporate Broadcasting', + 'Guest / Visitor Engagement', + 'Multi-location Communication Networks', + ], + 'Security & Surveillance Solutions' => [ + 'Surveillance & CCTV Integration', + 'Security Operations Centres (SOC)', + 'Perimeter Monitoring', + 'Incident Response Systems', + 'Secure Monitoring Environments', + ], + ]; + + /** Legacy record title => target title (rename instead of delete+create). */ + private const RENAMES = [ + 'Real-time Data Monitoring (CCTV, sensors)' => 'Real-time Data Monitoring (CCTV, IoT, sensors)', + 'Passenger Information Displays (Digital Signage)' => 'Passenger / Public Information Displays', + ]; + + protected function configure(): void + { + $this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report the plan - write nothing'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + Bootstrap::initializeBackendAuthentication(); + $dryRun = (bool)$input->getOption('dry-run'); + $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::TABLE); + + $solutionRoot = (int)$connection->fetchOne( + "SELECT uid FROM sys_category WHERE deleted = 0 AND parent = 0 AND title = 'Solution'" + ); + if ($solutionRoot <= 0) { + $output->writeln('Root category "Solution" not found.'); + return Command::FAILURE; + } + $categoryPid = (int)$connection->fetchOne('SELECT pid FROM sys_category WHERE uid = ' . $solutionRoot); + + // ---- 1. group categories ------------------------------------------ + $groupCats = []; + $groupDatamap = []; + $index = 0; + $newGroupIds = []; + foreach (array_keys(self::TARGET) as $group) { + $uid = (int)$connection->fetchOne( + 'SELECT uid FROM sys_category WHERE deleted = 0 AND parent = ? AND title = ?', + [$solutionRoot, $group] + ); + if ($uid > 0) { + $groupCats[$group] = $uid; + } else { + $newId = 'NEW' . ++$index; + $newGroupIds[$newId] = $group; + $groupDatamap['sys_category'][$newId] = ['pid' => $categoryPid, 'parent' => $solutionRoot, 'title' => $group]; + $output->writeln(($dryRun ? 'plan ' : 'create') . ' Gruppe: ' . $group); + } + } + if (!$dryRun && $groupDatamap !== []) { + $dataHandler = GeneralUtility::makeInstance(DataHandler::class); + $dataHandler->start($groupDatamap, []); + $dataHandler->process_datamap(); + foreach ($newGroupIds as $newId => $group) { + $groupCats[$group] = (int)($dataHandler->substNEWwithIDs[$newId] ?? 0); + } + } + + // target title => group + $groupByTitle = []; + foreach (self::TARGET as $group => $titles) { + foreach ($titles as $title) { + $groupByTitle[$title] = $group; + } + } + + $solutions = []; + foreach ($connection->fetchAllAssociative( + 'SELECT uid, title, slug FROM ' . self::TABLE . ' WHERE deleted = 0' + ) as $row) { + $solutions[(string)$row['title']] = ['uid' => (int)$row['uid'], 'slug' => (string)$row['slug']]; + } + $pids = $connection->fetchFirstColumn('SELECT pid FROM ' . self::TABLE . ' WHERE deleted = 0'); + $pidCounts = array_count_values(array_map('intval', $pids)); + arsort($pidCounts); + $storagePid = (int)(array_key_first($pidCounts) ?? 0); + + $datamap = []; + $usedSlugs = []; + + // ---- 2. renames ---------------------------------------------------- + foreach (self::RENAMES as $oldTitle => $targetTitle) { + if (!isset($solutions[$oldTitle])) { + continue; + } + if (isset($solutions[$targetTitle])) { + $output->writeln('Rename uebersprungen, Ziel existiert schon: ' . $targetTitle . ''); + continue; + } + $record = $solutions[$oldTitle]; + $datamap[self::TABLE][$record['uid']]['title'] = $targetTitle; + $datamap[self::TABLE][$record['uid']]['slug'] = $this->slugFor($targetTitle, $usedSlugs); + $solutions[$targetTitle] = $record; + unset($solutions[$oldTitle]); + $output->writeln(sprintf('%s uid %d: "%s" -> "%s"', $dryRun ? 'plan ' : 'rename', $record['uid'], $oldTitle, $targetTitle)); + } + + // ---- 3.-5. targets: create / categories / slugs ------------------- + $mm = []; + foreach ($connection->fetchAllAssociative( + "SELECT uid_local, uid_foreign FROM sys_category_record_mm + WHERE tablenames = ? AND fieldname = 'categories'", + [self::TABLE] + ) as $row) { + $mm[(int)$row['uid_foreign']][] = (int)$row['uid_local']; + } + + $created = 0; + $newIndex = 0; + foreach ($groupByTitle as $title => $group) { + $groupUid = (int)($groupCats[$group] ?? 0); + if (isset($solutions[$title])) { + $record = $solutions[$title]; + $have = $mm[$record['uid']] ?? []; + // categories := exactly the group (strips market categories) + if ($groupUid > 0 && ($have !== [$groupUid])) { + $datamap[self::TABLE][$record['uid']]['categories'] = (string)$groupUid; + } + if ($groupUid === 0 && $dryRun) { + $output->writeln('plan Kategorie (neu anzulegende Gruppe) fuer: ' . $title); + } + $slug = (string)($datamap[self::TABLE][$record['uid']]['slug'] ?? $record['slug']); + if ($slug === '') { + $datamap[self::TABLE][$record['uid']]['slug'] = $this->slugFor($title, $usedSlugs); + } + } else { + $newId = 'NEW_S' . ++$newIndex; + $datamap[self::TABLE][$newId] = [ + 'pid' => $storagePid, + 'title' => $title, + 'slug' => $this->slugFor($title, $usedSlugs), + 'categories' => $groupUid > 0 ? (string)$groupUid : '', + ]; + $created++; + $output->writeln(($dryRun ? 'plan ' : 'create') . ' Solution: ' . $title . ' [' . $group . ']'); + } + } + + // ---- 6. prune ------------------------------------------------------ + $cmdmap = []; + $deleted = 0; + foreach ($solutions as $title => $record) { + if (isset($groupByTitle[$title])) { + continue; + } + $cmdmap[self::TABLE][$record['uid']]['delete'] = 1; + $deleted++; + $output->writeln(sprintf('%s uid %d: %s', $dryRun ? 'plan-DELETE' : 'DELETE ', $record['uid'], $title)); + } + + $output->writeln(sprintf("\nZiel: %d Solutions in %d Gruppen | anlegen: %d, loeschen: %d, aktualisieren: %d", + count($groupByTitle), count(self::TARGET), $created, $deleted, + count(array_filter(array_keys($datamap[self::TABLE] ?? []), 'is_int')))); + + if ($dryRun) { + $output->writeln('DRY RUN - nichts geschrieben.'); + return Command::SUCCESS; + } + + foreach ([[$datamap, []], [[], $cmdmap]] as [$dm, $cm]) { + if ($dm === [] && $cm === []) { + continue; + } + $dataHandler = GeneralUtility::makeInstance(DataHandler::class); + $dataHandler->start($dm, $cm); + if ($dm !== []) { + $dataHandler->process_datamap(); + } + if ($cm !== []) { + $dataHandler->process_cmdmap(); + } + if ($dataHandler->errorLog !== []) { + foreach ($dataHandler->errorLog as $error) { + $output->writeln('' . $error . ''); + } + return Command::FAILURE; + } + } + $output->writeln('Fertig. Cache leeren: vendor/bin/typo3 cache:flush'); + return Command::SUCCESS; + } + + /** @param string[] $used by-reference collection of slugs handed out in this run */ + private function slugFor(string $title, array &$used): string + { + $slug = trim((string)preg_replace('/[^a-z0-9]+/', '-', str_replace('+', ' plus ', mb_strtolower($title))), '-'); + $candidate = $slug; + $suffix = 1; + while (in_array($candidate, $used, true)) { + $candidate = $slug . '-' . ++$suffix; + } + $used[] = $candidate; + return $candidate; + } +} diff --git a/packages/vitec/Classes/Command/MapSolutionPagesCommand.php b/packages/vitec/Classes/Command/MapSolutionPagesCommand.php new file mode 100644 index 0000000..16a66dc --- /dev/null +++ b/packages/vitec/Classes/Command/MapSolutionPagesCommand.php @@ -0,0 +1,176 @@ + "plus") + * 2. last page-slug segment == solution slug + * Only UNIQUE matches are written; `detail_page` is fill-only (a record that + * already points at a page is never changed - a differing find is reported). + * Hidden pages match too (pages may be unpublished while content is built); + * deleted and sys-folder/link pages never. + * + * vendor/bin/typo3 vitec:map-solution-pages --dry-run + * vendor/bin/typo3 vitec:map-solution-pages + */ +#[AsCommand( + name: 'vitec:map-solution-pages', + description: 'Write matching page uids into the solutions\' detail_page field (unique matches, fill-only)' +)] +final class MapSolutionPagesCommand extends Command +{ + private const TABLE = 'tx_vitec_domain_model_solution'; + + protected function configure(): void + { + $this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report the plan - write nothing'); + $this->addOption('prune-duplicate-pages', null, InputOption::VALUE_NONE, 'Soft-delete duplicate pages (slug suffix -N, zero content elements) that lost the disambiguation'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + Bootstrap::initializeBackendAuthentication(); + $dryRun = (bool)$input->getOption('dry-run'); + $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::TABLE); + + $solutions = $connection->fetchAllAssociative( + 'SELECT uid, title, slug, detail_page FROM ' . self::TABLE . ' WHERE deleted = 0 ORDER BY title' + ); + + // standard pages only (doktype 1); hidden allowed, deleted not + $pages = $connection->fetchAllAssociative( + 'SELECT uid, pid, title, slug, hidden FROM pages WHERE deleted = 0 AND doktype = 1' + ); + $pagesByNormTitle = []; + $pagesBySlugTail = []; + foreach ($pages as $page) { + $pagesByNormTitle[$this->normalize((string)$page['title'])][] = $page; + $tail = strtolower(trim((string)strrchr('/' . trim((string)$page['slug'], '/'), '/'), '/')); + if ($tail !== '') { + $pagesBySlugTail[$tail][] = $page; + } + } + + $datamap = []; + $planned = 0; + foreach ($solutions as $solution) { + $uid = (int)$solution['uid']; + $candidates = $pagesByNormTitle[$this->normalize((string)$solution['title'])] ?? []; + if ($candidates === [] && (string)$solution['slug'] !== '') { + $candidates = $pagesBySlugTail[strtolower((string)$solution['slug'])] ?? []; + } + if ($candidates === []) { + $output->writeln(sprintf('keine Seite: %s', $solution['title'])); + continue; + } + $duplicatePages = []; + if (count($candidates) > 1) { + // Page-tree duplicates carry TYPO3's slug dedupe suffix "-N" + // (found 2026-09-04: two solution branches were created twice, + // empty). Prefer the clean slug; the suffixed ones can be + // pruned via --prune-duplicate-pages. + $clean = array_values(array_filter($candidates, static fn(array $p): bool => !preg_match('/-\d+$/', (string)$p['slug']))); + if (count($clean) === 1) { + $duplicatePages = array_values(array_filter($candidates, static fn(array $p): bool => (int)$p['uid'] !== (int)$clean[0]['uid'])); + $candidates = $clean; + } + } + if (count($candidates) > 1) { + $output->writeln(sprintf('MEHRDEUTIG (%d Seiten): %s - uids %s', + count($candidates), $solution['title'], implode(',', array_column($candidates, 'uid')))); + continue; + } + foreach ($duplicatePages as $duplicate) { + $this->duplicates[(int)$duplicate['uid']] = (string)$duplicate['slug']; + } + $page = $candidates[0]; + $pageUid = (int)$page['uid']; + $current = (int)$solution['detail_page']; + if ($current === $pageUid) { + $output->writeln(sprintf('ok %s -> Seite %d (bereits gesetzt)', $solution['title'], $pageUid)); + continue; + } + if ($current > 0) { + $output->writeln(sprintf('KONFLIKT: %s hat detail_page=%d, Match waere Seite %d "%s" - nicht angefasst', + $solution['title'], $current, $pageUid, $page['title'])); + continue; + } + $datamap[self::TABLE][$uid]['detail_page'] = $pageUid; + $planned++; + $output->writeln(sprintf('%s %s -> Seite %d "%s" (/%s)%s', + $dryRun ? 'plan ' : 'WRITE ', $solution['title'], $pageUid, $page['title'], + trim((string)$page['slug'], '/'), $page['hidden'] ? ' [Seite versteckt]' : '')); + } + + $output->writeln(sprintf("\n%d von %d Solutions zu mappen.", $planned, count($solutions))); + + $prune = (bool)$input->getOption('prune-duplicate-pages'); + $cmdmap = []; + foreach ($this->duplicates as $pageUid => $slug) { + $contentCount = (int)$connection->fetchOne( + 'SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = ?', [$pageUid] + ); + if ($contentCount > 0) { + $output->writeln(sprintf('Duplikat-Seite %d (/%s) hat %d Inhaltselemente - NICHT geloescht', $pageUid, trim($slug, '/'), $contentCount)); + continue; + } + if ($prune) { + $cmdmap['pages'][$pageUid]['delete'] = 1; + } + $output->writeln(sprintf('%s Duplikat-Seite %d (/%s, leer)', $prune ? ($dryRun ? 'plan-DELETE' : 'DELETE ') : 'Duplikat: ', $pageUid, trim($slug, '/'))); + } + if (!$prune && $this->duplicates !== []) { + $output->writeln('(Duplikat-Seiten bleiben stehen - mit --prune-duplicate-pages loeschen.)'); + } + + if ($datamap === [] && $cmdmap === []) { + return Command::SUCCESS; + } + if ($dryRun) { + $output->writeln('DRY RUN - nichts geschrieben.'); + return Command::SUCCESS; + } + + $dataHandler = GeneralUtility::makeInstance(DataHandler::class); + $dataHandler->start($datamap, $cmdmap); + if ($datamap !== []) { + $dataHandler->process_datamap(); + } + if ($cmdmap !== []) { + $dataHandler->process_cmdmap(); + } + if ($dataHandler->errorLog !== []) { + foreach ($dataHandler->errorLog as $error) { + $output->writeln('' . $error . ''); + } + return Command::FAILURE; + } + $output->writeln('Fertig. Cache leeren: vendor/bin/typo3 cache:flush'); + return Command::SUCCESS; + } + + /** @var array duplicate page uid => slug, collected during matching */ + private array $duplicates = []; + + private function normalize(string $value): string + { + return (string)preg_replace('/[^a-z0-9]+/', '', str_replace('+', 'plus', mb_strtolower($value))); + } +}