Powershell GUI fronted (WPF) to run categorized console scripts

โŒˆโŒ‹ โŽ‡ branch:  ClickyColoury


Artifact [fb2017bd67]

Artifact fb2017bd6797bf6c2c9df6c92966632c7262cb61:

  • File modules/menu.psm1 — part of check-in [74c2c89475] at 2018-05-19 22:47:21 on branch trunk — Shorten starter.ps1 with `Is-PluginCompatible` function, scanning user plugins with standard scripts, and moving -Filter to separate script. (user: mario size: 12666)

# api: ps
# type: functions
# title: Utility code
# description: Output and input shortcuts, screen setup, menu handling, meta extraction
# doc: http://fossil.include-once.org/clickycoloury/wiki/plugin+meta+data
# version: 0.9.0
# license: PD
# category: misc
# author: mario
# config:
#    { name: cfg.hidden, type: bool, value: 0, description: also show hidden menu entries in CLI version }
# status: beta
# priority: core
#
# A couple of utility funcs:
#  ยท Read-HostCached    โ†’ Read-Host alias
#  ยท Get-NotepadCSV     โ†’ notepad Read-Host (long input)
#  ยท echo_n             โ†’ echo wo/ CRLF
#  ยท Edit-Config        โ†’ edit config file
#  ยท Extract-PluginMeta โ†’ tokenize comment fields
#  ยท preg_match_all     โ†’ PHP-esque regex matching
#  ยท Init-Screen        โ†’ print script summary
#  ยท Print-Menu         โ†’ output $menu 
#  ยท Print-MenuSection  โ†’ list one category
#  ยท Print-MenuHelp     โ†’ show help= entries
#  ยท Process-MenuTask   โ†’ invoke tools/scripts
#  ยท Process-Menu       โ†’ input/run $menu prompt
#
# Load with:
#  Import-Module. ".\modules\menu.psm1"


#-- implements Read-Host with remembering previous values
$CLI_previous_values = @{}
function Read-HostCached {
    Param($prompt, $prev="")
    $alias = Get-VarsAlias $prompt
    # input variants
    if (!$prompt) {
        $r = ($Host.UI.Readline()) # (& Read-Host) 
        return $r.trim()
    }
    elseif ($e.vars | ? { $_.name -eq $prompt -and $_.type -match "text|long|notepad"}) {
        $r = Get-NotepadCSV
    }
    elseif ($CLI_previous_values[$alias]) {
        Write-Host -f Yellow $prompt -N
        Write-Host -f Gray " [" -N
        Write-Host -f Blue $CLI_previous_values[$alias] -N
        Write-Host -f Gray "]" -N
        Write-Host -f Yellow "> " -N
        $r = (& Read-Host)
    }
    else {
        Write-Host -f Yellow "$prompt> " -N
        $r = (& Read-Host)
    }
    # return and/or keep value
    if ($r.length) {
        $CLI_previous_values[$alias] = $r
    }
    else {
        $r = $CLI_previous_values[$alias]
    }
    return $r.trim()
}

#-- opens notepad for editing a list/csv file in-place
function Get-NotepadCSV() {
    Param($text="", $EDITOR=$env:EDITOR)
    $tmpfn = [IO.Path]::GetTempFileName()
    $text | Out-File $tmpfn -Encoding UTF8
    [void](Start-Process $EDITOR $tmpfn -Wait)
    $text = Get-Content $tmpfn | Out-String
    [void](Remove-Item $tmpfn)
    return $text
}

#-- echo sans newline
function echo_n($str) {
    [void](Write-Host -NoNewLine $str)
}

#-- start notepad on config file (obsolete, now in separate script)
function Edit-Config() {
    Param($fn, $options, $overwrite=0, $CRLF="`r`n", $EDITOR=$ENV:EDITOR)
    if ($overwrite -or !(Test-Path $fn)) {
        # create parent dir
        $dir = Split-Path $fn -parent
        if ($dir -and !(Test-Path $dir)) { md -Force "$dir" }
        # assemble defaults
        $out = "# type: config$CRLF# fn: $fn$CRLF$CRLF"
        $options | % {
            $v = $_.value
            switch ($_.type) {
                bool { $v = @('$False', '$True')[[Int32]$v] }
                default { $v = "'$v'" }
            }
            $out += '$' + $_.name + " = " + $v + "; # $($_.description)$CRLF"
        }
        # write
        $out | Out-File $fn -Encoding UTF8
    }
    & $EDITOR "$fn"
}

#-- Regex/Select-String -Allmatches as nested array (convenience shortcut)
function preg_match_all() {
    Param($rx, $str)
    $str | Select-String $rx -AllMatches | % { $_.Matches } | % { ,($_.Groups | % { $_.Value }) }
}

