🐚

【PowerShell】対話形式でテンプレートから定型文を生成する

2022/05/24に公開

https://zenn.dev/skanehira/articles/2021-05-13-slack-reminder-cli

こちらの CLI に憧れて自分でもひとつツールを書いてみました。

環境:

> $PSVersionTable

Name                           Value
----                           -----
PSVersion                      7.2.4
PSEdition                      Core
GitCommitId                    7.2.4
OS                             Microsoft Windows 10.0.19044
Platform                       Win32NT
PSCompatibleVersions           {1.0, 2.0, 3.0, 4.0…}
PSRemotingProtocolVersion      2.3
SerializationVersion           1.1.0.1
WSManStackVersion              3.0

$PSStyle で出力に色をつけているのでバージョン7以降が必要です。

作ったもの

template.txt
昔、あるところに{{太郎}}がいました。
{{太郎}}は大きくなったら{{島}}に行って{{ターゲット|鬼;悪魔}}を退治するといい、{{お供}}を連れて旅立ちました。

このようなテンプレートを書いて、Invoke-Snippet .\template.txt と呼び出してしてやると…

こんな感じで入力欄をプレビュー表示してから、

各項目について入力プロンプトを出します。

複数選択の場合は番号で指定します。
ここで「その他」(-1)を入力するか何も回答せずに ENTER を押すと、あらためて自由回答欄を表示します。

全部入力したらテンプレートのプレースホルダーを置き換えた文字列がクリップボードにコピーされるようになっています。

コード

PsInteract というクラスを作ってみました。

コード全体

クラスや関数の内部から $PSStyle を使うときは $Global:PSStyle のようにグローバルスコープ変数としてやる必要があります。

class PsInteract {
    [string[]]$Template = @()
    [string[]]$Preview = @()
    [PSCustomObject[]]$Questions = @()
    [System.Collections.Specialized.OrderedDictionary]$ReplaceMap = @{}

    # コンストラクタ
    PsInteract($templatePath) {
        $this.Template = Get-Content $templatePath
        [regex]::Matches($this.Template, "{{.+?}}").Value | ForEach-Object {
            if ($_ -notin $this.ReplaceMap.Keys) {
                $this.ReplaceMap.Add($_, "") # まずは空文字を割り当てておく
            }
        }
        $p = $this.Template
        $this.ReplaceMap.Keys | ForEach-Object {
            $p = $p.replace(
                $_,
                ($Global:PSStyle.Background.BrightBlue + $Global:PSStyle.Foreground.Black + ( "[{0}]" -f [PsInteract]::ParseQuestion($_).Prompt ) + $Global:PSStyle.Reset)
            )
        }
        $this.Preview = $p
        $this.Questions = $this.ReplaceMap.Keys | ForEach-Object { [PsInteract]::ParseQuestion($_) }
    }

    # プレースホルダーを読み取って質問オブジェクトを生成する静的メソッド
    static [PSCustomObject] ParseQuestion([string]$q) {
        $content = $q.Trim("{}")
        if ($content.IndexOf("|") -ne -1) {
            return [PSCustomObject]@{
                "PlaceHolder" = $q;
                "FreeInput" = $false;
                "Prompt" = ($content.Split("|"))[0];
                "Options" = @(($content.split("|"))[1] -split "[;;]");
            }
        }
        return [PSCustomObject]@{
            "PlaceHolder" = $q;
            "FreeInput" = $true;
            "Prompt" = $content;
            "Options" = @();
        }
    }

    # 複数選択の場合の選択肢を表示する形式に整形する静的メソッド
    static [string[]] FormatOptions([string[]]$options) {
        $idx = 1
        $opts = @()
        $options | ForEach-Object {
            $opts += "[{0,2}] {1}" -f $idx, $_.Trim()
            $idx += 1
        }
        $opts += "[-1] その他"
        return @($opts).ForEach({"  {0}" -f $_})
    }

    [void] Ask() {
        $this.Questions | ForEach-Object {
            $userInput = ""
            $prompt = $Global:PSStyle.Foreground.BrightBlue + "`u{00A7}" + $_.Prompt + $Global:PSStyle.Reset
            if ($_.FreeInput) {
                $userInput = Read-Host -Prompt $prompt
            }
            else {
                $prompt | Write-Host
                [PsInteract]::FormatOptions($_.Options) | Write-Host
                $choice = (Read-Host -Prompt "Choice") -as [int]
                if ($choice -le 0) {
                    $userInput = Read-Host -Prompt "==>"
                }
                else {
                    $userInput = $_.Options[($choice - 1)]
                }
            }
            $this.ReplaceMap[$_.PlaceHolder] = $userInput
        }
    }

    [string[]] Apply() {
        $s = $this.Template
        $this.ReplaceMap.GetEnumerator() | ForEach-Object {
            $s = $s.replace($_.key, $_.value)
        }
        return $s
    }

}

下記のようにプロパティを用意しています。

  • Template :指定したテンプレートを格納する文字列配列
  • Preview :プレビュー表示用の文字列配列
  • Questions :プレースホルダーから生成した質問を格納するカスタムオブジェクトの配列
  • ReplaceMap :質問とそれに対する回答の対応表
    • @{} で宣言した PowerShell の連想配列は順番を保持しません。System.Collections.Specialized.OrderedDictionary の型を指定してやることで投入した順番に格納されます。
    • 代わりに [ordered]@{} と初期化するのでも同じことができます。

.Ask() で質問を開始して、.Apply() で回答内容をテンプレートに反映させます。

呼び出し側

function Invoke-Snippet {
    param (
        $templatePath
    )
    $a = [PsInteract]::new($templatePath)
    "========================================" | Write-Host
    $a.Preview | Write-Host
    "========================================" | Write-Host
    $a.Ask()
    $a.Apply() | Set-Clipboard
    "Filled fields and copied to clipboard!" | Write-Host -ForegroundColor Yellow
}

最近ようやくクラスの使い方が見えてきたような気がしています。

Discussion