~jan0sch/smederee

Showing details for patch 4250d329f0c8eca74b595d9c409f5528ed5c1137.
2023-09-16 (Sat), 3:54 PM - Jens Grassel - 4250d329f0c8eca74b595d9c409f5528ed5c1137

Add RSS feed feature for repositories.

- add base classes for describing RSS feeds
- add extension method to convert `VcsRepositoryPatchMetadata` into a RSS feed
  item
- add route to repository routes to enable feed for "owner/repo/feed.rss"
Summary of changes
2 files added
  • modules/hub/src/main/scala/de/smederee/hub/Rss.scala
  • modules/hub/src/main/twirl/de/smederee/hub/views/rss.scala.xml
4 files modified with 111 lines added and 2 lines removed
  • modules/hub/src/main/resources/messages.properties with 1 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/VcsRepository.scala with 26 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala with 83 added and 1 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryOverview.scala.html with 1 added and 1 removed lines
diff -rN -u old-smederee/modules/hub/src/main/resources/messages.properties new-smederee/modules/hub/src/main/resources/messages.properties
--- old-smederee/modules/hub/src/main/resources/messages.properties	2025-01-15 05:30:37.144089689 +0000
+++ new-smederee/modules/hub/src/main/resources/messages.properties	2025-01-15 05:30:37.144089689 +0000
@@ -251,6 +251,7 @@
 repository.overview.download.link=Source code (.tar.gz)
 repository.overview.latest-changes=Latest changes
 repository.overview.latest-changes.timestamp={0,date,yyyy-MM-dd (E)}, {0,time,short}
+repository.overview.rss-feed=RSS feed for this project.
 
 # User management / settings
 user.settings.account.delete.title=Delete your account
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/Rss.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/Rss.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/Rss.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/Rss.scala	2025-01-15 05:30:37.144089689 +0000
@@ -0,0 +1,99 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.hub
+
+import java.time._
+
+/** Data describing a RSS channel.
+  *
+  * @see
+  *   https://www.rssboard.org/rss-specification
+  *
+  * @param title
+  *   The name of the channel. It's how people refer to your service. If you have an HTML website that contains the same
+  *   information as your RSS file, the title of your channel should be the same as the title of your website.
+  * @param link
+  *   The URL to the HTML website corresponding to the channel.
+  * @param description
+  *   Phrase or sentence describing the channel.
+  * @param copyright
+  *   Copyright notice for content in the channel.
+  * @param image
+  *   Specifies a GIF, JPEG or PNG image that can be displayed with the channel.
+  * @param items
+  *   A channel may contain any number of <item>s. An item may represent a "story" -- much like a story in a newspaper
+  *   or magazine; if so its description is a synopsis of the story, and the link points to the full story.
+  */
+final case class RssChannel(
+    title: String,
+    link: String,
+    description: String,
+    copyright: Option[String],
+    image: Option[RssChannelImage],
+    items: List[RssItem]
+)
+
+/** Description of an image that may be displayed with an RSS channel.
+  *
+  * @param url
+  *   The URL of a GIF, JPEG or PNG image that represents the channel.
+  * @param title
+  *   Describes the image, it's used in the ALT attribute of the HTML <img> tag when the channel is rendered in HTML.
+  * @param link
+  *   The URL of the site, when the channel is rendered, the image is a link to the site. (Note, in practice the image
+  *   <title> and <link> should have the same value as the channel's <title> and <link>.
+  * @param description
+  *   Text that is included in the TITLE attribute of the link formed around the image in the HTML rendering.
+  * @param height
+  *   The height of the image in pixels.
+  * @param width
+  *   The width of the image in pixels.
+  */
+final case class RssChannelImage(
+    url: String,
+    title: String,
+    link: String,
+    description: Option[String],
+    height: Option[Int],
+    width: Option[Int]
+)
+
+/** Data describing an item (entry) within a RSS channel.
+  *
+  * @param title
+  *   The title of the item.
+  * @param link
+  *   The url of the item.
+  * @param description
+  *   The item synopsis.
+  * @param author
+  *   The author of the item which should per specification contain the email address but we may choose to not include
+  *   it due to privacy reasons.
+  * @param guid
+  *   A string that uniquely identifies the item. In regard of a repository it might be the full url to a patch hash.
+  * @param pubDate
+  *   Indicates when the item was published.
+  */
+final case class RssItem(
+    title: String,
+    link: String,
+    description: String,
+    author: Option[String],
+    guid: Option[String],
+    pubDate: Option[OffsetDateTime]
+)
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala	2025-01-15 05:30:37.144089689 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala	2025-01-15 05:30:37.148089695 +0000
@@ -709,6 +709,77 @@
       }
     } yield resp
 
