Backend module: CSV import for the VITEC domain models
New "VITEC Import" module under Web: one import page per domain model (v1: Market, Solution, Product - registry-driven, adding a model is one config entry). Workflow: upload a CSV (delimiter and encoding are auto-detected, including German-Excel semicolon/Windows-1252), map CSV columns to DB fields, persist the mapping per model together with the identity field used for matching (new table tx_vitec_import_mapping, no TCA - pure tool configuration), review a unified list of CSV rows matched against the DB records (new / update with differing fields / unchanged / db-only), then apply the checked rows through DataHandler. Each importable row carries an editable JSON payload textarea - what is written is the textarea content, not the raw CSV, so editors can fix values right in the review step. The parsed CSV travels through the form as a hidden JSON field: no session state, no temp files. Importable fields are derived from TCA at runtime (scalar types only; files, categories and other relations are excluded - a flat CSV cannot carry them). Payloads are whitelisted against that field list on apply; new records require a storage pid (prefilled from existing records). BE user permissions apply via DataHandler. The module ships its own CSS (backend-import.css, loaded only by this module) using the frontend button palette from _vitec.scss: orange #f47937 for primary actions, navy #26358c for secondary actions and structure. The stray <h2>Hi</h2> debug leftover in the shared backend layout is removed (also affects the OG Image module). A fourth tab "SEO Research" handles the recurring keyword-research CSV. It is deliberately not an import mask - the file carries research only (no meta title/description yet). Each upload is persisted as a delivery (tx_vitec_seo_research, never deleted) and evaluated: diff against the previous delivery keyed by URL, a structure check of the CSV tree against TYPO3 (pages by slug path; market/solution/product rows against the domain tables, matched by slug then normalized title), and the three work lists from the SEO flags (quick wins by GSC impressions, shared terms grouped by keyword, already ranking). CsvReader now deduplicates repeated header names, which that CSV has. New CLI command vitec:create-markets: creates the market records the structure check reports as missing, sourced from the stored delivery and matched through the same SeoResearchService - what the module lists is what the command creates. Each market gets a sys_category of the same title, found anywhere under the auto-detected market category root or created; sub-market categories are created under the parent market's category, so the category tree carries the hierarchy the flat market model cannot. Idempotent, dry-run first. New CLI commands vitec:create-markets and vitec:market-dummy-image. create-markets creates the market records the structure check reports as missing (root detection three-staged: option, auto-detect, find or create a "Markets" category). market-dummy-image assigns a shared placeholder (white logo on brand navy, fileadmin/placeholders/) to every market without an image - one sys_file for all, replacing the file restyles every placeholder at once. Both idempotent. Deliberately out of v1: import log with three-way compare (protection against overwriting manual edits), images/relations, multiple saved mappings per model.
This commit is contained in:
444
packages/vitec/Classes/Command/CreateMarketsCommand.php
Normal file
444
packages/vitec/Classes/Command/CreateMarketsCommand.php
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Command;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\ParameterType;
|
||||||
|
use Evomedien\Vitec\Import\SeoResearchRepository;
|
||||||
|
use Evomedien\Vitec\Import\SeoResearchService;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the market records that the SEO-research structure check reports
|
||||||
|
* as "Missing in TYPO3" - including their sys_category assignment.
|
||||||
|
*
|
||||||
|
* Source is the latest stored keyword-research delivery
|
||||||
|
* (tx_vitec_seo_research), evaluated through the very same
|
||||||
|
* SeoResearchService the backend module uses: whatever the module lists as
|
||||||
|
* missing is exactly what this command creates. Per market:
|
||||||
|
*
|
||||||
|
* - record: title = CSV page name, slug = last URL segment,
|
||||||
|
* pid = pid of the existing market records (or --pid)
|
||||||
|
* - category: a sys_category of the same title, looked up anywhere under
|
||||||
|
* the market category root; created when absent. Sub-markets
|
||||||
|
* get their category created UNDER the parent market's
|
||||||
|
* category (Page Ref hierarchy, e.g. 1.1.1 under 1.1), so the
|
||||||
|
* category tree carries the hierarchy the flat market model
|
||||||
|
* cannot.
|
||||||
|
*
|
||||||
|
* The category root is auto-detected as the most common parent of the
|
||||||
|
* categories assigned to existing markets; override with --category-parent.
|
||||||
|
* If an existing parent market has no category yet, it is assigned the
|
||||||
|
* (found or created) category, otherwise its sub-markets could not be
|
||||||
|
* attached to the tree.
|
||||||
|
*
|
||||||
|
* Idempotent: matched markets are skipped; re-running creates nothing twice.
|
||||||
|
*
|
||||||
|
* vendor/bin/typo3 vitec:create-markets --dry-run
|
||||||
|
* vendor/bin/typo3 vitec:create-markets --category-parent=42
|
||||||
|
*/
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'vitec:create-markets',
|
||||||
|
description: 'Create market records missing vs. the stored SEO research, with sys_category assignment'
|
||||||
|
)]
|
||||||
|
final class CreateMarketsCommand extends Command
|
||||||
|
{
|
||||||
|
private const TABLE = 'tx_vitec_domain_model_market';
|
||||||
|
private const CATEGORY_TABLE = 'sys_category';
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report only - no records, no categories');
|
||||||
|
$this->addOption('pid', null, InputOption::VALUE_REQUIRED, 'Storage pid for new market records (default: pid of existing markets)');
|
||||||
|
$this->addOption('category-parent', null, InputOption::VALUE_REQUIRED, 'Uid of the market category root (default: auto-detect, then a category titled "Markets", else it is created)');
|
||||||
|
$this->addOption('category-pid', null, InputOption::VALUE_REQUIRED, 'Sysfolder pid for newly created categories', '33');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
Bootstrap::initializeBackendAuthentication();
|
||||||
|
$dryRun = (bool)$input->getOption('dry-run');
|
||||||
|
|
||||||
|
// ------------------------------------------------ source: delivery
|
||||||
|
$repository = GeneralUtility::makeInstance(SeoResearchRepository::class);
|
||||||
|
$service = GeneralUtility::makeInstance(SeoResearchService::class);
|
||||||
|
$delivery = $repository->latest(0);
|
||||||
|
if ($delivery === null) {
|
||||||
|
$output->writeln('<error>No SEO research delivery stored - upload the CSV in the backend module first.</error>');
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
$output->writeln(sprintf('Delivery: %s (%s)', $delivery['filename'], date('Y-m-d H:i', $delivery['crdate'])));
|
||||||
|
|
||||||
|
$structure = $service->analyze($delivery['rows'], null)['structure']['market'];
|
||||||
|
$missing = $structure['missing'];
|
||||||
|
$both = $structure['both'];
|
||||||
|
$output->writeln(sprintf('Markets: %d matched, %d missing, %d only in TYPO3',
|
||||||
|
$structure['matched'], count($missing), count($structure['extra'])));
|
||||||
|
if ($missing === []) {
|
||||||
|
$output->writeln('Nothing to create.');
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------- pid for records
|
||||||
|
$pid = (int)($input->getOption('pid') ?? 0);
|
||||||
|
if ($pid <= 0) {
|
||||||
|
$pid = $this->detectMarketPid();
|
||||||
|
}
|
||||||
|
if ($pid <= 0) {
|
||||||
|
$output->writeln('<error>No --pid given and no existing market records to derive it from.</error>');
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------- category root + existing map
|
||||||
|
$categoryByMarketUid = $this->categoriesOfExistingMarkets();
|
||||||
|
$categoryPid = (int)$input->getOption('category-pid');
|
||||||
|
$categoryRoot = (int)($input->getOption('category-parent') ?? 0);
|
||||||
|
if ($categoryRoot <= 0) {
|
||||||
|
$categoryRoot = $this->detectCategoryRoot($categoryByMarketUid);
|
||||||
|
}
|
||||||
|
if ($categoryRoot <= 0) {
|
||||||
|
// No market has a category yet: look for (or create) a root
|
||||||
|
// category titled "Markets" on the category sysfolder.
|
||||||
|
$categoryRoot = $this->findCategoryByTitle('Markets', $categoryPid);
|
||||||
|
if ($categoryRoot > 0) {
|
||||||
|
$output->writeln(sprintf('Using existing category "Markets" (uid %d) as root.', $categoryRoot));
|
||||||
|
} elseif ($dryRun) {
|
||||||
|
$output->writeln(sprintf('Would create root category "Markets" (pid %d, top level) and attach everything under it.', $categoryPid));
|
||||||
|
} else {
|
||||||
|
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||||
|
$dataHandler->start([self::CATEGORY_TABLE => ['NEW1' => [
|
||||||
|
'pid' => $categoryPid,
|
||||||
|
'parent' => 0,
|
||||||
|
'title' => 'Markets',
|
||||||
|
]]], []);
|
||||||
|
$dataHandler->process_datamap();
|
||||||
|
$categoryRoot = (int)($dataHandler->substNEWwithIDs['NEW1'] ?? 0);
|
||||||
|
if ($categoryRoot <= 0) {
|
||||||
|
$output->writeln('<error>Could not create the root category "Markets": '
|
||||||
|
. implode(' | ', $dataHandler->errorLog) . '</error>');
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
$output->writeln(sprintf('<info>created root category "Markets" (uid %d, pid %d)</info>', $categoryRoot, $categoryPid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($categoryRoot > 0) {
|
||||||
|
$rootRow = $this->categoryRow($categoryRoot);
|
||||||
|
if ($rootRow === null) {
|
||||||
|
$output->writeln(sprintf('<error>Category %d does not exist.</error>', $categoryRoot));
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
$categoryPid = (int)$rootRow['pid'];
|
||||||
|
$output->writeln(sprintf('Storage pid: %d | category root: "%s" (uid %d)', $pid, $rootRow['title'], $categoryRoot));
|
||||||
|
} else {
|
||||||
|
$output->writeln(sprintf('Storage pid: %d | category root: "Markets" (created on the real run)', $pid));
|
||||||
|
}
|
||||||
|
|
||||||
|
$allCategories = $this->loadAllCategories();
|
||||||
|
$treeUids = $this->subtreeUids($allCategories, $categoryRoot);
|
||||||
|
|
||||||
|
// ref -> category uid for markets that already exist (via the both list)
|
||||||
|
$categoryByRef = [];
|
||||||
|
foreach ($both as $pair) {
|
||||||
|
$catUid = $categoryByMarketUid[(int)$pair['uid']] ?? 0;
|
||||||
|
if ($catUid > 0) {
|
||||||
|
$categoryByRef[$pair['ref']] = $catUid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// market uid by ref, to fix parents without category
|
||||||
|
$marketUidByRef = [];
|
||||||
|
foreach ($both as $pair) {
|
||||||
|
$marketUidByRef[$pair['ref']] = (int)$pair['uid'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ create
|
||||||
|
usort($missing, static fn(array $a, array $b): int =>
|
||||||
|
substr_count($a['ref'], '.') <=> substr_count($b['ref'], '.'));
|
||||||
|
|
||||||
|
$createdMarkets = 0;
|
||||||
|
$createdCategories = 0;
|
||||||
|
$warnings = [];
|
||||||
|
|
||||||
|
foreach ($missing as $row) {
|
||||||
|
$title = $row['name'];
|
||||||
|
$slug = mb_strtolower(trim((string)basename(rtrim($row['url'], '/'))));
|
||||||
|
$parentRef = str_contains($row['ref'], '.')
|
||||||
|
? substr($row['ref'], 0, (int)strrpos($row['ref'], '.'))
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Parent category: category of the parent market when the ref has
|
||||||
|
// one; the root otherwise. Top-level market refs ("1.3") have
|
||||||
|
// parentRef "1" = the section root -> attach to the category root.
|
||||||
|
$parentCategory = $categoryRoot;
|
||||||
|
if ($parentRef !== '' && str_contains($parentRef, '.')) {
|
||||||
|
$parentCategory = $categoryByRef[$parentRef] ?? 0;
|
||||||
|
if ($parentCategory <= 0 && isset($marketUidByRef[$parentRef])) {
|
||||||
|
// Existing parent market without category: create/find its
|
||||||
|
// category under the root and assign it, so the tree holds.
|
||||||
|
$parentTitle = $this->titleOfMarket($marketUidByRef[$parentRef]);
|
||||||
|
$parentCategory = $this->findOrCreateCategory(
|
||||||
|
$parentTitle, $categoryRoot, $categoryPid, $allCategories, $treeUids, $dryRun, $createdCategories, $output
|
||||||
|
);
|
||||||
|
if (!$dryRun && $parentCategory > 0) {
|
||||||
|
$this->assignCategory($marketUidByRef[$parentRef], $parentCategory, $output);
|
||||||
|
}
|
||||||
|
$categoryByRef[$parentRef] = $parentCategory;
|
||||||
|
}
|
||||||
|
if ($parentCategory <= 0) {
|
||||||
|
$warnings[] = sprintf('%s %s: parent %s not resolvable - category attached to the root instead.',
|
||||||
|
$row['ref'], $title, $parentRef);
|
||||||
|
$parentCategory = $categoryRoot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$categoryUid = $this->findOrCreateCategory(
|
||||||
|
$title, $parentCategory, $categoryPid, $allCategories, $treeUids, $dryRun, $createdCategories, $output
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$parentLabel = $parentCategory > 0 ? '#' . $parentCategory : '"Markets" (new root)';
|
||||||
|
$output->writeln(sprintf(' CREATE market %-8s %-45s slug=%s category=%s',
|
||||||
|
$row['ref'], mb_substr($title, 0, 45), $slug,
|
||||||
|
$categoryUid > 0 ? '#' . $categoryUid : '(new, under ' . $parentLabel . ')'));
|
||||||
|
$createdMarkets++;
|
||||||
|
$categoryByRef[$row['ref']] = $categoryUid;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||||
|
$dataHandler->start([self::TABLE => ['NEW1' => [
|
||||||
|
'pid' => $pid,
|
||||||
|
'title' => $title,
|
||||||
|
'slug' => $slug,
|
||||||
|
'categories' => (string)$categoryUid,
|
||||||
|
]]], []);
|
||||||
|
$dataHandler->process_datamap();
|
||||||
|
if ($dataHandler->errorLog !== []) {
|
||||||
|
$warnings[] = sprintf('%s: %s', $title, implode(' | ', $dataHandler->errorLog));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$newUid = (int)($dataHandler->substNEWwithIDs['NEW1'] ?? 0);
|
||||||
|
$categoryByRef[$row['ref']] = $categoryUid;
|
||||||
|
$createdMarkets++;
|
||||||
|
$output->writeln(sprintf(' <info>created market %s "%s" (uid %d, category %d)</info>',
|
||||||
|
$row['ref'], $title, $newUid, $categoryUid));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ report
|
||||||
|
$output->writeln('');
|
||||||
|
$output->writeln(sprintf('%s: %d market(s), %d categor%s.',
|
||||||
|
$dryRun ? 'DRY-RUN - would create' : 'Created',
|
||||||
|
$createdMarkets, $createdCategories, $createdCategories === 1 ? 'y' : 'ies'));
|
||||||
|
foreach ($warnings as $w) {
|
||||||
|
$output->writeln(' <comment>! ' . $w . '</comment>');
|
||||||
|
}
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ categories
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a category by normalized title anywhere under the market category
|
||||||
|
* root; create it under $parentCategory when absent.
|
||||||
|
*
|
||||||
|
* @param array<int,array<string,mixed>> $allCategories by uid
|
||||||
|
* @param array<int,bool> $treeUids uids belonging to the root's subtree
|
||||||
|
*/
|
||||||
|
private function findOrCreateCategory(
|
||||||
|
string $title,
|
||||||
|
int $parentCategory,
|
||||||
|
int $categoryPid,
|
||||||
|
array &$allCategories,
|
||||||
|
array &$treeUids,
|
||||||
|
bool $dryRun,
|
||||||
|
int &$createdCategories,
|
||||||
|
OutputInterface $output
|
||||||
|
): int {
|
||||||
|
$norm = $this->normalizeTitle($title);
|
||||||
|
foreach ($allCategories as $uid => $cat) {
|
||||||
|
if (isset($treeUids[$uid]) && $this->normalizeTitle((string)$cat['title']) === $norm) {
|
||||||
|
return (int)$uid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$createdCategories++;
|
||||||
|
if ($dryRun) {
|
||||||
|
$parentLabel = $parentCategory > 0 ? '#' . $parentCategory : '"Markets" (new root)';
|
||||||
|
$output->writeln(sprintf(' create category %-42s under %s', mb_substr($title, 0, 42), $parentLabel));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||||
|
$dataHandler->start([self::CATEGORY_TABLE => ['NEW1' => [
|
||||||
|
'pid' => $categoryPid,
|
||||||
|
'parent' => $parentCategory,
|
||||||
|
'title' => $title,
|
||||||
|
]]], []);
|
||||||
|
$dataHandler->process_datamap();
|
||||||
|
$uid = (int)($dataHandler->substNEWwithIDs['NEW1'] ?? 0);
|
||||||
|
if ($uid > 0) {
|
||||||
|
$allCategories[$uid] = ['uid' => $uid, 'parent' => $parentCategory, 'title' => $title, 'pid' => $categoryPid];
|
||||||
|
$treeUids[$uid] = true;
|
||||||
|
$output->writeln(sprintf(' <info>created category "%s" (uid %d, parent %d)</info>', $title, $uid, $parentCategory));
|
||||||
|
}
|
||||||
|
return $uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assignCategory(int $marketUid, int $categoryUid, OutputInterface $output): void
|
||||||
|
{
|
||||||
|
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||||
|
$dataHandler->start([self::TABLE => [(string)$marketUid => ['categories' => (string)$categoryUid]]], []);
|
||||||
|
$dataHandler->process_datamap();
|
||||||
|
$output->writeln(sprintf(' <info>assigned category %d to existing market %d</info>', $categoryUid, $marketUid));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int,int> market uid => first assigned category uid */
|
||||||
|
private function categoriesOfExistingMarkets(): array
|
||||||
|
{
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_category_record_mm');
|
||||||
|
$rows = $qb->select('uid_local', 'uid_foreign')->from('sys_category_record_mm')
|
||||||
|
->where(
|
||||||
|
$qb->expr()->eq('tablenames', $qb->createNamedParameter(self::TABLE, ParameterType::STRING)),
|
||||||
|
$qb->expr()->eq('fieldname', $qb->createNamedParameter('categories', ParameterType::STRING))
|
||||||
|
)
|
||||||
|
->orderBy('sorting', 'ASC')
|
||||||
|
->executeQuery()->fetchAllAssociative();
|
||||||
|
$map = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$market = (int)$row['uid_foreign'];
|
||||||
|
if (!isset($map[$market])) {
|
||||||
|
$map[$market] = (int)$row['uid_local'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Most common parent of the categories assigned to existing markets. */
|
||||||
|
private function detectCategoryRoot(array $categoryByMarketUid): int
|
||||||
|
{
|
||||||
|
if ($categoryByMarketUid === []) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::CATEGORY_TABLE);
|
||||||
|
$rows = $qb->select('uid', 'parent')->from(self::CATEGORY_TABLE)
|
||||||
|
->where(
|
||||||
|
$qb->expr()->in('uid', array_map('intval', array_values($categoryByMarketUid))),
|
||||||
|
$qb->expr()->eq('deleted', 0)
|
||||||
|
)
|
||||||
|
->executeQuery()->fetchAllAssociative();
|
||||||
|
$parents = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$parents[(int)$row['parent']] = ($parents[(int)$row['parent']] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
// Sub-market categories may already sit below a main-market category;
|
||||||
|
// the ROOT is the most common parent among top-level assignments.
|
||||||
|
arsort($parents);
|
||||||
|
return (int)array_key_first($parents);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int,array<string,mixed>> */
|
||||||
|
private function loadAllCategories(): array
|
||||||
|
{
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::CATEGORY_TABLE);
|
||||||
|
$rows = $qb->select('uid', 'pid', 'parent', 'title')->from(self::CATEGORY_TABLE)
|
||||||
|
->where($qb->expr()->eq('deleted', 0))
|
||||||
|
->executeQuery()->fetchAllAssociative();
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$out[(int)$row['uid']] = $row;
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int,bool> every category uid inside the root's subtree (root included) */
|
||||||
|
private function subtreeUids(array $allCategories, int $root): array
|
||||||
|
{
|
||||||
|
$children = [];
|
||||||
|
foreach ($allCategories as $uid => $cat) {
|
||||||
|
$children[(int)$cat['parent']][] = (int)$uid;
|
||||||
|
}
|
||||||
|
$result = [$root => true];
|
||||||
|
$queue = [$root];
|
||||||
|
while ($queue !== []) {
|
||||||
|
$current = array_shift($queue);
|
||||||
|
foreach ($children[$current] ?? [] as $childUid) {
|
||||||
|
if (!isset($result[$childUid])) {
|
||||||
|
$result[$childUid] = true;
|
||||||
|
$queue[] = $childUid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Category by (normalized) title, preferring the given pid. */
|
||||||
|
private function findCategoryByTitle(string $title, int $preferredPid): int
|
||||||
|
{
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::CATEGORY_TABLE);
|
||||||
|
$rows = $qb->select('uid', 'pid', 'title')->from(self::CATEGORY_TABLE)
|
||||||
|
->where($qb->expr()->eq('deleted', 0))
|
||||||
|
->executeQuery()->fetchAllAssociative();
|
||||||
|
$norm = $this->normalizeTitle($title);
|
||||||
|
$fallback = 0;
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if ($this->normalizeTitle((string)$row['title']) !== $norm) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ((int)$row['pid'] === $preferredPid) {
|
||||||
|
return (int)$row['uid'];
|
||||||
|
}
|
||||||
|
if ($fallback === 0) {
|
||||||
|
$fallback = (int)$row['uid'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string,mixed>|null */
|
||||||
|
private function categoryRow(int $uid): ?array
|
||||||
|
{
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::CATEGORY_TABLE);
|
||||||
|
$row = $qb->select('uid', 'pid', 'title')->from(self::CATEGORY_TABLE)
|
||||||
|
->where(
|
||||||
|
$qb->expr()->eq('uid', $qb->createNamedParameter($uid, ParameterType::INTEGER)),
|
||||||
|
$qb->expr()->eq('deleted', 0)
|
||||||
|
)
|
||||||
|
->executeQuery()->fetchAssociative();
|
||||||
|
return $row ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- misc
|
||||||
|
|
||||||
|
private function detectMarketPid(): int
|
||||||
|
{
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE);
|
||||||
|
$row = $qb->select('pid')->from(self::TABLE)
|
||||||
|
->where($qb->expr()->eq('deleted', 0))
|
||||||
|
->setMaxResults(1)
|
||||||
|
->executeQuery()->fetchAssociative();
|
||||||
|
return $row ? (int)$row['pid'] : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function titleOfMarket(int $uid): string
|
||||||
|
{
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE);
|
||||||
|
$row = $qb->select('title')->from(self::TABLE)
|
||||||
|
->where($qb->expr()->eq('uid', $qb->createNamedParameter($uid, ParameterType::INTEGER)))
|
||||||
|
->executeQuery()->fetchAssociative();
|
||||||
|
return $row ? (string)$row['title'] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeTitle(string $title): string
|
||||||
|
{
|
||||||
|
$title = (string)preg_replace('/\s*\(.*?\)/', '', $title);
|
||||||
|
$title = str_replace('&', 'and', mb_strtolower($title));
|
||||||
|
$title = (string)preg_replace('/[^a-z0-9]+/', ' ', $title);
|
||||||
|
return trim((string)preg_replace('/\s+/', ' ', $title));
|
||||||
|
}
|
||||||
|
}
|
||||||
126
packages/vitec/Classes/Command/MarketDummyImageCommand.php
Normal file
126
packages/vitec/Classes/Command/MarketDummyImageCommand.php
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Command;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\ParameterType;
|
||||||
|
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\Resource\File;
|
||||||
|
use TYPO3\CMS\Core\Resource\ResourceFactory;
|
||||||
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assigns the VITEC placeholder image (logo on brand navy) to every market
|
||||||
|
* record that has no image yet - so cards and detail pages never render
|
||||||
|
* imageless while the editors are still collecting real market imagery.
|
||||||
|
*
|
||||||
|
* The placeholder file lives at fileadmin/placeholders/ and is referenced,
|
||||||
|
* not copied: all imageless markets share one sys_file, and replacing that
|
||||||
|
* file later restyles every placeholder at once. Records that already have
|
||||||
|
* an image reference are never touched. Idempotent - re-running skips
|
||||||
|
* markets that got their reference in an earlier run.
|
||||||
|
*
|
||||||
|
* vendor/bin/typo3 vitec:market-dummy-image --dry-run
|
||||||
|
* vendor/bin/typo3 vitec:market-dummy-image
|
||||||
|
*/
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'vitec:market-dummy-image',
|
||||||
|
description: 'Assign the placeholder image to every market without an image'
|
||||||
|
)]
|
||||||
|
final class MarketDummyImageCommand extends Command
|
||||||
|
{
|
||||||
|
private const TABLE = 'tx_vitec_domain_model_market';
|
||||||
|
private const DEFAULT_FILE = 'fileadmin/placeholders/vitec-market-placeholder.png';
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addOption('file', null, InputOption::VALUE_REQUIRED, 'Placeholder image path', self::DEFAULT_FILE);
|
||||||
|
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report only');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
Bootstrap::initializeBackendAuthentication();
|
||||||
|
$dryRun = (bool)$input->getOption('dry-run');
|
||||||
|
$path = (string)$input->getOption('file');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$file = GeneralUtility::makeInstance(ResourceFactory::class)->retrieveFileOrFolderObject($path);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$file = null;
|
||||||
|
}
|
||||||
|
if (!$file instanceof File) {
|
||||||
|
$output->writeln(sprintf('<error>Placeholder not found in FAL: %s</error>', $path));
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
$output->writeln(sprintf('Placeholder: %s (sys_file %d)', $file->getIdentifier(), $file->getUid()));
|
||||||
|
|
||||||
|
// Markets that already have an image reference.
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_file_reference');
|
||||||
|
$withImage = $qb->select('uid_foreign')->from('sys_file_reference')
|
||||||
|
->where(
|
||||||
|
$qb->expr()->eq('tablenames', $qb->createNamedParameter(self::TABLE, ParameterType::STRING)),
|
||||||
|
$qb->expr()->eq('fieldname', $qb->createNamedParameter('image', ParameterType::STRING)),
|
||||||
|
$qb->expr()->eq('deleted', 0)
|
||||||
|
)
|
||||||
|
->executeQuery()->fetchFirstColumn();
|
||||||
|
$withImage = array_flip(array_map('intval', $withImage));
|
||||||
|
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE);
|
||||||
|
$markets = $qb->select('uid', 'pid', 'title')->from(self::TABLE)
|
||||||
|
->where($qb->expr()->eq('deleted', 0))
|
||||||
|
->orderBy('title', 'ASC')
|
||||||
|
->executeQuery()->fetchAllAssociative();
|
||||||
|
|
||||||
|
$assigned = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
foreach ($markets as $market) {
|
||||||
|
$uid = (int)$market['uid'];
|
||||||
|
if (isset($withImage[$uid])) {
|
||||||
|
$skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($dryRun) {
|
||||||
|
$output->writeln(sprintf(' ASSIGN placeholder -> %s (uid %d)', $market['title'], $uid));
|
||||||
|
$assigned++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||||
|
$dataHandler->start([
|
||||||
|
'sys_file_reference' => [
|
||||||
|
'NEWref' => [
|
||||||
|
'uid_local' => $file->getUid(),
|
||||||
|
'pid' => (int)$market['pid'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
self::TABLE => [
|
||||||
|
(string)$uid => ['image' => 'NEWref'],
|
||||||
|
],
|
||||||
|
], []);
|
||||||
|
$dataHandler->process_datamap();
|
||||||
|
if ($dataHandler->errorLog !== []) {
|
||||||
|
$output->writeln(sprintf(' <error>%s: %s</error>', $market['title'], implode(' | ', $dataHandler->errorLog)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$output->writeln(sprintf(' <info>assigned -> %s (uid %d)</info>', $market['title'], $uid));
|
||||||
|
$assigned++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$output->writeln('');
|
||||||
|
$output->writeln(sprintf(
|
||||||
|
'%s: %d assigned, %d already had an image.',
|
||||||
|
$dryRun ? 'DRY-RUN - would be' : 'Done',
|
||||||
|
$assigned,
|
||||||
|
$skipped
|
||||||
|
));
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ namespace Evomedien\Vitec\Controller\Backend;
|
|||||||
use Evomedien\Vitec\Import\CsvReader;
|
use Evomedien\Vitec\Import\CsvReader;
|
||||||
use Evomedien\Vitec\Import\ImportModelRegistry;
|
use Evomedien\Vitec\Import\ImportModelRegistry;
|
||||||
use Evomedien\Vitec\Import\MappingRepository;
|
use Evomedien\Vitec\Import\MappingRepository;
|
||||||
|
use Evomedien\Vitec\Import\SeoResearchRepository;
|
||||||
|
use Evomedien\Vitec\Import\SeoResearchService;
|
||||||
use Psr\Http\Message\ResponseInterface;
|
use Psr\Http\Message\ResponseInterface;
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
use TYPO3\CMS\Backend\Attribute\AsController;
|
use TYPO3\CMS\Backend\Attribute\AsController;
|
||||||
@@ -45,6 +47,8 @@ final class ImportController
|
|||||||
private readonly ImportModelRegistry $registry,
|
private readonly ImportModelRegistry $registry,
|
||||||
private readonly MappingRepository $mappingRepository,
|
private readonly MappingRepository $mappingRepository,
|
||||||
private readonly CsvReader $csvReader,
|
private readonly CsvReader $csvReader,
|
||||||
|
private readonly SeoResearchRepository $seoResearchRepository,
|
||||||
|
private readonly SeoResearchService $seoResearchService,
|
||||||
private readonly FlashMessageService $flashMessageService,
|
private readonly FlashMessageService $flashMessageService,
|
||||||
private readonly PageRenderer $pageRenderer,
|
private readonly PageRenderer $pageRenderer,
|
||||||
private readonly UriBuilder $uriBuilder,
|
private readonly UriBuilder $uriBuilder,
|
||||||
@@ -116,6 +120,46 @@ final class ImportController
|
|||||||
return $this->render($request, $modelKey, $csv, $mapping, $identity);
|
return $this->render($request, $modelKey, $csv, $mapping, $identity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------- SEO research tab
|
||||||
|
|
||||||
|
public function seoAction(ServerRequestInterface $request): ResponseInterface
|
||||||
|
{
|
||||||
|
$latest = $this->seoResearchRepository->latest(0);
|
||||||
|
$previous = $this->seoResearchRepository->latest(1);
|
||||||
|
$analysis = $latest !== null
|
||||||
|
? $this->seoResearchService->analyze($latest['rows'], $previous !== null ? $previous['rows'] : null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$this->pageRenderer->addCssFile('EXT:vitec/Resources/Public/Css/backend-import.css');
|
||||||
|
$view = $this->moduleTemplateFactory->create($request);
|
||||||
|
$view->assignMultiple([
|
||||||
|
'models' => $this->registry->all(),
|
||||||
|
'latest' => $latest,
|
||||||
|
'previous' => $previous,
|
||||||
|
'analysis' => $analysis,
|
||||||
|
]);
|
||||||
|
return $view->renderResponse('Import/Seo');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function seoUploadAction(ServerRequestInterface $request): ResponseInterface
|
||||||
|
{
|
||||||
|
$files = $request->getUploadedFiles();
|
||||||
|
$upload = $files['csvfile'] ?? null;
|
||||||
|
if ($upload !== null && $upload->getError() === UPLOAD_ERR_OK) {
|
||||||
|
$csv = $this->csvReader->parse((string)$upload->getStream());
|
||||||
|
$rows = $this->seoResearchService->normalizeRows($csv['rows']);
|
||||||
|
if ($rows === []) {
|
||||||
|
$this->flash('No usable rows found - is the "Potential URL" column present?', 'Upload', false);
|
||||||
|
} else {
|
||||||
|
$this->seoResearchRepository->add((string)($upload->getClientFilename() ?? 'upload.csv'), $rows);
|
||||||
|
$this->flash(sprintf('%d rows stored as new delivery.', count($rows)), 'Delivery stored', true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$this->flash('No file received.', 'Upload', false);
|
||||||
|
}
|
||||||
|
return new RedirectResponse((string)$this->uriBuilder->buildUriFromRoute('web_vitecimport.seo'), 303);
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------ rendering
|
// ------------------------------------------------------------ rendering
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -48,9 +48,22 @@ final class CsvReader
|
|||||||
return ['columns' => [], 'rows' => []];
|
return ['columns' => [], 'rows' => []];
|
||||||
}
|
}
|
||||||
$columns = [];
|
$columns = [];
|
||||||
|
$seen = [];
|
||||||
foreach ($header as $i => $name) {
|
foreach ($header as $i => $name) {
|
||||||
$name = trim((string)$name);
|
$name = trim((string)$name);
|
||||||
$columns[$i] = $name !== '' ? $name : ('column_' . ($i + 1));
|
if ($name === '') {
|
||||||
|
$name = 'column_' . ($i + 1);
|
||||||
|
}
|
||||||
|
// Duplicate headers (e.g. the keyword research CSV repeats
|
||||||
|
// "Vol (Global)" for the secondary block) get a _2/_3 suffix -
|
||||||
|
// otherwise the later block silently overwrites the earlier one.
|
||||||
|
if (isset($seen[$name])) {
|
||||||
|
$seen[$name]++;
|
||||||
|
$name .= '_' . $seen[$name];
|
||||||
|
} else {
|
||||||
|
$seen[$name] = 1;
|
||||||
|
}
|
||||||
|
$columns[$i] = $name;
|
||||||
}
|
}
|
||||||
|
|
||||||
$rows = [];
|
$rows = [];
|
||||||
|
|||||||
60
packages/vitec/Classes/Import/SeoResearchRepository.php
Normal file
60
packages/vitec/Classes/Import/SeoResearchRepository.php
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Import;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\ParameterType;
|
||||||
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||||
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the recurring SEO keyword-research deliveries (normalized rows as
|
||||||
|
* JSON, one record per upload) so the latest state is visible in the backend
|
||||||
|
* without the file, and each upload can be diffed against the previous one.
|
||||||
|
*
|
||||||
|
* Plain table without TCA (tx_vitec_seo_research) - tool data, never edited
|
||||||
|
* through FormEngine. Deliveries are never deleted: they are the audit trail.
|
||||||
|
*/
|
||||||
|
final class SeoResearchRepository
|
||||||
|
{
|
||||||
|
private const TABLE = 'tx_vitec_seo_research';
|
||||||
|
|
||||||
|
/** @param array<int,array<string,string>> $rows */
|
||||||
|
public function add(string $filename, array $rows): int
|
||||||
|
{
|
||||||
|
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::TABLE);
|
||||||
|
$connection->insert(self::TABLE, [
|
||||||
|
'pid' => 0,
|
||||||
|
'crdate' => time(),
|
||||||
|
'be_user' => (int)($GLOBALS['BE_USER']->user['uid'] ?? 0),
|
||||||
|
'filename' => mb_substr($filename, 0, 255),
|
||||||
|
'row_count' => count($rows),
|
||||||
|
'payload' => (string)json_encode($rows, JSON_UNESCAPED_UNICODE),
|
||||||
|
]);
|
||||||
|
return (int)$connection->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{uid:int,crdate:int,filename:string,rows:array<int,array<string,string>>}|null
|
||||||
|
*/
|
||||||
|
public function latest(int $offset = 0): ?array
|
||||||
|
{
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE);
|
||||||
|
$row = $qb->select('uid', 'crdate', 'filename', 'payload')->from(self::TABLE)
|
||||||
|
->orderBy('uid', 'DESC')
|
||||||
|
->setFirstResult($offset)
|
||||||
|
->setMaxResults(1)
|
||||||
|
->executeQuery()->fetchAssociative();
|
||||||
|
if (!$row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$rows = json_decode((string)$row['payload'], true);
|
||||||
|
return [
|
||||||
|
'uid' => (int)$row['uid'],
|
||||||
|
'crdate' => (int)$row['crdate'],
|
||||||
|
'filename' => (string)$row['filename'],
|
||||||
|
'rows' => is_array($rows) ? $rows : [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
285
packages/vitec/Classes/Import/SeoResearchService.php
Normal file
285
packages/vitec/Classes/Import/SeoResearchService.php
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Import;
|
||||||
|
|
||||||
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||||
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates one keyword-research delivery:
|
||||||
|
*
|
||||||
|
* - work lists from the SEO flags (quick wins, shared terms, already ranking)
|
||||||
|
* - structure check: does the CSV tree exist in TYPO3?
|
||||||
|
* pages -> every row's URL against pages.slug (slug = full path)
|
||||||
|
* markets -> level-1.x rows against tx_vitec_domain_model_market
|
||||||
|
* solutions -> level-2.x rows against tx_vitec_domain_model_solution
|
||||||
|
* products -> level-3.x.y+ rows against tx_vitec_domain_model_product
|
||||||
|
* (3.x rows are categories, not products - skipped)
|
||||||
|
* Record matching: slug == last URL segment, falling back to a
|
||||||
|
* normalized title comparison (parentheses stripped, & -> and).
|
||||||
|
* - diff against the previous delivery, keyed by URL
|
||||||
|
*
|
||||||
|
* Works on the normalized row schema produced by normalizeRows(). Read-only:
|
||||||
|
* this class never writes anything.
|
||||||
|
*/
|
||||||
|
final class SeoResearchService
|
||||||
|
{
|
||||||
|
private const SECTION_MODELS = [
|
||||||
|
// Markets/Solutions: every row below the section root is a record
|
||||||
|
// candidate - sub-markets ("Traffic & Smart Mobility" under
|
||||||
|
// "Transport & Infrastructure") and sub-solutions are records of the
|
||||||
|
// same (flat) model. Products: 3.x rows are categories, records
|
||||||
|
// start at 3.x.y.
|
||||||
|
'Markets' => ['key' => 'market', 'table' => 'tx_vitec_domain_model_market', 'depth' => [2, 9]],
|
||||||
|
'Solutions' => ['key' => 'solution', 'table' => 'tx_vitec_domain_model_solution', 'depth' => [2, 9]],
|
||||||
|
'Products' => ['key' => 'product', 'table' => 'tx_vitec_domain_model_product', 'depth' => [3, 9]],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map raw CSV rows (deduplicated headers) onto a stable schema, so stored
|
||||||
|
* deliveries stay comparable even if the CSV gains columns.
|
||||||
|
*
|
||||||
|
* @param array<int,array<string,string>> $raw
|
||||||
|
* @return array<int,array<string,string>>
|
||||||
|
*/
|
||||||
|
public function normalizeRows(array $raw): array
|
||||||
|
{
|
||||||
|
$rows = [];
|
||||||
|
foreach ($raw as $r) {
|
||||||
|
$url = trim((string)($r['Potential URL'] ?? ''));
|
||||||
|
if ($url === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$rows[] = [
|
||||||
|
'section' => trim((string)($r['Section'] ?? '')),
|
||||||
|
'ref' => trim((string)($r['Page Ref'] ?? '')),
|
||||||
|
'name' => trim((string)($r['Page Name'] ?? '')),
|
||||||
|
'url' => $url,
|
||||||
|
'primary' => trim((string)($r['Primary Keyword'] ?? '')),
|
||||||
|
'volGlobal' => trim((string)($r['Vol (Global)'] ?? '')),
|
||||||
|
'intent' => trim((string)($r['Intent'] ?? '')),
|
||||||
|
'kd' => trim((string)($r['KD'] ?? '')),
|
||||||
|
'gscPos' => trim((string)($r['GSC Pos (blended)'] ?? '')),
|
||||||
|
'gscImpr' => trim((string)($r['GSC Impr'] ?? '')),
|
||||||
|
'flag' => trim((string)($r['Flag'] ?? '')),
|
||||||
|
'secondary' => trim((string)($r['Secondary Keyword'] ?? '')),
|
||||||
|
'secVolGlobal' => trim((string)($r['Vol (Global)_2'] ?? '')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int,array<string,string>> $rows
|
||||||
|
* @param array<int,array<string,string>>|null $previousRows
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function analyze(array $rows, ?array $previousRows): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'quickWins' => $this->quickWins($rows),
|
||||||
|
'sharedTerms' => $this->sharedTerms($rows),
|
||||||
|
'alreadyRanking' => array_values(array_filter($rows, fn(array $r): bool => str_contains($r['flag'], 'Already ranking'))),
|
||||||
|
'structure' => $this->structure($rows),
|
||||||
|
'diff' => $previousRows !== null ? $this->diff($previousRows, $rows) : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ work lists
|
||||||
|
|
||||||
|
/** @param array<int,array<string,string>> $rows
|
||||||
|
* @return array<int,array<string,string>> */
|
||||||
|
private function quickWins(array $rows): array
|
||||||
|
{
|
||||||
|
$wins = array_values(array_filter($rows, fn(array $r): bool => str_contains($r['flag'], 'Quick win')));
|
||||||
|
usort($wins, static function (array $a, array $b): int {
|
||||||
|
return (int)preg_replace('/\D/', '', $b['gscImpr']) <=> (int)preg_replace('/\D/', '', $a['gscImpr']);
|
||||||
|
});
|
||||||
|
return $wins;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared-term rows grouped by primary keyword - each group is one
|
||||||
|
* cannibalization risk: several pages targeting the same term.
|
||||||
|
*
|
||||||
|
* @param array<int,array<string,string>> $rows
|
||||||
|
* @return array<int,array{keyword:string,pages:array<int,array<string,string>>}>
|
||||||
|
*/
|
||||||
|
private function sharedTerms(array $rows): array
|
||||||
|
{
|
||||||
|
$groups = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
if (!str_contains($r['flag'], 'Shared term')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$groups[mb_strtolower($r['primary'])]['keyword'] = $r['primary'];
|
||||||
|
$groups[mb_strtolower($r['primary'])]['pages'][] = $r;
|
||||||
|
}
|
||||||
|
// Pages sharing the keyword without carrying the flag themselves:
|
||||||
|
foreach ($groups as $kw => $group) {
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
if (mb_strtolower($r['primary']) === $kw && !in_array($r, $group['pages'], true)) {
|
||||||
|
$groups[$kw]['pages'][] = $r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return array_values($groups);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------- structure check
|
||||||
|
|
||||||
|
/** @param array<int,array<string,string>> $rows
|
||||||
|
* @return array<string,mixed> */
|
||||||
|
private function structure(array $rows): array
|
||||||
|
{
|
||||||
|
$out = ['pages' => $this->pagesCheck($rows)];
|
||||||
|
|
||||||
|
foreach (self::SECTION_MODELS as $section => $cfg) {
|
||||||
|
$candidates = array_values(array_filter($rows, function (array $r) use ($section, $cfg): bool {
|
||||||
|
$depth = substr_count($r['ref'], '.') + 1;
|
||||||
|
return $r['section'] === $section && $depth >= $cfg['depth'][0] && $depth <= $cfg['depth'][1];
|
||||||
|
}));
|
||||||
|
$out[$cfg['key']] = $this->recordsCheck($candidates, $cfg['table']);
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<int,array<string,string>> $rows
|
||||||
|
* @return array{total:int,found:int,missing:array<int,array<string,string>>} */
|
||||||
|
private function pagesCheck(array $rows): array
|
||||||
|
{
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages');
|
||||||
|
$slugs = $qb->select('slug')->from('pages')
|
||||||
|
->where(
|
||||||
|
$qb->expr()->eq('deleted', 0),
|
||||||
|
$qb->expr()->eq('sys_language_uid', 0)
|
||||||
|
)
|
||||||
|
->executeQuery()->fetchFirstColumn();
|
||||||
|
$existing = array_flip(array_map(static fn($s): string => rtrim((string)$s, '/') ?: '/', $slugs));
|
||||||
|
|
||||||
|
$missing = [];
|
||||||
|
$found = 0;
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$slug = rtrim($r['url'], '/') ?: '/';
|
||||||
|
if (isset($existing[$slug])) {
|
||||||
|
$found++;
|
||||||
|
} else {
|
||||||
|
$missing[] = $r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ['total' => count($rows), 'found' => $found, 'missing' => $missing];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int,array<string,string>> $candidates
|
||||||
|
* @return array{total:int,matched:int,missing:array<int,array<string,string>>,extra:array<int,array<string,string>>}
|
||||||
|
*/
|
||||||
|
private function recordsCheck(array $candidates, string $table): array
|
||||||
|
{
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
|
||||||
|
$records = $qb->select('uid', 'title', 'slug')->from($table)
|
||||||
|
->where($qb->expr()->eq('deleted', 0))
|
||||||
|
->executeQuery()->fetchAllAssociative();
|
||||||
|
|
||||||
|
$bySlug = [];
|
||||||
|
$byTitle = [];
|
||||||
|
foreach ($records as $rec) {
|
||||||
|
$slug = mb_strtolower(trim((string)($rec['slug'] ?? '')));
|
||||||
|
if ($slug !== '') {
|
||||||
|
$bySlug[$slug] = $rec;
|
||||||
|
}
|
||||||
|
$byTitle[$this->normalizeTitle((string)$rec['title'])] = $rec;
|
||||||
|
}
|
||||||
|
|
||||||
|
$missing = [];
|
||||||
|
$both = [];
|
||||||
|
$matchedUids = [];
|
||||||
|
foreach ($candidates as $r) {
|
||||||
|
$segment = mb_strtolower(trim((string)basename(rtrim($r['url'], '/'))));
|
||||||
|
$rec = $bySlug[$segment] ?? $byTitle[$this->normalizeTitle($r['name'])] ?? null;
|
||||||
|
if ($rec !== null) {
|
||||||
|
$matchedUids[(int)$rec['uid']] = true;
|
||||||
|
$both[] = [
|
||||||
|
'ref' => $r['ref'],
|
||||||
|
'name' => $r['name'],
|
||||||
|
'url' => $r['url'],
|
||||||
|
'uid' => (string)$rec['uid'],
|
||||||
|
'title' => (string)$rec['title'],
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$missing[] = $r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$extra = [];
|
||||||
|
foreach ($records as $rec) {
|
||||||
|
if (!isset($matchedUids[(int)$rec['uid']])) {
|
||||||
|
$extra[] = ['uid' => (string)$rec['uid'], 'title' => (string)$rec['title']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total' => count($candidates),
|
||||||
|
'matched' => count($matchedUids),
|
||||||
|
'both' => $both,
|
||||||
|
'missing' => $missing,
|
||||||
|
'extra' => $extra,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeTitle(string $title): string
|
||||||
|
{
|
||||||
|
$title = (string)preg_replace('/\s*\(.*?\)/', '', $title); // drop parenthetical suffixes
|
||||||
|
$title = str_replace('&', 'and', mb_strtolower($title));
|
||||||
|
$title = (string)preg_replace('/[^a-z0-9]+/', ' ', $title);
|
||||||
|
return trim((string)preg_replace('/\s+/', ' ', $title));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ diff
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int,array<string,string>> $old
|
||||||
|
* @param array<int,array<string,string>> $new
|
||||||
|
* @return array{added:array<int,string>,removed:array<int,string>,changed:array<int,string>}
|
||||||
|
*/
|
||||||
|
private function diff(array $old, array $new): array
|
||||||
|
{
|
||||||
|
$byUrlOld = [];
|
||||||
|
foreach ($old as $r) {
|
||||||
|
$byUrlOld[$r['url']] = $r;
|
||||||
|
}
|
||||||
|
$byUrlNew = [];
|
||||||
|
foreach ($new as $r) {
|
||||||
|
$byUrlNew[$r['url']] = $r;
|
||||||
|
}
|
||||||
|
|
||||||
|
$added = [];
|
||||||
|
$changed = [];
|
||||||
|
foreach ($byUrlNew as $url => $r) {
|
||||||
|
$o = $byUrlOld[$url] ?? null;
|
||||||
|
if ($o === null) {
|
||||||
|
$added[] = sprintf('%s (%s)', $url, $r['name']);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$changes = [];
|
||||||
|
foreach (['primary' => 'primary keyword', 'secondary' => 'secondary keyword', 'flag' => 'flag'] as $field => $label) {
|
||||||
|
if ($r[$field] !== $o[$field]) {
|
||||||
|
$changes[] = sprintf('%s "%s" -> "%s"', $label, $o[$field], $r[$field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($changes !== []) {
|
||||||
|
$changed[] = $url . ': ' . implode('; ', $changes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$removed = [];
|
||||||
|
foreach ($byUrlOld as $url => $r) {
|
||||||
|
if (!isset($byUrlNew[$url])) {
|
||||||
|
$removed[] = sprintf('%s (%s)', $url, $r['name']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['added' => $added, 'removed' => $removed, 'changed' => $changed];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,13 @@ return [
|
|||||||
'target' => ImportController::class . '::processAction',
|
'target' => ImportController::class . '::processAction',
|
||||||
'methods' => ['POST'],
|
'methods' => ['POST'],
|
||||||
],
|
],
|
||||||
|
'seo' => [
|
||||||
|
'target' => ImportController::class . '::seoAction',
|
||||||
|
],
|
||||||
|
'seo_upload' => [
|
||||||
|
'target' => ImportController::class . '::seoUploadAction',
|
||||||
|
'methods' => ['POST'],
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
'web_vitecogimage' => [
|
'web_vitecogimage' => [
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ this extension turns every content element into clean **JSON** for a React front
|
|||||||
- [Forms](#forms)
|
- [Forms](#forms)
|
||||||
- [Page‑level fields](#pagelevel-fields)
|
- [Page‑level fields](#pagelevel-fields)
|
||||||
- [Structured data (JSON‑LD)](#structured-data-json-ld)
|
- [Structured data (JSON‑LD)](#structured-data-json-ld)
|
||||||
|
- [Editorial tooling](#editorial-tooling)
|
||||||
- [Requirements](#requirements)
|
- [Requirements](#requirements)
|
||||||
- [Installation](#installation)
|
- [Installation](#installation)
|
||||||
- [Adding a new headless plugin](#adding-a-new-headless-plugin)
|
- [Adding a new headless plugin](#adding-a-new-headless-plugin)
|
||||||
@@ -188,6 +189,25 @@ endpoint) and `vitec/success-story-path-rewrite`, which lets the public SEO URL
|
|||||||
`Organization`, `WebSite` (root only), `BreadcrumbList`, `Product`, `VideoObject`,
|
`Organization`, `WebSite` (root only), `BreadcrumbList`, `Product`, `VideoObject`,
|
||||||
`FAQPage`, `ExhibitionEvent` and `NewsArticle`.
|
`FAQPage`, `ExhibitionEvent` and `NewsArticle`.
|
||||||
|
|
||||||
|
## Editorial tooling
|
||||||
|
|
||||||
|
**Backend module "VITEC Import"** (Web menu): CSV import per domain model
|
||||||
|
(Market, Solution, Product) with a persistable column mapper and a unified
|
||||||
|
review list (new / update / unchanged / db-only) — what gets written is the
|
||||||
|
editable per-row payload, applied through DataHandler. A fourth tab
|
||||||
|
**SEO Research** stores each delivery of the recurring keyword-research CSV,
|
||||||
|
diffs it against the previous one and checks the CSV structure against the
|
||||||
|
page tree and the domain records.
|
||||||
|
|
||||||
|
| CLI command | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `vitec:import-success-stories` | One-time migration of the old-site success stories |
|
||||||
|
| `vitec:import-downloads` | Import old-site downloads (Collateral only, idempotent, filename normalization) |
|
||||||
|
| `vitec:create-markets` | Create market records the SEO structure check reports missing, incl. sys_category assignment |
|
||||||
|
| `vitec:market-dummy-image` | Assign the shared placeholder image to markets without an image |
|
||||||
|
|
||||||
|
All commands support `--dry-run` and are safe to re-run.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
| Component | Version |
|
| Component | Version |
|
||||||
|
|||||||
@@ -19,6 +19,9 @@
|
|||||||
href="{f:be.uri(route: 'web_vitecimport', parameters: {model: m.key})}">{m.label}</a>
|
href="{f:be.uri(route: 'web_vitecimport', parameters: {model: m.key})}">{m.label}</a>
|
||||||
</li>
|
</li>
|
||||||
</f:for>
|
</f:for>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.seo')}">SEO Research</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<!-- Upload -->
|
<!-- Upload -->
|
||||||
|
|||||||
201
packages/vitec/Resources/Private/Templates/Import/Seo.html
Normal file
201
packages/vitec/Resources/Private/Templates/Import/Seo.html
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
|
||||||
|
data-namespace-typo3-fluid="true">
|
||||||
|
|
||||||
|
<f:layout name="Backend/Default" />
|
||||||
|
|
||||||
|
<f:section name="main">
|
||||||
|
|
||||||
|
<div class="module-docheader-bar module-docheader-bar-navigation">
|
||||||
|
<div class="module-docheader-bar-column-left">
|
||||||
|
<h2 class="t3js-title-inlineedit">VITEC Import — SEO Research</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<ul class="nav nav-tabs" style="margin-bottom:1rem;">
|
||||||
|
<f:for each="{models}" as="m">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport', parameters: {model: m.key})}">{m.label}</a>
|
||||||
|
</li>
|
||||||
|
</f:for>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" href="{f:be.uri(route: 'web_vitecimport.seo')}">SEO Research</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Upload -->
|
||||||
|
<div class="card" style="margin-bottom:1rem;">
|
||||||
|
<div class="card-body">
|
||||||
|
<form action="{f:be.uri(route: 'web_vitecimport.seo_upload')}" method="post" enctype="multipart/form-data" class="row row-cols-auto align-items-center g-2">
|
||||||
|
<div class="col">
|
||||||
|
<input type="file" name="csvfile" accept=".csv,text/csv" class="form-control" required="required" />
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<button type="submit" class="btn btn-primary">Store delivery</button>
|
||||||
|
</div>
|
||||||
|
<div class="col form-text">
|
||||||
|
Every upload is kept as a delivery and compared against the previous one.
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<f:if condition="{latest}">
|
||||||
|
|
||||||
|
<p class="text-muted">
|
||||||
|
Latest delivery: <strong>{latest.filename}</strong>,
|
||||||
|
<f:format.date format="d.m.Y H:i">@{latest.crdate}</f:format.date>
|
||||||
|
({analysis.structure.pages.total} rows)
|
||||||
|
<f:if condition="{previous}">
|
||||||
|
— compared against <f:format.date format="d.m.Y H:i">@{previous.crdate}</f:format.date>
|
||||||
|
</f:if>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Diff -->
|
||||||
|
<f:if condition="{analysis.diff}">
|
||||||
|
<div class="card" style="margin-bottom:1rem;">
|
||||||
|
<div class="card-header"><strong>Changes since previous delivery</strong></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<f:if condition="{analysis.diff.added}">
|
||||||
|
<p><strong>New pages ({analysis.diff.added -> f:count()})</strong></p>
|
||||||
|
<ul><f:for each="{analysis.diff.added}" as="line"><li>{line}</li></f:for></ul>
|
||||||
|
</f:if>
|
||||||
|
<f:if condition="{analysis.diff.removed}">
|
||||||
|
<p><strong>Removed pages ({analysis.diff.removed -> f:count()})</strong></p>
|
||||||
|
<ul><f:for each="{analysis.diff.removed}" as="line"><li>{line}</li></f:for></ul>
|
||||||
|
</f:if>
|
||||||
|
<f:if condition="{analysis.diff.changed}">
|
||||||
|
<p><strong>Changed rows ({analysis.diff.changed -> f:count()})</strong></p>
|
||||||
|
<ul><f:for each="{analysis.diff.changed}" as="line"><li>{line}</li></f:for></ul>
|
||||||
|
</f:if>
|
||||||
|
<f:if condition="!{analysis.diff.added} && !{analysis.diff.removed} && !{analysis.diff.changed}">
|
||||||
|
<p class="text-muted">No differences.</p>
|
||||||
|
</f:if>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</f:if>
|
||||||
|
|
||||||
|
<!-- Structure check -->
|
||||||
|
<div class="card" style="margin-bottom:1rem;">
|
||||||
|
<div class="card-header"><strong>Structure check — CSV vs. TYPO3</strong></div>
|
||||||
|
<div class="card-body">
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<span class="vitec-badge vitec-badge-update">Pages: {analysis.structure.pages.found} / {analysis.structure.pages.total} exist</span>
|
||||||
|
<span class="vitec-badge vitec-badge-update">Markets: {analysis.structure.market.matched} / {analysis.structure.market.total}</span>
|
||||||
|
<span class="vitec-badge vitec-badge-update">Solutions: {analysis.structure.solution.matched} / {analysis.structure.solution.total}</span>
|
||||||
|
<span class="vitec-badge vitec-badge-update">Products: {analysis.structure.product.matched} / {analysis.structure.product.total}</span>
|
||||||
|
</p>
|
||||||
|
<p class="form-text">
|
||||||
|
Markets/Solutions: every row below the section root, including sub-markets and
|
||||||
|
sub-solutions. Products: level x.y and deeper (the top product rows are
|
||||||
|
categories). Matching: record slug against the last URL segment, falling back
|
||||||
|
to a normalized title comparison. The Page Ref column shows the hierarchy.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<f:for each="{analysis.structure}" key="area" as="check">
|
||||||
|
<f:if condition="{area} != 'pages'">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<h4 style="text-transform:capitalize;">{area}</h4>
|
||||||
|
<f:if condition="{check.both}">
|
||||||
|
<p><strong>Both in CSV and TYPO3 ({check.both -> f:count()})</strong></p>
|
||||||
|
<ul>
|
||||||
|
<f:for each="{check.both}" as="pair">
|
||||||
|
<li><code>{pair.ref}</code> {pair.name} <small class="text-muted">→ uid {pair.uid} ({pair.title})</small></li>
|
||||||
|
</f:for>
|
||||||
|
</ul>
|
||||||
|
</f:if>
|
||||||
|
<f:if condition="{check.missing}">
|
||||||
|
<p><strong>Missing in TYPO3 ({check.missing -> f:count()})</strong></p>
|
||||||
|
<ul>
|
||||||
|
<f:for each="{check.missing}" as="row">
|
||||||
|
<li><code>{row.ref}</code> {row.name}</li>
|
||||||
|
</f:for>
|
||||||
|
</ul>
|
||||||
|
</f:if>
|
||||||
|
<f:if condition="{check.extra}">
|
||||||
|
<p><strong>Only in TYPO3 ({check.extra -> f:count()})</strong></p>
|
||||||
|
<ul>
|
||||||
|
<f:for each="{check.extra}" as="rec">
|
||||||
|
<li>{rec.title} <small class="text-muted">uid {rec.uid}</small></li>
|
||||||
|
</f:for>
|
||||||
|
</ul>
|
||||||
|
</f:if>
|
||||||
|
<f:if condition="!{check.missing} && !{check.extra}">
|
||||||
|
<p class="text-muted">Complete match — every CSV row has its record and vice versa.</p>
|
||||||
|
</f:if>
|
||||||
|
</div>
|
||||||
|
</f:if>
|
||||||
|
</f:for>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<f:if condition="{analysis.structure.pages.missing}">
|
||||||
|
<details>
|
||||||
|
<summary><strong>Pages missing in TYPO3 ({analysis.structure.pages.missing -> f:count()})</strong></summary>
|
||||||
|
<ul>
|
||||||
|
<f:for each="{analysis.structure.pages.missing}" as="row">
|
||||||
|
<li><code>{row.url}</code> — {row.name}</li>
|
||||||
|
</f:for>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</f:if>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick wins -->
|
||||||
|
<div class="card" style="margin-bottom:1rem;">
|
||||||
|
<div class="card-header"><strong>Quick wins</strong> <span class="vitec-badge vitec-badge-new">{analysis.quickWins -> f:count()}</span></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table class="table table-striped table-sm">
|
||||||
|
<thead><tr><th>Page</th><th>Primary keyword</th><th>Vol (Global)</th><th>GSC Pos</th><th>GSC Impr.</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<f:for each="{analysis.quickWins}" as="row">
|
||||||
|
<tr>
|
||||||
|
<td>{row.name}<br /><small class="text-muted">{row.url}</small></td>
|
||||||
|
<td>{row.primary}</td>
|
||||||
|
<td>{row.volGlobal}</td>
|
||||||
|
<td>{row.gscPos}</td>
|
||||||
|
<td>{row.gscImpr}</td>
|
||||||
|
</tr>
|
||||||
|
</f:for>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Shared terms -->
|
||||||
|
<div class="card" style="margin-bottom:1rem;">
|
||||||
|
<div class="card-header"><strong>Shared terms (cannibalization risk)</strong> <span class="vitec-badge vitec-badge-info">{analysis.sharedTerms -> f:count()}</span></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<f:for each="{analysis.sharedTerms}" as="group">
|
||||||
|
<p style="margin-bottom:4px;"><strong>{group.keyword}</strong></p>
|
||||||
|
<ul>
|
||||||
|
<f:for each="{group.pages}" as="row">
|
||||||
|
<li>{row.name} <small class="text-muted">{row.url}</small></li>
|
||||||
|
</f:for>
|
||||||
|
</ul>
|
||||||
|
</f:for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Already ranking -->
|
||||||
|
<div class="card" style="margin-bottom:1rem;">
|
||||||
|
<div class="card-header"><strong>Already ranking</strong> <span class="vitec-badge vitec-badge-unchanged">{analysis.alreadyRanking -> f:count()}</span></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<ul>
|
||||||
|
<f:for each="{analysis.alreadyRanking}" as="row">
|
||||||
|
<li>{row.name} <small class="text-muted">{row.url}</small> — {row.primary} (GSC Pos {row.gscPos})</li>
|
||||||
|
</f:for>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</f:if>
|
||||||
|
|
||||||
|
<f:if condition="!{latest}">
|
||||||
|
<p class="text-muted">No delivery stored yet - upload the keyword research CSV above.</p>
|
||||||
|
</f:if>
|
||||||
|
|
||||||
|
</f:section>
|
||||||
|
</html>
|
||||||
@@ -304,3 +304,14 @@ CREATE TABLE tx_vitec_import_mapping (
|
|||||||
PRIMARY KEY (uid),
|
PRIMARY KEY (uid),
|
||||||
KEY model (model)
|
KEY model (model)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE tx_vitec_seo_research (
|
||||||
|
uid int(11) NOT NULL auto_increment,
|
||||||
|
pid int(11) DEFAULT '0' NOT NULL,
|
||||||
|
crdate int(11) DEFAULT '0' NOT NULL,
|
||||||
|
be_user int(11) DEFAULT '0' NOT NULL,
|
||||||
|
filename varchar(255) DEFAULT '' NOT NULL,
|
||||||
|
row_count int(11) DEFAULT '0' NOT NULL,
|
||||||
|
payload mediumtext,
|
||||||
|
PRIMARY KEY (uid)
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user