撮った写真を良い感じに整理するツール

# はじめに

ミラーレスカメラを使う際、JPG+RAWみたいな形式で撮影している方は多いと思いますが、帰ってきてSDカードを開くとそこに広がるのは大量のファイル達。

同じ名前で拡張子だけ違うファイルが2つ生成されるし、表面上はいつ撮ったものなのかわからんし…

そこで、これらを良い感じに整理できるようなツールを作れないかということで、コードを書いてみました。

https://github.com/Soli0222/picture-arrange

この記事では、ツールの概要、コードの解説、リリースの自動化を紹介します。

# 概要

まずは、具体的に整理とはどのようなことをするのかを定義します。

例えば、このようなファイル群があるとします。
日付は撮影日とします。

```
/photos
    ├── IMG_0001.JPG //2024-08-30
    ├── IMG_0001.DNG //2024-08-30
    ├── IMG_0002.JPG //2024-08-30
    ├── IMG_0002.DNG //2024-08-30
    ├── IMG_0003.JPG //2024-09-01
    ├── IMG_0003.DNG //2024-09-01
    ├── DSC_1234.DNG //2024-08-31
    └── vacation_2024.JPG //2024-08-30
```

これをこのように、拡張子ごとのディレクトリ、その中に日付のディレクトリがあり、そこに画像ファイルが置かれる形にします。

```
/photos
    ├── JPG
    │   ├── 2024-08-30
    │   │   ├── IMG_0001.JPG
    │   │   ├── IMG_0002.JPG
    │   │   └── vacation_2024.JPG
    │   └── 2024-09-01
    │       └── IMG_0003.JPG
    └── DNG
        ├── 2024-08-30
        │   ├── IMG_0001.DNG
        │   └── IMG_0002.DNG
        ├── 2024-08-31
        │   └── DSC_1234.DNG
        └── 2024-09-01
            └── IMG_0003.DNG
```

また、柔軟性を持たせる仕様にもしたいと思います。
今回は、CLIツールを作るので、オプションで以下の2つを指定できるようにします。

- 操作対象のディレクトリ
- 扱う画像ファイルの拡張子

前者はまあ一般的だと思います。デフォルトではカレントディレクトリとします。

後者は、JPGだけを対象としたい場合や、カメラメーカーによって独自のRAWファイルの形式を採用しているケースもあります。そうした状況にも対応できるようにするため、実装します。デフォルトではJPGとDNGを対象とします。

# 作る

大体の仕様は決まったので作っていきます。
今回はGolangで作ります。

## 撮影日を取得する

まずは、画像ファイルの撮影日が取得できなければ何も始まりません。

画像ファイルにはExifというメタデータがくっついています。撮影日や撮影機材、設定など、いろいろな情報が入っているものです。

そこで、ここから撮影日を取得する関数を作ります。

