PowerShell

PowerShellで簡易ToDoリストを作る

概要

  • この記事はPowerShell Advent Calendar 2017の17日目だよ:blush:
  • PowerShellで簡易ToDoリストを作っていく、誰向けかわからない記事だよ:dizzy_face:
  • ところで、Poshってphosのアナグラムだよね(宝石の国脳):gem:

バージョン確認

$PSVersionTable.PSVersion
<# 
Major  Minor  Build  Revision
-----  -----  -----  --------
5      1      14393  1770    
#>
  • v5でやっていくよ:laughing:
  • コマンドの実行結果は'<# ... #>'のコメント形式で書いていくよ:scroll:

ToDoリストを表す変数を作る

$script:ToDo = @()
  • 配列でToDoリストを表すことにするよ。
  • 効率は度外視、速度も気にしないので、System.Collections系は使わないよ。
  • ToDoリストは複数の関数で操作するので、(あんまり意味は無いけど)変数のスコープscriptにしておくよ。

ToDoを追加する関数を作る

filter Add-ToDo
{
    Param
    (
        [Parameter(Mandatory=$True)]
        [string] $Value
    )

    $script:ToDo += New-Object PSObject -Property $PSBoundParameters
}
  • とりあえず関数を作ったよ:wrench:
  • 下記のような感じで動くよ。
Add-ToDo "A +Test"
Add-ToDo "B +Test"
$script:ToDo
<#
Value  
-----  
A +Test
B +Test
#>

:doughnut:ひとくちメモ

ToDoリストを表示する関数を作る

  • ToDoリストを呼び出すのにいちいち変数を使うのは何かかっこ悪い感じがするので関数にするよ:tired_face:
  • ついでに何番目のToDoかわかるようにIndexも表示してみるよ:muscle:
filter Get-ToDo
{
    $script:ToDo |
        ForEach-Object {$i = 0} {$_; $i++} |
        Select-Object @(
            @{Name = 'Index'; Expression = {$i}}
            'Value'
        )
}
  • 次のように表示できるようになったよ:tada:
Get-ToDo
<#
Index Value  
----- -----  
    0 A +Test
    1 B +Test
#>

:doughnut:ひとくちメモ

  • ForEach-Object ...は、スクリプトブロックをいくつか渡せるよ。

    • スクリプトブロックを2つ渡した場合、
    • 1番目のスクリプトブロックがBeginとして一回だけ評価されるよ。
    • 2番目のスクリプトブロックがProcessとして上流からオブジェクトが来るたびに評価されるよ。
  • Select-Objectは、Propertyパラメータに@{Name = ...; Expression = {...}}形式のHashTableを渡せるよ。

    • 元のオブジェクトにない値を格納できるから、ちょっとした計算結果をまとめるのに便利だよ。

Add-ToDoからToDoのプロパティ名を取得する

  • Get-ToDoの実装で'Value'と書いているのが何か負けた気がしてくるので、Add-ToDoからToDoのプロパティ名を取得してみるよ:triumph:
filter Get-ToDoPropertyName
{
    Get-Help Add-ToDo |
        Select-Object -ExpandProperty Parameters |
        Select-Object -ExpandProperty Parameter |
        Sort-Object Position |
        Select-Object -ExpandProperty Name
}

filter Get-ToDo
{
    $script:ToDo |
        ForEach-Object {$i = 0} {$_; $i++} |
        Select-Object @(
            @{Name = 'Index'; Expression = {$i}}
            Get-ToDoPropertyName
        )
}

:doughnut:ひとくちメモ

  • Get-HelpGet-Commandで定義した関数のメタデータを拾えたりするよ:hatching_chick:
  • ${function:Add-ToDo}.Astでも拾えるかもしれないよ:evergreen_tree:

取得するToDoをIndexで指定してみる

filter Get-ToDo
{
    Param
    (
        [ValidateRange(0, [int]::MaxValue)]
        [int[]] $Index
    )

    $bIndex = @{}
    if ($PSBoundParameters.ContainsKey('Index'))
        {$bIndex['Index'] = $Index}

    $script:ToDo |
        ForEach-Object {$i = 0} {$_; $i++} |
        Select-Object @bIndex |
        Select-Object @(
            @{Name = 'Index'; Expression = {$i}}
            Get-ToDoPropertyName
        )
}
  • 次のようにIndexを指定して表示できるよ:zap:
  • 指定しないと、今まで通り全てのToDoが出力されるよ。
