From 93f0f275614edad4c1c4c3f5e56f3da3b64cde05 Mon Sep 17 00:00:00 2001
From: flashwave
Date: Thu, 3 Oct 2024 20:39:40 +0000
Subject: [PATCH] Added RSS feed builder classes.
---
TODO.md | 17 +---
VERSION | 2 +-
src/Syndication/FeedBuilder.php | 147 ++++++++++++++++++++++++++++
src/Syndication/FeedItemBuilder.php | 137 ++++++++++++++++++++++++++
tests/SyndicationTest.php | 132 +++++++++++++++++++++++++
5 files changed, 418 insertions(+), 17 deletions(-)
create mode 100644 src/Syndication/FeedBuilder.php
create mode 100644 src/Syndication/FeedItemBuilder.php
create mode 100644 tests/SyndicationTest.php
diff --git a/TODO.md b/TODO.md
index 19f84dd..8f1320a 100644
--- a/TODO.md
+++ b/TODO.md
@@ -1,20 +1,5 @@
# TODO
-## High Prio
-
- - Create tests for everything testable.
-
- - Create similarly address GD2/Imagick wrappers.
-
- - Figure out the SQL Query builder.
- - Might make sense to just have a predicate builder and selector builder.
+ - Create similarly addressable GD2/Imagick wrappers.
- Create a URL formatter.
-
- - Add RSS/Atom feed construction utilities.
-
-## Low Prio
-
- - Get guides working on phpdoc.
-
- - Create phpdoc template.
diff --git a/VERSION b/VERSION
index 199aff6..c5fd99e 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.2410.21941
+0.2410.32039
diff --git a/src/Syndication/FeedBuilder.php b/src/Syndication/FeedBuilder.php
new file mode 100644
index 0000000..ff1d425
--- /dev/null
+++ b/src/Syndication/FeedBuilder.php
@@ -0,0 +1,147 @@
+title = $title;
+ }
+
+ /**
+ * Sets the description of the feed, may contain HTML.
+ *
+ * @param string $description Feed description.
+ */
+ public function setDescription(string $description): void {
+ $this->description = $description;
+ }
+
+ /**
+ * Sets the last updated time for this feed.
+ *
+ * @param DateTimeInterface|int $updatedAt Timestamp at which the feed was last updated.
+ */
+ public function setUpdatedAt(DateTimeInterface|int $updatedAt): void {
+ $this->updatedAt = XDateTime::toRfc822String($updatedAt);
+ }
+
+ /**
+ * Sets the URL to the content this feed represents.
+ *
+ * @param string $url Content URL.
+ */
+ public function setContentUrl(string $url): void {
+ $this->contentUrl = $url;
+ }
+
+ /**
+ * Sets the URL to this feed's XML file.
+ *
+ * @param string $url Feed XML URL.
+ */
+ public function setFeedUrl(string $url): void {
+ $this->feedUrl = $url;
+ }
+
+ /**
+ * Creates a feed item using a builder.
+ *
+ * @param callable(FeedItemBuilder): void $handler Item builder closure.
+ */
+ public function createEntry(callable $handler): void {
+ $builder = new FeedItemBuilder;
+ $handler($builder);
+ $this->items[] = $builder;
+ }
+
+ /**
+ * Creates a DOMDocument from this builder.
+ *
+ * @return DOMDocument XML document representing this feed.
+ */
+ public function toXmlDocument(): DOMDocument {
+ $document = new DOMDocument('1.0', 'UTF-8');
+ $document->preserveWhiteSpace = false;
+ $document->formatOutput = true;
+
+ $rss = $document->createElement('rss');
+ $rss->setAttribute('version', '2.0');
+ $rss->setAttribute('xmlns:atom', 'http://www.w3.org/2005/Atom');
+ $document->appendChild($rss);
+
+ $channel = $document->createElement('channel');
+
+ $elements = [
+ 'title' => $this->title,
+ 'pubDate' => $this->updatedAt,
+ 'link' => $this->contentUrl,
+ ];
+ foreach($elements as $elemName => $elemValue)
+ if(!XString::nullOrWhitespace($elemValue))
+ $channel->appendChild($document->createElement($elemName, $elemValue));
+
+ if(!XString::nullOrWhitespace($this->description)) {
+ $description = $document->createElement('description');
+ $description->appendChild($document->createCDATASection($this->description));
+ $channel->appendChild($description);
+ }
+
+ if(!XString::nullOrWhitespace($this->feedUrl)) {
+ $element = $document->createElement('atom:link');
+ $element->setAttribute('href', $this->feedUrl);
+ $element->setAttribute('ref', 'self');
+ $element->setAttribute('type', 'application/rss+xml');
+ $channel->appendChild($element);
+ }
+
+ foreach($this->items as $item)
+ $channel->appendChild($item->createElement($document));
+
+ $rss->appendChild($channel);
+
+ return $document;
+ }
+
+ /**
+ * Converts the XML Document to a printable string.
+ *
+ * @return string Printable XML document.
+ */
+ public function toXmlString(): string {
+ $string = $this->toXmlDocument()->saveXML();
+ if($string === false)
+ $string = '';
+
+ return $string;
+ }
+
+ public function __toString(): string {
+ return $this->toXmlString();
+ }
+}
diff --git a/src/Syndication/FeedItemBuilder.php b/src/Syndication/FeedItemBuilder.php
new file mode 100644
index 0000000..b77db6d
--- /dev/null
+++ b/src/Syndication/FeedItemBuilder.php
@@ -0,0 +1,137 @@
+title = $title;
+ }
+
+ /**
+ * Sets the description of the feed item, may contain HTML.
+ *
+ * @param string $description Item description.
+ */
+ public function setDescription(string $description): void {
+ $this->description = $description;
+ }
+
+ /**
+ * Sets the creation time for this feed item.
+ *
+ * @param DateTimeInterface|int $createdAt Timestamp at which the feed item was created.
+ */
+ public function setCreatedAt(DateTimeInterface|int $createdAt): void {
+ $this->createdAt = XDateTime::toRfc822String($createdAt);
+ }
+
+ /**
+ * Sets the last updated time for this feed item.
+ *
+ * @param DateTimeInterface|int $updatedAt Timestamp at which the feed item was last updated.
+ */
+ public function setUpdatedAt(DateTimeInterface|int $updatedAt): void {
+ $this->updatedAt = XDateTime::toIso8601String($updatedAt);
+ }
+
+ /**
+ * Sets the URL to the content this feed item represents.
+ *
+ * @param string $url Content URL.
+ */
+ public function setContentUrl(string $url): void {
+ $this->contentUrl = $url;
+ }
+
+ /**
+ * Sets the URL to where comments can be made on the content this feed item represents.
+ *
+ * @param string $url Comments URL.
+ */
+ public function setCommentsUrl(string $url): void {
+ $this->commentsUrl = $url;
+ }
+
+ /**
+ * Sets the name of the author of this feed item.
+ *
+ * @param string $name Name of the author.
+ */
+ public function setAuthorName(string $name): void {
+ $this->authorName = $name;
+ }
+
+ /**
+ * Sets the URL for the author of this feed item. Has no effect if no author name is provided.
+ *
+ * @param string $url URL for the author.
+ */
+ public function setAuthorUrl(string $url): void {
+ $this->authorUrl = $url;
+ }
+
+ /**
+ * Creates an XML document based on this item builder.
+ *
+ * @param DOMDocument $document Document to which this element is to be appended.
+ * @return DOMElement Element created from this builder.
+ */
+ public function createElement(DOMDocument $document): DOMElement {
+ $item = $document->createElement('item');
+
+ $elements = [
+ 'title' => $this->title,
+ 'pubDate' => $this->createdAt,
+ 'atom:updated' => $this->updatedAt,
+ 'link' => $this->contentUrl,
+ 'guid' => $this->contentUrl,
+ 'comments' => $this->commentsUrl,
+ ];
+ foreach($elements as $elemName => $elemValue)
+ if(!XString::nullOrWhitespace($elemValue))
+ $item->appendChild($document->createElement($elemName, $elemValue));
+
+ if(!XString::nullOrWhitespace($this->description)) {
+ $description = $document->createElement('description');
+ $description->appendChild($document->createCDATASection($this->description));
+ $item->appendChild($description);
+ }
+
+ if(!XString::nullOrWhitespace($this->authorName)) {
+ $author = $document->createElement('atom:author');
+ $author->appendChild($document->createElement('atom:name', $this->authorName));
+
+ if(!XString::nullOrWhitespace($this->authorUrl))
+ $author->appendChild($document->createElement('atom:uri', $this->authorUrl));
+
+ $item->appendChild($author);
+ }
+
+ return $item;
+ }
+}
diff --git a/tests/SyndicationTest.php b/tests/SyndicationTest.php
new file mode 100644
index 0000000..056a76a
--- /dev/null
+++ b/tests/SyndicationTest.php
@@ -0,0 +1,132 @@
+
+
+
+ Feed
+
+
+
+ XML;
+
+ private const NO_ITEMS_FEED = <<
+
+
+ Index RSS
+ Thu, 03 Oct 24 20:06:25 +0000
+ https://example.railgun.sh/articles
+ This should accept HTML!]]>
+
+
+
+
+ XML;
+
+ private const ITEMS_FEED = <<
+
+
+ Index RSS
+ Thu, 03 Oct 24 20:06:25 +0000
+ https://example.railgun.sh/articles
+ This should accept HTML!]]>
+
+ -
+ Article 1
+ Sat, 28 Sep 24 01:13:05 +0000
+ https://example.railgun.sh/articles/1
+ https://example.railgun.sh/articles/1
+ https://example.railgun.sh/articles/1#comments
+ This is an article!
+ It has HTML and multiple lines and all that awesome stuff!!!!
+ Meaning it can also have sections
+ Technology is so cool...
]]>
+
+ flashwave
+ https://example.railgun.sh/author/1
+
+
+ -
+ Article 2
+ Sun, 29 Sep 24 04:59:45 +0000
+ https://example.railgun.sh/articles/2
+ https://example.railgun.sh/articles/2
+ https://example.railgun.sh/articles/2#comments
+ hidden author spooky]]>
+
+ -
+ Article 3 without a description
+ Mon, 30 Sep 24 08:46:25 +0000
+ https://example.railgun.sh/articles/3
+ https://example.railgun.sh/articles/3
+ https://example.railgun.sh/articles/3#comments
+
+ Anonymous
+
+
+
+
+
+ XML;
+
+ public function testFeedBuilder(): void {
+ $feed = new FeedBuilder;
+
+ $this->assertEquals(self::BLANK_FEED, $feed->toXmlString());
+
+ $feed->setTitle('Index RSS');
+ $feed->setDescription('This should accept HTML!');
+ $feed->setUpdatedAt(1727985985);
+ $feed->setContentUrl('https://example.railgun.sh/articles');
+ $feed->setFeedUrl('https://example.railgun.sh/feed.xml');
+
+ $this->assertEquals(self::NO_ITEMS_FEED, $feed->toXmlString());
+
+ $feed->createEntry(function($item) {
+ $item->setTitle('Article 1');
+ $item->setDescription(<<This is an article!
+ It has HTML and multiple lines and all that awesome stuff!!!!
+ Meaning it can also have sections
+ Technology is so cool...
+ HTML);
+ $item->setCreatedAt(1727485985);
+ $item->setContentUrl('https://example.railgun.sh/articles/1');
+ $item->setCommentsUrl('https://example.railgun.sh/articles/1#comments');
+ $item->setAuthorName('flashwave');
+ $item->setAuthorUrl('https://example.railgun.sh/author/1');
+ });
+ $feed->createEntry(function($item) {
+ $item->setTitle('Article 2');
+ $item->setDescription(<<hidden author spooky
+ HTML);
+ $item->setCreatedAt(1727585985);
+ $item->setContentUrl('https://example.railgun.sh/articles/2');
+ $item->setCommentsUrl('https://example.railgun.sh/articles/2#comments');
+ });
+ $feed->createEntry(function($item) {
+ $item->setTitle('Article 3 without a description');
+ $item->setCreatedAt(1727685985);
+ $item->setContentUrl('https://example.railgun.sh/articles/3');
+ $item->setCommentsUrl('https://example.railgun.sh/articles/3#comments');
+ $item->setAuthorName('Anonymous');
+ });
+
+ $this->assertEquals(self::ITEMS_FEED, $feed->toXmlString());
+ }
+}