```golang
func extractDate(filename string) (string, error) {
    file, err := os.Open(filename)
    if err != nil {
        return "", err
    }
    defer file.Close()

    exifData, err := exif.Decode(file)
    if err != nil {
        return "", err
    }

    dateTime, err := exifData.Get(exif.DateTimeOriginal)
    if err != nil {
        return "", err
    }

    dateStr := strings.Trim(dateTime.String(), "\"")
    parsedTime, err := time.Parse("2006:01:02 15:04:05", dateStr)
    if err != nil {
        return "", err
    }

    return parsedTime.Format("2006-01-02"), nil
}
```
[goexif](https://github.com/rwcarlsen/goexif)というパッケージを用いて、Exifから撮影日を取得します。

また、このデータは`YYYY:MM:DD hh:mm:ss`という形式で入っており、今回は`YYYY-MM-DD`という形式で使いたいので、よしなにフォーマットします。
[timeパッケージのParse関数](https://pkg.go.dev/time#Parse)を使っていますが、`2006:01:02 15:04:05`の部分は仕様です。

これで、画像ファイルの撮影日を取得できました。

## ファイルを移動する

次に、ファイルを移動させる関数を作ります。
これは純粋に、`mv`コマンドのようなものを置くだけです。

```golang
func moveFile(src, dst string) error {
	return os.Rename(src, dst)
}
```

移動元のファイルパスと、移動先のファイルパスを引数とし、移動を行います。

## もろもろの処理をする

ここまできたら、あとはファイルを読み込んで、上二つの関数を呼び出して、ファイルの操作を行います。

```golang
func processFiles(baseDir, pattern, dir string) {
    //いろんな処理
}
```

設定している3つの引数ですが、これは

- 操作対象のディレクトリ
- `*.[拡張子]`な形
- `[拡張子]`な形

としています。
これは`main`関数から与えます。

まずは、`*.[拡張子]`にマッチするようなファイルをピックアップして、`files`に入れます。例えば、`*.JPG`なら、`JPG`ファイルだけがピックアップされます。

```golang
files, err := filepath.Glob(filepath.Join(baseDir, pattern))
    if err != nil {
        log.Fatal(err)
    }

    if len(files) == 0 {
        log.Printf("No files found for pattern %s in %s", pattern, baseDir)
        return
    }
```

もしファイルが無ければ、メッセージを出して終わらせます。

次に、出力先の拡張子名なディレクトリを作成します。

```golang
outputDir := filepath.Join(baseDir, dir)
    if err := os.Mkdir(outputDir, 0755); err != nil && !os.IsExist(err) {
        log.Fatal(err)
    }
```

ここから、ファイルごとに撮影日の取得や、ファイルの移動が始まります。
このままいくと、正常に動作している場合は何も表示されず、精神衛生上よろしくないので、プログレスバーを実装します。

まずは、長さがファイルの数分なプログレスバーを定義します。

```golang
bar := progressbar.NewOptions(
        len(files),
        progressbar.OptionSetDescription("Processing "+dir),
    )
```

その後、ファイルの数分のfor文を書くのですが、その中で

```golang
bar.Add(1)
```

このようなものを書くと、バーが1つ進みます。

また、プログレスバーを進めるfor文が終わったら改行を挟みます。
なぜなら、2本目のプログレスバーが現れたとき、これがないと上書きされてしまうためです。
残しておきたかったので、for文の後に改行を挟むことで、上書きを防ぐようにしました。
```
println()
```

これで、プログレスバーを実装することができました。

で、そのfor文はこんな感じです。
前述した撮影日を取得する関数から撮影日を取得し、撮影日のディレクトリが無ければ作成、ファイルを移動して、プログレスバーを一つ進める。といった処理になっています。

```golang
for _, file := range files {
        date, err := extractDate(file)
        if err != nil {
            log.Printf("Error processing %s: %v", file, err)
            bar.Add(1)
            continue
        }

        destDir := filepath.Join(outputDir, date)
        if err := os.Mkdir(destDir, 0755); err != nil && !os.IsExist(err) {
            log.Fatal(err)
        }

        if err := moveFile(file, filepath.Join(destDir, filepath.Base(file))); err != nil {
            log.Printf("Failed to move %s: %v", file, err)
        }

        bar.Add(1)
    }
```

これで、この部分の関数は完成です。このようになりました。

```golang
func processFiles(baseDir, pattern, dir string) {
    files, err := filepath.Glob(filepath.Join(baseDir, pattern))
    if err != nil {
        log.Fatal(err)
    }

    if len(files) == 0 {
        log.Printf("No files found for pattern %s in %s", pattern, baseDir)
        return
    }

    outputDir := filepath.Join(baseDir, dir)
    if err := os.Mkdir(outputDir, 0755); err != nil && !os.IsExist(err) {
        log.Fatal(err)
    }

    bar := progressbar.NewOptions(
        len(files),
        progressbar.OptionSetDescription("Processing "+dir),
    )

    for _, file := range files {
        date, err := extractDate(file)
        if err != nil {
            log.Printf("Error processing %s: %v", file, err)
            bar.Add(1)
            continue
        }

        destDir := filepath.Join(outputDir, date)
        if err := os.Mkdir(destDir, 0755); err != nil && !os.IsExist(err) {
            log.Fatal(err)
        }

        if err := moveFile(file, filepath.Join(destDir, filepath.Base(file))); err != nil {
            log.Printf("Failed to move %s: %v", file, err)
        }

        bar.Add(1)
    }

    println()
}
```

## メイン関数

最後にメイン関数を書きます。
ここでは、主にオプションの定義と取得、それを元にした処理関数の呼び出しを行います。

```golang
func main() {
    extFlag := flag.String("ext", "JPG,DNG", "Comma-separated list of file extensions to process (e.g., JPG,DNG,PNG)")
    dirFlag := flag.String("dir", ".", "Directory to process (default is current directory)")
    flag.Parse()

    extensions := strings.Split(*extFlag, ",")
    for _, ext := range extensions {
        processFiles(*dirFlag, "*."+strings.ToUpper(ext), strings.ToUpper(ext))
    }
}
```

今回は、`-ext=JPG,DNG`のような形で拡張子を取得、`-dir=/path/to/photo`のような形でディレクトリを取得するようにしました。

拡張子をコンマ区切りにして、その拡張子分処理関数を実行することにしています。

これで、すべて書き終わりました。

# 動かす

さて、動かしてみましょう。

`/Users/soli/Desktop/DCIM/100SIGMA`の中に、`2024/08/29`と`2024/08/30`に撮影したJPGファイルと、DNGファイルを置きました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3287582/1a1e3764-57e8-b442-cd68-858a4c3358da.png)

ホームディレクトリで以下のようなコマンドを実行します。

```bash
picture-arrange -dir=/Users/soli/Desktop/DCIM/100SIGMA -ext=JPG,DNG
```

しっかりとプログレスバーも表示されて…

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3287582/00bbf9cd-ecd3-283f-36f1-a4a040b9b73a.png)