Get-ToDo 1
<#
Index Value  
----- -----  
    1 B +Test
#>
  • これだったらGet-ToDo | Select-Object -Index 1で良いじゃないかって:question:
  • そうだね。ただ、スプラッティングを使いたかっただけだよ:innocent:

ToDoリストをクリアする

  • 喋り方に疲れたので、一旦リセットします。
  • ついでに、ToDoリストもリセットします。
filter Clear-ToDo
    {$script:ToDo = @()}
Add-ToDo "C +Test"
Clear-ToDo
Get-ToDo
<#
#>

ToDoに完了/未完了のプロパティを持たせる

完了できないと、ToDoが無限に増えてしまいます。そこで、完了を表すプロパティをToDoに持たせるため、Add-ToDoを修正します。今回は完了/未完了を表すboolean型のプロパティを追加します。

filter Add-ToDo
{
    Param
    (
        [Parameter(Mandatory=$True)]
        [string] $Value,

        [boolean] $IsClosed = $False
    )

    $bp = $PSBoundParameters

    Get-ToDoPropertyName |
        Where-Object {-not $bp.ContainsKey($_)} |
        ForEach-Object {$bp[$_] = Get-Variable $_ -ValueOnly}

    $script:ToDo += New-Object PSObject -Property $bp
}
Clear-ToDo
Add-ToDo "A"
Add-ToDo "B" $false
Add-ToDo "C" $true
Get-ToDo
<#
Index Value IsClosed
----- ----- --------
    0 A        False
    1 B        False
    2 C         True
#>

メモ

  • $PSBoundParametersは呼び出し側が引数を割り当てたパラメータしか束縛しないため、デフォルト引数の値は別途取ってくる必要がある。
  • 今回はGet-Variable ... -ValueOnlyで変数に格納されているデフォルト値を動的に取得している。

ToDoを未完了から完了にする

いくら完了状態を持たせても、状態を変更できないと意味が無いので、それ用の関数を作ります。

filter Close-ToDo
{
    Param
    (
        [Parameter(Mandatory=$True)]
        [ValidateRange(0, [int]::MaxValue)]
        [int[]] $Index
    )

    $script:ToDo |
        Select-Object -Index $Index |
        ForEach-Object {$_.IsClosed = $True}
}
Clear-ToDo
Add-ToDo "A"
Add-ToDo "B"
Add-ToDo "C"
Close-ToDo 0, 2
Get-ToDo
<#
Index Value IsClosed
----- ----- --------
    0 A         True
    1 B        False
    2 C         True
#>

既定では完了済みのToDoを表示しないようにする

終わったToDoをいつまで眺めててもしょうがないので、Get-ToDoを修正します。

filter Get-ToDo
{
    Param
    (
        [ValidateRange(0, [int]::MaxValue)]
        [int[]] $Index,

        [switch] $Force
    )

    $bIndex = @{}
    if ($PSBoundParameters.ContainsKey('Index'))
        {$bIndex['Index'] = $Index}

    $script:ToDo |
        ForEach-Object {$i = 0} {$_; $i++} |
        Where-Object {$Force -or -not $_.IsClosed} |
        Select-Object @bIndex |
        Select-Object @(
            @{Name = 'Index'; Expression = {$i}}
            Get-ToDoPropertyName
        )
}
Clear-ToDo
Add-ToDo "A"
Add-ToDo "B" $true
Add-ToDo "C"
Get-ToDo
<#
Index Value IsClosed
----- ----- --------
    0 A        False
    2 C        False
#>

Get-ToDo -Force
<#
Index Value IsClosed
----- ----- --------
    0 A        False
    1 B         True
    2 C        False
#>

メモ

  • 関数の挙動を切り替えられるようにスイッチパラメータForceを追加
  • Forceに応じて出力するToDoをフィルタリングするようWhere-Objectを追加

完了にするToDoのIndexをパイプライン経由のオブジェクトから受け取る

パイプラインで繋げられるように少し改造。

