シェルスクリプトで日付処理ならdateコマンドは投げ捨ててDateutilsを使おう！

## はじめに

シェルスクリプトで日付や時間の処理をするのって面倒ですよね？ `date` コマンドはオプションは機能が少ないし Linux と macOS で書き方が違うし便利な機能は移植性がありません。移植性がないため POSIX は新しい機能を標準化することができません。書き方の違いは macOS で GNU Coreutils をインストールすれば解決できますが機能の少なさはどうしようもありません。なにかいい方法はないかな？作るかな？と思っていたのですが Dateutils を使ってみたらこれが思いの外良くできていたので使い方の紹介です。

- project homepage: http://www.fresse.org/dateutils/
- github page: https://github.com/hroptatyr/dateutils

## Dateutils のコマンド一覧

`date` コマンドは [POSIX で標準化されている内容](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/date.html)から `-u` オプション（UTC で取得）と `+` で始まる書式しか移植性がないことがわかります。`date` コマンドは機能が少ないですが、それは元々が「現在」日時の取得と設定のために作られたシステム管理コマンドで、日時処理のためのコマンドではないからなのではないでしょうか[^1]。Unix 哲学の考え方「新しい仕事をするために、新しい『機能』を追加して古いプログラムを複雑にするのではなく、新しく構築する」（[To do a new job, build afresh rather than complicate old programs by adding new "features"](https://en.wikipedia.org/wiki/Unix_philosophy#Origin)）の通りです。日時処理は新しい仕事なので新しいコマンドが必要です。それが Dateutils です。

[^1]: OS を小さく保つために OS のインターフェースを最小限にするのは良い考えです。便利な機能は OS に依存させないほうが他の環境でも使いやすくなります。

Dateutils は一つのコマンドの名前ではなく以下の小さなコマンドの集まりです。（自分が知ってる Dateutils とコマンド名が違う？件については後半で説明します）

| コマンド    | 機能                                           |
| ----------- | ---------------------------------------------- |
| `dateadd`   | 日時の計算を行う                               |
| `dateconv`  | 日時の形式を変換する                           |
| `datediff`  | 二つの日時の差を計算する                       |
| `dategrep`  | 条件にマッチする日時を検索する                 |
| `dateround` | 日時を丸める（起点から指定した日時を探索する） |
| `dateseq`   | 連続する日時を出力する                         |
| `datesort`  | 日時でソートする                               |
| `datetest`  | 日時を比較する                                 |
| `datezone`  | タイムゾーン間で日時を変換する                 |
| `strptime`  | Cのstrptime関数のコマンドライン版              |

この中で**私が特に気に入ったコマンドは `dateround`** です。次点は `dategrep` です。この二つは特に強力で、awk やその他のコマンドを使って日時をこねくり回すような「無駄に難解なコード」を書かずに Dateutils のコマンド群だけで大抵のことはなんでもできてしまいます。専用のことをするには専用のコマンドを作ることが重要であることを思い出させてくれるでしょう。

## Dateutils の重要な特徴と使用例

大抵のコマンドは機能の説明から想像できると思いますし、公式サイトにも例があるので詳細を一つ一つ説明することはしません。その代わりに「Dateutils の使いこなしに必要な考え方」が分かるような例をいくつか紹介します。

重要な注意点ですが Dateutils はロケールをサポートしていますが、原則として[システムのロケール情報やユーザーの環境変数には依存していません](https://www.fresse.org/dateutils/#locale-support)。内部にロケールファイルを持っており引数でロケールを指定します。（OS の開発者には悪いのですが）OS の機能に依存すると環境の違いで動作に違いが出る可能性があるので（個人的には）嬉しい設計方針です。ただし Dateutils が対応しているロケール情報は現在は月と曜日のみなので、それ以外が必要な場合は Dateutils の `strptime` コマンドを使う必要があります。例外的にこのコマンドだけは OS の機能を使っているので使用する書式は OS 依存になります。ちなみにタイムゾーンやサマータイムにも対応しているようです。

Dateutils の重要な特徴は `date` コマンドのように日付を引数で指定できるのはもちろんのこと、引数に複数の日時を指定したり、標準入力から複数の日時をパイプで渡して一括処理ができることです。この特徴により日時のリストを扱う処理がコマンド一発でできてしまいます。

### 年月リストを出力する (dateseq)

まず最初に使うのは `dateseq` コマンドです。このコマンドはその名の通り、一定の範囲の日時を出力するコマンドです。

```console
$ # 今年の年月リストを出力する
$ dateseq 2023-01-01 +1mo 2023-12-01
2023-01-01
2023-02-01
2023-03-01
（省略）

$ # 注意 日を指定しないときに 00 日で出力されるのは仕様
$ # ただしそのまま使うと先月の末日として解釈される気がする
$ dateseq 2023-01 2023-12
2023-01-00
2023-02-00
2023-03-00
（省略）

$ # 年だけを指定・出力したい場合は、入力形式と出力形式を指定する
$ dateseq 2023 +1y 2025 -i '%Y' -f '%Y'
2023
2024
2025
```

補足ですが、年月リスト程度であれば `seq` コマンドで作ることもできますし、少し複雑な年月日リストでも GNU date を組み合わせて生成することができます。

```sh
$ # 今年の年月リストを出力する
$ seq -f '2023-%02.f-01' 12

$ # 今年の年月日リストを出力する（閏年は一日増えるので注意）
$ seq -f '2023-01-01 +%.f days' 0 364 | date -f - +'%Y-%m-%d'
2023-01-01
2023-01-02
　︙
2023-12-30
2023-12-31
```

### 日曜日のリストや各月の最終日を出力する (dateseq)

誰もが考えるように `dateseq` コマンドは何日おきに出力するなどもできますし、曜日を指定してスキップしたりもできます。月の最終日（またはその月に何日あるか）を出力するのも簡単です。

```console
$ # 日曜日のリストを出力する（月曜([mo]nday)から土曜([sa]turday)をスキップ）
$ dateseq 2023-01-01 2023-12-31 -s mo-sa
2023-01-01
2023-01-08
2023-01-15
2023-01-22
2023-01-29
2023-02-05
（省略）

$ # 各月の最終日を出力する
$ dateseq 2023-01-31 +1mo 2023-12-31
2023-01-31
2023-02-28
2023-03-31
2023-04-30
2023-05-31
2023-06-30
（省略）
```

月によってはありえない31日を指定すると最終日になってくれるのは少し驚きがありますが便利ですね。来月の1日の日付リストを出してから前日を出力するみたいなややこしい計算は必要ありません。

### n営業日後を取得する (dateadd)

営業日（business days）とは土日を除いた日のことです。今日が金曜日だとして3日かかる仕事だから月曜日には出来上がってるなんてことは考えたくないですよね？ そういった場合でも簡単です。今回は標準入力ではなく引数で指定していますが、標準入力から複数の日付を入力することもできます。またわかりやすいように曜日を日本語で出力します。

```console
$ # 悪魔のスケジュール（今日は 2023-06-02 金曜日）
$ dateadd today +3d --locale ja_JP -f '%Y-%m-%d（%A）'
2023-06-05（月曜日）

$ # 正しいスケジュール
$ dateadd today +3b --locale ja_JP -f '%Y-%m-%d（%A）'
2023-06-07（水曜日）
```

違いは `+3d` か `+3b` かです。便利ですがこの方法だと休みが土日ではない人に対応できませんし祝日を省くこともできません。`dateseq` コマンドみたいに `--skip wed+sun` や、祝日リストの読み込み機能なども欲しい所ですね。私が気づいていないだけでなにか機能があるかもしれませんし、他のコマンドと組み合わせて簡単に出力できるかもしれませんが、その他のケースを実現するには頭を使わないといけないかもしれません。

### 文字列の途中の日時を置換する (-S, --sed-mode)

少々変わった機能が `-S`, `--sed-mode` オプションです。これは文字列の中に含まれている日時部分を置換することができます。

```console
$ echo '3日後の2023-06-02までに必ず仕上げるように！' | dateadd -S +3
3日後の2023-06-05までに必ず仕上げるように！
```

sed mode、うーん、わかりやすいネーミング・・・なのか？

### 次の曜日や日付を求める (dateround)

次の日曜日を求めるには私のお気に入りの `dateround` コマンドを使います。使い方は指定した日付の後に探索する条件、日曜日であれば `sun`、1日であれば `1` などを指定するだけです。

```console
$ dateround 2023-06-02 sun # 2023-06-02 の次の日曜日
2023-06-04

$ dateround 2023-06-04 1 # 2023-06-02 の次の1日
2023-07-01

$ dateround 2023-06-04 sun # 指定日が日曜だと当日を出力する
2023-06-04

$ dateround -n 2023-06-04 sun # 「次」の日曜日は -n オプションが必要
2023-06-11

$ # 「前」の日曜日は -sun と書けば良いがオプションと区別するために -- が必要
$ dateround -n 2023-06-04 -- -sun 
2023-05-28
```

一つ注意が必要なのはデフォルトでは次といっても今日が含まれるということです。つまり今日が日曜日であれば今日を取得します。数値の round（丸め）のように 4 を丸めた数字は 4 になるのと同じ理屈です。本当の意味で「次」の日曜日を指定する場合は `-n` オプションを使用します。

### 月の第n○曜日を出力する (dateseq + dateround)

燃えないゴミの日とか月一回の第n○曜日とかしかなくて、出し忘れてしまうんですよね。ということがないように例として今年一年の第3木曜日のリストを出力してみましょう。今まで紹介したコマンドを組み合わせるだけでできます。まず `dateseq` コマンドで今年の月のリストを出力し、`dateround` コマンドで丸めるだけです。1 日が木曜日の場合もあるので、`dateround` を二回使用しています。

```console
$ # 今年一年の第3木曜日のリスト
$ dateseq 2023-01-01 +1mo 2023-12-01 | dateround thu | dateround -n thu thu
2023-01-19
2023-02-16
2023-03-16
2023-04-20
2023-05-18
2023-06-15
（略）

$ # これでも良いはずだが 00 日の仕様は不安感が大きい……
$ dateseq 2023-01 2023-12 | dateround -n thu thu thu
```

私が気に入っている所が `dateround -n thu thu` のように複数の引数を指定できる所で、例えば次の 15 日の次の木曜日みたいな指定ができるところです。私もこのような探索のインターフェースがあれば面白いんじゃないか？と思っていたのですが、それが実装されていたので驚きました。私は round というネーミング、デフォルトでは今日を含める、`-n` オプションまでは思いつかなかったですが。

### 月の第n○曜日と第○曜日を出力する (dateseq + dategrep)

前項の書き方は `dateround` を二回使わなければならず、第n○曜日と第m○曜日のような指定ができないという点で少々ダサいです。`dateround` による探索は面白いですが、条件を指定して絞り込みたいなら grep でしょ？ということで `dategrep` です。まずは小手調べとして先程と同じ今年一年の第3木曜日のリストを出力します。

```console
$ # 今年一年の第3木曜日のリスト
$ dateseq 2023-01-01 2023-12-31 | dategrep '%c=3&&%a="thu"'
2023-01-19
2023-02-16
2023-03-16
（省略）
```

前よりもシンプルになりました。なお `&&` の前後に空白などは入れてはいけないようです。読みづらいですし入れてはいけない理由もないと思うのでそのうち修正されるかもしれません。では本題、第1火曜日と第3木曜日のリストを出力してみましょう。式を調整するだけなので難しいところはないと思います。

```console
$ # 今年一年の第1火曜日と第3木曜日のリスト
$ dateseq 2023-01-01 2023-12-31 | dategrep '(%c=1&&%a="tue")||(%c=3&&%a="thu")'
2023-01-03
2023-01-19
2023-02-07
2023-02-16
2023-03-07
2023-03-16
（省略）
```

### 第n○曜日は何日？ (dateconv)

`dateconv` コマンドは日付の形式を変更するコマンドですが、うまく応用すると第n○曜日は何日かを調べることができます。例として今月の第三木曜日を出力します。出力形式を指定しない場合、入力と同じ形式（第n○曜日）で出力されるので、実際の日付に変換する必要があります。なお `%F` と `ymd` は同じ意味です。

```console
$ dateconv 2023-06-3-thu -i "%Y-%m-%c-%a" -f '%F' 
2023-06-15

$ # 今年や今月なら年月を省略可能
$ dateconv 3-thu -i "%c-%a" -f ymd
2023-06-15
```

もっとも今月ならカレンダーを出力して目視で調べたほうが早いと思いますが。

```console
$ cal
      6月 2023
日 月 火 水 木 金 土
             1  2  3
 4  5  6  7  8  9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30
```

### 世界の時刻は何時？ (datezone)

`datezone` コマンドを使用するとタイムゾーン間で日時を変換することが出来ます。

```console
$ datezone now Asia/Tokyo EST America/New_York UTC
2023-06-02T21:10:46+09:00	Asia/Tokyo
2023-06-02T07:10:46-05:00	EST
2023-06-02T08:10:46-04:00	America/New_York
2023-06-02T12:10:46+00:00	UTC
```

サマータイム (DST) の対応もおそらくされているようです。`America/New_York` は EST（東部標準時）または EDT（東部夏時間）で、現在は EST から1時間のずれがあります。また `--next` `--prev` でサマータイムの開始日時と終了日時を取得できるようです。

```console
$ datezone --prev --next Europe/Berlin Australia/Sydney 2023-06-01
2023-10-29T03:00:00+02:00 -> 2023-10-29T02:00:00+01:00	Europe/Berlin
2023-03-26T02:00:00+01:00 <- 2023-03-26T03:00:00+02:00	Europe/Berlin
2023-10-01T02:00:00+10:00 -> 2023-10-01T03:00:00+11:00	Australia/Sydney
2023-04-02T03:00:00+11:00 <- 2023-04-02T02:00:00+10:00	Australia/Sydney
```

### 和暦で出力する (strptime)

現在の Dateutils は月と曜日しかロケールに対応していませんが、`strptime` コマンドを使用するとシステムの `strptime`関数 と `strftime` 関数を使用するなので、（システムが対応していれば）うまく和暦で出力できます。`-l` オプションがポイントで、標準では C ロケールですがオプションを指定するとロケールの環境変数を参照します。

```console
$ locale # LC_TIME が ja_JP.UTF-8 であることを確認
LANG=ja_JP.UTF-8
LANGUAGE=
LC_CTYPE="ja_JP.UTF-8"
LC_NUMERIC="ja_JP.UTF-8"
LC_TIME="ja_JP.UTF-8"
LC_COLLATE="ja_JP.UTF-8"
LC_MONETARY="ja_JP.UTF-8"
LC_MESSAGES="ja_JP.UTF-8"
LC_PAPER="ja_JP.UTF-8"
LC_NAME="ja_JP.UTF-8"
LC_ADDRESS="ja_JP.UTF-8"
LC_TELEPHONE="ja_JP.UTF-8"
LC_MEASUREMENT="ja_JP.UTF-8"
LC_IDENTIFICATION="ja_JP.UTF-8"
LC_ALL=

$ dateseq 2023-06-01 2023-06-05 | strptime -l -i '%Y-%m-%d' -f '%Ex'
令和05年06月01日
令和05年06月02日
令和05年06月03日
令和05年06月04日
令和05年06月05日
```

## Dateutils のインストールとコマンド名

Dateutils は結構古く（2014年以前？）からあるコマンドで、多くの環境で標準的なパッケージ管理システムからインストールすることができます。例えば AlmaLinux では次のようにしてインストールすることができました。ただ少し前のバージョンで挙動が違う部分があったので最新版（現在は 0.4.10）をインストールしたほうが良いと思います。

```console
$ dnf install epel-release
$ dnf install dateutils
```

Debian / Ubuntu 系はおそらく標準のコマンドと名前が被らないように別の名前に変更されているようです。プロジェクトの正式なコマンド名はこの記事で説明したように `dateseq` のような名前です。ただ昔は `dseq` のような短い名前で被ってしまったようで `dateutils.dseq` のような長いコマンド名になってしまっています。

将来は再度変更になるかもしれませんが、これだと他の環境と互換性が取れず、少々使いづらいです。ターミナルから使う場合は `alias` を設定すると良いでしょう。面倒だと思うので予め用意しておきました。

```sh
alias dateadd='dateutils.dadd'
alias dateconv='dateutils.dconv'
alias datediff='dateutils.ddiff'
alias dategrep='dateutils.dgrep'
alias dateround='dateutils.dround'
alias dateseq='dateutils.dseq'
alias datesort='dateutils.dsort'
alias datetest='dateutils.dtest'
alias datezone='dateutils.dzone'
alias strptime='dateutils.strptime'
```

シェルスクリプトの場合は `alias` よりもシェル関数を定義したほうが良いと思います（`alias` は基本的に対話シェル用なので）。次のコードをシェルスクリプトの冒頭に書いておけば `dateutils.strptime` コマンドがある場合に、それぞれの長い名前から実際のコマンドを呼び出す関数が定義されます。

```sh
if type dateutils.strptime >/dev/null 2>&1; then
  strptime() { dateutils.strptime "$@"; }
  for i in add diff round sort zone conv grep seq test; do
    eval "date${i}() { dateutils.d${i} \"\$@\"; }"
  done
fi
```

Dateutils は多くの環境に移植されているので、これでシェルスクリプトの移植性もバッチリですね！

## さいごに

Dateutils は便利で難しく考えずに日時を出力できますが、少しややこしいケースだと頭を使わないといけないケースがあるかもしれません。発想力を刺激するようなシンプルで強力な例を思いついたら追記していこうと思います。もしもっと簡潔な書き方をご存知でしたらコメントで教えてください。

やっていることの割に無駄に長いシェルスクリプトをよく見かけます。難解なコードはバグの元になりやすいです。本来シェルスクリプトは頭を使って考えなければいけないようなものではありません。良いシェルスクリプトとは行数が少ないとか文字数が少ないコードではなく、コードをひと目見るだけで考え込まずともやっていることがすぐに読み取れるものです。Dateutils をうまく使うと日付処理を簡潔にわかりやすく書くことができます。

歴史的な Unix コマンドや POSIX コマンドだけを使うことにこだわって難解なパズルのようなシェルスクリプトを作らないようにしてください。そのためには今知っているコマンドや知識だけで問題を解こうとせず、新しいコマンドを探してそのコマンドが持っている機能を十分に調べることが重要です。適切なコマンドを適切に使えばシェルスクリプトでの日付処理はこんなにも簡単になります。シンプル・イズ・ベストですね！

### おまけ 日付処理をしたいけど Dateutils が使えない人へ

以前に実装した、完全シェルスクリプト版（外部コマンドなし）と awk 版のUNIX時間⇔日付の相互変換関数の紹介です。Dateutils コマンドをインストールできず、どうしてもシェルスクリプトで日時の計算をしなければならないという場合にどうぞ。これを利用して何かしら書けば日時の計算はできるでしょう。

- [シェルスクリプトでUNIX時間⇔日付の相互変換を行う関数（POSIX準拠）](https://qiita.com/ko1nksm/items/e61728f863c1b2bc829d)
- [awkでUNIX時間⇔日付の相互変換を行う関数（POSIX準拠）](https://qiita.com/ko1nksm/items/e42d9af00212090e910f)