From 6eaa5b44cf713a81815b6af4a84ee820aced144e Mon Sep 17 00:00:00 2001 From: Maxim Lobanov Date: Wed, 7 Dec 2022 14:20:14 +0100 Subject: [PATCH] Implement Software Report Base Module (#6707) * Implement first version * fix tables rendering * Fix scripts * update test files * implement calculate image diff script * Polish code and make e2e validation * remove test files * render add and removed firstly --- .../Calculate-ImagesDiff.ps1 | 48 +++ .../SoftwareReport.BaseNodes.psm1 | 47 +++ .../SoftwareReport.Comparer.psm1 | 303 ++++++++++++++++ .../SoftwareReport.Nodes.psm1 | 334 ++++++++++++++++++ .../software-report-base/SoftwareReport.psm1 | 28 ++ 5 files changed, 760 insertions(+) create mode 100644 helpers/software-report-base/Calculate-ImagesDiff.ps1 create mode 100644 helpers/software-report-base/SoftwareReport.BaseNodes.psm1 create mode 100644 helpers/software-report-base/SoftwareReport.Comparer.psm1 create mode 100644 helpers/software-report-base/SoftwareReport.Nodes.psm1 create mode 100644 helpers/software-report-base/SoftwareReport.psm1 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(" ") + } else { + # Skip rendering this cell at all + } + } else { + $sb.AppendLine(" ") + } + } + $sb.AppendLine(" ") + } + $sb.AppendLine(" ") + $sb.AppendLine("
$_
$($row.$_)$($row.$_)
") + + 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