# 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
}
}
}
}