HtmlReport.psm1

$PSModuleRoot = $PSScriptRoot

enum Emphasis {
  default
  primary
  success
  info
  warning
  danger
}

enum ChartType {
  LineChart
  PieChart
  ColumnChart
  BarChart
  AreaChart
  ScatterChart
  GeoChart
  Timeline
}

class DataWrapper {
    DataWrapper([string]$Title, [PSObject]$Data) {
        $this.Title = $Title
        $this.Data = $Data
    }

    DataWrapper([string]$Title, [PSObject]$Data, [string]$Description) {
        $this.Title = $Title
        $this.Description = $Description
        $this.Data = $Data
    }

    DataWrapper([string]$Title, [PSObject]$Data, [string]$Description, [Emphasis]$Emphasis) {
        $this.Title = $Title
        $this.Description = $Description
        $this.Emphasis = $Emphasis
        $this.Data = $Data
    }

    [string]$Title = ""
    [string]$Description = ""
    [Emphasis]$Emphasis = "default"
    [PSObject]$Data = $null
}

class Table : DataWrapper {
    Table([string]$Title, [PSObject]$Data) : base($Title, $Data) {}
    Table([string]$Title, [PSObject]$Data, [string]$Description) : base($Title, $Data, $Description) {}
    Table([string]$Title, [PSObject]$Data, [string]$Description, [Emphasis]$Emphasis) : base($Title, $Data, $Description, $Emphasis) {}
}

class Chart : DataWrapper {
    Chart([string]$Title, [PSObject]$Data) : base($Title, $Data) {}
    Chart([string]$Title, [PSObject]$Data, [string]$Description) : base($Title, $Data, $Description) {}
    Chart([string]$Title, [PSObject]$Data, [string]$Description, [Emphasis]$Emphasis) : base($Title, $Data, $Description, $Emphasis) {}
    [ChartType]$ChartType = "Line"
}function New-Chart {
    #.Synopsis
    # Creates a new ChartData for New-Report
    #.Description
    # Collects ChartData for New-Report.
    # ChartData should be in specific shapes in order to work properly, but it depends somewhat on the chart type you're trying to create (LineChart, PieChart, ColumnChart, BarChart, AreaChart, ScatterChart, GeoChart, Timeline). There should be examples for each in the help below...
    #.Notes
    # TODO: Write examples...
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param(
        # Chart Type
        [ChartType]$ChartType,

        # A title that goes on the top of the table
        [Parameter(Mandatory)]
        [string]$Title,

        # Description to go above the table
        [Parameter()]
        [string]$Description,

        # Data for the table (can be piped in)
        [Parameter(Mandatory,ValueFromPipeline)]
        [PSObject]$InputObject,

        # Emphasis value: default (unadorned), primary (highlighted), success (green), info (blue), warning (yellow), danger (red)
        [Parameter()]
        [Emphasis]$Emphasis = "default"
    )
    begin {
        $ChartData = @()
    }
    process {
        $ChartData += $InputObject
    }
    end {
        $Chart = [Chart]::new($Title, [PSObject]$ChartData, $Description, $Emphasis)
        $Chart.ChartType = $ChartType
        $Chart
    }
} $TemplatePath = Join-Path $PSModuleRoot Templates

$ChartTemplate = @'
    <div class="card panel ${Emphasis}">
        <div class="panel-header"><h4 class="panel-title">${Title}</h4></div>
        <div class="panel-body" id="chart${id}"></div>
        <div class="panel-body">${Description}</div>
    </div>
    <script>
        $(function() {
            new Chartkick.${ChartType}("chart${id}", ${data});
        })
    </script>
'@


$TableTemplate = @'
    <div class="row">
        <div class="col-xs-16">
            <div class="panel ${emphasis}">
                <div class="panel-heading"><h4 class="panel-title">${Title}</h4></div>
                <div class="panel-body">${Description}</div>
                <table class="table table-striped">
                ${data}
                </table>
            </div>
        </div>
    </div>
'@

