Import old-site downloads and add a type taxonomy

New CLI command vitec:import-downloads: reads the JSON export of the
old site (www.vitec.com/import), cuts it out of the surrounding page
template by brace counting, and imports the downloads idempotently by
slug. Scope is deliberately narrow: only files under
/fileadmin/downloads/Collateral/, records without title or file are
skipped, records sharing one file_url are merged. Files are fetched
resumably into fileadmin/downloads/Collateral/ and stored on pid 29.

Legacy filenames are normalized on fetch so every imported file
matches the <prefix>__<filetype>__<NN>-<letter> version convention:
__NN_A -> __NN-A, ___NN -> __NN, __NNA -> __NN-A, and a bare __NN
gets -A appended as its initial revision. Verified against the full
export: all 179 names match afterwards, none of the already-correct
ones change.

tx_vitec_domain_model_download gains a second category field "type"
(display taxonomy: Datasheet, Success Story, Brochure, Software) next
to the filename-driven filetype categories. No SQL change needed -
the core generates columns for category fields. The three download
renderers emit the field as a string analogous to filetype; MM rows
are distinguished by fieldname, existing queries filter on it and
remain unaffected.

Import values are matched via the custom columns sys_category.filetype
and .type (falling back to title); missing categories are created
(filetype under --category-parent, default 4; type under a "Download
Type" parent).

getDownloadFile() in the three download renderers gains a filepath
fallback (FAL -> convention -> filepath) as a safety net for any
future filename that escapes the convention - previously such records
emitted file: null.

Architecture spec bumped to v1.7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 12:08:48 +02:00
parent 27cc22dc69
commit baa0fdd331
7 changed files with 926 additions and 7 deletions

View File

@@ -0,0 +1,621 @@
<?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\Core\Environment;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Http\RequestFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Idempotent import of the old-site downloads (www.vitec.com/import) into
* tx_vitec_domain_model_download.
*
* The old site exposes a JSON export - embedded in a normal page template,
* so the JSON block is cut out of the HTML by brace counting. Scope is
* deliberately narrow (decisions 2026-08-06):
*
* - only records whose file_url lives under /fileadmin/downloads/Collateral/
* (other directories: reported, not imported)
* - files are downloaded into fileadmin/downloads/Collateral/ (resumable:
* existing files are not fetched again)
* - no FAL reference; `filepath` + `fileprefix` are set and the runtime
* resolution works via resolveFileByConvention() where the filename
* follows the <prefix>__<filetype>__<NN>-<letter>.<ext> convention
* - legacy names are normalized on fetch so every imported file matches
* the convention: __NN_A -> __NN-A, ___NN -> __NN, __NNA -> __NN-A,
* and a bare __NN gets -A appended as its initial revision; the
* filepath fallback in the renderers remains as a safety net
* - filetype[0] -> existing `categories` field (categories under
* --category-parent, matched via sys_category.filetype, then title)
* - type[0] -> new `type` field (categories under --type-parent,
* matched via sys_category.type, then title)
* - records without title or file_url are skipped; records sharing one
* file_url are merged (kept: filled fileprefix, then lowest uid)
* - existing records (matched by slug) are skipped unless --force
*
* Usage:
* vendor/bin/typo3 vitec:import-downloads --dry-run
* vendor/bin/typo3 vitec:import-downloads --only=aligo-datasheet --force
*
* Records are stored on pid 29 (the downloads sysfolder) unless --pid says
* otherwise.
*/
#[AsCommand(
name: 'vitec:import-downloads',
description: 'Import downloads from the old-website JSON export (Collateral directory only)'
)]
final class ImportDownloadsCommand extends Command
{
private const TABLE = 'tx_vitec_domain_model_download';
private const CATEGORY_TABLE = 'sys_category';
private const COLLATERAL_URL_PREFIX = 'https://www.vitec.com/fileadmin/downloads/Collateral/';
private const TARGET_REL_DIR = 'fileadmin/downloads/Collateral';
private const CONVENTION_PATTERN = '/^(.+)__([A-Za-z]+)__(\d+)-([A-Za-z]+)\.[A-Za-z0-9]+$/';
private const TYPE_PARENT_TITLE = 'Download Type';
protected function configure(): void
{
$this->addOption('url', null, InputOption::VALUE_REQUIRED, 'Export URL', 'https://www.vitec.com/import');
$this->addOption('file', null, InputOption::VALUE_REQUIRED, 'Read export from a local JSON/HTML file instead of the URL');
$this->addOption('pid', null, InputOption::VALUE_REQUIRED, 'Storage pid for new download records', '29');
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report only - no files, no records, no categories');
$this->addOption('only', null, InputOption::VALUE_REQUIRED, 'Import only the record with this slug');
$this->addOption('force', null, InputOption::VALUE_NONE, 'Update records that already exist (matched by slug)');
$this->addOption('skip-files', null, InputOption::VALUE_NONE, 'Do not download files, only write records');
$this->addOption('category-parent', null, InputOption::VALUE_REQUIRED, 'Parent uid of the filetype categories', '4');
$this->addOption('type-parent', null, InputOption::VALUE_REQUIRED, 'Parent uid of the type categories (0 = find/create "' . self::TYPE_PARENT_TITLE . '")', '0');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
Bootstrap::initializeBackendAuthentication();
$dryRun = (bool)$input->getOption('dry-run');
$force = (bool)$input->getOption('force');
$skipFiles = (bool)$input->getOption('skip-files');
$only = trim((string)$input->getOption('only'));
// ------------------------------------------------------------- load
$raw = $this->loadRaw($input, $output);
if ($raw === null) {
return Command::FAILURE;
}
$rows = $this->extractRows($raw, $output);
if ($rows === null) {
return Command::FAILURE;
}
$output->writeln(sprintf('Export: %d records', count($rows)));
// ---------------------------------------------------------- prepare
$outOfScope = [];
$skipped = [];
$inScope = [];
foreach ($rows as $r) {
$title = trim((string)($r['title'] ?? ''));
$fileUrl = trim((string)($r['file_url'] ?? ''));
$slug = trim((string)($r['slug'] ?? ''));
if ($title === '' || $fileUrl === '' || $slug === '') {
$skipped[] = sprintf('uid=%s (no title/file_url/slug)', $r['uid'] ?? '?');
continue;
}
if (!str_starts_with($fileUrl, self::COLLATERAL_URL_PREFIX)) {
$outOfScope[] = sprintf('uid=%s %s', $r['uid'] ?? '?', $fileUrl);
continue;
}
$inScope[] = $r;
}
// merge records sharing one file_url: keep filled fileprefix, then lowest uid
$byUrl = [];
$merged = [];
foreach ($inScope as $r) {
$url = (string)$r['file_url'];
if (!isset($byUrl[$url])) {
$byUrl[$url] = $r;
continue;
}
$kept = $byUrl[$url];
$keepNew = trim((string)($r['fileprefix'] ?? '')) !== '' && trim((string)($kept['fileprefix'] ?? '')) === '';
if (!$keepNew && trim((string)($r['fileprefix'] ?? '')) === trim((string)($kept['fileprefix'] ?? ''))) {
$keepNew = (int)$r['uid'] < (int)$kept['uid'];
}
if ($keepNew) {
$merged[] = sprintf('uid=%d superseded by uid=%d (%s)', (int)$kept['uid'], (int)$r['uid'], basename($url));
$byUrl[$url] = $r;
} else {
$merged[] = sprintf('uid=%d superseded by uid=%d (%s)', (int)$r['uid'], (int)$kept['uid'], basename($url));
}
}
$work = array_values($byUrl);
if ($only !== '') {
$work = array_values(array_filter($work, static fn(array $r): bool => (string)$r['slug'] === $only));
if ($work === []) {
$output->writeln(sprintf('<error>--only=%s matches nothing in scope.</error>', $only));
return Command::FAILURE;
}
}
$output->writeln(sprintf(
'In scope: %d (out of scope: %d, skipped: %d, merged duplicates: %d)',
count($work),
count($outOfScope),
count($skipped),
count($merged)
));
foreach ($skipped as $s) {
$output->writeln(' skip ' . $s);
}
foreach ($merged as $m) {
$output->writeln(' merge ' . $m);
}
if ($output->isVerbose()) {
foreach ($outOfScope as $o) {
$output->writeln(' outside ' . $o);
}
}
// -------------------------------------------------------------- pid
$pid = (int)($input->getOption('pid') ?? 0);
if ($pid <= 0) {
$pid = $this->detectPid();
}
if ($pid <= 0) {
$output->writeln('<error>No --pid given and no existing download records to derive it from.</error>');
return Command::FAILURE;
}
$output->writeln(sprintf('Storage pid: %d', $pid));
// -------------------------------------------------- category lookup
$categoryParent = (int)$input->getOption('category-parent');
$typeParent = (int)$input->getOption('type-parent');
$filetypeValues = $this->collectValues($work, 'filetype');
$typeValues = $this->collectValues($work, 'type');
if ($typeParent <= 0) {
$typeParent = $this->findOrCreateTypeParent($categoryParent, $dryRun, $output);
}
$filetypeMap = $this->resolveCategories($filetypeValues, $categoryParent, 'filetype', $dryRun, $output);
$typeMap = $this->resolveCategories($typeValues, $typeParent, 'type', $dryRun, $output);
// ------------------------------------------------------------ files
$targetDir = Environment::getPublicPath() . '/' . self::TARGET_REL_DIR;
if (!$dryRun && !$skipFiles && !is_dir($targetDir)) {
GeneralUtility::mkdir_deep($targetDir);
}
$created = $updated = $existing = $filesFetched = $filesPresent = $fileErrors = 0;
$nonConvention = [];
$renamed = [];
foreach ($work as $r) {
$slug = (string)$r['slug'];
$fileUrl = (string)$r['file_url'];
$filename = basename(parse_url($fileUrl, PHP_URL_PATH) ?: '');
if ($filename === '') {
$output->writeln(sprintf(' <error>%s: cannot derive filename from %s</error>', $slug, $fileUrl));
continue;
}
// Normalize legacy separators so the runtime convention matches
// on the new site (decision 2026-08-06: options A + C).
$normalized = $this->normalizeFilename($filename);
if ($normalized !== $filename) {
$renamed[] = sprintf('%s -> %s', $filename, $normalized);
$filename = $normalized;
}
if (!preg_match(self::CONVENTION_PATTERN, $filename)) {
$nonConvention[] = $filename;
}
// --- file
$targetFile = $targetDir . '/' . $filename;
if (!$skipFiles) {
if (is_file($targetFile)) {
$filesPresent++;
} elseif ($dryRun) {
$filesFetched++; // would fetch
} else {
if ($this->fetchFile($fileUrl, $targetFile, $output)) {
$filesFetched++;
} else {
$fileErrors++;
$output->writeln(sprintf(' <error>%s: download failed, record skipped</error>', $slug));
continue;
}
}
}
// --- record
$existingUid = $this->findBySlug($slug);
if ($existingUid > 0 && !$force) {
$existing++;
continue;
}
$data = [
'pid' => $pid,
'title' => (string)$r['title'],
'slug' => $slug,
'description' => (string)($r['description'] ?? ''),
'fileprefix' => (string)($r['fileprefix'] ?? ''),
'filepath' => '/' . self::TARGET_REL_DIR . '/' . $filename,
];
$ft = $this->firstValue($r, 'filetype');
if ($ft !== '' && isset($filetypeMap[$ft])) {
$data['categories'] = (string)$filetypeMap[$ft];
}
$ty = $this->firstValue($r, 'type');
if ($ty !== '' && isset($typeMap[$ty])) {
$data['type'] = (string)$typeMap[$ty];
}
if ($dryRun) {
$output->writeln(sprintf(
' %-8s %-45s cat=%s type=%s %s',
$existingUid > 0 ? 'UPDATE' : 'CREATE',
substr($slug, 0, 45),
$ft !== '' ? $ft : '-',
$ty !== '' ? $ty : '-',
$filename
));
$existingUid > 0 ? $updated++ : $created++;
continue;
}
$id = $existingUid > 0 ? (string)$existingUid : 'NEW' . md5($slug);
if ($existingUid > 0) {
unset($data['pid']);
}
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start([self::TABLE => [$id => $data]], []);
$dataHandler->process_datamap();
if ($dataHandler->errorLog !== []) {
$output->writeln(sprintf(' <error>%s: %s</error>', $slug, implode(' | ', $dataHandler->errorLog)));
continue;
}
$existingUid > 0 ? $updated++ : $created++;
}
// ------------------------------------------------------------ report
$output->writeln('');
$output->writeln(sprintf(
'%s: %d created, %d updated, %d already present (use --force to update)',
$dryRun ? 'DRY-RUN' : 'Done',
$created,
$updated,
$existing
));
if (!$skipFiles) {
$output->writeln(sprintf(
'Files: %d %s, %d already on disk, %d failed',
$filesFetched,
$dryRun ? 'to fetch' : 'fetched',
$filesPresent,
$fileErrors
));
}
if ($renamed !== []) {
$output->writeln(sprintf(
'<comment>%d file(s) renamed on fetch to match the version convention:</comment>',
count($renamed)
));
foreach ($renamed as $rn) {
$output->writeln(' ~ ' . $rn);
}
}
if ($nonConvention !== []) {
$output->writeln(sprintf(
'<comment>%d filename(s) do not match the <prefix>__<filetype>__<NN>-<letter> convention;'
. ' runtime resolution falls back to filepath for these:</comment>',
count($nonConvention)
));
foreach ($nonConvention as $n) {
$output->writeln(' ! ' . $n);
}
}
return $fileErrors === 0 ? Command::SUCCESS : Command::FAILURE;
}
// ------------------------------------------------------------ loading
private function loadRaw(InputInterface $input, OutputInterface $output): ?string
{
$file = trim((string)$input->getOption('file'));
if ($file !== '') {
if (!is_file($file)) {
$output->writeln(sprintf('<error>File not found: %s</error>', $file));
return null;
}
return (string)file_get_contents($file);
}
$url = (string)$input->getOption('url');
try {
$response = GeneralUtility::makeInstance(RequestFactory::class)
->request($url, 'GET', ['timeout' => 120]);
return (string)$response->getBody();
} catch (\Throwable $e) {
$output->writeln(sprintf('<error>Fetch failed: %s</error>', $e->getMessage()));
return null;
}
}
/**
* The export page wraps the JSON in the full site template. Cut the
* {"count":...} object out of the HTML by brace counting (string-safe).
*
* @return array<int,array<string,mixed>>|null
*/
private function extractRows(string $raw, OutputInterface $output): ?array
{
$start = strpos($raw, '{"count"');
if ($start === false) {
// maybe it is clean JSON already
$decoded = json_decode($raw, true);
if (is_array($decoded) && isset($decoded['downloads'])) {
return $decoded['downloads'];
}
$output->writeln('<error>No {"count" marker found in the response.</error>');
return null;
}
$depth = 0;
$inString = false;
$escaped = false;
$end = null;
$len = strlen($raw);
for ($i = $start; $i < $len; $i++) {
$ch = $raw[$i];
if ($inString) {
if ($escaped) {
$escaped = false;
} elseif ($ch === '\\') {
$escaped = true;
} elseif ($ch === '"') {
$inString = false;
}
continue;
}
if ($ch === '"') {
$inString = true;
} elseif ($ch === '{') {
$depth++;
} elseif ($ch === '}') {
$depth--;
if ($depth === 0) {
$end = $i + 1;
break;
}
}
}
if ($end === null) {
$output->writeln('<error>Unbalanced JSON block in the response.</error>');
return null;
}
$decoded = json_decode(html_entity_decode(substr($raw, $start, $end - $start), ENT_QUOTES | ENT_HTML5), true);
if (!is_array($decoded) || !isset($decoded['downloads']) || !is_array($decoded['downloads'])) {
$output->writeln('<error>Extracted block is not the expected {count, downloads[]} object.</error>');
return null;
}
return $decoded['downloads'];
}
// --------------------------------------------------------- categories
/** @param array<int,array<string,mixed>> $rows
* @return array<int,string> */
private function collectValues(array $rows, string $field): array
{
$values = [];
foreach ($rows as $r) {
$v = $this->firstValue($r, $field);
if ($v !== '') {
$values[$v] = true;
}
}
return array_keys($values);
}
/** @param array<string,mixed> $row */
private function firstValue(array $row, string $field): string
{
$v = $row[$field] ?? null;
if (is_array($v)) {
$v = $v[0] ?? '';
}
return trim((string)$v);
}
/**
* Map export values to sys_category uids under $parent. Match order:
* custom column ($matchColumn), then title. Missing categories are
* created (with $matchColumn set) unless dry-run.
*
* @param array<int,string> $values
* @return array<string,int>
*/
private function resolveCategories(array $values, int $parent, string $matchColumn, bool $dryRun, OutputInterface $output): array
{
$map = [];
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::CATEGORY_TABLE);
$cats = $qb
->select('uid', 'pid', 'title', $matchColumn)
->from(self::CATEGORY_TABLE)
->where(
$qb->expr()->eq('parent', $qb->createNamedParameter($parent, ParameterType::INTEGER)),
$qb->expr()->eq('deleted', 0)
)
->executeQuery()
->fetchAllAssociative();
$parentRow = $this->categoryRow($parent);
$catPid = $parentRow !== null ? (int)$parentRow['pid'] : 0;
foreach ($values as $value) {
foreach ($cats as $c) {
if (strcasecmp(trim((string)($c[$matchColumn] ?? '')), $value) === 0
|| strcasecmp(trim((string)$c['title']), $value) === 0
) {
$map[$value] = (int)$c['uid'];
continue 2;
}
}
if ($dryRun) {
$output->writeln(sprintf(' would create category "%s" (parent %d, %s)', $value, $parent, $matchColumn));
$map[$value] = 0; // placeholder so dry-run still reports assignment
continue;
}
$uid = $this->createCategory($value, $parent, $catPid, $matchColumn);
if ($uid > 0) {
$output->writeln(sprintf(' created category "%s" -> uid %d (parent %d)', $value, $uid, $parent));
$map[$value] = $uid;
} else {
$output->writeln(sprintf(' <error>could not create category "%s"</error>', $value));
}
}
return $map;
}
private function findOrCreateTypeParent(int $categoryParent, bool $dryRun, OutputInterface $output): int
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::CATEGORY_TABLE);
$row = $qb
->select('uid')
->from(self::CATEGORY_TABLE)
->where(
$qb->expr()->eq('title', $qb->createNamedParameter(self::TYPE_PARENT_TITLE, ParameterType::STRING)),
$qb->expr()->eq('deleted', 0)
)
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
if ($row) {
return (int)$row['uid'];
}
$anchor = $this->categoryRow($categoryParent);
$pid = $anchor !== null ? (int)$anchor['pid'] : 0;
if ($dryRun) {
$output->writeln(sprintf(' would create parent category "%s" (pid %d)', self::TYPE_PARENT_TITLE, $pid));
return 0;
}
$uid = $this->createCategory(self::TYPE_PARENT_TITLE, 0, $pid, '');
$output->writeln(sprintf(' created parent category "%s" -> uid %d', self::TYPE_PARENT_TITLE, $uid));
return $uid;
}
/** @return array<string,mixed>|null */
private function categoryRow(int $uid): ?array
{
if ($uid <= 0) {
return null;
}
$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)))
->executeQuery()->fetchAssociative();
return $row ?: null;
}
private function createCategory(string $title, int $parent, int $pid, string $matchColumn): int
{
$data = ['pid' => $pid, 'parent' => $parent, 'title' => $title];
if ($matchColumn !== '') {
$data[$matchColumn] = $title;
}
$id = 'NEW' . md5('cat' . $parent . $title);
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start([self::CATEGORY_TABLE => [$id => $data]], []);
$dataHandler->process_datamap();
return (int)($dataHandler->substNEWwithIDs[$id] ?? 0);
}
// -------------------------------------------------------------- misc
/**
* Bring legacy version segments onto the <NN>-<letter> convention:
* ___NN -> __NN (collapsed underscores)
* __NN_A -> __NN-A (underscore as version separator)
* __NNA -> __NN-A (missing separator)
* __NN -> __NN-A (no revision letter at all: -A = initial
* revision, so a future -B supersedes it)
* Verified against the full 2026-08 export: all 179 names match the
* convention afterwards, none of the already-correct ones change.
*/
private function normalizeFilename(string $filename): string
{
$filename = (string)preg_replace('/_{3,}(\d)/', '__$1', $filename);
$filename = (string)preg_replace('/__(\d+)_([A-Za-z]+)\.([A-Za-z0-9]+)$/', '__$1-$2.$3', $filename);
$filename = (string)preg_replace('/__(\d+)([A-Za-z]+)\.([A-Za-z0-9]+)$/', '__$1-$2.$3', $filename);
return (string)preg_replace('/__(\d+)\.([A-Za-z0-9]+)$/', '__$1-A.$2', $filename);
}
private function detectPid(): 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 findBySlug(string $slug): int
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE);
$row = $qb->select('uid')->from(self::TABLE)
->where(
$qb->expr()->eq('slug', $qb->createNamedParameter($slug, ParameterType::STRING)),
$qb->expr()->eq('deleted', 0)
)
->setMaxResults(1)
->executeQuery()->fetchAssociative();
return $row ? (int)$row['uid'] : 0;
}
private function fetchFile(string $url, string $targetFile, OutputInterface $output): bool
{
try {
$response = GeneralUtility::makeInstance(RequestFactory::class)
->request($url, 'GET', ['timeout' => 300]);
if ($response->getStatusCode() !== 200) {
return false;
}
$tmp = $targetFile . '.part';
$fh = fopen($tmp, 'wb');
if ($fh === false) {
return false;
}
$body = $response->getBody();
while (!$body->eof()) {
fwrite($fh, $body->read(1048576));
}
fclose($fh);
if (filesize($tmp) === 0) {
@unlink($tmp);
return false;
}
rename($tmp, $targetFile);
if ($output->isVerbose()) {
$output->writeln(sprintf(' fetched %s (%.1f KB)', basename($targetFile), filesize($targetFile) / 1024));
}
return true;
} catch (\Throwable $e) {
$output->writeln(sprintf(' <error>%s: %s</error>', basename($targetFile), $e->getMessage()));
return false;
}
}
}