+  /** Logic for generating the RSS feed for the requested repository.
+    *
+    * @param csrf
+    *   An optional CSRF-Token that shall be used.
+    * @param user
+    *   An optional user account for whom the list of repositories shall be rendered.
+    * @param repositoryOwnerName
+    *   The name of the user who owns the repository.
+    * @param repositoryName
+    *   The actual name of the repository.
+    * @return
+    *   An HTTP response containing the rendered RSS XML.
+    */
+  def doShowRepositoryRssFeed(
+      csrf: Option[CsrfToken]
+  )(user: Option[Account])(repositoryOwnerName: Username, repositoryName: VcsRepositoryName): F[Response[F]] =
+    for {
+      language  <- Sync[F].delay(user.flatMap(_.language).getOrElse(LanguageCode("en")))
+      repoAndId <- loadRepo(user)(repositoryOwnerName, repositoryName)
+      repo = repoAndId.map(_._1)
+      response <- repo match {
+        case None => NotFound()
+        case Some(repo) =>
+          for {
+            directory <- Sync[F].delay(
+              os.Path(
+                Paths.get(
+                  darcsConfig.repositoriesDirectory.toPath.toString,
+                  repositoryOwnerName.toString
+                )
+              )
+            )
+            countChanges    <- darcs.log(directory.toNIO)(repositoryName.toString)(Chain("--count"))
+            numberOfChanges <- Sync[F].delay(countChanges.stdout.toList.mkString.trim.toInt)
+            maxCount = 15
+            from     = 1
+            to =
+              if ((from + maxCount > numberOfChanges) && (numberOfChanges > 0))
+                numberOfChanges
+              else
+                from + maxCount
+            next = if (to < numberOfChanges) Option(to + 1) else None
+            vcsLog <- darcs.log(directory.toNIO)(repositoryName.toString)(
+              Chain(s"--index=$from-$to", "--summary", "--xml-output")
+            )
+            xmlLog  <- Sync[F].delay(scala.xml.XML.loadString(vcsLog.stdout.toList.mkString))
+            patches <- Sync[F].delay((xmlLog \ "patch").flatMap(VcsRepositoryPatchMetadata.fromDarcsXmlLog).toList)
+            repositoryBaseUri <- Sync[F].delay(
+              linkConfig.createFullUri(
+                Uri(path =
+                  Uri.Path(
+                    Vector(Uri.Path.Segment(s"~$repositoryOwnerName"), Uri.Path.Segment(repositoryName.toString))
+                  )
+                )
+              )
+            )
+            items = patches.map(_.toRssItem(repositoryBaseUri.addSegment("patch")))
+            channel = RssChannel(
+              title = s"Smederee: ~$repositoryOwnerName/$repositoryName",
+              link = repositoryBaseUri.toString,
+              description =
+                repo.description.map(_.toString).getOrElse(s"RSS feed for $repositoryOwnerName/$repositoryName"),
+              copyright = None,
+              image = None,
+              items = items
+            )
+            resp <- Ok(views.xml.rss(channel))
+          } yield resp
+      }
+    } yield response
+
   /** Get a diff for the requested patch and return the rendered response of it.
     *
     * @param csrf
@@ -1510,6 +1581,16 @@
       } yield resp
   }
 
+  private val showRepositoryRssFeed: HttpRoutes[F] = HttpRoutes.of {
+    case req @ GET -> Root / UsernamePathParameter(repositoryOwnerName) / VcsRepositoryNamePathParameter(
+          repositoryName
+        ) / "feed.rss" =>
+      for {
+        csrf <- Sync[F].delay(req.getCsrfToken)
+        resp <- doShowRepositoryRssFeed(csrf)(None)(repositoryOwnerName, repositoryName)
+      } yield resp
+  }
+
   private val showRepositoryPatchDetails: AuthedRoutes[Account, F] = AuthedRoutes.of {
     case ar @ GET -> Root / UsernamePathParameter(repositoryOwnerName) / VcsRepositoryNamePathParameter(
           repositoryName
@@ -1540,7 +1621,8 @@
   val routes =
     cloneRepository <+> downloadDistributionForGuests <+> showAllRepositoriesForGuests <+>
       showRepositoriesForGuests <+> showRepositoryOverviewForGuests <+> showRepositoryBranchesForGuests <+>
-      showRepositoryHistoryForGuests <+> showRepositoryPatchDetailsForGuests <+> showRepositoryFilesForGuests
+      showRepositoryHistoryForGuests <+> showRepositoryPatchDetailsForGuests <+> showRepositoryFilesForGuests <+>
+      showRepositoryRssFeed
 
 }
 
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepository.scala	2025-01-15 05:30:37.144089689 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepository.scala	2025-01-15 05:30:37.144089689 +0000
@@ -374,6 +374,8 @@
     *   an Instant representing the same instant, not null
     */
   def toInstant: Instant = timestamp.toInstant