function New-Report {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param(
        # The template to use for the report (must exist in templates folder)
        [Parameter()]
        [string]
        $Template = "template.html",

        # The title of the report
        [Parameter(ValueFromPipelineByPropertyName)]
        [string]
        $Title,
        
        # A sentence or two describing the report
        [Parameter(ValueFromPipelineByPropertyName)]
        [string]
        $Description,

        # The author of the report
        [Parameter(ValueFromPipelineByPropertyName)]
        [string]
        $Author=${Env:UserName},

        [Parameter(ValueFromPipeline)]
        $InputObject
    )
    
    begin {
        Write-Debug "Beginning $($PSBoundParameters | Out-String)"
        if($Template -notmatch "\.html$") { $Template += ".html" }
        if(!(Test-Path $Template)) {
            $Template = Join-Path $TemplatePath $Template
            if(!(Test-Path $Template)) {
                Write-Error "Template file not found in Templates: $Template"
            }
        }

        $TemplateContent = Get-Content $Template -Raw
        $FinalTable = @()
        $FinalChart = @()
        $Index = 0
        $Finished = $false

        if($InputObject -is [ScriptBlock]) {
            $null = $PSBoundParameters.Remove("InputObject")
            & $InputObject | New-Report @PSBoundParameters
            $Finished = $true
            return
        }
    }
    
    process {
        if($Finished) { return }
        Write-Debug "Processing $($_ | Out-String)"
        if($Title) {
            $FinalTitle = [System.Security.SecurityElement]::Escape($Title)
        }
        if($Description) {
            $FinalDescription = [System.Security.SecurityElement]::Escape($Description)
        }
        if($Author) {
            $FinalAuthor = [System.Security.SecurityElement]::Escape($Author)
        }
        
        if($InputObject -is [ScriptBlock]) {
            $Data = & $InputObject
        }
        elseif($InputObject -is [DataWrapper]) {
            if($InputObject.Data -is [ScriptBlock]) {
                $Data = & $InputObject.Data
            } else {
                $Data = $InputObject.Data
            }
        }
        else {
            $Data = $InputObject
        }

        if($InputObject -is [Table]) {
            $Data = $Data | Microsoft.PowerShell.Utility\ConvertTo-Html -As Table -Fragment
            # Make sure each row is on a line, and the headers are called out properly
            Write-Verbose "Table with $($Data.Count) rows of data."
            $Data = "<thead>`n{0}`n</thead>`n<tbody>`n{1}`n</tbody>" -f $Data[2], ($Data[3..($Data.Count - 2)] -join "`n")

            $Table = $TableTemplate -replace '\${Title}', $InputObject.Title `
                                    -replace '\${Description}', $InputObject.Description `
                                    -replace '\${Emphasis}', $("panel-" + $InputObject.Emphasis) `
                                    -replace '\${Data}', $Data

            $FinalTable += $Table
        }
        if($InputObject -is [Chart]) {
            Write-Verbose "$ChartType Chart with $($Data.Count) data.points"
            if($Data -isnot [string]) {
                # Microsoft's ConvertTo-Json doesn't handle PSObject unwrapping properly
                # https://windowsserver.uservoice.com/forums/301869-powershell/suggestions/15123162-convertto-json-doesn-t-serialize-simple-objects-pr
                # To bypass this bug, we must round-trip through the CliXml serializer
                $TP = [IO.Path]::GetTempFileName()
                Export-CliXml -InputObject $Data -LiteralPath $TP
                $Data =Import-CliXml -LiteralPath $TP | ConvertTo-json
                Remove-Item $TP
                # $Data = Microsoft.PowerShell.Utility\ConvertTo-Json -InputObject $Data
            }

            $Chart = $ChartTemplate -replace '\${Title}', $InputObject.Title `
                                    -replace '\${Description}', $InputObject.Description `
                                    -replace '\${Emphasis}', $("panel-" + $InputObject.Emphasis) `
                                    -replace '\${ChartType}', $InputObject.ChartType `
                                    -replace '\${Data}', $Data `
                                    -replace '\${id}', ($Index++)

            $FinalChart += $Chart
        }
    }
    
    end {
        if($Finished) { return }
        Write-Debug "Ending $($PSBoundParameters | Out-String)"
        $Output = $TemplateContent -replace '\${Title}', $FinalTitle `
                              -replace '\${Description}', $FinalDescription `
                              -replace '\${Author}', $FinalAuthor `
                              -replace '\${Tables}', ($FinalTable -join "`n`n") `
                              -replace '\${Charts}', ($FinalChart -join "`n")
        Write-Output $Output
    }
} function New-Table {
    #.Synopsis
    # Creates a new TableData object for rendering in New-Report
    #.Example
    # Get-ChildItem C:\Users -Directory |
    # Select LastWriteTime, @{Name="Length"; Expression={
    # (Get-ChildItem $_.FullName -Recurse -File -Force | Measure Length -Sum).Sum
    # } }, Name |
    # New-Table -Title $Pwd -Description "Full file listing from $($Pwd.Name)"
    #
    # Collect the list of user directories and measure the size of each
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param(
        # A title that goes on the top of the table
        [Parameter(Mandatory)]
        [string]$Title,

        # Description to go above the table
        [Parameter()]
        [string]$Description,

        # Data for the table (can be piped in)
        [Parameter(Mandatory,ValueFromPipeline)]
        [PSObject]$InputObject,

        # Emphasis value: default (unadorned), primary (highlighted), success (green), info (blue), warning (yellow), danger (red)
        [Parameter()]
        [Emphasis]$Emphasis = "primary"
    )
    begin {
        $TableData = @()
    }
    process {
        $TableData += $InputObject
    }
    end {
        [Table]::new($Title, [PSObject]$TableData, $Description, $Emphasis)
    }
}