NimでToDoリストのコマンドツールを作ってみた

プロセスメモリエディタ自作の①を
昨年のアドベントカレンダーに書いてから１年が過ぎました...早い...。
②を書こうかなと思ったのですが時間的に断念しまして
代わりに以前作っていたNim製コマンドツールについて書かせていただきますっ！

ToDoリストの管理を考えたときに最低限以下が必要だと考えました。

1. ToDoリストのテキストデータ保存先
2. タスクを一意にするためのハッシュ
3. タグをつけてタスクを管理

本当は完了したかどうかのステータスなども必要だけど、今回は簡易的に追加・削除で。

それから以下のようにコマンドラインの使用イメージを作成。
ツールのなまえはtdnimとしています。

```shell

# tdnimを初期化（保存用のファイルを作成しています）
$ tdnim init

# タスク追加
$ tdnim add <タスク> <タグ（なくても良い）>

# タスク一覧表示
$ tdnim list

# タグ一覧表示
$ tdnim list --tag

# タグ追加
$ tdnim add <タグ> --tag

# タスク削除
$ tdnim delete <タスクのハッシュ>
```

最近だとコマンドラインツールを作る際はcligenを使うことが多いと思うのですが
この頃はdocoptを使っていたのでそちらを使います。
まずはコマンドラインツールの定義です。
長くなってきたらこちらは別ファイルで作成してしまうのが良いと思います。

```Nim
# docopt用のコマンドライン引数定義
const Doc = """
Usage:
  tdnim <action> [[<content>],[<tag>]] [-t | --tag]

Options:
  <action>    ToDo Action
  <content>   ToDo Content
  <tag>       ToDo Task Tag
  -t --tag    ToDo Tag Option
"""
```

その次にデータを保存する先のファイルパスを定義します。

```Nim
const
  DataTaskDir = getHomeDir() & ".tdnim/tasks"
  DataTagDir = getHomeDir() & ".tdnim/tags"
```

getHomeDirでホームディレクトリがとれるので便利ですね！
ちなみにNimは文字列結合を以下のように","でやってもOKです（定番）。

```Nim
DataTaskDir = getHomeDir(), ".tdnim/tasks"
```

## メイン処理

次にmainですが、こちらではdocoptによるコマンドラインツール設定と
コマンド実行時の引数を判定しています。
特徴的なところとしては--tagsオプションをつけるか否かで
処理を分岐させているところですね！
contentなしの場合、nilという文字列で結果が帰ってくるので要注意です。
（これはどうなのよという感じ。）

```Nim
proc main =
  let
    args = docopt(Doc, version = "1.0.0")
    action: string = $args["<action>"]
    content: string = $args["<content>"]
    tag: string = $args["<tag>"]
    isTag: bool = if args["--tag"]: true else: false

  case action:
    of "init":
      init()
      echo "Initilize Completed."
    of "add":
      if content == "nil":
        echo "content required"
      else:
        if isTag:
          addTag(content)
        else:
          addTask(content, tag)
    of "list":
      if isTag:
        listTag()
      else:
        listTask()
    of "delete":
      delTask(content)
        echo "delete"
    else:
      echo "command does not exists : " & action
```

## 初期化処理

os.existsFileではファイルの存在確認をしています。
さらにファイル権限を確認する必要があればgetFilePermissionを使うのが良いと思います。
ファイルが存在していなければcreateDirでディレクトリの作成、writeFileでファイルの作成を行います。

```Nim
# tdnimの初期化
proc init =
  if os.existsFile(DataTaskDir) and os.existsFile(DataTagDir):
    echo "already initialized."
  else:
    # create data directory
    createDir(getHomeDir() & ".tdnim")
    writeFile(DataTaskDir, "")
    writeFile(DataTagDir, "")
```

## 追加処理
追加処理では基本的に受け取った引数を保存先ファイルに書き込みをしています。

```Nim
# タスクの追加
proc addTask(task: string, tag: string) =
  let taskLine: string = getNowHash() & " " & task & " " & tag
  #writeFile(DataTaskDir, taskLine)
  var f : File = open(DataTaskDir, FileMode.fmAppend)
  writeLine(f, taskLine)
  defer :
    close(f)
  echo "Task added: " & task

# ラベルの追加
proc addTag(tag: string) =
  let tagLine: string = getNowHash() & " " & tag
  #writeFile(DataTagDir, tagLine)
  var f: File = open(DataTagDir, FileMode.fmAppend)
  writeLine(f, tagLine)
  defer:
    close(f)
  echo "Tag added: " & tag
```

書き込みをする際にgetNowHashを使って現在の日時からハッシュを生成し、
こちらを一緒に追加することで一意のデータと認識できるようにしています。

```Nim
# 現在時刻のSHA256ハッシュを生成
proc getNowHash: string =
  let nowStr: string = format(now(), "yyyy/MM/dd HH:mm:ss")
  return $(computeSHA256(nowStr).hex())
```

## 表示処理
表示処理では先ほど追加した保存先ファイルを読み取ってただ表示するだけです。
FileMode.fmReadを使って読み取りモードでファイルを開き、readAllでファイルの全行を出力しています。
deferはtry~finallyと同じような処理で、echo f.readAll()の処理が終わったらclose(f)を実行します。