+
+  def toOffsetDateTime: OffsetDateTime = timestamp
 }
 
 /** Summary construct for a file modification within a patch.
@@ -481,6 +483,8 @@
 object VcsRepositoryPatchMetadata {
   private val log = LoggerFactory.getLogger(classOf[VcsRepositoryPatchMetadata])
 
+  private val replaceEmail = """<([a-zA-Z0-9.!#$%&’'*+/=?^_`{|}~-]+)@([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*)>""".r
+
   val DarcsCommentFilter: Regex          = "^Ignore-this: [0-9a-f]+".r
   val DarcsDateFormat: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss")
 
@@ -517,6 +521,28 @@
         None
       case scala.util.Success(patchMetadata) => patchMetadata
     }
+
+  extension (patch: VcsRepositoryPatchMetadata) {
+
+    /** Convert the patch metadata information into an RSS item that may be included in a RSS feed.
+      *
+      * @param baseUri
+      *   The URI needed to access the patch on platform i.e. the address to which only the hash needs to be appended.
+      * @return
+      *   An RSS item describing the patch.
+      */
+    def toRssItem(baseUri: Uri): RssItem = {
+      val uri = baseUri.addSegment(patch.hash.toString).toString
+      RssItem(
+        title = patch.name.toString,
+        link = uri,
+        description = patch.comment.map(_.toString).getOrElse(patch.name.toString).replaceAll("\n", "<br />"),
+        author = replaceEmail.replaceAllIn(patch.author.toString, "").trim.some,
+        guid = uri.some,
+        pubDate = patch.timestamp.toOffsetDateTime.some
+      )
+    }
+  }
 }
 
 /** Data about a VCS respository.
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/rss.scala.xml new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/rss.scala.xml
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/rss.scala.xml	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/rss.scala.xml	2025-01-15 05:30:37.148089695 +0000
@@ -0,0 +1,23 @@
+@(channel: de.smederee.hub.RssChannel)
+<?xml version="1.0" encoding="utf-8"?>
+@defining(java.time.format.DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss zzz").withZone(java.time.ZoneOffset.UTC)) { formatter =>
+<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
+  <channel>
+    <title>@channel.title</title>
+    <link>@channel.link</link>
+    <description>@channel.description</description>
+    @for(copyright <- channel.copyright) {<copyright>@copyright</copyright>}
+    <atom:link href="@channel.link/feed.rss" rel="self" type="application/rss+xml" />
+    @for(item <- channel.items) {
+    <item>
+      <title>@item.title</title>
+      <link>@item.link</link>
+      <description>@item.description></description>
+      @for(author <- item.author) {<author>@author</author>}
+      @for(guid <- item.guid) {<guid>@guid</guid>}
+      @for(date <- item.pubDate) {<pubDate>@{date.format(formatter)}</pubDate>}
+    </item>
+    }
+  </channel>
+</rss>
+}
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryOverview.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryOverview.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryOverview.scala.html	2025-01-15 05:30:37.144089689 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryOverview.scala.html	2025-01-15 05:30:37.148089695 +0000
@@ -37,7 +37,7 @@
     <div class="pure-g">
       <div class="pure-u-1 pure-u-lg-3-5">
         <div class="l-box">
-          <h3>@Messages("repository.overview.latest-changes")</h3>
+          <h3>@Messages("repository.overview.latest-changes") <a href="@{actionBaseUri.addSegment("feed.rss")}" title="@Messages("repository.overview.rss-feed")">@icon(baseUri)("rss")</a></h3>
           <div class="overview-latest-changes">
             @for(patch <- vcsRepositoryHistory) {
               @repositoryPatchMetadata(actionBaseUri.some, patch)