Files
VITEC-website/packages/vitec/Classes/Service/DownloadFileResolver.php
Oliver Rasche 7bd1161abf Product text import from agency XLSX workbooks
VITEC Import module: Products tab is now a searchable/sortable product
overview. "Edit Product" opens an XLSX upload with per-field source
mapping (component + cell), old/new preview and checkbox apply via
DataHandler; the mapping and manual matches are persisted and preselect
the next workbook. Category workbooks are detected and rejected.

- new: ProductXlsxReader (PhpSpreadsheet), ProductTextImportController,
  Products/ProductTexts templates, 4 module routes
- related products: per-pair card text in new side table
  tx_vitec_product_related_text (survives MM rewrites), emitted as
  `cardtext` in the product JSON; missing MM relations added add-only
- card copy read from Body Copy (D) with fallback to CTA/Card Copy (E) -
  the workbooks fill either depending on row type
- product detail page <title> now uses seotitle with title fallback
  (provider made singleton and fed from the JSON renderer)
- per-user recent-search badges; last loaded workbook stored per product
  (tx_vitec_product_workbook), Edit Product reopens on it
- composer: add phpoffice/phpspreadsheet ^5.9
- diagnostics: migrations/check_workbook.php
2026-08-21 11:00:45 +02:00

227 lines
7.7 KiB
PHP

<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Service;
use Doctrine\DBAL\ParameterType;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Resolves a download record to its file on disk.
*
* Resolution order is the one the three download renderers use for their
* `file` payload - FAL reference, then the Collateral naming convention,
* then the filepath column - so a record link and the JSON always point at
* the same file. The renderers still carry private copies of this logic for
* their richer payload (thumbnail, title, url); consolidating them onto this
* service is the standing 10.2 goal (Annex B-4).
*
* Contract: null means "no file". Never throws.
*/
final class DownloadFileResolver
{
/**
* @return array{path: string, name: string, size: int, mimeType: string}|null
*/
public static function resolve(int $downloadUid): ?array
{
if ($downloadUid <= 0) {
return null;
}
try {
$download = self::loadDownload($downloadUid);
if ($download === null) {
return null;
}
$path = self::resolveByFal($downloadUid)
?? self::resolveByConvention(
(string)($download['fileprefix'] ?? ''),
self::fileType($downloadUid)
)
?? self::resolveByFilepath((string)($download['filepath'] ?? ''));
return $path !== null ? self::describe($path) : null;
} catch (\Throwable $e) {
return null;
}
}
/** @return array<string,mixed>|null */
private static function loadDownload(int $uid): ?array
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_download');
$row = $qb
->select('uid', 'fileprefix', 'filepath')
->from('tx_vitec_domain_model_download')
->where(
$qb->expr()->eq('uid', $qb->createNamedParameter($uid, ParameterType::INTEGER)),
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
return $row ?: null;
}
private static function resolveByFal(int $downloadUid): ?string
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_file_reference');
$row = $qb
->select('f.identifier')
->from('sys_file_reference', 'fr')
->join('fr', 'sys_file', 'f', 'fr.uid_local = f.uid')
->where(
$qb->expr()->eq('fr.tablenames', $qb->createNamedParameter('tx_vitec_domain_model_download', ParameterType::STRING)),
$qb->expr()->eq('fr.fieldname', $qb->createNamedParameter('file', ParameterType::STRING)),
$qb->expr()->eq('fr.uid_foreign', $qb->createNamedParameter($downloadUid, ParameterType::INTEGER)),
$qb->expr()->eq('fr.deleted', 0),
$qb->expr()->eq('f.missing', 0)
)
->orderBy('fr.sorting_foreign', 'ASC')
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
$identifier = (string)($row['identifier'] ?? '');
if ($identifier === '') {
return null;
}
$path = Environment::getPublicPath() . '/fileadmin' . $identifier;
return is_file($path) && is_readable($path) ? $path : null;
}
/**
* Highest revision of <prefix>__<filetype>__<NN>-<letters>.<ext> in the
* Collateral directory - same pattern and ranking as the renderers.
*/
private static function resolveByConvention(string $fileprefix, string $filetype): ?string
{
$fileprefix = trim($fileprefix);
$filetype = trim($filetype);
if ($fileprefix === '' || $filetype === '') {
return null;
}
$baseDir = Environment::getPublicPath() . '/fileadmin/downloads/Collateral';
if (!is_dir($baseDir) || !is_readable($baseDir)) {
return null;
}
$pattern = '/^' . preg_quote($fileprefix, '/') . '__' . preg_quote($filetype, '/')
. '__(\\d+)-([A-Za-z]+)\\.([A-Za-z0-9]+)$/';
$entries = scandir($baseDir);
if ($entries === false) {
return null;
}
$bestFile = null;
$bestNumber = -1;
$bestLetterRank = -1;
foreach ($entries as $entry) {
if (!is_string($entry) || !preg_match($pattern, $entry, $matches)) {
continue;
}
$number = (int)$matches[1];
$letterRank = self::letterSequenceToRank((string)$matches[2]);
if ($number > $bestNumber || ($number === $bestNumber && $letterRank > $bestLetterRank)) {
$bestNumber = $number;
$bestLetterRank = $letterRank;
$bestFile = $entry;
}
}
if ($bestFile === null) {
return null;
}
$path = $baseDir . '/' . $bestFile;
return is_file($path) && is_readable($path) ? $path : null;
}
private static function resolveByFilepath(string $filepath): ?string
{
$filepath = trim($filepath);
if ($filepath === '') {
return null;
}
$path = Environment::getPublicPath() . '/' . ltrim($filepath, '/');
return is_file($path) && is_readable($path) ? $path : null;
}
/** Filetype segment from the assigned filetype category (parent 4). */
private static function fileType(int $downloadUid): string
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_category');
$categories = $qb
->select('c.filetype')
->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('categories', 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),
$qb->expr()->eq('c.parent', $qb->createNamedParameter(4, ParameterType::INTEGER))
)
->orderBy('mm.sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
foreach ($categories as $category) {
$filetype = trim((string)($category['filetype'] ?? ''));
if ($filetype !== '') {
return $filetype;
}
}
return '';
}
/** "A" = 1 ... "Z" = 26, "AA" = 27 - revision letters as a rank. */
private static function letterSequenceToRank(string $letters): int
{
$rank = 0;
foreach (str_split(strtoupper($letters)) as $char) {
$rank = $rank * 26 + (ord($char) - 64);
}
return $rank;
}
/** @return array{path: string, name: string, size: int, mimeType: string} */
private static function describe(string $path): array
{
$mimeType = function_exists('mime_content_type') ? (string)(mime_content_type($path) ?: '') : '';
return [
'path' => $path,
'name' => basename($path),
'size' => (int)(filesize($path) ?: 0),
'mimeType' => $mimeType,
];
}
}