#-- config: list extraction
function Extract-PluginConfig() {
    <#
      .DESCRIPTION
         Unpacks the JSOL-style config{} and vars{} lists.
    #>
    Param($block, $_cfg=@(), $S="'", $D='"')
    preg_match_all -rx "\{((?>\s+|[^$S$D\}]+|$S[^$S]+$S|$D[^$D]+$D)+)\}" -str $block | % {
        $r = @{};            #      varname         =          literal    "quoted"    "single quoted"
        preg_match_all -rx "(?x) [$]?([\w.-]+) \s*[:=]\s* (?: ([^,;$D'}]+) | $D([^$D]+)$D | '([^']+)' ) " -str $_[1] | % {
            $r[$_[1]] = (($_[2..5] -ne "")[0]).trim()
        };
        $_cfg += $r;
    }
    return $_cfg
}

#-- baseline plugin meta data support
function Extract-PluginMeta() {
    <#
      .SYNOPSIS
         Reads top-comment block and plugin meta data from given filename
      .DESCRIPTION
         Plugin meta data is a cross-language documentation scheme to manage
         application-level feature plugins. This function reads the leading
         comment and converts key:value entries into a hash.
      .PARAMERER fn
         Script to read from
      .OUTPUTS
         Returns a HashTable of field: values, including the config: list/hash.
      .EXAMPLE
         In ClickyColoury it reads all "plugins" at once like this:
           $menu = (Get-Iten tools*/*.ps1 | % { Extract-PluginMeta $_ })
         Entries than can be accessed like:
           $menu | % { $_.title -and $_.category -eq "network" }
      .NOTES
         Each entry contains an .id basename and .fn field, additionaly to what
         the plugin itself defines. Packs comment remainder as .doc field.
    #>
    Param($fn, $meta=@{})

    # read file
    $str = [System.IO.File]::ReadAllText($fn) -replace "\r+\n","`n" # rx simplify and CRCRLF fix

    # look for first comment block
    if ($m = [regex]::match($str, '(?m)((?:^[ \t]{0,8}#+.*\r*\n)+|<#[\s\S]+?#>)')) {

        # remove leading #โฃ from lines, then split remainder comment
        $str = $m.groups[1] -replace "(?m)^\s*#[ \t]{0,3}", ""
        $str, $doc = [regex]::split($str, '\n\n')

        # find all `key:value` pairs
        preg_match_all -rx "(?m)^([\w-]+):\s*(.*(?:$)(?:\n(?!\w+:).+$)*)" -str $str | % {
            $meta[$_[1]] = $_[2].trim()
        }

        # split out config: and vars: (into list of dicts)
        $meta.config = @(Extract-PluginConfig ($meta.config))
        $meta.vars = @(Extract-PluginConfig ($meta.vars))

        # merge into hashtable
        $meta.fn = "$fn"
        $meta.id = ($fn -replace "^.+[\\/]|\.\w+$","") -replace "[^\w]","_"
        $meta.doc = ($doc -join "`r`n")
    }
    return $meta  # or return as (New-Object PSCustomObject -Prop $meta)
}


#-- general PMD check
function Is-PluginCompatible {
    Param($m)
    $m.type -and $m.title -and $m.category -and ($m.api -match "mt|multitool|clicky|ps|powershell|automat|^$")
}


#-- script header
function Init-Screen() {
    param($x=100,$y=45)
    #-- screen size // better than `mode con` as it retains scrolling:
    if ($host.Name -match "Console") {
        $con = $host.UI.RawUI
        $buf = $con.BufferSize; $buf.height = 16*$y; $buf.width = 2*$x; $con.BufferSize = $buf;
        $win = $con.WindowSize; $win.height =    $y; $win.width =   $x; $con.WindowSize = $win;
        $buf = $con.BufferSize; $buf.height = 16*$y; $buf.width =   $x; $con.BufferSize = $buf;
    }
    #-- header
    $meta = $cfg.main
    Write-Host -b DarkBlue -f White ("  {0,-60} {1,15} " -f $meta.title, $meta.version)
    Write-Host -b DarkBlue -f Gray  ("  {0,-60} {1,15} " -f $meta.description, $meta.category)
    try { $host.UI.RawUI.WindowTitle = $meta.title } catch { }
}

#-- group plugin list by category, sort by sort: / key: / title:
function Sort-Menu($menu) {
    $usort_cat = { (@{cmd=1; user=2; powershell=3; onbehalf=11; exchange=4; empirum=5; network=6; info=7; wmi=8}[$_.category], $_.category) -ne $null }
    $usort_key = { if ($_.key -match "(\d+)") { [int]$matches[1] } else { $_.key } }
    return ($menu | ? { $_.title -and $_.category } | Sort-Object $usort_cat, {$_.sort}, $usort_key, {$_.title})
}

#-- string cutting
function substr($str, $from, $to) {
    if ($to -lt $str.length) {
        $str.substring($from, $to)
    }
    else {
        $str
    }
}


# Reimplements Print-Menu, but sections the menus according to categories.
# Keeps current menu in global $menu_section
$global:menu_section = ""
$global:menu_groups = @(
    @{key="m"; category=""; title="Menu"}
    @{key="cmd"; category="cmd"; title="CMD/Windows"}
    @{key="u"; category="user"; title="User"}
    @{key="ps"; category="powershell"; title="Powershell"}
    @{key="em"; category="empirum"; title="Empirum"}
    @{key="ex"; category="exchange"; title="Exchange"}
    @{key="net"; category="network|server"; title="Network"}
    @{key="i"; category="info"; title="Info tools"}
    @{key="wmi"; category="wmi"; title="WMI queries"}
    @{key="beta"; category="beta"; title="Beta scripts"}
    @{key="extra"; category="extra|config|update|cron|misc"; title="Extra tools"}
)

#-- Write out menu list 
function Print-Menu() {
    param($menu)

    # submenus
    Print-MenuSection ($menu_groups | ? {$_.title}) "Submenus" Dark 0

    # filter menu
    if ($global:menu_section -eq "") {
        Print-MenuSection ($menu | ? { $_.shortcut } | Sort shortcut) Shortcuts Dark
        $global:menu_section = ""
        Print-MenuSection ($menu | ? { $_.category -eq "cmd" }) "> CMD <"
    }
    else {
        $ls = $menu | ? { $_.category -match "^($menu_section)" }
        Print-MenuSection $ls "> $menu_section <"
    }

    echo "" 
}

#-- individual section (submenus, favorites, current menu section)
function Print-MenuSection {
    Param(
        $ls = @(),
        $header = "", $dark="", $sort=1
    )
    $ls = $ls | ? { $_.title -and $_.key -and ((!$cfg.hidden) -or !$_.hidden) }
    if ($header) {
        Write-Host -f Blue ("`r`n{0,12}" -f $header)
    }
    if ($sort) {
        $ls = Sort-Menu $ls
    }
    ForEach ($m in $ls) {
        Write-Host -N -f "$($dark)Cyan" ("{0,10}" -f (substr $m.key.split("|")[0] 0 9))
        Write-Host -N -f DarkYellow  (" โ†’ ")
        Write-Host -f "White" ("{0,-61}" -f (substr $m.title 0 61))
    }
}

#-- print help= entries from $menu (โ†’ not very pretty)
function Print-MenuHelp($menu) {
    $menu | ? { $_.title -and $_.key } | % {
        Write-Host -N -f Green (" " + $_.key.split("|")[0..2] -join ", ").PadRight(15)
        Write-Host -f White (" " + $_.title)
        Write-Host -f Gray ("                " + ($_.description))
    }
}

#-- Invoked on one menu entry โ†’ executes .command, or .func, or loads .fn script
filter Process-MenuTask() {
    Param($params)
    $host.UI.RawUI.WindowTitle = "MultiTool โ†’ $($_.title)"
    Write-Host -b DarkBlue -f Cyan ("{0,-60} {1,18}" -f $_.title, $_.version)
    echo ""
    #-- commands or function
    $script:e = $_
    try {
        if ($_.command) {
            Invoke-Expression $_.command  # no options
        }
        elseif ($_.func) {
            Invoke-Expression "$($_.func) $($params)" # pass optional flags
        }
        #-- no fn?
        elseif (!$_.fn) {
            Write-Host -f Red "No processor for task:"
            $_ | FT | Write-Host
        }
        #-- start in separate "window"
        elseif ($_.type -eq "window") {
            Start-Process powershell.exe -ArgumentList "-STA -ExecutionPolicy ByPass -File $($_.fn) $global:machine"
        }
        #-- dot-source file simply (for e.g. "inline" and "cli" type)
        else {
            Invoke-Expression ". $($_.fn) $($params)"  # run script
        }
    }
    catch {
        Write-Host -b DarkRed -f White ($_ | Out-String)
        $Error.Clear()
    }
    $host.UI.RawUI.WindowTitle = "MultiTool"
}


#-- Promp/input loop (REPL)
function Process-Menu() {
    param($menu, $prompt="Func")
    Set-Alias -Name Read-Host -Value Read-HostCached -Scope Global

    #-- prompt+exec loop
    while ($True) {
        Write-Host -N -f Yellow "$prompt> "
        $which = (Read-Host)
        if ($which -match "^([\w.-]+)(?:\b|\s|$)(.*)$") { $params = $matches[2] } else { $params = $null }

        #-- submenu match?
        if ($m = $menu_groups | ? { $which -eq $_.key }) {
            if ($which -ne "m") { $global:menu_section = $m.category }
            Print-Menu $menu
            continue
        }

        #-- find according menu entry: run func or expression
        ForEach ($m in $menu) {
            if ($m.key -and $which -match "^($($m.key))\b") {
                do {
                    $m | Process-MenuTask $params
                }
                while ($m.repeat -and ((Read-Host "Repeat?") -match "^[jay1rw]"))
                
                break
            }
        }
    }
}