View File

@@ -265,10 +265,54 @@ class DatasheetsJsonRenderer
'description' => RteResolver::html($download['description'] ?? ''), 'description' => RteResolver::html($download['description'] ?? ''),
'tstamp' => (int)($download['tstamp'] ?? 0), 'tstamp' => (int)($download['tstamp'] ?? 0),
'filetype' => $filetype, 'filetype' => $filetype,
'type' => $this->getDownloadTypeLabel($uid),
'file' => $this->getDownloadFile($uid, (string)($download['fileprefix'] ?? ''), $filetype), 'file' => $this->getDownloadFile($uid, (string)($download['fileprefix'] ?? ''), $filetype),
]; ];
} }
/**
* Display taxonomy from the `type` category field (sys_category.type,
* fallback title) - analogous to getDownloadFileType()/`categories`,
* distinguished in the MM table by fieldname = 'type'.
*/
private function getDownloadTypeLabel(int $downloadUid): string
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_category');
$categories = $qb
->select('c.type', 'c.title')
->from('sys_category', 'c')
->join(
'c',
'sys_category_record_mm',
'mm',
'mm.uid_local = c.uid AND mm.tablenames = ' .
$qb->createNamedParameter('tx_vitec_domain_model_download', ParameterType::STRING) .
' AND mm.fieldname = ' .
$qb->createNamedParameter('type', ParameterType::STRING)
)
->where(
$qb->expr()->eq('mm.uid_foreign', $qb->createNamedParameter($downloadUid, ParameterType::INTEGER)),
$qb->expr()->eq('c.deleted', 0),
$qb->expr()->eq('c.hidden', 0)
)
->orderBy('mm.sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
foreach ($categories as $category) {
$label = trim((string)($category['type'] ?? ''));
if ($label === '') {
$label = trim((string)($category['title'] ?? ''));
}
if ($label !== '') {
return $label;
}
}
return '';
}
private function getDownloadFileType(int $downloadUid): string private function getDownloadFileType(int $downloadUid): string
{ {
$qb = GeneralUtility::makeInstance(ConnectionPool::class) $qb = GeneralUtility::makeInstance(ConnectionPool::class)
@@ -414,7 +458,8 @@ class DatasheetsJsonRenderer
->fetchAssociative(); ->fetchAssociative();
if (!$row) { if (!$row) {
return $this->resolveFileByConvention($fileprefix, $filetype); return $this->resolveFileByConvention($fileprefix, $filetype)
?? $this->resolveFileByFilepath($downloadUid);
} }
return [ return [
@@ -506,6 +551,57 @@ class DatasheetsJsonRenderer
]; ];
} }
/**
* Last-resort file resolution via the record's `filepath` column - for
* filenames outside the <prefix>__<filetype>__<NN>-<letter> convention
* (the 2026-08 import left a handful of such legacy names in place).
* Same payload shape as resolveFileByConvention(). Exception-safe.
*/
private function resolveFileByFilepath(int $downloadUid): ?array
{
try {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_download');
$row = $qb
->select('filepath')
->from('tx_vitec_domain_model_download')
->where(
$qb->expr()->eq('uid', $qb->createNamedParameter($downloadUid, ParameterType::INTEGER))
)
->executeQuery()
->fetchAssociative();
$filepath = trim((string)($row['filepath'] ?? ''));
if ($filepath === '') {
return null;
}
$relative = '/' . ltrim($filepath, '/');
$fullPath = Environment::getPublicPath() . $relative;
if (!is_file($fullPath) || !is_readable($fullPath)) {
return null;
}
$name = basename($fullPath);
$extension = strtolower(pathinfo($name, PATHINFO_EXTENSION));
$mimeType = function_exists('mime_content_type') ? (string)(mime_content_type($fullPath) ?: '') : '';
return [
'uid' => 0,
'name' => $name,
'url' => rtrim(dirname($relative), '/') . '/' . rawurlencode($name),
'thumbnail' => null,
'size' => (int)(filesize($fullPath) ?: 0),
'extension' => $extension,
'mimeType' => $mimeType,
'title' => '',
'description' => '',
];
} catch (\Throwable $e) {
return null;
}
}
private function letterSequenceToRank(string $letters): int private function letterSequenceToRank(string $letters): int
{ {
$rank = 0; $rank = 0;

View File

@@ -261,6 +261,7 @@ class DownloadcardJsonRenderer
'filepath' => (string)($download['filepath'] ?? ''), 'filepath' => (string)($download['filepath'] ?? ''),
'fileprefix' => $fileprefix, 'fileprefix' => $fileprefix,
'filetype' => $filetype, 'filetype' => $filetype,
'type' => $this->getDownloadTypeLabel($uid),
'private_download' => (bool)($download['private_download'] ?? false), 'private_download' => (bool)($download['private_download'] ?? false),
'hideonapp' => (bool)($download['hideonapp'] ?? false), 'hideonapp' => (bool)($download['hideonapp'] ?? false),
'hideonwebsite' => (bool)($download['hideonwebsite'] ?? false), 'hideonwebsite' => (bool)($download['hideonwebsite'] ?? false),
@@ -270,6 +271,49 @@ class DownloadcardJsonRenderer
]; ];
} }
/**
* Display taxonomy from the `type` category field (sys_category.type,
* fallback title) - analogous to getDownloadFileType()/`categories`,
* distinguished in the MM table by fieldname = 'type'.
*/
private function getDownloadTypeLabel(int $downloadUid): string
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_category');
$categories = $qb
->select('c.type', 'c.title')
->from('sys_category', 'c')
->join(
'c',
'sys_category_record_mm',
'mm',
'mm.uid_local = c.uid AND mm.tablenames = ' .
$qb->createNamedParameter('tx_vitec_domain_model_download', ParameterType::STRING) .
' AND mm.fieldname = ' .
$qb->createNamedParameter('type', ParameterType::STRING)
)
->where(
$qb->expr()->eq('mm.uid_foreign', $qb->createNamedParameter($downloadUid, ParameterType::INTEGER)),
$qb->expr()->eq('c.deleted', 0),
$qb->expr()->eq('c.hidden', 0)
)
->orderBy('mm.sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
foreach ($categories as $category) {
$label = trim((string)($category['type'] ?? ''));
if ($label === '') {
$label = trim((string)($category['title'] ?? ''));
}
if ($label !== '') {
return $label;
}
}
return '';
}
private function getDownloadFileType(int $downloadUid): string private function getDownloadFileType(int $downloadUid): string
{ {
$qb = GeneralUtility::makeInstance(ConnectionPool::class) $qb = GeneralUtility::makeInstance(ConnectionPool::class)
@@ -334,7 +378,8 @@ class DownloadcardJsonRenderer
->fetchAssociative(); ->fetchAssociative();
if (!$row) { if (!$row) {
return $this->resolveFileByConvention($fileprefix, $filetype); return $this->resolveFileByConvention($fileprefix, $filetype)
?? $this->resolveFileByFilepath($downloadUid);
} }
$thumbnailUrl = null; $thumbnailUrl = null;
@@ -437,6 +482,57 @@ class DownloadcardJsonRenderer
]; ];
} }
/**
* Last-resort file resolution via the record's `filepath` column - for
* filenames outside the <prefix>__<filetype>__<NN>-<letter> convention
* (the 2026-08 import left a handful of such legacy names in place).
* Same payload shape as resolveFileByConvention(). Exception-safe.
*/
private function resolveFileByFilepath(int $downloadUid): ?array
{
try {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_download');
$row = $qb
->select('filepath')
->from('tx_vitec_domain_model_download')
->where(
$qb->expr()->eq('uid', $qb->createNamedParameter($downloadUid, ParameterType::INTEGER))
)
->executeQuery()
->fetchAssociative();
$filepath = trim((string)($row['filepath'] ?? ''));
if ($filepath === '') {
return null;
}
$relative = '/' . ltrim($filepath, '/');
$fullPath = Environment::getPublicPath() . $relative;
if (!is_file($fullPath) || !is_readable($fullPath)) {
return null;
}
$name = basename($fullPath);
$extension = strtolower(pathinfo($name, PATHINFO_EXTENSION));
$mimeType = function_exists('mime_content_type') ? (string)(mime_content_type($fullPath) ?: '') : '';
return [
'uid' => 0,
'name' => $name,
'url' => rtrim(dirname($relative), '/') . '/' . rawurlencode($name),
'thumbnail' => null,
'size' => (int)(filesize($fullPath) ?: 0),
'extension' => $extension,
'mimeType' => $mimeType,
'title' => '',
'description' => '',
];
} catch (\Throwable $e) {
return null;
}
}
private function letterSequenceToRank(string $letters): int private function letterSequenceToRank(string $letters): int
{ {
$rank = 0; $rank = 0;

View File

@@ -265,6 +265,7 @@ class DownloadcardcollectionJsonRenderer
'filepath' => (string)($download['filepath'] ?? ''), 'filepath' => (string)($download['filepath'] ?? ''),
'fileprefix' => $fileprefix, 'fileprefix' => $fileprefix,
'filetype' => $filetype, 'filetype' => $filetype,
'type' => $this->getDownloadTypeLabel($uid),
'private_download' => (bool)($download['private_download'] ?? false), 'private_download' => (bool)($download['private_download'] ?? false),
'hideonapp' => (bool)($download['hideonapp'] ?? false), 'hideonapp' => (bool)($download['hideonapp'] ?? false),
'hideonwebsite' => (bool)($download['hideonwebsite'] ?? false), 'hideonwebsite' => (bool)($download['hideonwebsite'] ?? false),
@@ -274,6 +275,49 @@ class DownloadcardcollectionJsonRenderer
]; ];
} }
/**
* Display taxonomy from the `type` category field (sys_category.type,
* fallback title) - analogous to getDownloadFileType()/`categories`,
* distinguished in the MM table by fieldname = 'type'.
*/
private function getDownloadTypeLabel(int $downloadUid): string
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_category');
$categories = $qb
->select('c.type', 'c.title')
->from('sys_category', 'c')
->join(
'c',
'sys_category_record_mm',
'mm',
'mm.uid_local = c.uid AND mm.tablenames = ' .
$qb->createNamedParameter('tx_vitec_domain_model_download', ParameterType::STRING) .
' AND mm.fieldname = ' .
$qb->createNamedParameter('type', ParameterType::STRING)
)
->where(
$qb->expr()->eq('mm.uid_foreign', $qb->createNamedParameter($downloadUid, ParameterType::INTEGER)),
$qb->expr()->eq('c.deleted', 0),
$qb->expr()->eq('c.hidden', 0)
)
->orderBy('mm.sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
foreach ($categories as $category) {
$label = trim((string)($category['type'] ?? ''));
if ($label === '') {
$label = trim((string)($category['title'] ?? ''));
}
if ($label !== '') {
return $label;
}
}
return '';
}
private function getDownloadFileType(int $downloadUid): string private function getDownloadFileType(int $downloadUid): string
{ {
$qb = GeneralUtility::makeInstance(ConnectionPool::class) $qb = GeneralUtility::makeInstance(ConnectionPool::class)
@@ -338,7 +382,8 @@ class DownloadcardcollectionJsonRenderer
->fetchAssociative(); ->fetchAssociative();
if (!$row) { if (!$row) {
return $this->resolveFileByConvention($fileprefix, $filetype); return $this->resolveFileByConvention($fileprefix, $filetype)
?? $this->resolveFileByFilepath($downloadUid);
} }
@@ -441,6 +486,57 @@ class DownloadcardcollectionJsonRenderer
]; ];
} }
/**
* Last-resort file resolution via the record's `filepath` column - for
* filenames outside the <prefix>__<filetype>__<NN>-<letter> convention
* (the 2026-08 import left a handful of such legacy names in place).
* Same payload shape as resolveFileByConvention(). Exception-safe.
*/
private function resolveFileByFilepath(int $downloadUid): ?array
{
try {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_download');
$row = $qb
->select('filepath')
->from('tx_vitec_domain_model_download')
->where(
$qb->expr()->eq('uid', $qb->createNamedParameter($downloadUid, ParameterType::INTEGER))
)
->executeQuery()
->fetchAssociative();
$filepath = trim((string)($row['filepath'] ?? ''));
if ($filepath === '') {
return null;
}
$relative = '/' . ltrim($filepath, '/');
$fullPath = Environment::getPublicPath() . $relative;
if (!is_file($fullPath) || !is_readable($fullPath)) {
return null;
}
$name = basename($fullPath);
$extension = strtolower(pathinfo($name, PATHINFO_EXTENSION));
$mimeType = function_exists('mime_content_type') ? (string)(mime_content_type($fullPath) ?: '') : '';
return [
'uid' => 0,
'name' => $name,
'url' => rtrim(dirname($relative), '/') . '/' . rawurlencode($name),
'thumbnail' => null,
'size' => (int)(filesize($fullPath) ?: 0),
'extension' => $extension,
'mimeType' => $mimeType,
'title' => '',
'description' => '',
];
} catch (\Throwable $e) {
return null;
}
}
private function letterSequenceToRank(string $letters): int private function letterSequenceToRank(string $letters): int
{ {
$rank = 0; $rank = 0;

View File

@@ -21,7 +21,7 @@ return [
], ],
'types' => [ 'types' => [
'1' => ['showitem' => 'hidden, title, slug, keywords, teaser, description, '1' => ['showitem' => 'hidden, title, slug, keywords, teaser, description,
--div--;File, useolddl, file, fileprefix, categories, --div--;File, useolddl, file, fileprefix, categories, type,
--div--;Visibility, hideonapp, hideonwebsite, hideondatasheets, hideonproducts, --div--;Visibility, hideonapp, hideonwebsite, hideondatasheets, hideonproducts,
--div--;Currently not in use,icon, filepath, private_download, onlyondatasheets, sort1, sort2, sort3, --div--;Currently not in use,icon, filepath, private_download, onlyondatasheets, sort1, sort2, sort3,
--div--;LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:tabs.access, starttime, endtime'], --div--;LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:tabs.access, starttime, endtime'],
@@ -112,6 +112,14 @@ return [
'type' => 'category' 'type' => 'category'
] ]
], ],
'type' => [
'exclude' => true,
'label' => 'Type',
'description' => 'Display taxonomy (Datasheet, Success Story, ...) - independent of the filename-driven filetype categories.',
'config' => [
'type' => 'category'
]
],
'title' => [ 'title' => [
'exclude' => true, 'exclude' => true,
'label' => 'Title', 'label' => 'Title',

View File

@@ -3,7 +3,7 @@
| | | | | |
|---|---| |---|---|
| **Document identifier** | EVOVITECHL001 | | **Document identifier** | EVOVITECHL001 |
| **Version** | 1.5 | | **Version** | 1.7 |
| **Status** | Released | | **Status** | Released |
| **Date** | 20260805 | | **Date** | 20260805 |
| **Applies to** | `evomedien/vitec` on TYPO3 v14.3 (headless) | | **Applies to** | `evomedien/vitec` on TYPO3 v14.3 (headless) |
@@ -19,6 +19,8 @@
| 1.3 | 20260805 | **New plugin** `vitec_marketlist` (`MarketListJsonRenderer`, payload key `markets`) — first list plugin built entirely on the shared serializer per 9.2, with editorcontrolled selection and ordering. Clause 7.13 restructured into detail (7.13.1) and list (7.13.2); catalogue updated. | | 1.3 | 20260805 | **New plugin** `vitec_marketlist` (`MarketListJsonRenderer`, payload key `markets`) — first list plugin built entirely on the shared serializer per 9.2, with editorcontrolled selection and ordering. Clause 7.13 restructured into detail (7.13.1) and list (7.13.2); catalogue updated. |
| 1.4 | 20260805 | **Interface change (additive):** `tx_vitec_domain_model_market` gained a `detail_page` field (TCA `group`/`pages`), emitted as the **resolved** `detailUrl` in both market payloads (7.13.1, 7.13.2). Not added to Solution — see B11. | | 1.4 | 20260805 | **Interface change (additive):** `tx_vitec_domain_model_market` gained a `detail_page` field (TCA `group`/`pages`), emitted as the **resolved** `detailUrl` in both market payloads (7.13.1, 7.13.2). Not added to Solution — see B11. |
| 1.5 | 20260805 | **Defect fix, outputchanging:** richtext fields were emitted as raw database content by every VITEC UserFunc renderer, leaving `t3://` links unresolved in the JSON. New `RteResolver` service and mandatory convention 9.11; applied at all 19 richtext call sites across 11 renderers. Duplication register 10.2 updated. | | 1.5 | 20260805 | **Defect fix, outputchanging:** richtext fields were emitted as raw database content by every VITEC UserFunc renderer, leaving `t3://` links unresolved in the JSON. New `RteResolver` service and mandatory convention 9.11; applied at all 19 richtext call sites across 11 renderers. Duplication register 10.2 updated. |
| 1.6 | 20260806 | **Interface change (additive):** `tx_vitec_domain_model_download` gained a second category field `type` (display taxonomy; MM rows distinguished by `fieldname`), emitted as the string `type` in the downloadcard, downloadcardcollection and datasheets payloads — analogous to `filetype`. New CLI command `vitec:import-downloads` migrates the oldsite downloads (Collateral directory only; idempotent by slug; files fetched resumably; duplicate `file_url`s merged). |
| 1.7 | 20260806 | **Robustness:** the import normalizes legacy filenames on fetch so every imported file matches the version convention (`__NN_A``__NN-A`, `___NN``__NN`, `__NNA``__NN-A`, bare `__NN``__NN-A` as initial revision), and the three download renderers gained a `filepath` fallback in `getDownloadFile()` (FAL → convention → filepath) as a safety net for anything that still escapes it. Extends the B4 duplication (three copies of the fallback) — consolidation target remains a shared fileresolver service (10.2). |
This document is drafted in the style of, and adopts the terminology conventions of, This document is drafted in the style of, and adopts the terminology conventions of,
ISO/IEC/IEEE 42010 (architecture description), ISO/IEC/IEEE 26514 (information for ISO/IEC/IEEE 42010 (architecture description), ISO/IEC/IEEE 26514 (information for
@@ -1053,4 +1055,4 @@ remediation.
- Header convention — the uniform header section across CEs, plugins and containers. - Header convention — the uniform header section across CEs, plugins and containers.
- `Configuration/Sets/Vitecset/setup.typoscript` — the single TypoScript entry point. - `Configuration/Sets/Vitecset/setup.typoscript` — the single TypoScript entry point.
*End of document EVOVITECHL001 v1.5.* *End of document EVOVITECHL001 v1.7.*

View File

@@ -25,7 +25,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<title>VITEC</title> <title>VITEC</title>
<script type="module" crossorigin src="/_frontend/assets/index-DBk7e4hk.js"></script> <script type="module" crossorigin src="/_frontend/assets/index-DjsvU0JX.js"></script>
<link rel="stylesheet" crossorigin href="/_frontend/assets/index-CUeIsXnS.css"> <link rel="stylesheet" crossorigin href="/_frontend/assets/index-CUeIsXnS.css">
</head> </head>
<body> <body>