PowerShellでTodo管理 その2

前回で、Todoドライブへのcdが失敗したので、とりあえずcdができるようにする。

といっても、NavigationCmdletProviderにはオーバーライドできるメソッドが山程あるので、どれをどうすればいいのかわからない。仕方が無いので、手当たり次第にオーバーライドしてブレークポイントを設定、デバッグしてみた。

その結果、以下のメソッドをオーバーライドすることでcdができるようになった。

TodoProvider.cs
protected override bool IsItemContainer(string path) {
    return Directory.Exists(path);
}
protected override bool ItemExists(string path) {
    return true;
}

cdしようとするとまず、ItemExistsメソッドが呼ばれ、移動先のパスが存在するか問われる。オーバーライドしなければここはfalseが返されるのでcdに失敗していたわけだ。そして移動先のパスが存在するとわかるとIsItemContainerメソッドが呼ばれ、移動先のパスがコンテナ(フォルダ)かどうかを問われる。

この二つを上記の様に実装することでcdが可能になった。

Todoアイテムの追加

次はTodoアイテムの追加をできるようにする。

アイテムの情報をどのように持つかは悩むところだけど、ここではTodoアイテムをファイルにシリアル化して持つことにする。

そのためにはまず、Todoアイテムの情報を格納する以下のクラスを定義する。

TodoItem.cs

using System;
using System.Diagnostics;

namespace PSTodo {
    /// <summary>
    /// Todoアイテムの情報を格納するクラス
    /// </summary>
    [Serializable]
    [DebuggerStepThrough]
    public class TodoItem {
        private string title;
        private string description;
        private bool isComplete;

        /// <summary>
        /// タイトルを取得、設定します。
        /// </summary>
        public string Title {
            get { return title; }
            set { title = value; }
        }
        /// <summary>
        /// 詳細を取得、設定します。
        /// </summary>
        public string Description {
            get { return description; }
            set { description = value; }
        }
        /// <summary>
        /// 完了したかどうかを取得、設定します。
        /// </summary>
        public bool IsComplete {
            get { return isComplete; }
            set { isComplete = value; }
        }

        public TodoItem() { }
        public TodoItem(string title) {
            this.title = title;
        }
    }
}

あとは、New-Itemコマンドレットが呼び出された時に、このオブジェクトをインスタンス化し、ファイルにシリアル化すればいい。

TodoProvider.cs
protected override void NewItem(string path, string itemTypeName, object newItemValue) {
    TodoItem todoItem = new TodoItem(Path.GetFileName(path));
    todoItem.Description = newItemValue != null ? newItemValue.ToString() : string.Empty;

    Serialize(todoItem, path);

    WriteItemObject(todoItem, path, false);
}

private void Serialize(TodoItem todoItem, string path) {
    using(FileStream fs = new FileStream(path, FileMode.Create, FileAccess.Write)) {
        BinaryFormatter formatter = new BinaryFormatter();

        formatter.Serialize(fs, todoItem);
    }
}

NewItemメソッドをオーバーライドすることで、New-Itemコマンドレットの呼び出しに対応できる。

この他にもRemoveItemメソッドなどがあるところを見ると、New-ItemコマンドレットやRemove-Itemコマンドレットなどのアイテム操作系のコマンドレットは、実際の処理を全てProviderに委譲しているということがわかる。この辺はLINQのIQueryProviderなんかと同じ考え方なので、すんなり理解できる。

ちなみにWriteItemObjectメソッドはWrite-Outputコマンドレットに相当するメソッド。パイプラインにオブジェクトを出力するあれね。

PowerShellからは以下のようにして使う。

PS Todo:\> New-Item ご飯 -value ご飯を食べる

-valueでDescriptionを指定するのが面倒くさいので、以下のような関数を定義しておく。

pstodo.ps1

function add([string]$title, [string]$description) {
    New-Item $title -value $description
}

これで以下のように使いやすくなる。

PS Todo:\> add ご飯 ご飯を食べる

Todoアイテムの列挙

次は追加したアイテムの列挙。dir(Get-ChildItem)に相当するもの。

これはGetChildItemsメソッドをオーバーライドするとできる。

TodoProvider.cs
protected override void GetChildItems(string path, bool recurse) {
    foreach(string fileName in Directory.GetFiles(path)) {
        WriteItemObject(
            Deserialize(fileName), fileName, false
        );
    }
}

private TodoItem Deserialize(string path) {
    using(FileStream fs = new FileStream(path, FileMode.Open, FileAccess.Read)) {
        BinaryFormatter formatter = new BinaryFormatter();

        return (TodoItem)formatter.Deserialize(fs);
    }
}

逆シリアル化して、オブジェクトをパイプラインに書き込むだけ。

PowerShellでdirしてみると、

PS Todo:\> dir

アセンブリが見つからないというエラーが出た。

Get-ChildItem : アセンブリ 'PSTodo, Version=1.0.0.0, Culture=neutral, PublicKeyToken=*********' が見つかりません
。
発生場所 行:1 文字:3
+ dir <<<<

そりゃそうか。これを動かそうと思うと、カレントディレクトリにPSTodo.dllを配置しておく必要がある。これはさすがに面倒くさいのでPSTodo.dllをGACに登録することにした。

PS > gacutil.exe PSTodo.dll