filter Close-ToDo
{
    Param
    (
        [Parameter(
            Mandatory=$True,
            ValueFromPipelineByPropertyName=$True
        )]
        [ValidateRange(0, [int]::MaxValue)]
        [int[]] $Index
    )

    $script:ToDo |
        Select-Object -Index $Index |
        ForEach-Object {$_.IsClosed = $True}
}
Clear-ToDo
Add-ToDo "A"
Add-ToDo "B"
Get-ToDo | Close-ToDo
Get-ToDo -Force
<#
Index Value IsClosed
----- ----- --------
    0 A         True
    1 B         True
#>

Clear-ToDo
Add-ToDo "A +test"
Add-ToDo "B"
Add-ToDo "C +test"
Get-ToDo | ? {$_.Value -like "*+test"} | Close-ToDo
Get-ToDo -Force
<#
Index Value   IsClosed
----- -----   --------
    0 A +test     True
    1 B          False
    2 C +test     True
#>

メモ

  • ValueFromPipelineByPropertyNameを指定すると、パイプライン経由で飛んでくるオブジェクトのプロパティを引数の値として取得できる。

パイプライン経由で引数を取得する

パイプラインで繋げられると便利そうなので、Add-ToDoGet-ToDoClose-ToDoと同様パイプラインのプロパティから値を取得するよう変更する。

function Add-ToDo
{
    Param
    (
        [Parameter(
            Mandatory=$True,
            ValueFromPipelineByPropertyName=$True
        )]
        [string] $Value,

        [Parameter(ValueFromPipelineByPropertyName=$True)]        
        [boolean] $IsClosed = $False
    )

    $bp = $PSBoundParameters

    Get-ToDoProperty |
        Where-Object {-not $bp.ContainsKey($_)} |
        ForEach-Object {$bp[$_] = Get-Variable $_ -ValueOnly}

    $script:ToDo += New-Object PSObject -Property $bp
}

filter Get-ToDo
{
    Param
    (
        [Parameter(ValueFromPipelineByPropertyName=$True)]
        [ValidateRange(0, [int]::MaxValue)]
        [int[]] $Index,

        [switch] $Force
    )

    $bIndex = @{}
    if ($PSBoundParameters.ContainsKey('Index'))
        {$bIndex['Index'] = $Index}

    $script:ToDo |
        ForEach-Object {$i = 0} {$_; $i++} |
        Where-Object {$Force -or -not $_.IsClosed} |
        Select-Object @bIndex |
        Select-Object @(
            @{Name = 'Index'; Expression = {$i}}
            Get-ToDoProperty
        )
}
Clear-ToDo
Add-ToDo "A +test"
Get-ToDo | % {$_.Value = $_.Value -replace "A", "B"; $_} | Add-ToDo
Get-ToDo | Get-ToDo
<#
Index Value   IsClosed
----- -----   --------
    0 A +test    False
    1 B +test    False
#>

どうせならもっとパイプラインでつなげる

Add-ToDoClose-ToDoの後ろにもパイプラインを繋げられるように改造してみます。

filter Add-ToDo
{
    Param
    (
        [Parameter(
            Mandatory=$True,
            ValueFromPipelineByPropertyName=$True
        )]
        [string] $Value,

        [Parameter(ValueFromPipelineByPropertyName=$True)]        
        [boolean] $IsClosed = $False,

        [switch] $PassThru
    )

    $bp = $PSBoundParameters

    Get-ToDoPropertyName |
        Where-Object {-not $bp.ContainsKey($_)} |
        ForEach-Object {$bp[$_] = Get-Variable $_ -ValueOnly}

    $script:ToDo += New-Object PSObject -Property $bp

    if ($PassThru)
        {Get-ToDo -Index ($script:ToDo.Count - 1) -Force}
}

filter Get-ToDoPropertyName
{
    Get-Help Add-ToDo |
        Select-Object -ExpandProperty Parameters |
        Select-Object -ExpandProperty Parameter |
        Where-Object {$_.pipelineInput -match 'ByPropertyName'} |
        Sort-Object Position |
        Select-Object -ExpandProperty Name
}