```Nim
# TASKリストを表示
proc listTask =
  var f : File = open(DataTaskDir , FileMode.fmRead)
  defer :
    close(f)
  echo f.readAll()

# Tagリストを表示
proc listTag =
  var f : File = open(DataTagDir, FileMode.fmRead)
  defer :
    close(f)
  echo f.readAll()
```

## 削除処理
削除処理ではコマンドライン引数で指定したハッシュを検索し、
該当した行を削除します。
ただやっている処理は結構な力技でハッシュに該当した行だけを
書き込み直すということをしています。
該当行の行数を取得してそこを消すような処理にした方が本当はいいはずです...

```Nim
# TASK削除
proc delTask(task_hash: string) =
  # TODO hashに該当したラインを削除
  var f: File = open(DataTaskDir, fmRead)
  var task_lines = newStringStream(f.readAll())
  defer:
    close(f)
  writeFile(DataTaskDir, "")

  var f2: File = open(DataTaskDir, fmAppend)
  for task in task_lines.lines():
    if(not contains(task, task_hash)):
      writeLine(f2, task)
  defer:
    close(f2)
  echo "Task Deleted: " & task_hash
```

タグ削除もつくっていたつもりだったんですが、まだなかった...orz
いい練習になるので是非作ってみてくださいw

## ソースコード全文

以下のPragmaを追加すると定義順関係なしで関数呼び出しができます。
mainを必ず最後に書かなければいけない問題もこれで解決！

```Nim
{.experimental: "codeReordering".}
```

最後に全文を載せておきます。

```Nim
import system, os, options, random, times, strUtils
import docopt
import nimSHA2
import streams

{.experimental: "codeReordering".}

# docopt用のコマンドライン引数定義
const Doc = """
Usage:
  tdnim <action> [[<content>],[<tag>]] [-t | --tag]

  Options:
    <action>    ToDo Action
    <content>   ToDo Content
    <tag>       ToDo Task Tag
    -t --tag    ToDo Tag Option
"""

const
  DataTaskDir = getHomeDir() & ".tdnim/tasks"
  DataTagDir = getHomeDir() & ".tdnim/tags"

proc main =
  let
    args = docopt(Doc, version = "1.0.0")
    action: string = $args["<action>"]
    content: string = $args["<content>"]
    tag: string = $args["<tag>"]
    isTag: bool = if args["--tag"]: true else: false

  case action:
  of "init":
    # dataディレクトリを初期化
    init()
    echo "Initilize Completed."
  of "add":
    # contentがnilでなければ追加を実行
    if content == "nil":
      echo "content required"
    else:
      if isTag:
        addTag(content)
          else:
        addTask(content, tag)
  of "list":
    if isTag:
      listTag()
    else:
      listTask()
  of "delete":
    delTask(content)
    echo "delete"
  else:
    echo "command does not exists : " & action

# 現在時刻のSHA256ハッシュを生成
proc getNowHash: string =
  let nowStr: string = format(now(), "yyyy/MM/dd HH:mm:ss")
  return $(computeSHA256(nowStr).hex())

# tdnimの初期化
proc init =
  if os.existsFile(DataTaskDir) and os.existsFile(DataTagDir):
    echo "already initialized."
  else:
    # create data directory
    createDir(getHomeDir() & ".tdnim")
  writeFile(DataTaskDir, "")
  writeFile(DataTagDir, "")

##########################################################################
## 追加

# タスクの追加
proc addTask(task: string, tag: string) =
  let taskLine: string = getNowHash() & " " & task & " " & tag
  #writeFile(DataTaskDir, taskLine)
  var f : File = open(DataTaskDir, FileMode.fmAppend)
  writeLine(f, taskLine)
  defer :
    close(f)
  echo "Task added: " & task

# ラベルの追加
proc addTag(tag: string) =
  let tagLine: string = getNowHash() & " " & tag
  #writeFile(DataTagDir, tagLine)
  var f: File = open(DataTagDir, FileMode.fmAppend)
  writeLine(f, tagLine)
  defer:
    close(f)
  echo "Tag added: " & tag

##########################################################################
## リスト表示

# TASKリストを表示
proc listTask =
  var f : File = open(DataTaskDir , FileMode.fmRead)
  defer :
    close(f)
  echo f.readAll()

# Tagリストを表示
proc listTag =
  var f : File = open(DataTagDir , FileMode.fmRead)
  defer :
    close(f)
  echo f.readAll()

##########################################################################
## 削除

# TASK削除
proc delTask(task_hash: string) =
  var f: File = open(DataTaskDir, fmRead)
  var task_lines = newStringStream(f.readAll())
  defer:
    close(f)
  writeFile(DataTaskDir, "")

  var f2: File = open(DataTaskDir, fmAppend)
  for task in task_lines.lines():
    if(not contains(task, task_hash)):
      writeLine(f2, task)
  defer:
    close(f2)
  echo "Task Deleted: " & task_hash

```

未設定のタグがnilと表示されたり、
トークンまで表示してるのでみづらかったりを今後改善しようかなと思ってます。
あとステータス管理の追加も！
みなさん好きにカスタマイズして使ってみてください〜！