diff --git a/helpers/software-report-base/Calculate-ImagesDiff.ps1 b/helpers/software-report-base/Calculate-ImagesDiff.ps1
new file mode 100644
index 00000000..6d874a04
--- /dev/null
+++ b/helpers/software-report-base/Calculate-ImagesDiff.ps1
@@ -0,0 +1,48 @@
+using module ./SoftwareReport.psm1
+using module ./SoftwareReport.Comparer.psm1
+
+<#
+.SYNOPSIS
+ Calculates the difference between two software reports and saves it to a file.
+.PARAMETER PreviousJsonReportPath
+ Path to the previous software report.
+.PARAMETER CurrentJsonReportPath
+ Path to the current software report.
+.PARAMETER OutputFile
+ Path to the file where the difference will be saved.
+#>
+
+Param (
+ [Parameter(Mandatory=$true)]
+ [string] $PreviousJsonReportPath,
+ [Parameter(Mandatory=$true)]
+ [string] $CurrentJsonReportPath,
+ [Parameter(Mandatory=$true)]
+ [string] $OutputFile
+)
+
+$ErrorActionPreference = "Stop"
+$global:ErrorView = "NormalView"
+
+function Read-SoftwareReport {
+ Param (
+ [Parameter(Mandatory=$true)]
+ [string] $JsonReportPath
+ )
+
+ if (-not (Test-Path $JsonReportPath)) {
+ throw "File '$JsonReportPath' does not exist"
+ }
+
+ $jsonReport = Get-Content -Path $JsonReportPath -Raw
+ $report = [SoftwareReport]::FromJson($jsonReport)
+ return $report
+}
+
+$previousReport = Read-SoftwareReport -JsonReportPath $PreviousJsonReportPath
+$currentReport = Read-SoftwareReport -JsonReportPath $CurrentJsonReportPath
+
+$comparer = [SoftwareReportComparer]::new($previousReport, $currentReport)
+$comparer.CompareReports()
+$diff = $comparer.GetMarkdownReport()
+$diff | Out-File -Path $OutputFile -Encoding utf8NoBOM
diff --git a/helpers/software-report-base/SoftwareReport.BaseNodes.psm1 b/helpers/software-report-base/SoftwareReport.BaseNodes.psm1
new file mode 100644
index 00000000..545b7a38
--- /dev/null
+++ b/helpers/software-report-base/SoftwareReport.BaseNodes.psm1
@@ -0,0 +1,47 @@
+############################
+### Abstract base nodes ####
+############################
+
+# Abstract base class for all nodes
+class BaseNode {
+ [Boolean] ShouldBeIncludedToDiff() {
+ return $False
+ }
+
+ [Boolean] IsSimilarTo([BaseNode] $OtherNode) {
+ throw "Abtract method 'IsSimilarTo' is not implemented for '$($this.GetType().Name)'"
+ }
+
+ [Boolean] IsIdenticalTo([BaseNode] $OtherNode) {
+ throw "Abtract method 'IsIdenticalTo' is not implemented for '$($this.GetType().Name)'"
+ }
+}
+
+# Abstract base class for all nodes that describe a tool and should be rendered inside diff table
+class BaseToolNode: BaseNode {
+ [String] $ToolName
+
+ BaseToolNode([String] $ToolName) {
+ $this.ToolName = $ToolName
+ }
+
+ [Boolean] ShouldBeIncludedToDiff() {
+ return $True
+ }
+
+ [String] GetValue() {
+ throw "Abtract method 'GetValue' is not implemented for '$($this.GetType().Name)'"
+ }
+
+ [Boolean] IsSimilarTo([BaseNode] $OtherNode) {
+ if ($this.GetType() -ne $OtherNode.GetType()) {
+ return $False
+ }
+
+ return $this.ToolName -eq $OtherNode.ToolName
+ }
+
+ [Boolean] IsIdenticalTo([BaseNode] $OtherNode) {
+ return $this.IsSimilarTo($OtherNode) -and ($this.GetValue() -eq $OtherNode.GetValue())
+ }
+}
\ No newline at end of file
diff --git a/helpers/software-report-base/SoftwareReport.Comparer.psm1 b/helpers/software-report-base/SoftwareReport.Comparer.psm1
new file mode 100644
index 00000000..8adb7332
--- /dev/null
+++ b/helpers/software-report-base/SoftwareReport.Comparer.psm1
@@ -0,0 +1,303 @@
+using module ./SoftwareReport.psm1
+using module ./SoftwareReport.BaseNodes.psm1
+using module ./SoftwareReport.Nodes.psm1
+
+
+# SoftwareReportComparer is used to calculate differences between two SoftwareReport objects
+class SoftwareReportComparer {
+ hidden [SoftwareReport] $PreviousReport
+ hidden [SoftwareReport] $CurrentReport
+
+ hidden [Collections.Generic.List[ReportDifferenceItem]] $AddedItems
+ hidden [Collections.Generic.List[ReportDifferenceItem]] $ChangedItems
+ hidden [Collections.Generic.List[ReportDifferenceItem]] $DeletedItems
+
+ SoftwareReportComparer([SoftwareReport] $PreviousReport, [SoftwareReport] $CurrentReport) {
+ $this.PreviousReport = $PreviousReport
+ $this.CurrentReport = $CurrentReport
+ }
+
+ [void] CompareReports() {
+ $this.AddedItems = @()
+ $this.ChangedItems = @()
+ $this.DeletedItems = @()
+
+ $this.CompareInternal($this.PreviousReport.Root, $this.CurrentReport.Root, @())
+ }
+
+ hidden [void] CompareInternal([HeaderNode] $previousReportPointer, [HeaderNode] $currentReportPointer, [Array] $Headers) {
+ $currentReportPointer.Children ?? @() | Where-Object { $_.ShouldBeIncludedToDiff() -and $this.FilterExcludedNodes($_) } | ForEach-Object {
+ $currentReportNode = $_
+ $sameNodeInPreviousReport = $previousReportPointer ? $previousReportPointer.FindSimilarChildNode($currentReportNode) : $null
+
+ if ($currentReportNode -is [HeaderNode]) {
+ $this.CompareInternal($sameNodeInPreviousReport, $currentReportNode, $Headers + $currentReportNode.Title)
+ } else {
+ if ($sameNodeInPreviousReport -and ($currentReportNode.IsIdenticalTo($sameNodeInPreviousReport))) {
+ # Nodes are identical, nothing changed, just ignore it
+ } elseif ($sameNodeInPreviousReport) {
+ # Nodes are equal but not identical, so something was changed
+ $this.ChangedItems.Add([ReportDifferenceItem]::new($sameNodeInPreviousReport, $currentReportNode, $Headers))
+ } else {
+ # Node was not found in previous report, new node was added
+ $this.AddedItems.Add([ReportDifferenceItem]::new($null, $currentReportNode, $Headers))
+ }
+ }
+ }
+
+ # Detecting nodes that were removed
+ $previousReportPointer.Children ?? @() | Where-Object { $_.ShouldBeIncludedToDiff() -and $this.FilterExcludedNodes($_) } | ForEach-Object {
+ $previousReportNode = $_
+ $sameNodeInCurrentReport = $currentReportPointer ? $currentReportPointer.FindSimilarChildNode($previousReportNode) : $null
+
+ if (-not $sameNodeInCurrentReport) {
+ if ($previousReportNode -is [HeaderNode]) {
+ $this.CompareInternal($previousReportNode, $null, $Headers + $previousReportNode.Title)
+ } else {
+ $this.DeletedItems.Add([ReportDifferenceItem]::new($previousReportNode, $null, $Headers))
+ }
+ }
+ }
+ }
+
+ [String] GetMarkdownReport() {
+ $reporter = [SoftwareReportComparerReport]::new()
+ $report = $reporter.GenerateMarkdownReport($this.CurrentReport, $this.PreviousReport, $this.AddedItems, $this.ChangedItems, $this.DeletedItems)
+ return $report
+ }
+
+ hidden [Boolean] FilterExcludedNodes([BaseNode] $Node) {
+ # We shouldn't show "Image Version" diff because it is already shown in report header
+ if (($Node -is [ToolNode]) -and ($Node.ToolName -eq "Image Version:")) {
+ return $False
+ }
+
+ return $True
+ }
+}
+
+# SoftwareReportComparerReport is used to render results of SoftwareReportComparer in markdown format
+class SoftwareReportComparerReport {
+ [String] GenerateMarkdownReport([SoftwareReport] $CurrentReport, [SoftwareReport] $PreviousReport, [ReportDifferenceItem[]] $AddedItems, [ReportDifferenceItem[]] $ChangedItems, [ReportDifferenceItem[]] $DeletedItems) {
+ $sb = [System.Text.StringBuilder]::new()
+
+ $rootNode = $CurrentReport.Root
+ $imageVersion = $this.GetImageVersion($CurrentReport)
+ $previousImageVersion = $this.GetImageVersion($PreviousReport)
+
+ #############################
+ ### Render report header ####
+ #############################
+
+ $sb.AppendLine("# :desktop_computer: $($rootNode.Title)")
+
+ # ToolNodes on root level contains main image description so just copy-paste them to final report
+ $rootNode.Children | Where-Object { $_ -is [BaseToolNode] } | ForEach-Object {
+ $sb.AppendLine($_.ToMarkdown(0))
+ }
+ $sb.AppendLine()
+
+ $sb.AppendLine("## :mega: What's changed?")
+
+ ###########################
+ ### Render added items ####
+ ###########################
+
+ [ReportDifferenceItem[]] $addedItemsExcludeTables = $AddedItems | Where-Object { $_.IsBaseToolNode() }
+ if ($addedItemsExcludeTables.Count -gt 0) {
+ $tableItems = $addedItemsExcludeTables | ForEach-Object {
+ [PSCustomObject]@{
+ "Category" = $this.RenderCategory($_.Headers, $True);
+ "Tool name" = $this.RenderToolName($_.CurrentReportNode.ToolName);
+ "Current ($imageVersion)" = $_.CurrentReportNode.GetValue();
+ }
+ }
+
+ $sb.AppendLine("### Added :heavy_plus_sign:")
+ $sb.AppendLine($this.RenderHtmlTable($tableItems, "Category"))
+ $sb.AppendLine()
+ }
+
+ # Render added tables separately
+ $AddedItems | Where-Object { $_.IsTableNode() } | ForEach-Object {
+ $sb.AppendLine($this.RenderTableNodesDiff($_))
+ }
+
+ #############################
+ ### Render deleted items ####
+ #############################
+
+ [ReportDifferenceItem[]] $deletedItemsExcludeTables = $DeletedItems | Where-Object { $_.IsBaseToolNode() }
+ if ($deletedItemsExcludeTables.Count -gt 0) {
+ $tableItems = $deletedItemsExcludeTables | ForEach-Object {
+ [PSCustomObject]@{
+ "Category" = $this.RenderCategory($_.Headers, $True);
+ "Tool name" = $this.RenderToolName($_.PreviousReportNode.ToolName);
+ "Previous ($previousImageVersion)" = $_.PreviousReportNode.GetValue();
+ }
+ }
+
+ $sb.AppendLine("### Deleted :heavy_minus_sign:")
+ $sb.AppendLine($this.RenderHtmlTable($tableItems, "Category"))
+ $sb.AppendLine()
+ }
+
+ #############################
+ ### Render updated items ####
+ #############################
+
+ [ReportDifferenceItem[]] $changedItemsExcludeTables = $ChangedItems | Where-Object { $_.IsBaseToolNode() }
+ if ($changedItemsExcludeTables.Count -gt 0) {
+ $tableItems = $changedItemsExcludeTables | ForEach-Object {
+ [PSCustomObject]@{
+ "Category" = $this.RenderCategory($_.Headers, $True);
+ "Tool name" = $this.RenderToolName($_.CurrentReportNode.ToolName);
+ "Previous ($previousImageVersion)" = $_.PreviousReportNode.GetValue();
+ "Current ($imageVersion)" = $_.CurrentReportNode.GetValue();
+ }
+ }
+
+ $sb.AppendLine("### Updated")
+ $sb.AppendLine($this.RenderHtmlTable($tableItems, "Category"))
+ $sb.AppendLine()
+ }
+
+ # Render updated tables separately
+ $ChangedItems | Where-Object { $_.IsTableNode() } | ForEach-Object {
+ $sb.AppendLine($this.RenderTableNodesDiff($_))
+ }
+
+ # Render deleted tables separately
+ $DeletedItems | Where-Object { $_.IsTableNode() } | ForEach-Object {
+ $sb.AppendLine($this.RenderTableNodesDiff($_))
+ }
+
+ return $sb.ToString()
+ }
+
+ [String] RenderHtmlTable([PSCustomObject[]] $Table, $RowSpanColumnName) {
+ $headers = $Table[0].PSObject.Properties.Name
+
+ $sb = [System.Text.StringBuilder]::new()
+ $sb.AppendLine("
")
+ $sb.AppendLine(" ")
+ $headers | ForEach-Object {
+ $sb.AppendLine(" | $_ | ")
+ }
+ $sb.AppendLine(" ")
+ $sb.AppendLine(" ")
+
+
+ $tableRowSpans = $this.CalculateHtmlTableRowSpan($Table, $RowSpanColumnName)
+ for ($rowIndex = 0; $rowIndex -lt $Table.Count; $rowIndex++) {
+ $row = $Table[$rowIndex]
+
+ $sb.AppendLine(" ")
+ $headers | ForEach-Object {
+ if ($_ -eq $RowSpanColumnName) {
+ if ($tableRowSpans[$rowIndex] -gt 0) {
+ $sb.AppendLine(" | $($row.$_) | ")
+ } else {
+ # Skip rendering this cell at all
+ }
+ } else {
+ $sb.AppendLine(" $($row.$_) | ")
+ }
+ }
+ $sb.AppendLine("
")
+ }
+ $sb.AppendLine(" ")
+ $sb.AppendLine("
")
+
+ return $sb.ToString()
+ }
+
+ [int[]] CalculateHtmlTableRowSpan([PSCustomObject[]] $Table, $keyColumn) {
+ $result = @(0) * $Table.Count
+
+ for ($rowIndex = $Table.Count - 1; $rowIndex -ge 0; $rowIndex--) {
+ if (($rowIndex -lt ($Table.Count - 1)) -and ($Table[$rowIndex].$keyColumn -eq $Table[$rowIndex + 1].$keyColumn)) {
+ # If the current row is the same as the next row
+ # Then rowspan of current row should be equal to rowspan of the next row + 1
+ # And rowspan of the next row should be 0 because it is already included in the rowspan of the current row
+ $result[$rowIndex] = $result[$rowIndex + 1] + 1
+ $result[$rowIndex + 1] = 0
+ } else {
+ $result[$rowIndex] = 1
+ }
+ }
+
+ return $result
+ }
+
+ [String] RenderTableNodesDiff([ReportDifferenceItem] $DiffItem) {
+ # Use the simplest approach for now: first, print all removed lines. Then print added lines
+ # It will work well for most cases like changing existing rows, adding new rows and removing rows
+ # But can produce not so pretty results for cases when some rows are changed and some rows are added at the same time
+ # Let's see how it works in practice and improve it later if needed
+
+ [String] $tableHeaders = ($DiffItem.CurrentReportNode ?? $DiffItem.PreviousReportNode).Headers
+ [System.Collections.ArrayList] $tableRows = @()
+ $DiffItem.PreviousReportNode.Rows ?? @() | Where-Object { $_ -notin $DiffItem.CurrentReportNode.Rows } | ForEach-Object {
+ $tableRows.Add($this.StrikeTableRow($_))
+ }
+ $DiffItem.CurrentReportNode.Rows ?? @() | Where-Object { $_ -notin $DiffItem.PreviousReportNode.Rows } | ForEach-Object {
+ $tableRows.Add($_)
+ }
+
+ $sb = [System.Text.StringBuilder]::new()
+ $sb.AppendLine("#### $($this.RenderCategory($DiffItem.Headers, $False))")
+ $sb.AppendLine([TableNode]::new($tableHeaders, $tableRows).ToMarkdown(0))
+ return $sb.ToString()
+ }
+
+ [String] RenderCategory([Array] $Headers, [Boolean] $AddLineSeparator) {
+ # Always skip the first header because it is "Installed Software"
+ [Array] $takeHeaders = $Headers | Select-Object -Skip 1
+ if ($takeHeaders.Count -eq 0) {
+ return ""
+ }
+
+ $lineSeparator = $AddLineSeparator ? "
": ""
+ return [String]::Join(" >$lineSeparator ", $takeHeaders)
+ }
+
+ [String] RenderToolName([String] $ToolName) {
+ return $ToolName.TrimEnd(":")
+ }
+
+ [String] StrikeTableRow([String] $Row) {
+ # Convert "a|b|c" to "~~a~~|~~b~~|~~c~~
+ $cells = $Row.Split("|")
+ $strikedCells = $cells | ForEach-Object { "~~$($_)~~"}
+ return [String]::Join("|", $strikedCells)
+ }
+
+ [String] GetImageVersion([SoftwareReport] $Report) {
+ $imageVersionNode = $Report.Root.Children ?? @() | Where-Object { ($_ -is [ToolNode]) -and ($_.ToolName -eq "Image Version:") } | Select-Object -First 1
+ return $imageVersionNode.Version ?? "Unknown version"
+ }
+}
+
+# Temporary structure to store the single difference between two reports
+class ReportDifferenceItem {
+ [BaseNode] $PreviousReportNode
+ [BaseNode] $CurrentReportNode
+ [Array] $Headers
+
+ ReportDifferenceItem([BaseNode] $PreviousReportNode, [BaseNode] $CurrentReportNode, [Array] $Headers) {
+ $this.PreviousReportNode = $PreviousReportNode
+ $this.CurrentReportNode = $CurrentReportNode
+ $this.Headers = $Headers
+ }
+
+ [Boolean] IsBaseToolNode() {
+ $node = $this.CurrentReportNode ?? $this.PreviousReportNode
+ return $node -is [BaseToolNode]
+ }
+
+ [Boolean] IsTableNode() {
+ $node = $this.CurrentReportNode ?? $this.PreviousReportNode
+ return $node -is [TableNode]
+ }
+}
\ No newline at end of file
diff --git a/helpers/software-report-base/SoftwareReport.Nodes.psm1 b/helpers/software-report-base/SoftwareReport.Nodes.psm1
new file mode 100644
index 00000000..1385782a
--- /dev/null
+++ b/helpers/software-report-base/SoftwareReport.Nodes.psm1
@@ -0,0 +1,334 @@
+using module ./SoftwareReport.BaseNodes.psm1
+
+#########################################
+### Nodes to describe image software ####
+#########################################
+
+# NodesFactory is used to simplify parsing different types of notes
+# Every node has own logic of parsing and this method just invokes "FromJsonObject" of correct node type
+class NodesFactory {
+ static [BaseNode] ParseNodeFromObject($jsonObj) {
+ if ($jsonObj.NodeType -eq [HeaderNode].Name) {
+ return [HeaderNode]::FromJsonObject($jsonObj)
+ } elseif ($jsonObj.NodeType -eq [ToolNode].Name) {
+ return [ToolNode]::FromJsonObject($jsonObj)
+ } elseif ($jsonObj.NodeType -eq [ToolVersionsNode].Name) {
+ return [ToolVersionsNode]::FromJsonObject($jsonObj)
+ } elseif ($jsonObj.NodeType -eq [TableNode].Name) {
+ return [TableNode]::FromJsonObject($jsonObj)
+ } elseif ($jsonObj.NodeType -eq [NoteNode].Name) {
+ return [NoteNode]::FromJsonObject($jsonObj)
+ }
+
+ throw "Unknown node type in ParseNodeFromObject '$($jsonObj.NodeType)'"
+ }
+}
+
+# Node type to describe headers: "## Installed software"
+class HeaderNode: BaseNode {
+ [String] $Title
+ [System.Collections.ArrayList] $Children
+
+ HeaderNode([String] $Title) {
+ $this.Title = $Title
+ $this.Children = @()
+ }
+
+ [Boolean] ShouldBeIncludedToDiff() {
+ return $True
+ }
+
+ [void] AddNode([BaseNode] $node) {
+ $similarNode = $this.FindSimilarChildNode($node)
+ if ($similarNode) {
+ throw "This HeaderNode already contains the similar child node. It is not allowed to add the same node twice.`nFound node: $($similarNode.ToJsonObject() | ConvertTo-Json)`nNew node: $($node.ToJsonObject() | ConvertTo-Json)"
+ }
+
+ $this.Children.Add($node)
+ }
+
+ [void] AddNodes([Array] $nodes) {
+ $nodes | ForEach-Object {
+ $this.AddNode($_)
+ }
+ }
+
+ [HeaderNode] AddHeaderNode([String] $Title) {
+ $node = [HeaderNode]::new($Title)
+ $this.AddNode($node)
+ return $node
+ }
+
+ [void] AddToolNode([String] $ToolName, [String] $Version) {
+ $this.AddNode([ToolNode]::new($ToolName, $Version))
+ }
+
+ [void] AddToolVersionsNode([String] $ToolName, [Array] $Version) {
+ $this.AddNode([ToolVersionsNode]::new($ToolName, $Version))
+ }
+
+ [void] AddTableNode([Array] $Table) {
+ $this.AddNode([TableNode]::FromObjectsArray($Table))
+ }
+
+ [void] AddNoteNode([String] $Content) {
+ $this.AddNode([NoteNode]::new($Content))
+ }
+
+ [String] ToMarkdown($level) {
+ $sb = [System.Text.StringBuilder]::new()
+ $sb.AppendLine()
+ $sb.AppendLine("$("#" * $level) $($this.Title)")
+ $this.Children | ForEach-Object {
+ $sb.AppendLine($_.ToMarkdown($level + 1))
+ }
+
+ return $sb.ToString().TrimEnd()
+ }
+
+ [PSCustomObject] ToJsonObject() {
+ return [PSCustomObject]@{
+ NodeType = $this.GetType().Name
+ Title = $this.Title
+ Children = $this.Children | ForEach-Object { $_.ToJsonObject() }
+ }
+ }
+
+ static [HeaderNode] FromJsonObject($jsonObj) {
+ $node = [HeaderNode]::new($jsonObj.Title)
+ $jsonObj.Children | Where-Object { $_ } | ForEach-Object { $node.AddNode([NodesFactory]::ParseNodeFromObject($_)) }
+ return $node
+ }
+
+ [Boolean] IsSimilarTo([BaseNode] $OtherNode) {
+ if ($OtherNode.GetType() -ne [HeaderNode]) {
+ return $false
+ }
+
+ return $this.Title -eq $OtherNode.Title
+ }
+
+ [Boolean] IsIdenticalTo([BaseNode] $OtherNode) {
+ return $this.IsSimilarTo($OtherNode)
+ }
+
+ [BaseNode] FindSimilarChildNode([BaseNode] $Find) {
+ foreach ($childNode in $this.Children) {
+ if ($childNode.IsSimilarTo($Find)) {
+ return $childNode
+ }
+ }
+
+ return $null
+ }
+}
+
+# Node type to describe the tool with single version: "Bash 5.1.16"
+class ToolNode: BaseToolNode {
+ [String] $Version
+
+ ToolNode([String] $ToolName, [String] $Version): base($ToolName) {
+ $this.Version = $Version
+ }
+
+ [String] ToMarkdown($level) {
+ return "- $($this.ToolName) $($this.Version)"
+ }
+
+ [String] GetValue() {
+ return $this.Version
+ }
+
+ [PSCustomObject] ToJsonObject() {
+ return [PSCustomObject]@{
+ NodeType = $this.GetType().Name
+ ToolName = $this.ToolName
+ Version = $this.Version
+ }
+ }
+
+ static [BaseNode] FromJsonObject($jsonObj) {
+ return [ToolNode]::new($jsonObj.ToolName, $jsonObj.Version)
+ }
+}
+
+# Node type to describe the tool with multiple versions "Toolcache Node.js 14.17.6 16.2.0 18.2.3"
+class ToolVersionsNode: BaseToolNode {
+ [Array] $Versions
+
+ ToolVersionsNode([String] $ToolName, [Array] $Versions): base($ToolName) {
+ $this.Versions = $Versions
+ }
+
+ [String] ToMarkdown($level) {
+ $sb = [System.Text.StringBuilder]::new()
+ $sb.AppendLine()
+ $sb.AppendLine("$("#" * $level) $($this.ToolName)")
+ $this.Versions | ForEach-Object {
+ $sb.AppendLine("- $_")
+ }
+
+ return $sb.ToString().TrimEnd()
+ }
+
+ [String] GetValue() {
+ return $this.Versions -join ', '
+ }
+
+ [PSCustomObject] ToJsonObject() {
+ return [PSCustomObject]@{
+ NodeType = $this.GetType().Name
+ ToolName = $this.ToolName
+ Versions = $this.Versions
+ }
+ }
+
+ static [ToolVersionsNode] FromJsonObject($jsonObj) {
+ return [ToolVersionsNode]::new($jsonObj.ToolName, $jsonObj.Versions)
+ }
+}
+
+# Node type to describe tables
+class TableNode: BaseNode {
+ # It is easier to store the table as rendered lines because it will simplify finding differences in rows later
+ [String] $Headers
+ [System.Collections.ArrayList] $Rows
+
+ TableNode($Headers, $Rows) {
+ $this.Headers = $Headers
+ $this.Rows = $Rows
+ }
+
+ [Boolean] ShouldBeIncludedToDiff() {
+ return $True
+ }
+
+ static [TableNode] FromObjectsArray([Array] $Table) {
+ # take column names from the first row in table because we expect all rows to have the same columns
+ [String] $tableHeaders = [TableNode]::ArrayToTableRow($Table[0].PSObject.Properties.Name)
+ [System.Collections.ArrayList] $tableRows = @()
+
+ $Table | ForEach-Object {
+ $tableRows.Add([TableNode]::ArrayToTableRow($_.PSObject.Properties.Value))
+ }
+
+ return [TableNode]::new($tableHeaders, $tableRows)
+ }
+
+ [String] ToMarkdown($level) {
+ $maxColumnWidths = $this.Headers.Split("|") | ForEach-Object { $_.Length }
+ $columnsCount = $maxColumnWidths.Count
+
+ $this.Rows | ForEach-Object {
+ $columnWidths = $_.Split("|") | ForEach-Object { $_.Length }
+ for ($colIndex = 0; $colIndex -lt $columnsCount; $colIndex++) {
+ $maxColumnWidths[$colIndex] = [Math]::Max($maxColumnWidths[$colIndex], $columnWidths[$colIndex])
+ }
+ }
+
+ $delimeterLine = [String]::Join("|", @("-") * $columnsCount)
+
+ $sb = [System.Text.StringBuilder]::new()
+ @($this.Headers) + @($delimeterLine) + $this.Rows | ForEach-Object {
+ $sb.Append("|")
+ $row = $_.Split("|")
+
+ for ($colIndex = 0; $colIndex -lt $columnsCount; $colIndex++) {
+ $padSymbol = $row[$colIndex] -eq "-" ? "-" : " "
+ $cellContent = $row[$colIndex].PadRight($maxColumnWidths[$colIndex], $padSymbol)
+ $sb.Append(" $($cellContent) |")
+ }
+
+ $sb.AppendLine()
+ }
+
+ return $sb.ToString().TrimEnd()
+ }
+
+ [PSCustomObject] ToJsonObject() {
+ return [PSCustomObject]@{
+ NodeType = $this.GetType().Name
+ Headers = $this.Headers
+ Rows = $this.Rows
+ }
+ }
+
+ static [TableNode] FromJsonObject($jsonObj) {
+ return [TableNode]::new($jsonObj.Headers, $jsonObj.Rows)
+ }
+
+ [Boolean] IsSimilarTo([BaseNode] $OtherNode) {
+ if ($OtherNode.GetType() -ne [TableNode]) {
+ return $false
+ }
+
+ # We don't support having multiple TableNode instances on the same header level so such check is fine
+ return $true
+ }
+
+ [Boolean] IsIdenticalTo([BaseNode] $OtherNode) {
+ if (-not $this.IsSimilarTo($OtherNode)) {
+ return $false
+ }
+
+ if ($this.Headers -ne $OtherNode.Headers) {
+ return $false
+ }
+
+ if ($this.Rows.Count -ne $OtherNode.Rows.Count) {
+ return $false
+ }
+
+ for ($rowIndex = 0; $rowIndex -lt $this.Rows.Count; $rowIndex++) {
+ if ($this.Rows[$rowIndex] -ne $OtherNode.Rows[$rowIndex]) {
+ return $false
+ }
+ }
+
+ return $true
+ }
+
+ hidden static [String] ArrayToTableRow([Array] $Values) {
+ # TO-DO: Add validation for the case when $Values contains "|"
+ return [String]::Join("|", $Values)
+ }
+}
+
+class NoteNode: BaseNode {
+ [String] $Content
+
+ NoteNode([String] $Content) {
+ $this.Content = $Content
+ }
+
+ [String] ToMarkdown($level) {
+ return @(
+ '```',
+ $this.Content,
+ '```'
+ ) -join "`n"
+ }
+
+ [PSCustomObject] ToJsonObject() {
+ return [PSCustomObject]@{
+ NodeType = $this.GetType().Name
+ Content = $this.Content
+ }
+ }
+
+ static [NoteNode] FromJsonObject($jsonObj) {
+ return [NoteNode]::new($jsonObj.Content)
+ }
+
+ [Boolean] IsSimilarTo([BaseNode] $OtherNode) {
+ if ($OtherNode.GetType() -ne [NoteNode]) {
+ return $false
+ }
+
+ return $this.Content -eq $OtherNode.Content
+ }
+
+ [Boolean] IsIdenticalTo([BaseNode] $OtherNode) {
+ return $this.IsSimilarTo($OtherNode)
+ }
+}
diff --git a/helpers/software-report-base/SoftwareReport.psm1 b/helpers/software-report-base/SoftwareReport.psm1
new file mode 100644
index 00000000..81f3cfd9
--- /dev/null
+++ b/helpers/software-report-base/SoftwareReport.psm1
@@ -0,0 +1,28 @@
+using module ./SoftwareReport.BaseNodes.psm1
+using module ./SoftwareReport.Nodes.psm1
+
+class SoftwareReport {
+ [HeaderNode] $Root
+
+ SoftwareReport([String] $Title) {
+ $this.Root = [HeaderNode]::new($Title)
+ }
+
+ SoftwareReport([HeaderNode] $Root) {
+ $this.Root = $Root
+ }
+
+ [String] ToJson() {
+ return $this.Root.ToJsonObject() | ConvertTo-Json -Depth 10
+ }
+
+ static [SoftwareReport] FromJson($jsonString) {
+ $jsonObj = $jsonString | ConvertFrom-Json
+ $rootNode = [NodesFactory]::ParseNodeFromObject($jsonObj)
+ return [SoftwareReport]::new($rootNode)
+ }
+
+ [String] ToMarkdown() {
+ return $this.Root.ToMarkdown(1).Trim()
+ }
+}
\ No newline at end of file