シェルスクリプトでUNIX時間⇔日付の相互変換や日時計算を行う関数（POSIX準拠）

## はじめに

参考としてシェルスクリプトで UNIX 時間 と日時を取得・変換する "一般的な" コマンドです。

```sh
# 現在の UNIX 時間を出力
$ date +%s
1586250991

# 指定した日時の UNIX時間 を出力
$ date +%s --date '2020-01-02 03:04:05' # Linux
$ date -j -f "%Y-%m-%d %H:%M:%S" '2020-01-02 03:04:05' +%s # macOS
1577901845

# UNIX 時間 で日時を指定して指定したフォーマットで出力
$ date --date @1577901845 +"%Y-%m-%d %H:%M:%S" # Linux
$ date -r 1577901845 +"%Y-%m-%d %H:%M:%S" # macOS
2020-01-02 03:04:05
```

見てわかると思いますが Linux (GNU版) と macOS (FreeBSD版) でそれぞれ書き方が異なります。[date](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/date.html) コマンド自体は POSIX で規定されていますが、よく使いそうなこれらのオプションは POSIX 準拠ではありません。また UNIX 時間 を出力する `+%s` は macOS でも使えますが、これも POSIX 準拠ではないため、例えば Solaris 10 の `date` コマンドは対応していません。

そこで `date` コマンドのうち POSIX 準拠のオプションのみを使って実装した UNIX 時間の出力関数と、UNIX 時間と日常の日時との相互変換を行うシェル関数を実装しました。外部コマンドは `date` コマンドを現在の日時を取得するのに使っているだけで、日付の変換はシェルスクリプトだけで実装しているので高速です。

