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:
621
packages/vitec/Classes/Command/ImportDownloadsCommand.php
Normal file
621
packages/vitec/Classes/Command/ImportDownloadsCommand.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Document identifier** | EVO‑VITEC‑HL‑001 |
|
| **Document identifier** | EVO‑VITEC‑HL‑001 |
|
||||||
| **Version** | 1.5 |
|
| **Version** | 1.7 |
|
||||||
| **Status** | Released |
|
| **Status** | Released |
|
||||||
| **Date** | 2026‑08‑05 |
|
| **Date** | 2026‑08‑05 |
|
||||||
| **Applies to** | `evomedien/vitec` on TYPO3 v14.3 (headless) |
|
| **Applies to** | `evomedien/vitec` on TYPO3 v14.3 (headless) |
|
||||||
@@ -19,6 +19,8 @@
|
|||||||
| 1.3 | 2026‑08‑05 | **New plugin** `vitec_marketlist` (`MarketListJsonRenderer`, payload key `markets`) — first list plugin built entirely on the shared serializer per 9.2, with editor‑controlled selection and ordering. Clause 7.13 restructured into detail (7.13.1) and list (7.13.2); catalogue updated. |
|
| 1.3 | 2026‑08‑05 | **New plugin** `vitec_marketlist` (`MarketListJsonRenderer`, payload key `markets`) — first list plugin built entirely on the shared serializer per 9.2, with editor‑controlled selection and ordering. Clause 7.13 restructured into detail (7.13.1) and list (7.13.2); catalogue updated. |
|
||||||
| 1.4 | 2026‑08‑05 | **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 B‑11. |
|
| 1.4 | 2026‑08‑05 | **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 B‑11. |
|
||||||
| 1.5 | 2026‑08‑05 | **Defect fix, output‑changing:** 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 | 2026‑08‑05 | **Defect fix, output‑changing:** 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 | 2026‑08‑06 | **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 old‑site downloads (Collateral directory only; idempotent by slug; files fetched resumably; duplicate `file_url`s merged). |
|
||||||
|
| 1.7 | 2026‑08‑06 | **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 B‑4 duplication (three copies of the fallback) — consolidation target remains a shared file‑resolver 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 EVO‑VITEC‑HL‑001 v1.5.*
|
*End of document EVO‑VITEC‑HL‑001 v1.7.*
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user