~jan0sch/smederee
Showing details for patch fc0eb30673fb0a6ec3f765965899490ab951d05d.
diff -rN -u old-smederee/modules/hub/src/main/resources/assets/css/main.css new-smederee/modules/hub/src/main/resources/assets/css/main.css --- old-smederee/modules/hub/src/main/resources/assets/css/main.css 2025-03-10 01:29:04.057306411 +0000 +++ new-smederee/modules/hub/src/main/resources/assets/css/main.css 2025-03-10 01:29:04.057306411 +0000 @@ -138,6 +138,58 @@ word-wrap: break-word; } +.overview-latest-changes { + font-size: 85%; +} + +.patch { + background-color: #eee; + margin: 0em 0em 1em 0em; + padding: 0em 0.25em 0em 0.25em; +} + +.patch-summary-added, .patch-summary-added-details { + font-size: 0.8em; +} + +.patch-summary-added-details ul { + list-style: none; + padding-inline-start: 1em; +} + +.patch-summary-added-details ul li:before { + content: "+ "; + font-weight: bold; +} + +.patch-summary-modified, .patch-summary-modified-details { + font-size: 0.8em; +} + +.patch-summary-modified-details ul { + list-style: none; + padding-inline-start: 1em; +} + +.patch-summary-modified-details ul li:before { + content: "~ "; + font-weight: bold; +} + +.patch-summary-removed, .patch-summary-removed-details { + font-size: 0.8em; +} + +.patch-summary-removed-details ul { + list-style: none; + padding-inline-start: 1em; +} + +.patch-summary-removed-details ul li:before { + content: "- "; + font-weight: bold; +} + pre.latest-changes { overflow-x: auto; overflow-y: hidden; @@ -148,10 +200,6 @@ word-wrap: break-word; } -pre.latest-changes code { - font-size: 85%; -} - pre.repository-file-content { display: block; overflow-x: auto; diff -rN -u old-smederee/modules/hub/src/main/resources/messages_en.properties new-smederee/modules/hub/src/main/resources/messages_en.properties --- old-smederee/modules/hub/src/main/resources/messages_en.properties 2025-03-10 01:29:04.057306411 +0000 +++ new-smederee/modules/hub/src/main/resources/messages_en.properties 2025-03-10 01:29:04.057306411 +0000 @@ -123,6 +123,12 @@ repositories.yours.column.name=Name repositories.yours.none-found=Looks like you don't have any repositories created yet. +repository.changes.patch.summary.title=Summary of changes +repository.changes.patch.summary.added={0} files added +repository.changes.patch.summary.modified={0} files modified with {1} lines added and {2} lines removed +repository.changes.patch.summary.modified.details={0} with {1} added and {2} removed lines +repository.changes.patch.summary.removed={0} files removed + repository.menu.changes.next=Next repository.menu.changes=Changes repository.menu.files=Files @@ -136,4 +142,5 @@ repository.overview.clone.read-only=read-only repository.overview.clone.read-write=read-write repository.overview.latest-changes=Latest changes +repository.overview.latest-changes.timestamp={0,date,yyyy-MM-dd (E)}, {0,time,short} 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-03-10 01:29:04.057306411 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala 2025-03-10 01:29:04.057306411 +0000 @@ -187,7 +187,9 @@ ) ) ) - log <- darcs.log(directory.toNIO)(repositoryName.toString)(Chain(s"--max-count=2")) + log <- darcs.log(directory.toNIO)(repositoryName.toString)(Chain(s"--max-count=2", "--xml-output")) + xmlLog <- Sync[F].delay(scala.xml.XML.loadString(log.stdout.toList.mkString)) + patches <- Sync[F].delay((xmlLog \ "patch").flatMap(VcsRepositoryPatchMetadata.fromDarcsXmlLog).toList) readmeData <- repo.traverse(repo => doLoadReadme(repo)) readme <- readmeData match { case Some((lines, Some(filename))) => @@ -217,7 +219,7 @@ user )( repo, - vcsRepositoryHistory = log.stdout.toList.mkString("\n").some, + vcsRepositoryHistory = patches, vcsRepositoryReadme = readme, vcsRepositoryReadmeFilename = readmeName, vcsRepositorySshUri = sshUri @@ -389,7 +391,11 @@ else from + maxCount next = if (to < numberOfChanges) Option(to + 1) else None - history <- darcs.log(directory.toNIO)(repositoryName.toString)(Chain(s"--index=$from-$to", "--summary")) + log <- darcs.log(directory.toNIO)(repositoryName.toString)( + Chain(s"--index=$from-$to", "--summary", "--xml-output") + ) + xmlLog <- Sync[F].delay(scala.xml.XML.loadString(log.stdout.toList.mkString)) + patches <- Sync[F].delay((xmlLog \ "patch").flatMap(VcsRepositoryPatchMetadata.fromDarcsXmlLog).toList) actionBaseUri <- Sync[F].delay( linkConfig.createFullUri( Uri(path = @@ -430,7 +436,7 @@ Option(goBackUri), s"Smederee - History of ~$repositoryOwnerName/$repositoryName".some, user - )(history.stdout.toList.mkString("\n"), next, repositoryBaseUri, repo) + )(patches, next, repositoryBaseUri, repo) ) } } yield resp 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-03-10 01:29:04.057306411 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepository.scala 2025-03-10 01:29:04.057306411 +0000 @@ -17,11 +17,16 @@ package de.smederee.hub +import java.time._ +import java.time.format.DateTimeFormatter + import cats._ import cats.data._ import cats.syntax.all._ import org.http4s.Uri +import org.slf4j.LoggerFactory +import scala.util.Try import scala.util.matching.Regex /** The types of version control software that we support. @@ -141,6 +146,306 @@ } +opaque type VcsPatchAuthor = String +object VcsPatchAuthor { + val RemoveEmail: Regex = """<?([a-zA-Z0-9.!#$%&’'*+/=?^_`{|}~-]+)@([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*)>?""".r + + /** Create an instance of VcsPatchAuthor from the given String type. + * + * @param source + * An instance of type String which will be returned as a VcsPatchAuthor. + * @return + * The appropriate instance of VcsPatchAuthor. + */ + def apply(source: String): VcsPatchAuthor = source + + /** Try to create an instance of VcsPatchAuthor from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a VcsPatchAuthor. + * @return + * An option to the successfully converted VcsPatchAuthor. + */ + def from(source: String): Option[VcsPatchAuthor] = Option(source).filter(_.nonEmpty) + +} + +extension (author: VcsPatchAuthor) { + + /** Remove everything from the author string which could be an email address. + * + * @return + * A string with every occurence of a potential email address replaced by an empty string. + */ + def withoutEmail: String = VcsPatchAuthor.RemoveEmail.replaceAllIn(author.toString, "") + +} + +opaque type VcsPatchComment = String +object VcsPatchComment { + + /** Create an instance of VcsPatchComment from the given String type. + * + * @param source + * An instance of type String which will be returned as a VcsPatchComment. + * @return + * The appropriate instance of VcsPatchComment. + */ + def apply(source: String): VcsPatchComment = source + + /** Try to create an instance of VcsPatchComment from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a VcsPatchComment. + * @return + * An option to the successfully converted VcsPatchComment. + */ + def from(source: String): Option[VcsPatchComment] = Option(source).filter(_.nonEmpty) + +} + +opaque type VcsPatchFilename = String +object VcsPatchFilename { + + /** Create an instance of VcsPatchFilename from the given String type. + * + * @param source + * An instance of type String which will be returned as a VcsPatchFilename. + * @return + * The appropriate instance of VcsPatchFilename. + */ + def apply(source: String): VcsPatchFilename = source + + /** Try to create an instance of VcsPatchFilename from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a VcsPatchFilename. + * @return + * An option to the successfully converted VcsPatchFilename. + */ + def from(source: String): Option[VcsPatchFilename] = Option(source).filter(_.nonEmpty) + +} + +opaque type VcsPatchHash = String +object VcsPatchHash { + + /** Create an instance of VcsPatchHash from the given String type. + * + * @param source + * An instance of type String which will be returned as a VcsPatchHash. + * @return + * The appropriate instance of VcsPatchHash. + */ + def apply(source: String): VcsPatchHash = source + + /** Try to create an instance of VcsPatchHash from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a VcsPatchHash. + * @return + * An option to the successfully converted VcsPatchHash. + */ + def from(source: String): Option[VcsPatchHash] = Option(source).filter(_.nonEmpty) + +} + +opaque type VcsPatchName = String +object VcsPatchName { + + /** Create an instance of VcsPatchName from the given String type. + * + * @param source + * An instance of type String which will be returned as a VcsPatchName. + * @return + * The appropriate instance of VcsPatchName. + */ + def apply(source: String): VcsPatchName = source + + /** Try to create an instance of VcsPatchName from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a VcsPatchName. + * @return + * An option to the successfully converted VcsPatchName. + */ + def from(source: String): Option[VcsPatchName] = Option(source).filter(_.nonEmpty) + +} + +/** A VcsPatchTimestamp is a OffsetDateTime with an `UTC` zone offset. + */ +opaque type VcsPatchTimestamp = OffsetDateTime +object VcsPatchTimestamp { + + /** Create an instance of VcsPatchTimestamp from the given OffsetDateTime type. + * + * @param source + * An instance of type OffsetDateTime which will be returned as a VcsPatchTimestamp. + * @return + * The appropriate instance of VcsPatchTimestamp. + */ + def apply(source: OffsetDateTime): VcsPatchTimestamp = source.withOffsetSameLocal(ZoneOffset.UTC) + + /** Try to create an instance of VcsPatchTimestamp from the given OffsetDateTime. + * + * @param source + * A OffsetDateTime that should fulfil the requirements to be converted into a VcsPatchTimestamp. + * @return + * An option to the successfully converted VcsPatchTimestamp. + */ + def from(source: OffsetDateTime): Option[VcsPatchTimestamp] = + source.getOffset() match { + case ZoneOffset.UTC => Option(source) + case _ => None + } + +} + +extension (timestamp: VcsPatchTimestamp) { + + /** Converts this date-time to an Instant. This returns an Instant representing the same point on the + * time-line as this date-time. + * + * @return + * an Instant representing the same instant, not null + */ + def toInstant: Instant = timestamp.toInstant +} + +/** Summary construct for a file modification within a patch. + * + * @param added + * The number of lines added to the file which might be 0. + * @param name + * The name of the file within the repository. + * @param removed + * The number of lines removed from the file which might be 0. + */ +final case class VcsPatchSummaryFileModification(added: Int, name: VcsPatchFilename, removed: Int) + +/** Summary of file operations within a patch. + * + * @param added + * A list of file names that are added within this patch. + * @param modified + * A list of file modification summaries within this patch. + * @param removed + * A list of file names that are removed within this patch. + */ +final case class VcsPatchSummary( + added: List[VcsPatchFilename], + modified: List[VcsPatchSummaryFileModification], + removed: List[VcsPatchFilename] +) + +object VcsPatchSummary { + private val log = LoggerFactory.getLogger(classOf[VcsPatchSummary]) + + /** Parse the given xml element which SHALL originate from the xml output of a darcs log command. Any + * exceptional errors which occur during conversion are logged. + * + * @param darcsLogEntry + * A log entry from a darcs log using the xml output (i.e. the `<patch>` element). + * @return + * An option to a successfully extracted patch metadata object. + */ + def fromDarcsXmlLog(darcsLogEntry: scala.xml.Node): Option[VcsPatchSummary] = + Try { + val summary = (darcsLogEntry \ "summary").headOption + val added = summary + .map(summary => (summary \ "add_file").flatMap(file => VcsPatchFilename.from(file.text)).toList) + .getOrElse(List.empty) + val modified = summary + .map(summary => + (summary \ "modify_file").toList.flatMap { file => + val filename = VcsPatchFilename.from(file.text) + val added_lines = (file \ "added_lines" \@ "num").headOption.map(_.toInt).getOrElse(0) + val removed_lines = (file \ "removed_lines" \@ "num").headOption.map(_.toInt).getOrElse(0) + filename.map(filename => VcsPatchSummaryFileModification(added_lines, filename, removed_lines)) + } + ) + .getOrElse(List.empty) + val removed = summary + .map(summary => (summary \ "remove_file").flatMap(file => VcsPatchFilename.from(file.text)).toList) + .getOrElse(List.empty) + summary.map(_ => VcsPatchSummary(added, modified, removed)) + } match { + case scala.util.Failure(exception) => + log.error( + "Error occured while trying to extract patch summary from darcs log xml output!", + exception + ) + None + case scala.util.Success(patchSummary) => patchSummary + } +} + +/** Data describing a patch in a vcs repository. + * + * @param author + * The author of the patch which is usually a line of the format `Jane Doe <jane@example.com>`. + * @param comment + * A comment (which should be a detailed description) for the patch. + * @param hash + * A unique hash identifying the patch. + * @param name + * The name (title) of the patch. + * @param summary + * A summary of the file operations that were done with this patch. + * @param timestamp + * The timestamp in UTC when the patch was recorded. + */ +final case class VcsRepositoryPatchMetadata( + author: VcsPatchAuthor, + comment: Option[VcsPatchComment], + hash: VcsPatchHash, + name: VcsPatchName, + summary: Option[VcsPatchSummary], + timestamp: VcsPatchTimestamp +) + +object VcsRepositoryPatchMetadata { + private val log = LoggerFactory.getLogger(classOf[VcsRepositoryPatchMetadata]) + + val DarcsCommentFilter: Regex = "^Ignore-this: [0-9a-f]+".r + val DarcsDateFormat: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss") + + /** Parse the given xml element which SHALL originate from the xml output of a darcs log command. Any + * exceptional errors which occur during conversion are logged. + * + * @param darcsLogEntry + * A log entry from a darcs log using the xml output (i.e. the `<patch>` element). + * @return + * An option to a successfully extracted patch metadata object. + */ + def fromDarcsXmlLog(darcsLogEntry: scala.xml.Node): Option[VcsRepositoryPatchMetadata] = + Try { + val author = VcsPatchAuthor.from(darcsLogEntry \@ "author") + val comment = (darcsLogEntry \ "comment").headOption + .map(comment => DarcsCommentFilter.replaceFirstIn(comment.text, "").trim) + .flatMap(VcsPatchComment.from) + val hash = VcsPatchHash.from(darcsLogEntry \@ "hash") + val name = (darcsLogEntry \ "name").headOption.map(_.text).flatMap(VcsPatchName.from) + val summary = VcsPatchSummary.fromDarcsXmlLog(darcsLogEntry) + val timestamp = + VcsPatchTimestamp.from( + LocalDateTime.parse(darcsLogEntry \@ "date", DarcsDateFormat).atOffset(ZoneOffset.UTC) + ) + (author, hash, name, timestamp).mapN { case (author, hash, name, timestamp) => + VcsRepositoryPatchMetadata(author, comment, hash, name, summary, timestamp) + } + } match { + case scala.util.Failure(exception) => + log.error( + "Error occured while trying to extract patch information from darcs log xml output!", + exception + ) + None + case scala.util.Success(patchMetadata) => patchMetadata + } +} + /** Data about a VCS respository. * * @param name diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/repositoryPatchMetadata.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/repositoryPatchMetadata.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/repositoryPatchMetadata.scala.html 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/repositoryPatchMetadata.scala.html 2025-03-10 01:29:04.057306411 +0000 @@ -0,0 +1,51 @@ +@(patch: VcsRepositoryPatchMetadata)(implicit locale: java.util.Locale) +<div class="patch"> + <div class="patch-details"> + <span class="timestamp">@Messages("repository.overview.latest-changes.timestamp", java.util.Date.from(patch.timestamp.toInstant))</span> - <span class="author">@patch.author.withoutEmail</span> + </div> + <h4 class="patch-name">@patch.name</h4> + <div class="patch-comments"> + <pre class="latest-changes"><code>@patch.comment</code></pre> + </div> + @for(summary <- patch.summary) { + <h5>@Messages("repository.changes.patch.summary.title")</h5> + <div class="patch-summary"> + @if(summary.added.size > 0) { + <div class="patch-summary-added">@Messages("repository.changes.patch.summary.added", summary.added.size)</div> + <div class="patch-summary-added-details"> + <ul> + @for(filename <- summary.added) { + <li>@filename</li> + } + </ul> + </div> + } else { + <!-- No files were added with this patch. --> + } + @if(summary.modified.size > 0) { + <div class="patch-summary-modified">@Messages("repository.changes.patch.summary.modified", summary.modified.size, summary.modified.foldLeft(0)((acc, mod) => acc + mod.added), summary.modified.foldLeft(0)((acc, mod) => acc + mod.removed))</div> + <div class="patch-summary-modified-details"> + <ul> + @for(modified <- summary.modified) { + <li>@Messages("repository.changes.patch.summary.modified.details", modified.name, modified.added, modified.removed)</li> + } + </ul> + </div> + } else { + <!-- No files were modified with this patch. --> + } + @if(summary.removed.size > 0) { + <div class="patch-summary-removed">@Messages("repository.changes.patch.summary.removed", summary.removed.size)</div> + <div class="patch-summary-removed-details"> + <ul> + @for(filename <- summary.removed) { + <li>@filename</li> + } + </ul> + </div> + } else { + <!-- No files were removed with this patch. --> + } + </div> + } +</div> diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryHistory.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryHistory.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryHistory.scala.html 2025-03-10 01:29:04.057306411 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryHistory.scala.html 2025-03-10 01:29:04.061306407 +0000 @@ -1,4 +1,4 @@ -@(baseUri: Uri, lang: LanguageCode = LanguageCode("en"))(actionBaseUri: Uri, csrf: Option[CsrfToken] = None, goBackUri: Option[Uri] = None, title: Option[String] = None, user: Option[Account])(history: String, nextEntry: Option[Int], repositoryBaseUri: Uri, vcsRepository: VcsRepository) +@(baseUri: Uri, lang: LanguageCode = LanguageCode("en"))(actionBaseUri: Uri, csrf: Option[CsrfToken] = None, goBackUri: Option[Uri] = None, title: Option[String] = None, user: Option[Account])(history: List[VcsRepositoryPatchMetadata], nextEntry: Option[Int], repositoryBaseUri: Uri, vcsRepository: VcsRepository) @main(baseUri, lang)()(csrf, title, user) { @defining(lang.toLocale) { implicit locale => <div class="content"> @@ -23,7 +23,11 @@ <div class="content"> <div class="pure-g"> <div class="pure-u-1-1 pure-u-md-1-1"> - <div class="l-box"><pre><code>@history</code></pre></div> + <div class="l-box"> + @for(patch <- history) { + @repositoryPatchMetadata(patch) + } + </div> </div> <div class="pure-u-1-1 pure-u-md-1-1"> <div class="l-box"> 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-03-10 01:29:04.057306411 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryOverview.scala.html 2025-03-10 01:29:04.061306407 +0000 @@ -1,4 +1,4 @@ -@(baseUri: Uri, lang: LanguageCode = LanguageCode("en"))(actionBaseUri: Uri, csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Option[Account])(vcsRepository: VcsRepository, vcsRepositoryHistory: Option[String], vcsRepositoryReadme: Option[String] = None, vcsRepositoryReadmeFilename: Option[String] = None, vcsRepositorySshUri: Option[String] = None) +@(baseUri: Uri, lang: LanguageCode = LanguageCode("en"))(actionBaseUri: Uri, csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Option[Account])(vcsRepository: VcsRepository, vcsRepositoryHistory: List[VcsRepositoryPatchMetadata], vcsRepositoryReadme: Option[String] = None, vcsRepositoryReadmeFilename: Option[String] = None, vcsRepositorySshUri: Option[String] = None) @main(baseUri, lang)()(csrf, title, user) { @defining(lang.toLocale) { implicit locale => <div class="content"> @@ -28,7 +28,11 @@ <div class="pure-u-3-5 pure-u-md-3-5"> <div class="l-box"> <h3>@Messages("repository.overview.latest-changes")</h3> - <pre class="latest-changes"><code>@vcsRepositoryHistory</code></pre> + <div class="overview-latest-changes"> + @for(patch <- vcsRepositoryHistory) { + @repositoryPatchMetadata(patch) + } + </div> </div> </div> <div class="pure-u-2-5 pure-u-md-2-5">