Commit 180eac83 authored by Thomas Löffler's avatar Thomas Löffler
Browse files

Merge branch 'feature/extensions-xml-refactoring' into 'develop'

[!!!][TASK] Refactor generation of extensions.xml.gz

See merge request t3o/ter!491
parents a5e5bf87 1837c122
Pipeline #9057 passed with stages
in 9 minutes and 24 seconds
......@@ -27,6 +27,9 @@
],
"require": {
"php": ">=7.0",
"ext-pdo": "*",
"ext-dom": "*",
"ext-zlib": "*",
"t3o/ter-layout": "^0.1",
"t3o/ter-soap": "^2.0",
"t3o/ter-frontend": "^0.4",
......
......@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "ecec99c9b9b50f55527928f2d30f488f",
"content-hash": "aaa1147d06f6fd0d5c691408e91ba9f3",
"packages": [
{
"name": "adoy/fastcgi-client",
......@@ -7125,7 +7125,10 @@
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": ">=7.0"
"php": ">=7.0",
"ext-pdo": "*",
"ext-dom": "*",
"ext-zlib": "*"
},
"platform-dev": [],
"platform-overrides": {
......
......@@ -14,6 +14,8 @@ namespace T3o\Ter\Task;
* The TYPO3 project - inspiring people to share!
*/
use T3o\TerFe2\Domain\Repository\CombinedExtensionRepository;
use T3o\TerFe2\Service\ExtensionIndexService;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Utility\GeneralUtility;
......@@ -76,25 +78,17 @@ class UpdateCurrentVersionListTask extends \TYPO3\CMS\Extbase\Scheduler\Task
*/
protected function generateExtensionFilesForExtensionManager(): bool
{
// Check extension configuration
if (empty($GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS']['ter'])) {
throw new \Exception('No extension configuration found in $TYPO3_CONF_VARS', 1303220916);
}
// Check extension repository path
$extensionConfig = $GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS']['ter'];
if (empty($extensionConfig['repositoryDir'])) {
throw new \Exception('No repository path found in extension configuration', 1303220917);
$indexCreator = GeneralUtility::makeInstance(ExtensionIndexService::class);
if ($indexCreator->isOldUpdateRequested()) {
return false;
}
$startTime = microtime();
$extensionDetails = GeneralUtility::makeInstance(CombinedExtensionRepository::class)->getExtensionDetailsWithVersionsAndDownloadNumbers();
$dom = $indexCreator->compileXmlStructure($extensionDetails, $startTime);
// Write new extensions xml file
$repositoryDir = rtrim($extensionConfig['repositoryDir'], '/') . '/';
$dummyObject = new \stdClass();
$dummyObject->repositoryDir = $repositoryDir;
/** @var \tx_ter_helper $terHelper */
$terHelper = GeneralUtility::makeInstance(\tx_ter_helper::class, $dummyObject);
$terHelper->writeExtensionIndexFile();
$indexCreator->writeXmlToFile($dom);
$indexCreator->writeSerializedExtensionInformationToFile($extensionDetails);
return true;
}
}
......@@ -191,7 +191,7 @@ class tx_ter_api
$this->uploadExtension_writeExtensionAndIconFile($extensionInfoData, $filesData);
$this->uploadExtension_writeExtensionInfoToDB($accountData, $extensionInfoData, $filesData);
$this->helperObj->requestUpdateOfExtensionIndexFile();
$this->requestUpdateOfExtensionIndexFile();
static::notifyExtensionVersionUpload($extensionInfoData);
......@@ -276,7 +276,7 @@ class tx_ter_api
// Upload...
$instance->uploadExtension_writeExtensionAndIconFile($extensionInfoData, $filesData);
$instance->uploadExtension_writeExtensionInfoToDB($accountData, $extensionInfoData, $filesData);
$instance->helperObj->requestUpdateOfExtensionIndexFile();
$instance->requestUpdateOfExtensionIndexFile();
static::notifyExtensionVersionUpload($extensionInfoData);
......@@ -323,7 +323,7 @@ class tx_ter_api
}
$this->deleteExtension_deleteFromDBAndRemoveFiles($extensionKey, $version);
$this->helperObj->requestUpdateOfExtensionIndexFile();
$this->requestUpdateOfExtensionIndexFile();
return [
'resultCode' => TX_TER_RESULT_EXTENSIONSUCCESSFULLYDELETED,
......@@ -570,7 +570,7 @@ class tx_ter_api
throw new \T3o\Ter\Exception\UnauthorizedException('Access denied.', TX_TER_ERROR_MODIFYEXTENSIONKEY_ACCESSDENIED);
}
$resultCode = $this->modifyExtensionKey_writeModifiedKeyRecordIntoDB($accountData, $modifyExtensionKeyData);
$this->helperObj->requestUpdateOfExtensionIndexFile();
$this->requestUpdateOfExtensionIndexFile();
} else {
$resultCode = TX_TER_ERROR_MODIFYEXTENSIONKEY_KEYDOESNOTEXIST;
}
......@@ -611,7 +611,7 @@ class tx_ter_api
$this->setReviewState_writeNewStateIntoDB($setReviewStateData);
//Regeneration of index file is currently deactived:
//$this->helperObj->requestUpdateOfExtensionIndexFile();
//$this->requestUpdateOfExtensionIndexFile();
return [
'resultCode' => TX_TER_RESULT_GENERAL_OK,
......@@ -1481,4 +1481,9 @@ class tx_ter_api
}
}
}
protected function requestUpdateOfExtensionIndexFile(): void
{
GeneralUtility::makeInstance(\T3o\TerFe2\Service\ExtensionIndexService::class)->requestUpdate();
}
}
......@@ -324,233 +324,6 @@ class tx_ter_helper
return $latestVersion;
}
/**
* Sets a flag so the cron job knows that the extensions.xml.gz file has to be
* regenerated. Call this whenever data has changed which also exists in
* extensions.xml.gz
*
* Note: Depending on the cron job it might take a while until the index file really
* has been updated. See "cli/build-extension-index.php" for more information
*
* @access public
*/
public function requestUpdateOfExtensionIndexFile()
{
GeneralUtility::writeFile(
$this->pluginObj->repositoryDir . 'extensions.xml.gz.needsupdate',
'Dear cron-job. The extensions.xml.gz file needs to be regenerated, please do so as soon as you find the time for it.' . chr(
10
) . 'Thanks, your TER helper class'
);
}
/**
* Updates the "extensions.xml" file which contains an index of all uploaded
* extensions in the TER.
*
* @access public
* @throws \T3o\Ter\Exception\InternalServerErrorException
*/
public function writeExtensionIndexfile()
{
GeneralUtility::devLog('writing extension index!', 'tx_ter_helper', 0);
if (!@is_dir($this->pluginObj->repositoryDir)) {
throw new \T3o\Ter\Exception\InternalServerErrorException(
'Extension repository directory does not exist.',
TX_TER_ERROR_GENERAL_EXTREPDIRDOESNTEXIST
);
}
// Check if update of files requested
$updateRequestedFile = $this->pluginObj->repositoryDir . 'extensions.xml.gz.needsupdate';
if (file_exists($updateRequestedFile) && @filemtime($updateRequestedFile) <= @filemtime($this->pluginObj->repositoryDir . 'extensions.xml.gz')) {
return;
}
$trackTime = microtime();
$res = $this->getDatabaseConnection()->exec_SELECTquery(
'uid,tstamp,extensionkey,version,title,description,state,reviewstate,category,downloadcounter,t3xfilemd5',
'tx_ter_extensions',
'1'
);
// Read the extension records from the DB:
$extensionsAndVersionsArr = [];
$extensionsTotalDownloadsArr = [];
while ($row = $this->getDatabaseConnection()->sql_fetch_assoc($res)) {
$res2 = $this->getDatabaseConnection()->exec_SELECTquery(
'ownerusername,downloadcounter',
'tx_ter_extensionkeys',
'extensionkey=' . $this->getDatabaseConnection()->fullQuoteStr($row['extensionkey'], 'tx_ter_extensionkeys')
);
$extensionKeyRow = $this->getDatabaseConnection()->sql_fetch_assoc($res2);
$row['ownerusername'] = $extensionKeyRow['ownerusername'];
$extensionsTotalDownloadsArr[$row['extensionkey']] = $extensionKeyRow['downloadcounter'];
$res2 = $this->getDatabaseConnection()->exec_SELECTquery(
'lastuploaddate,uploadcomment,dependencies,composerinfo,authorname,authoremail,authorcompany',
'tx_ter_extensiondetails',
'extensionuid=' . (int)$row['uid']
);
$detailsRow = $this->getDatabaseConnection()->sql_fetch_assoc($res2);
if (is_array($detailsRow)) {
$row = $row + $detailsRow;
}
$extensionsAndVersionsArr [$row['extensionkey']]['versions'][$row['version']] = $row;
}
// Prepare the DOM object:
$dom = new DOMDocument('1.0', 'utf-8');
$dom->formatOutput = true;
$extensionsObj = $dom->appendChild(new DOMElement('extensions'));
$documentationService = GeneralUtility::makeInstance(\T3o\TerFe2\Service\DocumentationService::class);
// Create the nested XML structure:
foreach ($extensionsAndVersionsArr as $extensionKey => $extensionVersionsArr) {
$extensionObj = $extensionsObj->appendChild(new DOMElement('extension'));
$extensionObj->appendChild(new DOMAttr('extensionkey', $extensionKey));
$extensionObj->appendChild(
new DOMElement(
'downloadcounter',
$this->xmlentities($extensionsTotalDownloadsArr[$extensionKey])
)
);
$extensionRecord = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
->getConnectionForTable('tx_terfe2_domain_model_extension')
->select(
['uid', 'external_manual'],
'tx_terfe2_domain_model_extension',
[
'ext_key' => $extensionKey
]
)
->fetch();
foreach ($extensionVersionsArr['versions'] as $versionNumber => $extensionVersionArr) {
$versionObj = $extensionObj->appendChild(new DOMElement('version'));
$versionObj->appendChild(new DOMAttr('version', $versionNumber));
$versionObj->appendChild(new DOMElement('title', $this->xmlentities($extensionVersionArr['title'])));
$versionObj->appendChild(new DOMElement('description', $this->xmlentities($extensionVersionArr['description'])));
$versionObj->appendChild(new DOMElement('state', $this->xmlentities($extensionVersionArr['state'])));
$versionObj->appendChild(new DOMElement('reviewstate', intval($extensionVersionArr['reviewstate'])));
$versionObj->appendChild(new DOMElement('category', $this->xmlentities($extensionVersionArr['category'])));
if ($extensionVersionArr['category'] === 'distribution') {
$prefixPathOnServer = PATH_site . 'fileadmin/ter/';
$prefixDistributionFilePath = $extensionKey[0] . '/' . $extensionKey[1] . '/' . $extensionKey . '_' . $versionNumber . '_';
$distributionImage = $prefixDistributionFilePath . 'Distribution.png';
$distributionWelcomeImage = $prefixDistributionFilePath . 'DistributionWelcome.png';
if (is_file($prefixPathOnServer . $distributionImage)) {
$versionObj->appendChild(new DOMElement('distributionImage', $this->xmlentities($distributionImage)));
}
if (is_file($prefixPathOnServer . $distributionWelcomeImage)) {
$versionObj->appendChild(new DOMElement('distributionImageWelcome', $this->xmlentities($distributionWelcomeImage)));
}
}
$versionObj->appendChild(
new DOMElement(
'downloadcounter',
$this->xmlentities($extensionVersionArr['downloadcounter'])
)
);
$versionObj->appendChild(new DOMElement('lastuploaddate', $extensionVersionArr['lastuploaddate']));
$versionObj->appendChild(
new DOMElement(
'uploadcomment',
$this->xmlentities($extensionVersionArr['uploadcomment'])
)
);
$versionObj->appendChild(new DOMElement('dependencies', $extensionVersionArr['dependencies']));
$versionObj->appendChild(new DOMElement('composerinfo', $extensionVersionArr['composerinfo']));
$versionObj->appendChild(new DOMElement('authorname', $this->xmlentities($extensionVersionArr['authorname'])));
$versionObj->appendChild(new DOMElement('authoremail', $this->xmlentities($extensionVersionArr['authoremail'])));
$versionObj->appendChild(
new DOMElement(
'authorcompany',
$this->xmlentities($extensionVersionArr['authorcompany'])
)
);
$versionObj->appendChild(
new DOMElement(
'ownerusername',
$this->xmlentities($extensionVersionArr['ownerusername'])
)
);
$versionObj->appendChild(new DOMElement('t3xfilemd5', $extensionVersionArr['t3xfilemd5']));
$documentationLink = '';
try {
$documentationLink = $documentationService->getDocumentationLink($extensionKey, $versionNumber, true);
} catch (Exception $e) {
}
$versionObj->appendChild(new DOMElement('documentation_link', $extensionRecord['external_manual'] ?: $documentationLink));
}
}
$extensionsObj->appendChild(new DOMComment('Index created at ' . date('D M j G:i:s T Y')));
$extensionsObj->appendChild(new DOMComment('Index created in ' . (microtime() - $trackTime) . ' ms'));
// Write XML data to disk:
$fh = fopen($this->pluginObj->repositoryDir . 'new-extensions.xml.gz', 'wb');
if (!$fh) {
throw new \T3o\Ter\Exception\InternalServerErrorException(
'Write error while writing extensions index file: ' . $this->pluginObj->repositoryDir . 'extensions.xml',
TX_TER_ERROR_UPLOADEXTENSION_WRITEERRORWHILEWRITINGEXTENSIONSINDEX
);
}
fwrite($fh, gzencode($dom->saveXML(), 9));
fclose($fh);
if (!@filesize($this->pluginObj->repositoryDir . 'new-extensions.xml.gz') > 0) {
GeneralUtility::devLog('Newly created extension index is zero bytes!', 'tx_ter_helper', 0);
throw new \T3o\Ter\Exception\InternalServerErrorException(
'Write error while writing extensions index file (zero bytes): ' . $this->pluginObj->repositoryDir . 'extensions.xml',
TX_TER_ERROR_UPLOADEXTENSION_WRITEERRORWHILEWRITINGEXTENSIONSINDEX
);
}
@unlink($this->pluginObj->repositoryDir . 'extensions.xml.gz');
rename($this->pluginObj->repositoryDir . 'new-extensions.xml.gz', $this->pluginObj->repositoryDir . 'extensions.xml.gz');
GeneralUtility::writeFile(
$this->pluginObj->repositoryDir . 'extensions.md5',
md5_file($this->pluginObj->repositoryDir . 'extensions.xml.gz')
);
// Write serialized array file to disk:
$fh = fopen($this->pluginObj->repositoryDir . 'new-extensions.bin', 'wb');
if (!$fh) {
throw new \T3o\Ter\Exception\InternalServerErrorException(
'Write error while writing extensions index file: ' . $this->pluginObj->repositoryDir . 'extensions.bin',
TX_TER_ERROR_UPLOADEXTENSION_WRITEERRORWHILEWRITINGEXTENSIONSINDEX
);
}
fwrite($fh, serialize($extensionsAndVersionsArr));
fclose($fh);
if (!@filesize($this->pluginObj->repositoryDir . 'new-extensions.bin') > 0) {
GeneralUtility::devLog('Newly created extension index is zero bytes!', 'tx_ter_helper', 0);
throw new \T3o\Ter\Exception\InternalServerErrorException(
'Write error while writing extensions index file (zero bytes): ' . $this->pluginObj->repositoryDir . 'extensions.bin',
TX_TER_ERROR_UPLOADEXTENSION_WRITEERRORWHILEWRITINGEXTENSIONSINDEX
);
}
@unlink($this->pluginObj->repositoryDir . 'extensions.bin');
rename($this->pluginObj->repositoryDir . 'new-extensions.bin', $this->pluginObj->repositoryDir . 'extensions.bin');
}
/**
* Equivalent to htmlentities but for XML content
*
* @param string $string : String to encode
* @return string &,",',< and > replaced by entities
* @access public
*/
public function xmlentities($string)
{
// Until I have found a better solution for guaranteeing valid characters, I use this regex:
$string = (preg_replace('/[^\w\s"%&\[\]\(\)\.\,\;\:\/\?\{\}!\$\-\/\@]/u', '', $string));
return str_replace(['&', '"', "'", '<', '>'], ['&amp;', '&quot;', '&apos;', '&lt;', '&gt;'], $string);
}
/***
* Load an instance of the BE_USER to use with TCEFORM
*
......
<?php
declare(strict_types = 1);
/*
* This file is part of TYPO3 CMS-extension "ter_fe2", created by Benni Mack.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*/
namespace T3o\TerFe2\Command;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use T3o\Ter\Exception\InternalServerErrorException;
use T3o\TerFe2\Domain\Repository\CombinedExtensionRepository;
use T3o\TerFe2\Service\ExtensionIndexService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Creates the extensions.xml.gz file from all versions of all released extensions, with a lot of metadata,
* and stores it (safely) into fileadmin/ter/.
*/
class CreateExtensionIndexCommand extends Command implements LoggerAwareInterface
{
use LoggerAwareTrait;
/**
* @var CombinedExtensionRepository
*/
protected $combinedExtensionRepository;
/**
* @var ExtensionIndexService
*/
protected $extensionIndexService;
public function __construct(string $name = null, CombinedExtensionRepository $combinedExtensionRepository = null, ExtensionIndexService $extensionIndexService = null)
{
$this->combinedExtensionRepository = $combinedExtensionRepository ?? GeneralUtility::makeInstance(CombinedExtensionRepository::class);
$this->extensionIndexService = $extensionIndexService ?? GeneralUtility::makeInstance(ExtensionIndexService::class);
parent::__construct($name);
}
protected function configure()
{
$this->setDescription('Creates extensions.xml.gz by finding all published extensions (and their versions)');
}
/**
* Updates the "extensions.xml" file which contains an index of all uploaded
* extensions in the TER.
*
* @param InputInterface $input
* @param OutputInterface $output
* @return int
*/
protected function execute(InputInterface $input, OutputInterface $output)
{
$io = new SymfonyStyle($input, $output);
$io->section('Writing extension index');
// Check if update of files requested
if ($this->extensionIndexService->isOldUpdateRequested()) {
$io->error('There was a .needsupdate file, but an already generated file is there. Aborting.');
return 1;
}
$processStarted = microtime();
// Read the extension records from the DB:
$io->writeln('Fetching all published extensions and their versions from the database. Starting at ' . date('d-m-Y H:i:s'));
$extensionsAndVersionsArr = $this->combinedExtensionRepository->getExtensionDetailsWithVersionsAndDownloadNumbers();
$io->writeln('Building a XML node structure out of the entries Starting at ' . date('d-m-Y H:i:s'));
$dom = $this->extensionIndexService->compileXmlStructure($extensionsAndVersionsArr, $processStarted);
try {
$io->writeln('Saving the XML to a file. Starting at ' . date('d-m-Y H:i:s'));
$fileName = $this->extensionIndexService->writeXmlToFile($dom);
$io->success('Stored in ' . $fileName);
$io->writeln('Saving the serialized extension list to a file. Starting at ' . date('d-m-Y H:i:s'));
$serializedDataFileName = $this->extensionIndexService->writeSerializedExtensionInformationToFile($extensionsAndVersionsArr);
$io->success('Stored in ' . $serializedDataFileName);
} catch (InternalServerErrorException $e) {
$io->error($e->getMessage());
return 1;
}
$io->success('New extensions index file created. Finished on ' . date('d-m-Y H:i:s'));
return 0;
}
}
<?php
declare(strict_types = 1);
namespace T3o\TerFe2\Domain\Repository;
/*
* This file is part of TYPO3 CMS-extension "ter_fe2", created by Benni Mack.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*/
use T3o\TerFe2\Service\DocumentationService;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* This class combines all data of an extension and all versions from various database tables
*
* tx_ter_extensions -> contains all versions of all extensions
* tx_ter_extensionkeys -> contains all registered extension keys (even if there was no version uploaded)
* tx_ter_extensiondetails -> contains more information of an extension version
* tx_terfe2_domain_model_extension -> contains information of an external manual
*
* Currently this class is used as a wrapper, but should vanish once we migrated the database structures properly
*/
class CombinedExtensionRepository
{
/**
* @var DocumentationService
*/
protected $documentationService;
public function __construct(DocumentationService $documentationService = null)
{
$this->documentationService = $documentationService ?? GeneralUtility::makeInstance(DocumentationService::class);
}
/**
* Fetches all information about a given list of extension keys that is not version-specific.
*
* @param array $extensionKeys
* @return array
*/
protected function getBasicExtensionInformation(array $extensionKeys): array
{
$basicExtensionInformation = [];
foreach ($extensionKeys as $extensionKey) {
$keysQueryBuilder = $this->getQueryBuilder('tx_ter_extensionkeys');
$extensionKeyRow = $keysQueryBuilder
->select('ownerusername', 'downloadcounter')
->from('tx_ter_extensionkeys')
->where(
$keysQueryBuilder->expr()->eq('extensionkey', $keysQueryBuilder->createNamedParameter($extensionKey))
)
->execute()
->fetch();
$manualQueryBuilder = $this->getQueryBuilder('tx_terfe2_domain_model_extension');
$manualRecord = $manualQueryBuilder
->select('uid', 'external_manual')
->from('tx_terfe2_domain_model_extension')
->where(
$manualQueryBuilder->expr()->eq('ext_key', $manualQueryBuilder->createNamedParameter($extensionKey))
)
->execute()
->fetch();
$basicExtensionInformation[$extensionKey] = [
'ownerusername' => $extensionKeyRow['ownerusername'],
'downloads' => $extensionKeyRow['downloadcounter'],
'external_manual' => $manualRecord['external_manual'] ?: ''
];
}
return $basicExtensionInformation;
}
public function getExtensionDetailsWithVersionsAndDownloadNumbers(): array
{
$groupedVersionsByExtension = [];
$queryBuilder = $this->getQueryBuilder('tx_ter_extensions');
$versions = $queryBuilder
->select('uid', 'tstamp', 'extensionkey', 'version', 'title', 'description', 'state', 'reviewstate', 'category', 'downloadcounter', 't3xfilemd5')
->from('tx_ter_extensions')
->execute()
->fetchAll();
$usedExtensionKeys = array_column($versions, 'extensionkey');
$usedExtensionKeys = array_unique($usedExtensionKeys);
$basicExtensionInformation = $this->getBasicExtensionInformation($usedExtensionKeys);
foreach ($versions as $row) {
$extensionKey = $row['extensionkey'];
$versionNumber = $row['version'];
$genericExtensionInformation = $basicExtensionInformation[$extensionKey];
$groupedVersionsByExtension[$extensionKey]['ownerusername'] = $genericExtensionInformation['ownerusername'];
$groupedVersionsByExtension[$extensionKey]['downloads'] = $genericExtensionInformation['downloads'];
$detailsQueryBuilder = $this->getQueryBuilder('tx_ter_extensiondetails');
$detailsRow = $detailsQueryBuilder
->select('lastuploaddate', 'uploadcomment', 'dependencies', 'composerinfo', 'authorname', 'authoremail', 'authorcompany')
->from('tx_ter_extensiondetails')
->where(
$detailsQueryBuilder->expr()->eq('extensionuid', $detailsQueryBuilder->createNamedParameter($row['uid'], \PDO::PARAM_INT))
)
->execute()
->fetch();
if (is_array($detailsRow)) {
$row = $row + $detailsRow;
}
if (!empty($genericExtensionInformation['external_manual'])) {
$row['external_manual'] = $genericExtensionInformation['external_manual'];
} else {
try {
$row['external_manual'] = $this->documentationService->getDocumentationLink($extensionKey, $versionNumber, true);
} catch (\Exception $e) {
$row['external_manual'] = '';