src/Twig/AppRuntime.php line 166

  1. <?php
  2. namespace App\Twig;
  3. use App\Entity\News;
  4. use App\Repository\EventRepository;
  5. use App\Repository\NewsRepository;
  6. use App\Repository\UseCaseRepository;
  7. use App\Service\GalileaLargeLanguageModels\GalileaLargeLanguageModelsProviderInterface;
  8. use DateTime;
  9. use Sulu\Bundle\MediaBundle\Api\Media;
  10. use Sulu\Component\SmartContent\ArrayAccessItem;
  11. use Sulu\Component\Webspace\Analyzer\Attributes\RequestAttributes;
  12. use Sulu\Component\Webspace\Manager\WebspaceManagerInterface;
  13. use Sulu\Component\Webspace\Webspace;
  14. use Symfony\Component\Filesystem\Exception\FileNotFoundException;
  15. use Symfony\Component\HttpFoundation\RequestStack;
  16. use Symfony\Component\Intl\Countries;
  17. use Symfony\Component\Intl\Currencies;
  18. use Twig\Extension\RuntimeExtensionInterface;
  19. class AppRuntime implements RuntimeExtensionInterface
  20. {
  21.     // Defines the max words count for each element in teaser-grid/news-grid
  22.     public const MAX_WORDS_COUNT 20;
  23.     private string $flyOutNavigationPath;
  24.     public function __construct(
  25.         private GalileaLargeLanguageModelsProviderInterface $galileaLargeLanguageModelsProvider,
  26.         private readonly NewsRepository    $newsRepository,
  27.         private readonly UseCaseRepository $useCaseRepository,
  28.         private readonly RequestStack      $requestStack,
  29.         private readonly EventRepository   $eventRepository,
  30.         string                             $projectDir,
  31.         private readonly bool              $forceWebpThumbnails,
  32.         private readonly array             $forceWebpThumbnailsTypes
  33.     )
  34.     {
  35.         $this->flyOutNavigationPath $projectDir '/navigation/flyOutNavigation.json';
  36.     }
  37.     /**
  38.      * @return array
  39.      */
  40.     public function getFlyOutNavigation(): array
  41.     {
  42.         if (!file_exists($this->flyOutNavigationPath)) {
  43.             throw new FileNotFoundException('Fly-Out-Navigation JSON file does not exist!');
  44.         }
  45.         $contentJSON file_get_contents($this->flyOutNavigationPath);
  46.         $webSpaceKey $this->getCurrentWebspace()->getKey();
  47.         $flyoutData json_decode($contentJSONtrue);
  48.         return $flyoutData[$webSpaceKey];
  49.     }
  50.     /**
  51.      * Return all news from database.
  52.      * News are filtered by tags from news-grid.
  53.      *
  54.      * @param string|null $newsGridTags
  55.      * @return array
  56.      */
  57.     public function getNews(?string $newsGridTags): array
  58.     {
  59.         $newsArray = [];
  60.         $news $this->newsRepository->findAll();
  61.         if (count($news) > 0) {
  62.             /** @var News $item */
  63.             foreach ($news as $item) {
  64.                 $itemTags $item->getTags();
  65.                 // news-grid has Tags
  66.                 if (!empty($newsGridTags) && !empty(trim($newsGridTags))) {
  67.                     // But news item does not --> Continue to next news item!
  68.                     if (empty($itemTags)) {
  69.                         continue;
  70.                     }
  71.                     if (str_contains($itemTags',') !== false) {
  72.                         $newsItemTagsArray explode(','$itemTags);
  73.                         $newsItemTagsArray array_map('trim'$newsItemTagsArray);
  74.                     } else {
  75.                         $newsItemTagsArray = [trim($itemTags)];
  76.                     }
  77.                     $matched false;
  78.                     foreach ($newsItemTagsArray as $newsItemTag) {
  79.                         // news item has at least one tag that matches news-grid Tags
  80.                         if (str_contains(strtolower($newsGridTags), strtolower($newsItemTag)) !== false) {
  81.                             $matched true;
  82.                             break;
  83.                         }
  84.                     }
  85.                     // news item does not have any tag that matches news-grid Tags --> Continue to next news item!
  86.                     if (!$matched) {
  87.                         continue;
  88.                     }
  89.                 }
  90.                 $image json_decode($item->getImage(), true);
  91.                 $dateObject = new DateTime($item->getDate());
  92.                 $newsArray[] = [
  93.                     'title' => $item->getTitle(),
  94.                     'description' => $item->getDescription(),
  95.                     'source' => $item->getSource(),
  96.                     'date' => $item->getDate(),
  97.                     'dateGerman' => $dateObject->format('d.m.Y'),
  98.                     'image' => $image['id'],
  99.                     'thumbnails' => null,
  100.                     'url' => $item->getUrl(),
  101.                 ];
  102.             }
  103.         }
  104.         return $newsArray;
  105.     }
  106.     /**
  107.      * Return all news from database.
  108.      * News are filtered by tags from news-grid.
  109.      *
  110.      * @return array
  111.      */
  112.     public function getUseCases(string $locale, array $tags): array
  113.     {
  114.         $useCasesArray = [];
  115.         $useCases $tags $this->useCaseRepository->findByTagsWithTopics($locale$tags) : $this->useCaseRepository->findAllWithTopics($locale);
  116.         if (count($useCases) > 0) {
  117.             foreach ($useCases as $item) {
  118.                 $image json_decode($item->getImage(), true);
  119.                 $useCasesArray[] = [
  120.                     'topic' => [
  121.                         'title' => $item->getUseCaseTopic()->getTitle(),
  122.                         'description' => $item->getUseCaseTopic()->getDescription(),
  123.                         'color' => $item->getUseCaseTopic()->getColor(),
  124.                     ],
  125.                     'title' => $item->getTitle(),
  126.                     'titleMobile' => $item->getTitleMobile(),
  127.                     'description' => $item->getDescription(),
  128.                     'image' => $image['id'],
  129.                     'tags' => $item->getTags(),
  130.                     'thumbnails' => null,
  131.                 ];
  132.             }
  133.         }
  134.         return $useCasesArray;
  135.     }
  136.     /**
  137.      * Return matching events from database.
  138.      * Events are filtered by tags from event-list.
  139.      *
  140.      * @param string $locale
  141.      * @param array $tags
  142.      * @return array
  143.      */
  144.     public function getEvents(string $locale, array $tags): array
  145.     {
  146.         $eventsArray = [];
  147.         $events $tags $this->eventRepository->findByTagsForLocale($locale$tags) : $this->eventRepository->findAllForLocale($locale);
  148.         if (count($events) > 0) {
  149.             foreach ($events as $item) {
  150.                 $image json_decode($item->getImage(), true);
  151.                 $eventsArray[] = [
  152.                     'lead' => $item->getLead(),
  153.                     'title' => $item->getTitle(),
  154.                     'leadMobile' => $item->getLeadMobile(),
  155.                     'titleMobile' => $item->getTitleMobile(),
  156.                     'description' => $item->getDescription(),
  157.                     'image' => $image['id'],
  158.                     'link' => $item->getLink(),
  159.                     'linkLabel' => $item->getLinkLabel(),
  160.                     'startAt' => $item->getStartAt(),
  161.                     'location' => $item->getLocation(),
  162.                     'tags' => $item->getTags(),
  163.                     'thumbnails' => null,
  164.                 ];
  165.             }
  166.         }
  167.         return $eventsArray;
  168.     }
  169.     public function getLargeLanguageModels(string $sourceUrl): array
  170.     {
  171.         $llmCatalogue $this->galileaLargeLanguageModelsProvider->loadModels($sourceUrl);
  172.         if(!$llmCatalogue) {
  173.             return [];
  174.         }
  175.         // build country registry
  176.         $countries array_map(fn(\stdClass $item) => (object)[
  177.             'id' => strtolower($item->key),
  178.             'name' => $item->name,
  179.             'icon' => '/assets/galilea/llms/' $item->icon
  180.         ], $llmCatalogue->countries ?? [
  181.             (object)[
  182.                 'key' => 'us',
  183.                 'name' => 'USA',
  184.                 'icon' => 'location/us.svg'
  185.             ],
  186.             (object)[
  187.                 'key' => 'de',
  188.                 'name' => 'Deutschland',
  189.                 'icon' => 'location/de.svg'
  190.             ],
  191.             (object)[
  192.                 'key' => 'eu',
  193.                 'name' => 'EU',
  194.                 'icon' => 'location/eu.svg'
  195.             ],
  196.             (object)[
  197.                 'key' => 'cn',
  198.                 'name' => 'China',
  199.                 'icon' => 'location/cn.svg'
  200.             ],
  201.         ]);
  202.         $countriesById array_column($countriesnull'id');
  203.         $vendors array_map(static function(\stdClass $item) use ($countriesById) {
  204.             $countryId strtolower($item->country);
  205.             if (!isset($countriesById[$countryId])) {
  206.                 error_log("Country not found for ID: " $countryId);
  207.                 return null// Skip this vendor if country is missing
  208.             }
  209.             $country $countriesById[$countryId];
  210.             return [
  211.                 'id' => $item->key,
  212.                 'name' => $item->name,
  213.                 'logo' => '/assets/galilea/llms/' $item->icon,
  214.                 'hq' => $country->name,
  215.                 'hq_logo' => $country->icon,
  216.             ];
  217.         }, $llmCatalogue->providers);
  218.         $hosters array_map(static function(\stdClass $item) use ($countriesById) {
  219.             $countryId strtolower($item->companyLocation);
  220.             if (!isset($countriesById[$countryId])) {
  221.                 error_log("Country not found for ID: " $countryId);
  222.                 return null// Skip this hoster if country is missing
  223.             }
  224.             $country $countriesById[$countryId];
  225.             return [
  226.                 'id' => $item->key,
  227.                 'name' => $item->name,
  228.                 'logo' => '/assets/galilea/llms/' $item->icon,
  229.                 'hq' => $country->name,
  230.                 'hq_logo' => $country->icon,
  231.             ];
  232.         }, $llmCatalogue->deployers);
  233.         $features array_map(fn(\stdClass $item) => [
  234.             'id' => $item->key,
  235.             'name' => $item->name,
  236.             'icon' => '/assets/galilea/llms/' $item->icon,
  237.         ], $llmCatalogue->useCases);
  238.         $validateDate = function($date$format 'Y-m-d')
  239.         {
  240.             $d DateTime::createFromFormat($format$date);
  241.             // The Y ( 4 digits year ) returns TRUE for any integer with any number of digits so changing the comparison from == to === fixes the issue.
  242.             return $d && strtolower($d->format($format)) === strtolower($date);
  243.         };
  244.         $models array_map(fn(\stdClass $item) => [
  245.             'name' => $item->name,
  246.             'quality' => $item->stats->quality ?: 0,
  247.             'speed' => $item->stats->speed ?: 0,
  248.             'cost' => $item->stats->costs ?: 0,
  249.             'version' => $item->modelVersion ?: '-',
  250.             'languages' => count($item->languages),
  251.             'type' => $item->type ucfirst($item->type) : '-',
  252.             'license' => $item->license ucfirst($item->license) : '-',
  253.             'summary' => $item->summary ?: '-',
  254.             'description' => $item->description ?: '-',
  255.             'location' => $item->hostingLocation->name ?: '-',
  256.             'location_logo' => strtolower($item->hostingLocation->country ?: '-'),
  257.             'knowledge_cutoff' => $item->knowledgeCutoff && $validateDate($item->knowledgeCutoff) ? $item->knowledgeCutoff '-',
  258.             'structured_output' => $item->structuredOutput ?: false,
  259.             'function_calling' => $item->functionCalling ?: false,
  260.             'context' => $item->contextWindow ?: '-',
  261.             'token_input' => $item->pricing->inputTokenPricePerMillion $item->pricing->inputTokenPricePerMillion '€/1M Tokens' '-',
  262.             'token_output' => $item->pricing->outputTokenPricePerMillion $item->pricing->outputTokenPricePerMillion '€/1M Tokens' '-',
  263.             'vendor' => $item->provider,
  264.             'hoster' => $item->deployer,
  265.             'logo' => '/assets/galilea/llms/' $item->icon,
  266.             'features' => $item->useCases,
  267.             'rankings' => array_map(static fn(\stdClass $ranking) => [
  268.                 'text' => $ranking->name,
  269.                 'direction' => $ranking->rank,
  270.                 'type' => ['good' => 'green''neutral' => 'gray''bad' => 'red'][$ranking->rating],
  271.             ], $item->rankings)
  272.         ], $llmCatalogue->models);
  273.         // Pre-process arrays for efficient lookups by ID
  274.         $vendorsById array_column($vendorsnull'id');
  275.         $hostersById array_column($hostersnull'id');
  276.         $featuresById array_column($featuresnull'id');
  277.         // Group models by vendor and expand references
  278.         $data = [];
  279.         foreach ($models as $idx => $model) {
  280.             if(isset($model['enabled']) && !$model['enabled']) {
  281.                 // skip disabled models
  282.                 continue;
  283.             }
  284.             $vendorId $model['vendor'];
  285.             $hosterId $model['hoster'];
  286.             $featureIds $model['features'];
  287.             // Look up vendor data
  288.             if (!isset($vendorsById[$vendorId])) {
  289.                 error_log("Vendor not found for ID: " $vendorId);
  290.                 continue; // Skip this model if vendor is missing
  291.             }
  292.             $vendor $vendorsById[$vendorId];
  293.             // Look up hoster data
  294.             if (!isset($hostersById[$hosterId])) {
  295.                 error_log("Hoster not found for ID: " $hosterId);
  296.                 continue; // Skip this model if hoster is missing
  297.             }
  298.             $hoster $hostersById[$hosterId];
  299.             // Look up feature data
  300.             $modelFeatures = [];
  301.             foreach ($featureIds as $featureId) {
  302.                 if (isset($featuresById[$featureId])) {
  303.                     $modelFeatures[] = $featuresById[$featureId];
  304.                 } else {
  305.                     error_log("Feature not found for ID: " $featureId);
  306.                 }
  307.             }
  308.             // Create expanded model data
  309.             $expandedModel array_merge($model, [
  310.                 'hoster' => $hoster,
  311.                 'features' => $modelFeatures
  312.             ]);
  313.             $countryId strtolower($model['location_logo']);
  314.             if (!isset($countriesById[$countryId])) {
  315.                 error_log("Country not found for ID: " $countryId);
  316.                 continue; // Skip this model if country is missing
  317.             }
  318.             $country $countriesById[$countryId];
  319.             $expandedModel['location_logo'] = $country->icon;
  320.             $expandedModel['location_country_name'] = $country->name;
  321.             // Initialize vendor group if it doesn't exist
  322.             if (!isset($data[$vendorId])) {
  323.                 $data[$vendorId] = [
  324.                     'vendor' => $vendor,
  325.                     'models' => []
  326.                 ];
  327.             }
  328.             // Add model to its vendor group
  329.             $data[$vendorId]['models'][] = $expandedModel;
  330.         }
  331.         return $data;
  332.     }
  333.     private function decodeDataUri(string $assetDataUri)
  334.     {
  335.         $matches = [];
  336.         if(!preg_match('/^data:(image\/svg\+xml|png|jpeg|gif|webp)(;base64)?,(.+)$/'$assetDataUri$matches)) {
  337.             return null;
  338.         }
  339.         $mimeType $matches[1];
  340.         $isBase64 $matches[2] ?? false;
  341.         $assetData $matches[3];
  342.         switch($mimeType) {
  343.             case 'image/svg+xml':
  344.                 $svgData $isBase64 base64_decode(strtr($assetData'-_.''+/=')) : $assetData;
  345.                 if(preg_match('/%3Csvg/'$svgData)) {
  346.                     $svgData urldecode($svgData);
  347.                 }
  348.                 return $svgData;
  349.             case 'image/png':
  350.             case 'image/jpeg':
  351.             case 'image/gif':
  352.             case 'image/webp':
  353.                 return $isBase64 base64_decode(strtr($assetData'-_.''+/=')) : $assetData;
  354.             default:
  355.                 return null;
  356.         }
  357.     }
  358.     private function formatShortAproximateNumber($number)
  359.     {
  360.         if(!is_numeric($number)) {
  361.             return $number;
  362.         }
  363.         if($number >= 10000000000) {
  364.             return round($number 1000000000,0) . 'B';
  365.         }
  366.         if($number >= 1000000000) {
  367.             return round($number 10000000001) . 'B';
  368.         }
  369.         if($number >= 10000000) {
  370.             return round($number 10000000) . 'M';
  371.         }
  372.         if($number >= 1000000) {
  373.             return round($number 10000001) . 'M';
  374.         }
  375.         if($number >= 10000) {
  376.             return round($number 10000) . 'k';
  377.         }
  378.         if($number >= 1000) {
  379.             return round($number 10001) . 'k';
  380.         }
  381.         if($number >= 10) {
  382.             return round($number0);
  383.         }
  384.         return round($number1);
  385.     }
  386.     /**
  387.      * - Map stories to array like news
  388.      * - Merge stories and news
  389.      * - Sort by date
  390.      *
  391.      * @param array $stories
  392.      * @param array $publications
  393.      * @param array $pages
  394.      * @param array $news
  395.      * @param bool $contentMixed
  396.      * @return array
  397.      */
  398.     public function sortByDate(
  399.         array $stories,
  400.         array $publications,
  401.         array $pages,
  402.         array $news,
  403.         bool $contentMixed false
  404.     ): array {
  405.         $pagesArray = [];
  406.         // Get other pages only if contentMixed is true
  407.         if ($contentMixed) {
  408.             $pagesArray array_merge($stories$publications$pages);
  409.         }
  410.         $newsArray $news;
  411.         if (count($pagesArray) > 0) {
  412.             $newsArray array_merge($pagesArray$news);
  413.         }
  414.         // Sort descending by date
  415.         usort($newsArray, function ($a$b) {
  416.             return $a['date'] < $b['date'];
  417.         });
  418.         return $newsArray;
  419.     }
  420.     /**
  421.      * Prepare data from smart content for news-grid.
  422.      * If description has more than 20 words, the first 20 words will be cut and ... will be added to the end.
  423.      * If alternativeText is available, replace description. Text will not be cut. Length remains unchanged.
  424.      *
  425.      * @param array $items
  426.      * @param string $locale
  427.      * @return array
  428.      */
  429.     public function getDataFromSmartContent(array $itemsstring $locale): array
  430.     {
  431.         $data = [];
  432.         /** @var ArrayAccessItem $item */
  433.         foreach ($items as $item) {
  434.             /** @var Media $image */
  435.             $excerptImage $item['excerptAlternativeImage'] ?? $item['excerptImages'] ?? null;
  436.             $thumbnails $excerptImage instanceof Media $excerptImage->getThumbnails() : null;
  437.             // Teaser grid title is preferred, then title!
  438.             $title $item['excerptTitleTeaserGrid'] ?? '';
  439.             if (empty($title)) {
  440.                 $title $item['excerptTitle'] ?? '';
  441.             }
  442.             // Custom Text is preferred, then description!
  443.             $description $item['excerptCustomText'] ?? '';
  444.             if (empty($description)) {
  445.                 $description $item['excerptDescription'] ?? '';
  446.                 if (!empty($description)) {
  447.                     $descriptionArray explode(' '$description);
  448.                     // Get the first 20 words
  449.                     if (count($descriptionArray) > self::MAX_WORDS_COUNT) {
  450.                         $descriptionArray array_slice($descriptionArray0self::MAX_WORDS_COUNT);
  451.                         $description implode(' '$descriptionArray);
  452.                         $description .= ' ...';
  453.                     }
  454.                 }
  455.             }
  456.             // Creation date from settings is date of story!
  457.             $authored $item['authored'];
  458.             $data[] = [
  459.                 'title' => $title,
  460.                 'description' => $description,
  461.                 'source' => $item['excerptType'] ?? '',
  462.                 'date' => $authored->format('Y-m-d'),
  463.                 'dateGerman' => $authored->format('d.m.Y'),
  464.                 'image' => $thumbnails['2880x'] ?? null,
  465.                 'thumbnails' => $thumbnails,
  466.                 'url' => '/' $locale $item['url'],  // Add locale to URL
  467.             ];
  468.         }
  469.         return $data;
  470.     }
  471.     public function getThumbnailUrl($media$size)
  472.     {
  473.         if($media instanceof Media) {
  474.             $thumbnails $media->getThumbnails();
  475.             if(!array_key_exists($size$thumbnails)) {
  476.                 throw new \InvalidArgumentException('Unknown thumbnail size "' $size '".');
  477.             }
  478.             if($this->forceWebpThumbnails && in_array($media->getFileVersion()->getMimeType(), $this->forceWebpThumbnailsTypes)) {
  479.                 $size .= '.webp';
  480.             }
  481.             return $thumbnails[$size];
  482.         } else if(is_array($media) && array_key_exists('thumbnails'$media)) {
  483.             $thumbnails $media['thumbnails'];
  484.             if(!array_key_exists($size$thumbnails)) {
  485.                 throw new \InvalidArgumentException('Unknown thumbnail size "' $size '".');
  486.             }
  487.             $extensionMimeMap = [
  488.                 'jpg' => 'image/jpeg',
  489.                 'jpeg' => 'image/jpeg',
  490.                 'png' => 'image/png',
  491.                 'webp' => 'image/webp'
  492.             ];
  493.             $ext strtolower(pathinfo(parse_url($media['image'],  PHP_URL_PATH), PATHINFO_EXTENSION));
  494.             if($this->forceWebpThumbnails && array_key_exists($ext$extensionMimeMap) && in_array($extensionMimeMap[$ext], $this->forceWebpThumbnailsTypes)) {
  495.                 $size .= '.webp';
  496.             }
  497.             return $thumbnails[$size];
  498.         }
  499.     }
  500.     public function pricingTableFeatureGroups(array $features): array
  501.     {
  502.         // reduce((groups, feature, idx) => feature.pricingFeatureGroupName ? groups|merge([{ name: feature.pricingFeatureGroupName, features: ['feature_' ~ idx] }]) : groups|map((group, idx) => idx == groups|keys|last and group.features|merge(['feature_' ~ idx]) ? groups : groups), []),
  503.         $groups = [];
  504.         $currentGroup null;
  505.         foreach($features as $idx => $feature) {
  506.             if(!$currentGroup || ($feature['pricingFeatureGroupName'] && $currentGroup['name'] !== $feature['pricingFeatureGroupName'])) {
  507.                 if($currentGroup) {
  508.                     $groups[] = $currentGroup;
  509.                 }
  510.                 $currentGroup = [
  511.                     'name' => $feature['pricingFeatureGroupName'],
  512.                     'features' => []
  513.                 ];
  514.             }
  515.             $currentGroup['features'][] = 'feature_' $idx;
  516.         }
  517.         $groups[] = $currentGroup;
  518.         return $groups;
  519.     }
  520.     private function getCurrentWebspace(): ?Webspace
  521.     {
  522.         $currentRequest $this->requestStack->getCurrentRequest();
  523.         if (!$currentRequest) {
  524.             return null;
  525.         }
  526.         $suluAttributes $currentRequest->attributes->get('_sulu');
  527.         if (!$suluAttributes instanceof RequestAttributes) {
  528.             return null;
  529.         }
  530.         return $suluAttributes->getAttribute('webspace');
  531.     }
  532. }