- **おまけ** awk 版も実装しました「[awkでUNIX時間⇔日付の相互変換を行う関数（POSIX準拠）](https://qiita.com/ko1nksm/items/e42d9af00212090e910f)」
- **おまけ２** [シェルスクリプトで日付処理ならdateコマンドは投げ捨ててDateutilsを使おう！](https://qiita.com/ko1nksm/items/46f74707693213a0da3d)


## 実装

特に解説するようなこともないのでいきなり実装です。

**2023-06-03 追記** 別実装を下の方に追記しています。

```sh
# 現在の UNIX 時間 の取得
unixtime() {
  datetime2unixtime "$(date -u +'%Y-%m-%d %H:%M:%S')"
}

# 日時（%Y-%m-%d %H:%M:%S 形式）-> UNIX 時間
datetime2unixtime() {
  set -- "${1%% *}" "${1##* }"
  set -- "${1%%-*}" "${1#*-}" "${2%%:*}" "${2#*:}"
  set -- "$1" "${2%%-*}" "${2#*-}" "$3" "${4%%:*}" "${4#*:}"
  set -- "$1" "${2#0}" "${3#0}" "${4#0}" "${5#0}" "${6#0}"
  [ "$2" -lt 3 ] && set -- $(( $1-1 )) $(( $2+12 )) "$3" "$4" "$5" "$6"
  set -- $(( (365*$1)+($1/4)-($1/100)+($1/400) )) "$2" "$3" "$4" "$5" "$6"
  set -- "$1" $(( (306*($2+1)/10)-428 )) "$3" "$4" "$5" "$6"
  set -- $(( ($1+$2+$3-719163)*86400+$4*3600+$5*60+$6 ))
  echo "$1"
}

# UNIX 時間 -> 日時（%Y-%m-%d %H:%M:%S 形式）
unixtime2datetime() {
  set -- $(( $1%86400 )) $(( $1/86400+719468 )) 146097 36524 1461
  set -- "$1" "$2" $(( $2-(($2+2+3*$2/$3)/$5)+($2-$2/$3)/$4-(($2+1)/$3) ))
  set -- "$1" "$2" $(( $3/365 ))
  set -- "$@" $(( $2-( (365*$3)+($3/4)-($3/100)+($3/400) ) ))
  set -- "$@" $(( ($4-($4+20)/50)/30 ))
  set -- "$@" $(( 12*$3+$5+2 ))
  set -- "$1" $(( $6/12 )) $(( $6%12+1 )) $(( $4-(30*$5+3*($5+4)/5-2)+1 ))
  set -- "$2" "$3" "$4" $(( $1/3600 )) $(( $1%3600 ))
  set -- "$1" "$2" "$3" "$4" $(( $5/60 )) $(( $5%60 ))
  printf "%04d-%02d-%02d %02d:%02d:%02d\n" "$@"
}

# 使用例
unixtime # => 現在の UNIX 時間
date +%s # 同等のコマンド (Linux)

datetime2unixtime "2020-04-07 01:23:45" # => 1586222625
date -u +%s --date "2020-04-07 01:23:45" # 同等のコマンド (Linux)

unixtime2datetime "1586222625" # => 2020-04-07 01:23:45
date -u --date @1586222625 +"%Y-%m-%d %H:%M:%S" # 同等のコマンド (Linux)
```

一見してなんじゃこりゃ？と思うかもしれませんが、たくさんの `set` は変数を使用しないようにしているためです。シェルスクリプトは POSIX 準拠の範囲ではグローバル変数しかないので使用している変数名がぶつからないように代わりに位置パラメータを使っています。少し読みづらいと思いますが一つずつ展開していけばよく見かけるような公式に戻ります。

計算式についてはネットで検索して見つけたものを（書き方の変更程度で）使用しているだけなので解説はできません。`date2unixtime` の公式は[（日本だけで？）有名なフェアフィールドの公式](https://qiita.com/ko1nksm/items/a4da288edba955725ada)らしいです。参考にした場所は忘れてしまいましたが検索すればいくつも出てくると思います。`unixtime2date` は[こちら](https://unix.stackexchange.com/questions/548278/convert-unix-timestamp-to-human-readable-time#548764) のコードを参考にしました。

タイムゾーンや ISO 8601、RFC 3339 の対応は意図的にやっていません。これらに対応するのは難しくないはずなので必要な人がやればよいというスタンスです。スクリプトに組み込んで再利用しやすいようにコアのみをシェル関数として実装しています。（コードは修正の有無に関係なく自由に使ってもらって構いません。参照元とかも明記しなくて良いです。）

## テスト

こういうのは計算式を見た所で正しいかなんて判断のしようがないので `date` コマンドの結果と比較してテストします。時・分・秒に関しては計算は難しくないので軽く済ませて、日付についてはエポック日の `1970-01-01` から 1日（86400秒）単位で 3000 年まで一致するかをテストしています。（テストコードは Linux用です。POSIX 準拠にはしていません。）

```sh
i=0 t=5025 # 1970-01-01 01:23:45
while [ $t -lt 32503647600 ]; do # 3000-01-01 00:00:00
  a=$(unixtime2datetime "$t")
  b=$(date -u +"%Y-%m-%d %H:%M:%S" --date @$t)
  c=$(datetime2unixtime "$b")
  if [ "$a" = "$b" ] && [ "$c" = "$t" ]; then
    [ $(( i % 1000)) -eq 0 ] && date -u --date @$t
    t=$((t+86400)) i=$((i+1))
  else
    echo "error $a $b : $c $t"
    exit 1
  fi
done
```

## 実装（変数に戻す＋ISO 8601版）

**2023-06-03 追記**

標準出力に出力するのではなく変数に戻すタイプに修正し、日付の形式を ISO 8601（タイムゾーンなし）に変更しました。変数に戻す理由はパフォーマンスのためで標準出力に出力しないのであればこちらの方が速いです。

**2024-05-05 追記**

- リポジトリを追加しました
- 0000-01-01から動作するように修正し9999-12-31までのテストも追加しました

https://github.com/ko1nksm-shlab/sh-datetime

```sh
# 現在の UNIX 時間 の取得
unixtime() {
  set -- "$1" "${2:--utc}" && [ "$2" = -local ] && set -- "$1"
  datetime2unixtime "$1" "$(date ${2+-u} +'%Y-%m-%dT%H:%M:%S')"
}

# 日時（%Y-%m-%dT%H:%M:%S 形式）-> UNIX 時間
datetime2unixtime() {
  set -- "$1" "${2%%-*}" "${2%%T*}" "${2##*T}"
  set -- "$1" "${2#"${2%%[!0]*}"}" "${3#*-}" "${4%%:*}" "${4#*:}"
  set -- "$1" "$2" "${3%%-*}" "${3#*-}" "$4" "${5%%:*}" "${5#*:}"
  set -- "$1" "${2:-0}" "${3#0}" "${4#0}" "${5#0}" "${6#0}" "${7#0}"
  [ "$3" -lt 3 ] && set -- "$1" $(($2-1)) $(($3+12)) "$4" "$5" "$6" "$7"
  set -- "$1" $((365*$2+$2/4-$2/100+$2/400)) "$3" "$4" "$5" "$6" "$7"
  set -- "$1" "$2" $(((306*($3+1)/10)-428)) "$4" "$5" "$6" "$7"
  eval "$1=$((($2+$3+$4-719163)*86400+$5*3600+$6*60+$7))"
}

# UNIX 時間 -> 日時（%Y-%m-%dT%H:%M:%S 形式）
unixtime2datetime() {
  set -- "$1" $(($2%86400)) $(($2/86400+719468)) 146097 36524 1461
  set -- "$@" $(($3-(($3+2+3*$3/$4)/$6)+($3-$3/$4)/$5-(($3+1)/$4)))
  set -- "$1" "$2" "$3" $(($7/365))
  set -- "$1" "$2" "$4" $(($3-(365*$4+$4/4-$4/100+$4/400)))
  set -- "$1" "$2" "$3" "$4" $((($4-($4+20)/50)/30))
  set -- "$1" "$2" "$4" "$5" $((12*$3+$5+2))
  set -- "$@" $(($5/12)) $(($5%12+1)) $(($3-(30*$4+3*($4+4)/5-2)+1))
  set -- "$1" "$6" "$7" "$8" $(($2/3600)) $(($2%3600/60)) $(($2%3600%60))
  set -- "$1" "$2" $(($3+100)) $(($4+100)) $(($5+100)) $(($6+100)) $(($7+100))
  eval "$1=$2-${3#1}-${4#1}T${5#1}:${6#1}:${7#1}"
}
```

使用例

```sh
unixtime v && echo "$v" # => 現在の UNIX 時間
date +%s                # => 現在の UNIX 時間

datetime2unixtime v "2023-01-02T01:23:45" && echo "$v" # => 1672622625
date -u +%s --date "2023-01-02T01:23:45"               # => 1672622625

datetime2unixtime v "2023-12-31T01:23:45" && echo "$v" # => 1672622625
date -u +%s --date "2023-12-31T01:23:45"               # => 1672622625

unixtime2datetime v "1672622625" && echo "$v"   # => 2020-04-07T01:23:45
date -u --date @1672622625 +"%Y-%m-%dT%H:%M:%S" # => 2020-04-07T01:23:45
```