feat: import buspro xml files from remote sftp source

This commit is contained in:
2026-09-03 10:02:36 +02:00
parent e27b376285
commit 07b42c9334
11 changed files with 954 additions and 29 deletions
@@ -28,10 +28,10 @@ namespace EP\EpProducts\Command;
***************************************************************/
use EP\EpProducts\Service\DateImportService;
use EP\EpProducts\Service\ProductImportSourceFactory;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface;
use TYPO3\CMS\Extbase\Service\CacheService;
@@ -53,21 +53,29 @@ class DateCommandController extends Command
*/
protected $configurationManager;
/**
* @var ProductImportSourceFactory
*/
protected $importSourceFactory;
/**
* @param DateImportService $importService
* @param CacheService $cacheService
* @param ConfigurationManagerInterface $configurationManager
* @param ProductImportSourceFactory $importSourceFactory
*/
public function __construct
(
DateImportService $importService,
CacheService $cacheService,
ConfigurationManagerInterface $configurationManager
ConfigurationManagerInterface $configurationManager,
ProductImportSourceFactory $importSourceFactory
)
{
$this->dateImportService = $importService;
$this->cacheService = $cacheService;
$this->configurationManager = $configurationManager;
$this->importSourceFactory = $importSourceFactory;
parent::__construct();
}
@@ -79,8 +87,15 @@ class DateCommandController extends Command
*/
public function execute(InputInterface $input, OutputInterface $output): int
{
$path = GeneralUtility::getFileAbsFileName('fileadmin/xmlexport');
$importSource = $this->importSourceFactory->get();
$path = $importSource->acquire();
if ($path === null) {
$output->writeln('No new import available.');
return 0;
}
$count = $this->dateImportService->import($path);
$importSource->markImported($path);
$output->writeln(sprintf('%d rows imported.', $count ));
$settings = $this->getSettings();
$resellerPageUid = $settings['resellerExportPageUid'];
@@ -85,6 +85,11 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
*/
protected $cacheService;
/**
* @var ProductImportSourceFactory
*/
protected $importSourceFactory;
/**
* @var array
*/
@@ -95,10 +100,16 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
/**
* @param ConfigurationManagerInterface $configurationManager
* @param CacheService $cacheService
* @param ProductImportSourceFactory $importSourceFactory
*/
public function __construct(ConfigurationManagerInterface $configurationManager, CacheService $cacheService) {
public function __construct(
ConfigurationManagerInterface $configurationManager,
CacheService $cacheService,
ProductImportSourceFactory $importSourceFactory
) {
$this->configurationManager = $configurationManager;
$this->cacheService = $cacheService;
$this->importSourceFactory = $importSourceFactory;
$settings = GeneralUtility::removeDotsFromTS(
$this->configurationManager->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_FULL_TYPOSCRIPT)
@@ -121,7 +132,7 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
$this->db = $this->getDbConnection();
$this->getRoomMappings();
$this->getHotelMappings();
$this->importPickups();
$this->importPickups($path);
$this->createTempTables();
$this->logger->info('starting product import');
$dateCount = $this->parseXmlFilesForProducts($path);
@@ -135,24 +146,7 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
public function isImportRequired(): bool
{
$timestampService = new ImportTimestampService();
return $timestampService->isImportRequired(
'fileadmin/xmlexport/uebertragung.info',
'fileadmin/products_import.info'
);
}
/**
* @param $path
*
* @return bool
*/
public function checkActiveUpload($path): bool
{
$tempFile = $path . '/.pureftpd-upload.*';
return count(glob($tempFile)) > 0;
return $this->importSourceFactory->get()->isNewImportAvailable();
}
public function parseXmlFilesForProducts($path): int
@@ -877,12 +871,13 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
}
}
protected function importPickups(): void
protected function importPickups(string $path): void
{
libxml_use_internal_errors (true);
$path = GeneralUtility::getFileAbsFileName('fileadmin/xmlexport/zustiege.xml');
if (!$xmlData = simplexml_load_string(file_get_contents($path))) {
$file = $path . '/zustiege.xml';
if (!is_file($file) || !$xmlData = simplexml_load_string((string) file_get_contents($file))) {
libxml_clear_errors();
$this->logger->warning('pickup xml missing or invalid', ['file' => $file]);
return;
}
@@ -0,0 +1,49 @@
<?php
namespace EP\EpProducts\Service;
use TYPO3\CMS\Core\SingletonInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Legacy import source: the busProNet exporter drops the XML files straight into
* fileadmin/xmlexport/ (historically via pure-ftpd on the server). Kept as an
* opt-in fallback so the import can run without the SFTP pull — selected with the
* `productImportSource = local` extension configuration switch.
*/
final class LocalImportSource implements ProductImportSource, SingletonInterface
{
private const XML_DIR = 'fileadmin/xmlexport';
private const UPLOAD_MARKER = 'fileadmin/xmlexport/uebertragung.info';
private const IMPORT_MARKER = 'fileadmin/products_import.info';
public function acquire(): ?string
{
if (!$this->isNewImportAvailable()) {
return null;
}
return GeneralUtility::getFileAbsFileName(self::XML_DIR);
}
public function markImported(string $path): void
{
(new ImportTimestampService())->writeImportTimestamp(self::IMPORT_MARKER);
}
public function isNewImportAvailable(): bool
{
$timestampService = new ImportTimestampService();
$uploadMarkerPath = GeneralUtility::getFileAbsFileName(self::UPLOAD_MARKER);
if ($uploadMarkerPath === '' || !is_file($uploadMarkerPath)) {
// No supplier marker at all: import whatever is in the folder (legacy behaviour).
return true;
}
$uploadedAt = $timestampService->readImportTimestamp(self::UPLOAD_MARKER);
$lastImportedAt = $timestampService->readImportTimestamp(self::IMPORT_MARKER);
return $uploadedAt === null || $lastImportedAt === null || $uploadedAt > $lastImportedAt;
}
}
@@ -0,0 +1,30 @@
<?php
namespace EP\EpProducts\Service;
/**
* A source of the busProNet product export (Ziel_*.xml, zustiege.xml,
* uebertragung.info). Implementations either read a local drop directory or
* pull the files from a remote SFTP server; the caller does not care which.
*/
interface ProductImportSource
{
/**
* Returns a local directory ready to hand to {@see DateImportService::import()},
* or null when there is nothing newer than the last successful import.
*
* @throws SftpImportException on an unrecoverable acquisition failure
*/
public function acquire(): ?string;
/**
* Called once {@see DateImportService::import()} of the acquired path has
* succeeded, so the source can advance its "last imported" bookkeeping.
*/
public function markImported(string $path): void;
/**
* Best-effort, side-effect-free check for whether a newer export is available.
*/
public function isNewImportAvailable(): bool;
}
@@ -0,0 +1,35 @@
<?php
namespace EP\EpProducts\Service;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\SingletonInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Picks the product import source based on the `productImportSource` extension
* configuration switch: `sftp` (default) pulls from the remote SFTP server,
* `local` reads the legacy fileadmin/xmlexport/ drop directory.
*/
final class ProductImportSourceFactory implements SingletonInterface
{
public const MODE_SFTP = 'sftp';
public const MODE_LOCAL = 'local';
public function get(): ProductImportSource
{
if ($this->getMode() === self::MODE_LOCAL) {
return GeneralUtility::makeInstance(LocalImportSource::class);
}
return GeneralUtility::makeInstance(SftpImportSource::class);
}
public function getMode(): string
{
$config = GeneralUtility::makeInstance(ExtensionConfiguration::class)->get('ep_products');
$mode = strtolower(trim((string)(is_array($config) ? ($config['productImportSource'] ?? '') : '')));
return $mode === self::MODE_LOCAL ? self::MODE_LOCAL : self::MODE_SFTP;
}
}
@@ -0,0 +1,7 @@
<?php
namespace EP\EpProducts\Service;
class SftpImportException extends \RuntimeException
{
}
@@ -0,0 +1,532 @@
<?php
namespace EP\EpProducts\Service;
use phpseclib3\Net\SFTP;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\SingletonInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Pulls the busProNet product export (Ziel_*.xml, zustiege.xml, uebertragung.info)
* from a remote SFTP server into a local staging directory so the existing
* {@see DateImportService::import()} can keep operating on a plain local folder.
*
* Change detection compares the remote uebertragung.info timestamp against the
* staged copy of the last successfully imported export
* (var/transient/ep_products_import/current/uebertragung.info) — not the
* fileadmin/products_import.info marker, which is now display-only.
*/
final class SftpImportSource implements ProductImportSource, SingletonInterface, LoggerAwareInterface
{
use LoggerAwareTrait;
private const MARKER_FILE = 'uebertragung.info';
private const PICKUPS_FILE = 'zustiege.xml';
private const PRODUCT_PREFIX = 'Ziel';
private const MARKER_TS_FORMAT = 'd.m.Y H:i:s';
private const STAGING_SUBDIR = 'ep_products_import';
private const PUBLISHED_DIR = 'current';
/** phpseclib rawlist() attribute value for a directory (NET_SFTP_TYPE_DIRECTORY). */
private const REMOTE_TYPE_DIRECTORY = 2;
private const IMPORT_MARKER = 'fileadmin/products_import.info';
/**
* @var array|null
*/
private $configuration;
/**
* {@see ProductImportSource::acquire()} — alias for {@see fetchIfNewer()}.
*/
public function acquire(): ?string
{
return $this->fetchIfNewer();
}
/**
* {@see ProductImportSource::markImported()} — publish the download as the new
* change-detection baseline (only reached after a successful import), then
* refresh the display-only marker for the BE module and system-info toolbar.
*/
public function markImported(string $path): void
{
$this->commit($path);
(new ImportTimestampService())->writeImportTimestamp(self::IMPORT_MARKER);
}
/**
* {@see ProductImportSource::isNewImportAvailable()} — alias for {@see isRemoteNewer()}.
*/
public function isNewImportAvailable(): bool
{
return $this->isRemoteNewer();
}
/**
* Timestamp of the export currently offered on the remote server, or null when
* the marker is absent/unreadable/unparseable (supplier upload not finished).
*/
public function getRemoteUploadTimestamp(): ?\DateTime
{
$config = $this->buildConfig();
$sftp = $this->connect($config);
try {
[, $markerBytes] = $this->readRemoteMarker($sftp, $config['remotePath']);
} finally {
$sftp->disconnect();
}
return $markerBytes !== null ? $this->parseMarkerTimestamp($markerBytes) : null;
}
/**
* Timestamp of the export we last successfully imported (staged marker copy).
*/
public function getStagedImportedTimestamp(): ?\DateTime
{
$marker = $this->stagingBasePath() . '/' . self::PUBLISHED_DIR . '/' . self::MARKER_FILE;
if (!is_file($marker)) {
return null;
}
return $this->parseMarkerTimestamp((string)file_get_contents($marker));
}
/**
* True when the remote export is newer than the last imported one (or nothing
* has been imported yet). False when there is nothing to do.
*/
public function isRemoteNewer(): bool
{
$remoteTs = $this->getRemoteUploadTimestamp();
if ($remoteTs === null) {
return false;
}
$stagedTs = $this->getStagedImportedTimestamp();
return $stagedTs === null || $remoteTs > $stagedTs;
}
/**
* Freshness check + (only when newer) full download into a fresh temp dir.
* Returns the temp-dir path — NOT yet published. The caller runs
* DateImportService::import() against it and calls {@see commit()} on success.
* Returns null when the remote export is not newer / not ready.
*
* @throws SftpImportException on connect/auth/host-key/partial-transfer failure
*/
public function fetchIfNewer(): ?string
{
$this->cleanupStale();
$config = $this->buildConfig();
$sftp = $this->connect($config);
try {
[$remoteDir, $markerBytes] = $this->readRemoteMarker($sftp, $config['remotePath']);
$remoteTs = $markerBytes !== null ? $this->parseMarkerTimestamp($markerBytes) : null;
if ($remoteTs === null) {
$this->logWarning('remote upload marker missing or unparseable; skipping product import', [
'host' => $config['host'],
'remotePath' => $config['remotePath'],
]);
return null;
}
$stagedTs = $this->getStagedImportedTimestamp();
if ($stagedTs !== null && $remoteTs <= $stagedTs) {
return null;
}
return $this->downloadInto($sftp, $remoteDir, $markerBytes);
} finally {
$sftp->disconnect();
}
}
/**
* Unconditional download into a fresh temp dir. Returns the temp-dir path.
*
* @throws SftpImportException
*/
public function fetch(): string
{
$this->cleanupStale();
$config = $this->buildConfig();
$sftp = $this->connect($config);
try {
[$remoteDir, $markerBytes] = $this->readRemoteMarker($sftp, $config['remotePath']);
if ($markerBytes === null) {
throw new SftpImportException(
'Remote export has no readable ' . self::MARKER_FILE . ' under "' . $config['remotePath'] . '"'
);
}
return $this->downloadInto($sftp, $remoteDir, $markerBytes);
} finally {
$sftp->disconnect();
}
}
/**
* Publish a temp dir produced by {@see fetchIfNewer()} / {@see fetch()} to
* .../current, making its marker the new change-detection baseline. Call only
* after import() succeeded.
*
* @throws SftpImportException
*/
public function commit(string $stagedPath): string
{
$base = $this->stagingBasePath();
$stagedPath = rtrim($stagedPath, '/');
if (strpos($stagedPath . '/', $base . '/') !== 0 || !is_dir($stagedPath)) {
throw new SftpImportException('Refusing to publish unknown staging directory: ' . $stagedPath);
}
$published = $base . '/' . self::PUBLISHED_DIR;
if (is_dir($published)) {
$retired = $published . '.old-' . time() . '-' . bin2hex(random_bytes(3));
if (!@rename($published, $retired)) {
throw new SftpImportException('Cannot move previous import aside: ' . $published);
}
}
if (!@rename($stagedPath, $published)) {
throw new SftpImportException('Cannot publish staging directory to ' . $published);
}
foreach ((array)glob($base . '/' . self::PUBLISHED_DIR . '.old-*', GLOB_ONLYDIR) as $old) {
GeneralUtility::rmdir($old, true);
}
return $published;
}
/**
* @param array{host:string,port:int,user:string,password:string,remotePath:string,timeout:int,hostKey:string} $config
*/
private function connect(array $config): SFTP
{
try {
$sftp = new SFTP($config['host'], $config['port'], $config['timeout']);
} catch (\Throwable $e) {
throw new SftpImportException(
sprintf('Cannot connect to SFTP host %s:%d: %s', $config['host'], $config['port'], $e->getMessage()),
0,
$e
);
}
if ($config['hostKey'] !== '') {
$this->verifyHostKey($sftp, $config['host'], $config['hostKey']);
}
try {
$authenticated = $sftp->login($config['user'], $config['password']);
} catch (\Throwable $e) {
throw new SftpImportException(
sprintf('Cannot connect to SFTP host %s:%d: %s', $config['host'], $config['port'], $e->getMessage()),
0,
$e
);
}
if ($authenticated !== true) {
throw new SftpImportException(sprintf('SFTP authentication failed for user "%s"', $config['user']));
}
return $sftp;
}
private function verifyHostKey(SFTP $sftp, string $host, string $expected): void
{
try {
$actual = $sftp->getServerPublicHostKey();
} catch (\Throwable $e) {
throw new SftpImportException('Cannot read SSH host key for ' . $host . ': ' . $e->getMessage(), 0, $e);
}
if ($actual === false) {
throw new SftpImportException('Cannot read SSH host key for ' . $host);
}
$expected = trim($expected);
$actual = trim($actual);
if (hash_equals($actual, $expected)) {
return;
}
$parts = explode(' ', $actual);
$rawKey = isset($parts[1]) ? base64_decode($parts[1], true) : false;
if ($rawKey !== false) {
$fingerprint = 'SHA256:' . rtrim(base64_encode(hash('sha256', $rawKey, true)), '=');
if (hash_equals($fingerprint, $expected)) {
return;
}
}
throw new SftpImportException('SSH host key mismatch for ' . $host . ' — refusing to connect');
}
/**
* Reads the upload marker, probing each acceptable form of the configured
* remote directory. Some SFTP servers (e.g. Hetzner Storage Box) only accept
* paths relative to the login directory and silently fail on a leading slash.
*
* @return array{0:string,1:?string} the directory form that worked, and the marker bytes (null if none did)
*/
private function readRemoteMarker(SFTP $sftp, string $configuredPath): array
{
$candidates = $this->remoteDirCandidates($configuredPath);
foreach ($candidates as $dir) {
try {
$contents = $sftp->get($this->joinRemote($dir, self::MARKER_FILE));
} catch (\Throwable $e) {
throw new SftpImportException(
'Failed to read remote ' . self::MARKER_FILE . ': ' . $e->getMessage(),
0,
$e
);
}
if ($contents !== false) {
return [$dir, (string)$contents];
}
}
return [$candidates[0], null];
}
/**
* @return string[] non-empty, unique
*/
private function remoteDirCandidates(string $configuredPath): array
{
$configured = trim($configuredPath);
$relative = ltrim($configured, '/');
$candidates = [];
if ($configured !== '' && $configured !== '/') {
$candidates[] = $configured;
}
if ($relative !== '' && $relative !== $configured) {
$candidates[] = $relative;
}
$candidates[] = '.';
return array_values(array_unique($candidates));
}
/**
* Download Ziel_*.xml + zustiege.xml + uebertragung.info into a fresh temp dir,
* verifying each file's size against the remote listing. The marker is written
* last, from the bytes already fetched for the freshness check.
*
* @throws SftpImportException
*/
private function downloadInto(SFTP $sftp, string $remotePath, string $markerBytes): string
{
$list = $sftp->rawlist($remotePath, false);
if ($list === false) {
throw new SftpImportException('Cannot list remote directory ' . $remotePath);
}
$productFiles = [];
$pickupSize = null;
foreach ($list as $name => $attrs) {
if ($name === '.' || $name === '..' || !is_array($attrs)) {
continue;
}
if ((int)($attrs['type'] ?? 0) === self::REMOTE_TYPE_DIRECTORY) {
continue;
}
if (strpos($name, self::PRODUCT_PREFIX) === 0 && substr($name, -4) === '.xml') {
$productFiles[$name] = (int)($attrs['size'] ?? -1);
} elseif ($name === self::PICKUPS_FILE) {
$pickupSize = (int)($attrs['size'] ?? -1);
}
}
if (count($productFiles) === 0) {
throw new SftpImportException('Remote export contains no ' . self::PRODUCT_PREFIX . '_*.xml files');
}
$previousCount = $this->stagedProductFileCount();
if ($previousCount > 0 && count($productFiles) < (int)floor($previousCount * 0.5)) {
throw new SftpImportException(sprintf(
'Remote export looks truncated (%d %s_*.xml files vs %d previously) — refusing to import',
count($productFiles),
self::PRODUCT_PREFIX,
$previousCount
));
}
$toDownload = $productFiles;
if ($pickupSize !== null) {
$toDownload[self::PICKUPS_FILE] = $pickupSize;
} else {
$this->logWarning('remote export has no ' . self::PICKUPS_FILE . '; pickups will be skipped');
}
$tmp = $this->newTempDir();
foreach ($toDownload as $name => $expectedSize) {
$target = $tmp . '/' . $name;
try {
$ok = $sftp->get($this->joinRemote($remotePath, $name), $target);
} catch (\Throwable $e) {
GeneralUtility::rmdir($tmp, true);
throw new SftpImportException('Download failed for ' . $name . ': ' . $e->getMessage(), 0, $e);
}
if ($ok === false || !is_file($target)) {
GeneralUtility::rmdir($tmp, true);
throw new SftpImportException('Download failed for ' . $name);
}
clearstatcache(true, $target);
if ($expectedSize >= 0 && filesize($target) !== $expectedSize) {
$actualSize = filesize($target);
GeneralUtility::rmdir($tmp, true);
throw new SftpImportException(sprintf(
'Size mismatch for %s (got %d bytes, expected %d)',
$name,
$actualSize,
$expectedSize
));
}
}
// Write the completion marker last, mirroring the supplier's upload order.
GeneralUtility::writeFile($tmp . '/' . self::MARKER_FILE, $markerBytes, false);
return $tmp;
}
private function stagedProductFileCount(): int
{
$dir = $this->stagingBasePath() . '/' . self::PUBLISHED_DIR;
if (!is_dir($dir)) {
return 0;
}
$count = 0;
foreach ((array)scandir($dir) as $name) {
if (strpos((string)$name, self::PRODUCT_PREFIX) === 0 && substr((string)$name, -4) === '.xml') {
$count++;
}
}
return $count;
}
private function parseMarkerTimestamp(string $contents): ?\DateTime
{
$line = trim((string)strtok($contents, "\r\n"));
if ($line === '') {
return null;
}
$date = \DateTime::createFromFormat(self::MARKER_TS_FORMAT, $line);
return $date instanceof \DateTime ? $date : null;
}
private function logWarning(string $message, array $context = []): void
{
if ($this->logger !== null) {
$this->logger->warning($message, $context);
}
}
private function stagingBasePath(): string
{
return Environment::getVarPath() . '/transient/' . self::STAGING_SUBDIR;
}
private function newTempDir(): string
{
$dir = $this->stagingBasePath() . '/.tmp-' . getmypid() . '-' . bin2hex(random_bytes(4));
GeneralUtility::mkdir_deep($dir);
if (!is_dir($dir) || !is_writable($dir)) {
throw new SftpImportException('Cannot create staging directory: ' . $dir);
}
return $dir;
}
private function cleanupStale(): void
{
$base = $this->stagingBasePath();
if (!is_dir($base)) {
return;
}
$stale = array_merge(
(array)glob($base . '/.tmp-*', GLOB_ONLYDIR),
(array)glob($base . '/' . self::PUBLISHED_DIR . '.old-*', GLOB_ONLYDIR)
);
foreach ($stale as $dir) {
GeneralUtility::rmdir($dir, true);
}
}
private function joinRemote(string $base, string $name): string
{
return rtrim($base, '/') . '/' . ltrim($name, '/');
}
/**
* @return array{host:string,port:int,user:string,password:string,remotePath:string,timeout:int,hostKey:string}
*/
private function buildConfig(): array
{
if ($this->configuration === null) {
$raw = GeneralUtility::makeInstance(ExtensionConfiguration::class)->get('ep_products');
$raw = is_array($raw) ? $raw : [];
$host = trim((string)($raw['productImportSftpHost'] ?? ''));
$user = trim((string)($raw['productImportSftpUser'] ?? ''));
$password = (string)($raw['productImportSftpPassword'] ?? '');
$remotePath = trim((string)($raw['productImportSftpRemotePath'] ?? '/'));
$port = (int)($raw['productImportSftpPort'] ?? 22);
$timeout = (int)($raw['productImportSftpTimeout'] ?? 15);
$hostKey = trim((string)($raw['productImportSftpHostKey'] ?? ''));
if ($host === '' || $user === '') {
throw new SftpImportException(
'SFTP product import is not configured: host and username are required '
. '(Admin Tools → Settings → Extension Configuration → ep_products).'
);
}
if ($password === '') {
throw new SftpImportException('SFTP product import is not configured: password is required.');
}
$this->configuration = [
'host' => $host,
'port' => $port > 0 ? $port : 22,
'user' => $user,
'password' => $password,
'remotePath' => $remotePath === '' ? '/' : $remotePath,
'timeout' => $timeout > 0 ? $timeout : 15,
'hostKey' => $hostKey,
];
}
return $this->configuration;
}
}
@@ -28,6 +28,7 @@ namespace EP\EpProducts\Task;
***************************************************************/
use EP\EpProducts\Service\DateImportService;
use EP\EpProducts\Service\ProductImportSourceFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Configuration\ConfigurationManager;
use TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface;
@@ -39,11 +40,20 @@ class ImportDatesTask extends AbstractTask
public function execute(): bool
{
$importService = GeneralUtility::makeInstance(DateImportService::class);
$importSource = GeneralUtility::makeInstance(ProductImportSourceFactory::class)->get();
$cacheService = GeneralUtility::makeInstance(CacheService::class);
if ($importService->isImportRequired()) {
$path = GeneralUtility::getFileAbsFileName('fileadmin/xmlexport');
// Acquire the export only when it is newer than the one we last imported
// (SFTP mode: download to a staging dir; local mode: fileadmin/xmlexport).
// An acquisition failure (SFTP connect/auth/transfer) is left to propagate
// so the scheduler marks the task failed.
$path = $importSource->acquire();
if ($path !== null) {
$importService->import($path);
// Advance the "last imported" bookkeeping only after a successful
// import, so a crashed import retries on the next run.
$importSource->markImported($path);
}
$settings = $this->getSettings();
@@ -39,3 +39,27 @@ bpnConnectApiTimeout = 5
# cat=MyEpAPI; type=int; label=Bpn Connect API max total request duration in seconds (0 = unlimited)
bpnConnectApiMaxDuration = 15
# cat=SftpImport; type=options[Remote SFTP server=sftp,Local directory (fileadmin/xmlexport)=local]; label=Product/date import source
productImportSource = sftp
# cat=SftpImport; type=string; label=SFTP host for product/date import
productImportSftpHost =
# cat=SftpImport; type=int+; label=SFTP port
productImportSftpPort = 22
# cat=SftpImport; type=string; label=SFTP username
productImportSftpUser =
# cat=SftpImport; type=string; label=SFTP password
productImportSftpPassword =
# cat=SftpImport; type=string; label=Remote directory holding Ziel_*.xml, zustiege.xml and uebertragung.info
productImportSftpRemotePath = /
# cat=SftpImport; type=string; label=Expected SSH host key (optional; "ssh-ed25519 AAAA..." or "SHA256:..."; empty disables the check)
productImportSftpHostKey =
# cat=SftpImport; type=int+; label=SFTP connection timeout in seconds
productImportSftpTimeout = 15