PowerShellで簡易ToDoリストを作る


# 概要

* この記事は[PowerShell Advent Calendar 2017](https://qiita.com/advent-calendar/2017/powershell)の17日目だよ:blush:
* PowerShellで簡易ToDoリストを作っていく、誰向けかわからない記事だよ:dizzy_face:
* ところで、Poshってphosのアナグラムだよね（宝石の国脳）:gem:

# バージョン確認

```.ps1
$PSVersionTable.PSVersion
<# 
Major  Minor  Build  Revision
-----  -----  -----  --------
5      1      14393  1770    
#>
```

* v5でやっていくよ:laughing:
* コマンドの実行結果は'<# ... #>'のコメント形式で書いていくよ:scroll:

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

```.ps1
$script:ToDo = @()
```

* [配列](https://technet.microsoft.com/ja-jp/library/hh847882.aspx)でToDoリストを表すことにするよ。
* 効率は度外視、速度も気にしないので、[System.Collections](https://msdn.microsoft.com/ja-jp/library/system.collections(v=vs.110).aspx)系は使わないよ。
* ToDoリストは複数の関数で操作するので、（あんまり意味は無いけど）変数の[スコープ](https://technet.microsoft.com/ja-jp/library/hh847849.aspx)を`script`にしておくよ。

# ToDoを追加する関数を作る

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

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

* とりあえず[関数](https://technet.microsoft.com/ja-jp/library/hh847829.aspx)を作ったよ:wrench:
* 下記のような感じで動くよ。

```.ps1
Add-ToDo "A +Test"
Add-ToDo "B +Test"
$script:ToDo
<#
Value  
-----  
A +Test
B +Test
#>
```
:doughnut:**ひとくちメモ**

* `Param(...)`の意味がわからない場合は、[about_Functions_Advanced_Parameters](https://technet.microsoft.com/ja-jp/library/hh847743.aspx)を読むと良いよ。
* `+=`の意味がわからない場合は、[about_Assignment_Operators](https://technet.microsoft.com/ja-jp/library/hh847875.aspx)を読むと良いよ。
* `$PSBoundParameters`の意味がわからない場合は、[about_Automatic_Variables](https://technet.microsoft.com/ja-jp/library/hh847768.aspx)を読むと良いよ。
* `New-Object`については、[New-Object](https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.utility/new-object?view=powershell-5.1)を読んでもよくわからないので、カスタムオブジェクトでググろう。


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

* ToDoリストを呼び出すのにいちいち変数を使うのは何かかっこ悪い感じがするので関数にするよ:tired_face:
* ついでに何番目のToDoかわかるようにIndexも表示してみるよ:muscle:

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

* 次のように表示できるようになったよ:tada:

```.ps1
Get-ToDo
<#
Index Value  
----- -----  
    0 A +Test
    1 B +Test
#>
```

:doughnut:**ひとくちメモ**

* `ForEach-Object ...`は、[スクリプトブロック](https://technet.microsoft.com/ja-jp/library/hh847893.aspx)をいくつか渡せるよ。
    + スクリプトブロックを２つ渡した場合、
    + １番目のスクリプトブロックがBeginとして一回だけ評価されるよ。
    + ２番目のスクリプトブロックがProcessとして上流からオブジェクトが来るたびに評価されるよ。

* `Select-Object`は、Propertyパラメータに`@{Name = ...; Expression = {...}}`形式の[HashTable](https://technet.microsoft.com/ja-jp/library/hh847780.aspx)を渡せるよ。
    + 元のオブジェクトにない値を格納できるから、ちょっとした計算結果をまとめるのに便利だよ。

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

* `Get-ToDo`の実装で`'Value'`と書いているのが何か負けた気がしてくるので、`Add-ToDo`からToDoのプロパティ名を取得してみるよ:triumph:

```.ps1
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-Help`や`Get-Command`で定義した関数のメタデータを拾えたりするよ:hatching_chick:
* `${function:Add-ToDo}.Ast`でも拾えるかもしれないよ:evergreen_tree:


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

```.ps1
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が出力されるよ。

```.ps1
Get-ToDo 1
<#
Index Value  
----- -----  
    1 B +Test
#>
```

* これだったら`Get-ToDo | Select-Object -Index 1`で良いじゃないかって:question:
* そうだね。ただ、[スプラッティング](https://technet.microsoft.com/ja-jp/library/jj672955.aspx)を使いたかっただけだよ:innocent:


# ToDoリストをクリアする

* 喋り方に疲れたので、一旦リセットします。
* ついでに、ToDoリストもリセットします。

```.ps1
filter Clear-ToDo
    {$script:ToDo = @()}
```

```.ps1
Add-ToDo "C +Test"
Clear-ToDo
Get-ToDo
<#
#>
```

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

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

```.ps1
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
}
```

```.ps1
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を未完了から完了にする

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

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

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

```.ps1
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`を修正します。

```.ps1
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
        )
}
```

```.ps1
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をパイプライン経由のオブジェクトから受け取る

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

```.ps1
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}
}
```

```.ps1
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-ToDo`と`Get-ToDo`を`Close-ToDo`と同様パイプラインのプロパティから値を取得するよう変更する。

```.ps1
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
        )
}
```

```.ps1
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-ToDo`と`Close-ToDo`の後ろにもパイプラインを繋げられるように改造してみます。

```.ps1
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}
}
```

```.ps1
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-ToDo`に`PassThru`のパラメータが増える関係上、`Get-ToDoPropertyName`も改造する必要があります。


# ToDoを未完了にする

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

```.ps1
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}
}
```

```.ps1
Clear-ToDo
Add-ToDo "A +test" -PassThru | Close-ToDo -PassThru | Open-ToDo -PassThru
<#
Index Value   IsClosed
----- -----   --------
    0 A +test    False
#>
```

# ToDoを削除する

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

```.ps1
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}    
    }
}
```

```.ps1
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を編集する

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

```.ps1

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

```.ps1
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`の実装は読者の演習課題とします。

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