で登録できる。

GACに登録したアセンブリをinstallutilから指定するには、以下のようにする。

PS > installutil.exe /AssemblyName "PSTodo.dll, culture=neutral, publicKeyToken=************, version=1.0.0.0"

毎回、これをやるのは面倒くさいので、NAntのビルドファイルを作っておいた。

default.build

<?xml version="1.0" encoding="shift-jis"?>
<project xmlns="http://nant.sf.net/release/0.85/nant.xsd" name="PSTodo" default="install" basedir=".">
    <!-- 出力ファイル -->
    <property name="out.file" value="bin\net-2.0\PSTodo.dll" />
    <!-- アセンブリの完全限定修飾名 -->
    <property name="assembly.name"
        value='"${project::get-name()}, culture=neutral, publicKeyToken=************, version=1.0.0.0"' />
    <!-- gacutil.exe -->
    <property name="gacutil.file"
        value="${environment::get-variable('VS80COMNTOOLS')}..\..\SDK\v2.0\Bin\gacutil.exe" />
    <!-- installutil.exe -->
    <property name="installutil.file"
        value="${environment::get-variable('windir')}\Microsoft.NET\Framework\v2.0.50727\installutil.exe" />
    
    <target name="install" description="モジュールをインストールします">
        <exec program="${gacutil.file}" commandline="/f /i ${out.file}" />
        <exec program="${installutil.file}" commandline='/AssemblyName ${assembly.name}' />
    </target>
    
    <target name="uninstall" description="モジュールをアンインストールします">
        <exec program="${installutil.file}" commandline='/u /AssemblyName ${assembly.name}' />
        <exec program="${gacutil.file}" commandline="/u ${project::get-name()}" />
    </target>
</project>

これで逆シリアル化ができるようになったが、コンソールに表示される内容は以下のようになる。

PSPath        : PSTodo\Todo::C:\PSTodo\ご飯
PSParentPath  : PSTodo\Todo::C:\PSTodo
PSChildName   : ご飯
PSDrive       : Todo
PSProvider    : PSTodo\Todo
PSIsContainer : False
Title         : ご飯
Description   : ご飯を食べる
IsComplete    : False

これはFormat-Tableコマンドレットなどの整形系のコマンドレットを使えば整えられるけど、毎回指定するのが面倒くさいので、フォーマットファイルで対応する。

pstodo.format.ps1xml

<?xml version="1.0" encoding="utf-8" ?>
<Configuration>
    <ViewDefinitions>
        <View>
            <Name>PSTodo.TodoItem</Name>
            <ViewSelectedBy>
                <TypeName>PSTodo.TodoItem</TypeName>
            </ViewSelectedBy>

            <TableControl>
                <TableHeaders>
                    <TableColumnHeader>
                        <Label>完了</Label>
                        <Width>8</Width>
                    </TableColumnHeader>
                    <TableColumnHeader>
                        <Label>タイトル</Label>
                        <Width>20</Width>
                    </TableColumnHeader>
                    <TableColumnHeader>
                        <Label>詳細</Label>
                    </TableColumnHeader>
                </TableHeaders>
                
                <TableRowEntries>
                    <TableRowEntry>
                        <TableColumnItems>
                            <TableColumnItem>
                                <PropertyName>IsComplete</PropertyName>
                            </TableColumnItem>
                            <TableColumnItem>
                                <PropertyName>Title</PropertyName>
                            </TableColumnItem>
                            <TableColumnItem>
                                <PropertyName>Description</PropertyName>
                            </TableColumnItem>
                        </TableColumnItems>
                    </TableRowEntry>
                 </TableRowEntries>
            </TableControl>
        </View>
    </ViewDefinitions>
</Configuration>

このファイルをUpdate-FormatData コマンドレットで読み込ませる。

PS > Update-FormatData -prependPath pstodo.format.ps1xml

これでdirをすると、

完了     タイトル             詳細
----     --------             ----
False    ご飯                 ご飯を食べる

のように表示される。

Todoアイテムの削除

以下の二つのメソッドを実装する。

TodoProvider.cs
protected override void RemoveItem(string path, bool recurse) {
    File.Delete(path);
}

protected override bool HasChildItems(string path) {
    return Directory.Exists(path) && Directory.GetFiles(path).Length > 0;
}

RemoveItem メソッドはそのまんま。HasChildItems メソッドはフォルダ配下にアイテムが存在するかどうかを問い合わせてくる。これはRemove-Itemコマンドレットを呼び出す時に-forceオプションを付けるか付けないかで変わってくる。

その他

Todoドライブを設定するとき、New-PSDriveコマンドレットで毎回設定しているけど、InitializeDefaultDrives メソッドをオーバーライドすると、デフォルトのドライブを設定できる。

TodoProvider.cs
protected override Collection<PSDriveInfo> InitializeDefaultDrives() {
    string rootPath = Path.Combine(
        Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "PSTodo"
    );
    Directory.CreateDirectory(rootPath);

    Collection<PSDriveInfo> driveInfos = new Collection<PSDriveInfo>();
    driveInfos.Add(
        new PSDriveInfo("Todo", base.ProviderInfo, rootPath, "", base.Credential)
    );
    return driveInfos;
}

他にも実装する機能はいろいろあるけど、とりあえずここまで。