filter Close-ToDo
{
    Param
    (
        [Parameter(
            Mandatory=$True,
            ValueFromPipelineByPropertyName=$True
        )]
        [ValidateRange(0, [int]::MaxValue)]
        [int[]] $Index,

        [switch] $PassThru
    )

    $script:ToDo |
        Select-Object -Index $Index |
        ForEach-Object {$_.IsClosed = $True}

    if ($PassThru)
        {Get-ToDo -Index $Index -Force}
}
Clear-ToDo
Add-ToDo "A +test"
Add-ToDo "B +test" -PassThru | Close-ToDo -PassThru
<#
Index Value   IsClosed
----- -----   --------
    1 B +test     True
#>

メモ

  • スイッチパラメータPassThruを付けて、PassThruが有効であれば内部でGet-ToDoを呼べば後続のパイプラインに繋げられるようになります。
  • ただし、Add-ToDoPassThruのパラメータが増える関係上、Get-ToDoPropertyNameも改造する必要があります。

ToDoを未完了にする

CloseがあるならOpenもあるよね。

filter Open-ToDo
{
    Param
    (
        [Parameter(
            Mandatory=$True,
            ValueFromPipelineByPropertyName=$True
        )]
        [ValidateRange(0, [int]::MaxValue)]
        [int[]] $Index,

        [switch] $PassThru
    )

    $script:ToDo |
        Select-Object -Index $Index |
        ForEach-Object {$_.IsClosed = $False}

    if ($PassThru)
        {Get-ToDo -Index $Index -Force}
}
Clear-ToDo
Add-ToDo "A +test" -PassThru | Close-ToDo -PassThru | Open-ToDo -PassThru
<#
Index Value   IsClosed
----- -----   --------
    0 A +test    False
#>

ToDoを削除する

完了にするだけじゃなくて、削除もしたいよね。

function Remove-ToDo
{
    Param
    (
        [Parameter(
            Mandatory=$True,
            ValueFromPipelineByPropertyName=$True
        )]
        [ValidateRange(0, [int]::MaxValue)]
        [int[]] $Index,

        [switch] $PassThru
    )

    Begin
        {$is = @()}

    Process
        {$is += $Index}

    End
    {
        if ($PassThru)
            {$ret = Get-ToDo -Index $is -Force}

        $i = 0
        $script:ToDo = @(
            $script:ToDo | Where-Object {$is -notcontains $i; $i++}
        )

        if ($PassThru)
            {$ret}    
    }
}
Clear-ToDo
Add-ToDo "A +test"
Add-ToDo "B +test"
Add-ToDo "C +test"
Get-ToDo 0, 2 | Remove-ToDo -PassThru
<#
Index Value   IsClosed
----- -----   --------
    0 A +test    False
    2 C +test    False
#>

Get-ToDo
<#
Index Value   IsClosed
----- -----   --------
    0 B +test    False
#>

ToDoを編集する

編集できないとか不便過ぎるので。

filter Set-ToDo
{
    Param
    (
        [Parameter(
            Mandatory=$True,
            ValueFromPipelineByPropertyName=$True
        )]
        [ValidateRange(0, [int]::MaxValue)]
        [int[]] $Index,

        [Parameter(ValueFromPipelineByPropertyName=$True)]
        [string] $Value,

        [Parameter(ValueFromPipelineByPropertyName=$True)]        
        [boolean] $IsClosed,

        [switch] $PassThru
    )

    $keys =
        Get-ToDoPropertyName |
        Where-Object {$PSBoundParameters.ContainsKey($_)}

    $script:ToDo |
        Select-Object -Index $Index |
        ForEach-Object {
            foreach ($key in $keys)
                {$_.$key = $PSBoundParameters[$key]}
        }

    if ($PassThru)
        {Get-ToDo -Index $Index -Force}
}
Clear-ToDo
Add-ToDo "A +test"
Add-ToDo "B +test"
Get-ToDo | % {$_.Value = $_.Value -replace "test", "set"; $_} | Set-ToDo
Get-ToDo
<#
Index Value  IsClosed
----- -----  --------
    0 A +set    False
    1 B +set    False
#>

唐突なおわり

おわりです。急に思い立ってアドベントカレンダーに参加したもんだからよくわからない記事ができてしまった。正直すまんかった。

なお、下記コードでToDoを並べ替えることはできますが、その機能をまとめたSort-ToDoの実装は読者の演習課題とします。

Get-ToDo -Force | Remove-ToDo -PassThru | Sort-Object IsClosed, Value | Add-ToDo