Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Redia XML fixes #1725

Merged
merged 10 commits into from
Dec 2, 2024
45 changes: 45 additions & 0 deletions web/modules/custom/dpl_event/src/EventWrapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

namespace Drupal\dpl_event;

use Brick\Math\BigDecimal;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\drupal_typed\DrupalTyped;
use Drupal\node\NodeInterface;
use Drupal\paragraphs\ParagraphInterface;
use Drupal\recurring_events\Entity\EventInstance;
use Psr\Log\LoggerInterface;
use Safe\DateTimeImmutable;
Expand Down Expand Up @@ -170,6 +172,49 @@ public function getState(): ?EventState {
return NULL;
}

/**
* Get the url of the event if available.
*
* The url will usually be the place where visitors can by tickets for the
* event.
*/
public function getLink() : ?string {
$linkField = $this->getField('event_link');
return $linkField?->getString();
}

/**
* Get the price(s) for the event.
*
* @return int[]|float[]
* Price(s) for the available ticket categories.
*/
public function getTicketPrices(): array {
$field = $this->getField('event_ticket_categories');
if (!$field instanceof FieldItemListInterface) {
return [];
}

$ticketCategories = $field->referencedEntities();
return array_map(function (ParagraphInterface $ticketCategory) {
return $ticketCategory->get('field_ticket_category_price')->value;
}, $ticketCategories);
}

/**
* Returns whether the event can be freely attended.
*
* This means that the event does not require ticketing or that all ticket
* categories are free.
*/
public function isFreeToAttend(): bool {
$nonFreePrice = array_filter($this->getTicketPrices(), function (int|float $price) {
$price = BigDecimal::of($price);
return !$price->isZero();
});
return empty($nonFreePrice);
}

/**
* Loading the field if it exists.
*
Expand Down
27 changes: 27 additions & 0 deletions web/modules/custom/dpl_event/src/PriceFormatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -150,4 +150,31 @@ public function formatRawPrice(string $price_string): string {
}
}

/**
* Formats a range of numeric prices into a string.
*
* Sorts and formats raw prices without currency pre/suffixes or rewriting 0
* to "Free".
*
* @param float[]|int[] $prices
* Array of price values (numbers).
*
* @return string
* Formatted price range string.
*/
public function formatRawPriceRange(array $prices): string {
sort($prices);
$lowest_price = min($prices);
$highest_price = max($prices);

if ($lowest_price != $highest_price) {
$lowest_price = $this->formatRawPrice((string) $lowest_price);
$highest_price = $this->formatRawPrice((string) $highest_price);

return "$lowest_price - $highest_price";
}

return $this->formatRawPrice((string) $lowest_price);
}

}
49 changes: 49 additions & 0 deletions web/modules/custom/dpl_event/tests/src/Unit/PriceFormatterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -148,4 +148,53 @@ public function testPriceRangeFormatting(
);
}

/**
* Provides examples of price arrays and their expected range formatting.
*
* @return array<array{array<int>, string}>
* Array of examples. Each example contains an array of prices and how
* they should be formatted. This matches signature of
* testPriceRangeFormatting().
*/
public function rawPriceRangeProvider(): array {
return [
// Only free prices.
[[0], "0"],
// Free and a single price.
[[0, 20], "0 - 20"],
// Range of prices.
[[20, 30], "20 - 30"],
// Single price.
[[20], "20"],
// Multiple prices.
[[10, 20, 30], "10 - 30"],
// Free with multiple prices.
[[0, 10, 20], "0 - 20"],
// Larger range of prices.
[[50, 100, 150], "50 - 150"],
[[0, 1000], "0 - 1000"],
];
}