見事整理することができました！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3287582/c82cac81-f166-9cad-7e9d-01489e2b544f.png)

# リリース

さて、うまく動くことがわかったので、最後にリリースを行います。
GolangはOS, アーキテクチャごとにクロスコンパイルが可能ですが、それを手作業で用意してアップロードするのは非常に大変です。

そこで、[GoReleaser](https://goreleaser.com/)とGithub Actionsを用いて、自動的にクロスコンパイルとリリースを行います。

## GoReleaserのセットアップ

まずは、Goreleaserのセットアップを行います。[公式サイトの言うとおり](https://goreleaser.com/install/)にインストールします。

次に、プロジェクトのディレクトリで初期化します。

```bash
goreleaser init
```

すると、`.goreleaser.yaml`というファイルが生成されます。これがGoReleaserのコンフィグファイルになりますが、今回はなにもいじらず使います。

次に、手元でうまくクロスコンパイルしてくれるか試しましょう。

```bash
goreleaser release --snapshot --clean
```

`--snapshot`とすることで、手元で確認をすることができます。また、`--clean`を付けると、成果物のディレクトリ`/dist`をまっさらにしてからビルドしてくれます。

うまくいくと、このような感じで成果物が出てきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3287582/0257fba2-de10-7bf8-46c4-e537c3140539.png)

## Github Actionsで自動リリース

手元でうまくいくことがわかったので、Github Actionsを用いて自動リリースしましょう。

[GoReleaserのAction](https://github.com/goreleaser/goreleaser-action)のリポジトリに、サンプルワークフローがあるので、これを使いましょう。

```yaml:.github/workflows/release.yml
name: goreleaser

on:
  pull_request:
  push:

permissions:
  contents: write

jobs:
  goreleaser:
    runs-on: ubuntu-latest
    steps:
      -
        name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0
      -
        name: Set up Go
        uses: actions/setup-go@v5
      -
        name: Run GoReleaser
        uses: goreleaser/goreleaser-action@v6
        with:
          # either 'goreleaser' (default) or 'goreleaser-pro'
          distribution: goreleaser
          # 'latest', 'nightly', or a semver
          version: '~> v2'
          args: release --clean
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          # Your GoReleaser Pro key, if you are using the 'goreleaser-pro' distribution
          # GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
```

今回は、プルリク時ではなく、バージョンのタグが付けられたときにしたいので、以下のように変更します。

```yaml:.github/workflows/release.yml
name: goreleaser

on:
  push:
    tags:
      - "v*"

permissions:
  contents: write

jobs:
  goreleaser:
    runs-on: ubuntu-latest
    steps:
      -
        name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0
      -
        name: Set up Go
        uses: actions/setup-go@v5
      -
        name: Run GoReleaser
        uses: goreleaser/goreleaser-action@v6
        with:
          # either 'goreleaser' (default) or 'goreleaser-pro'
          distribution: goreleaser
          # 'latest', 'nightly', or a semver
          version: '~> v2'
          args: release --clean
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          
```

これで、タグが付けられたときに、Goreleaserが動いてくれます。
リリースタグのページに行くと

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3287582/d4e5faab-772e-2d16-3a9d-32957bb73bcd.png)

うまく行っていることがわかります。

# 終わりに

今回は、撮影した画像を整理するためのツールをGolangで作成し、自動的に整理できる方法を紹介しました。写真の整理はなかなか手間がかかる作業ですが、このツールを使うことで、簡単に日付ごとにファイルを整理することができます。

また、ツールを開発する過程でのポイントや、リリースの自動化についても解説しました。GoReleaserとGitHub Actionsを活用することで、クロスコンパイルやリリースの手間を大幅に削減できる点も重要です。

写真整理が必要な方にとって、このツールが少しでも役立てば幸いです。ぜひ使ってみてください！

フィードバックやリクエストがあれば、コメントやIssue, PRなどお待ちしています！