/**
* Test raw price range formatting.
*
* @param int[] $prices
* Array of integers representing prices.
* @param string $expected
* Expected formatted string.
*
* @dataProvider rawPriceRangeProvider
*/
public function testRawPriceRangeFormatting(
array $prices,
string $expected,
): void {
$priceFormatter = new PriceFormatter($this->getStringTranslationStub(), $this->getConfigFactoryStub($this->mockConfig));
$this->assertSame(
$expected,
$priceFormatter->formatRawPriceRange($prices)
);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Drupal\Core\Datetime\DateFormatterInterface;
use Drupal\Core\File\FileUrlGeneratorInterface;
use Drupal\Core\Url;
use Drupal\dpl_event\PriceFormatter;
use Drupal\dpl_redia_legacy\RediaEvent;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\Request;
Expand All @@ -24,6 +25,7 @@ class EventsController extends ControllerBase {
public function __construct(
protected FileUrlGeneratorInterface $fileUrlGenerator,
protected DateFormatterInterface $dateFormatter,
protected PriceFormatter $priceFormatter,
) {}

/**
Expand All @@ -33,6 +35,7 @@ public static function create(ContainerInterface $container): static {
return new static(
$container->get('file_url_generator'),
$container->get('date.formatter'),
$container->get('dpl_event.price_formatter'),
);
}

Expand Down Expand Up @@ -80,7 +83,7 @@ private function getItems(): array {
$items = [];

foreach ($events as $event) {
$items[] = new RediaEvent($event);
$items[] = new RediaEvent($event, $this->priceFormatter);
}

return $items;
Expand All @@ -104,48 +107,101 @@ private function buildRss(array $items, Request $request): string {

$date = $this->dateFormatter->format(time(), 'custom', 'r');

$rss_feed = <<<RSS
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xml:base="$site_url" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" xmlns:content-rss="http://xml.redia.dk/rss">
<channel>
<title>$site_title</title>
<link>$site_url</link>
<atom:link rel="self" href="$feed_url" />
<language>da</language>
<pubDate>$date</pubDate>
<lastBuildDate>$date</lastBuildDate>
RSS;

foreach ($items as $item) {
$rss_feed .= <<<ITEM
<item>
<title>{$item->title}</title>
<description>{$item->description}</description>
<author>{$item->author}</author>
<guid isPermaLink="false">{$item->id}</guid>
<pubDate>{$item->date}</pubDate>
<source url="$feed_url">$site_title</source>
<media:content url="{$item->media?->url}" fileSize="{$item->media?->size}" type="{$item->media?->type}" contentmedium="{$item->media?->medium}" width="{$item->media?->width}" height="{$item->media?->height}">
<media:hash algo="md5">{$item->media?->md5}</media:hash>
</media:content>
<media:thumbnail url="{$item->mediaThumbnail?->url}" width="{$item->mediaThumbnail?->width}" height="{$item->mediaThumbnail?->height}" />
<content-rss:subheadline>{$item->subtitle}</content-rss:subheadline>
<content-rss:arrangement-starttime>{$item->startTime}</content-rss:arrangement-starttime>
<content-rss:arrangement-endtime>{$item->endTime}</content-rss:arrangement-endtime>
<content-rss:arrangement-location>{$item->branch?->label()}</content-rss:arrangement-location>
<content-rss:library-id>{$item->branch?->id()}</content-rss:library-id>
<content-rss:promoted>{$item->promoted}</content-rss:promoted>
</item>
ITEM;
}

$rss_feed .= <<<RSS
</channel>
</rss>
RSS;

return $rss_feed;

// Disable formatting rules. We use indentation to mark start/end elements.
// phpcs:disable Drupal.WhiteSpace.ScopeIndent.IncorrectExact
// @formatter:off
$xml = new \XMLWriter();
$xml->openMemory();
$xml->startDocument('1.0', 'UTF-8');
$xml->startElement('rss');
$xml->writeAttribute('version', '2.0');
$xml->writeAttribute('xml:base', $site_url);
// We intentionally do not use the built-in XML Writer namespace handling.
// This allows us to produce output that matches the existing
// implementation as closely as possible.
$xml->writeAttribute('xmlns:atom', 'http://www.w3.org/2005/Atom');
$xml->writeAttribute('xmlns:media', 'http://search.yahoo.com/mrss/');
$xml->writeAttribute('xmlns:content-rss', 'http://xml.redia.dk/rss');

$xml->startElement('channel');
$xml->writeElement('title', $site_title);
$xml->writeElement('link', $site_url);
$xml->startElement('atom:link');
$xml->writeAttribute('rel', 'self');
$xml->writeAttribute('href', $feed_url);
$xml->endElement();
$xml->writeElement('language', 'da');
$xml->writeElement('pubDate', $date);
$xml->writeElement('lastBuildDate', $date);

foreach ($items as $item) {
$xml->startElement('item');
$xml->writeElement('title', $item->title);
$xml->writeElement('description', $item->description);
$xml->writeElement('author', $item->author);
$xml->startElement('guid');
$xml->writeAttribute('isPermaLink', 'false');
$xml->text((string) $item->id);
$xml->endElement();
$xml->writeElement('pubDate', $item->date);
$xml->startElement('source');
$xml->writeAttribute('url', $feed_url);
$xml->text($site_title);
$xml->endElement();

if ($item->media && $item->media->url) {
$xml->startElement('media:content');
$xml->writeAttribute('url', $item->media->url);
$xml->writeAttribute('fileSize', (string) $item->media->size);
$xml->writeAttribute('type', (string) $item->media->type);
$xml->writeAttribute('medium', $item->media->medium);
$xml->writeAttribute('width', (string) $item->media->width);
$xml->writeAttribute('height', (string) $item->media->height);
if ($item->media->md5) {
$xml->startElement('media:hash');
$xml->writeAttribute('algo', 'md5');
$xml->text($item->media->md5);
$xml->endElement();
}
$xml->endElement();
}

if ($item->mediaThumbnail && $item->mediaThumbnail->url) {
$xml->startElement('media:thumbnail');
$xml->writeAttribute('url', $item->mediaThumbnail->url);
$xml->writeAttribute('width', (string) $item->mediaThumbnail->width);
$xml->writeAttribute('height', (string) $item->mediaThumbnail->height);
$xml->endElement();
}

$xml->writeElement('content-rss:subheadline', $item->subtitle);
$xml->writeElement('content-rss:arrangement-starttime', $item->startTime);
$xml->writeElement('content-rss:arrangement-endtime', $item->endTime);

if ($item->branch) {
$xml->writeElement('content-rss:arrangement-location', $item->branch->label());
$xml->writeElement('content-rss:library-id', (string) $item->branch->id());
}

if ($item->bookingUrl) {
$xml->writeElement('content-rss:booking-url', $item->bookingUrl);
}

// Events without a price element are interpreted as free.
if ($item->prices) {
$xml->writeElement('content-rss:arrangement-price', $item->prices);
}

$xml->writeElement('content-rss:promoted', $item->promoted);
$xml->endElement();
}
$xml->endElement();

$xml->endElement();
$xml->endDocument();
return $xml->outputMemory();
// @formatter:on
// phpcs:enable Drupal.WhiteSpace.ScopeIndent.IncorrectExact
}

}
18 changes: 14 additions & 4 deletions web/modules/custom/dpl_redia_legacy/src/RediaEvent.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use Drupal\Core\Datetime\DrupalDateTime;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\dpl_event\EventWrapper;
use Drupal\dpl_event\PriceFormatter;
use Drupal\node\NodeInterface;
use Drupal\recurring_events\Entity\EventInstance;

Expand All @@ -27,6 +28,8 @@ class RediaEvent extends ControllerBase {
public ?NodeInterface $branch;
public ?RediaEventMedia $media;
public ?RediaEventMedia $mediaThumbnail;
public ?string $bookingUrl;
public ?string $prices;
// phpcs:enable

/**
Expand All @@ -37,7 +40,7 @@ class RediaEvent extends ControllerBase {
*/
public string $promoted;

public function __construct(EventInstance $event_instance) {
public function __construct(EventInstance $event_instance, PriceFormatter $price_formatter) {
$event_wrapper = new EventWrapper($event_instance);

$branch = $event_wrapper->getBranches()[0] ?? NULL;
Expand All @@ -54,9 +57,7 @@ public function __construct(EventInstance $event_instance) {
}

$this->title = $event_instance->label();
// The description for an event may contain HTML tags which are not allowed
// in an RSS/XML feed. Encode them.
$this->description = htmlspecialchars($event_wrapper->getDescription() ?? "");
$this->description = $event_wrapper->getDescription();
$this->author = $event_instance->getOwner()->get('field_author_name')->getString();
$this->id = $event_instance->id();
$this->date = $changed_date->format('r');
Expand All @@ -72,6 +73,15 @@ public function __construct(EventInstance $event_instance) {
}

$this->branch = $branch;
$this->bookingUrl = $event_wrapper->getLink();

if (!$event_wrapper->isFreeToAttend()) {
$prices = $event_wrapper->getTicketPrices();
$this->prices = $price_formatter->formatRawPriceRange($prices);
}
else {
$this->prices = NULL;
}

// In the old system, there was a way for editors to mark content a
// promoted. However, this does not exist in the new CMS, so we wil
Expand Down
Loading