日本語諸方言コーパスをDB化して遊ぶ (8) ファイル形式変換機能をつける

連載記事です。前回までに談話・話者ごとの発話総覧を作ったので、今回は最後の機能「サイト上での Excel と TextGrid の相互変換」を実装します。完全に自分用の作業メモで、説明もいろいろ足りていないと思いますが、ご容赦ください。

先述のとおり、今回は既に TextGrid と Excel を変換する Python スクリプトが存在しているので（内容には詳しく触れません）、これを Laravel アプリに組み込んでサーバー上で実行することを目指します。

- 第１回： [日本語諸方言コーパスをDB化して遊ぶ (1) 構成を考える](https://qiita.com/a_eau_/items/dcb83886c4b580565ce7)
- 第２回： [日本語諸方言コーパスをDB化して遊ぶ (2) SQLite3 で DB 化](https://qiita.com/a_eau_/items/0bb4f3f8271c6dbc12a5)
- 第３回： [日本語諸方言コーパスをDB化して遊ぶ (3) PHP Laravel で操作する](https://qiita.com/a_eau_/items/09f648ede77fd22e7585)
- 第４回： [日本語諸方言コーパスをDB化して遊ぶ (4) サービスの全体像を決める](https://qiita.com/a_eau_/items/0af125f960dd1d16fcde)
- 第５回： [日本語諸方言コーパスをDB化して遊ぶ (5) データベースの移行とモデルの作成](https://qiita.com/a_eau_/items/3cad488add22bfacfeaa)
- 第６回： [日本語諸方言コーパスをDB化して遊ぶ (6) 談話ごとの発話総覧を作る](https://qiita.com/a_eau_/items/2a7072e3cab4729623cb)
- 第７回： [日本語諸方言コーパスをDB化して遊ぶ (7) 話者ごとの発話総覧を作る](https://qiita.com/a_eau_/items/ccb472a0fc2ac6ca9919)
- 第８回： 日本語諸方言コーパスをDB化して遊ぶ (8) ファイル形式変換機能をつける　←今ここ
- 第９回： [日本語諸方言コーパスをDB化して遊ぶ (9) Heroku でデプロイする](https://qiita.com/a_eau_/items/1ae966836d0cf4a6ba0e)

## 事前準備
今回はサーバー上のストレージにファイルを保存し、それを変換し、ダウンロードする仕組みを作るので、最初にそのあたりの設定をしておきます。アップロードしたファイルは `storage` フォルダに保存されますが、一般公開されるのは `public` フォルダなので、慣例に従い `public/storage` から `storage/app/public` へシンボリックリンクを張ります。下記の artisan コマンドで勝手に貼ってくれます[^symbolic]。

[^symbolic]: 後述しますが、ローカルで張ったシンボリックリンクが Heroku 上で勝手に張られることはないので、必要な命令を `composer.json` に書き込んでおいて、ビルド時にシンボリックリンクが張られるようにしておく必要があります。

```cmd:cmd
php artisan storage:link
```

- [Laravel 7.x ファイルストレージ | ReaDouble](https://readouble.com/laravel/7.x/ja/filesystem.html)

## 画面遷移図
画面遷移図を再掲します。１ページですけど。

![func_3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/526487/3a4ba1f4-8f73-f00d-1a34-c87ad5579573.png)

## コンポーネントのルーティング
コンポーネントがひとつしかないので、特に解説することはありません。

```diff:resources/js/app.js
+ import ConvertComponent from "./components/ConvertComponent";

+        {
+            path: "/convert",
+            name: "convert",
+            component: ConvertComponent
+        }
```

## コンポーネントの作成
１画面ではありますが、たくさん機能をつけるので前回までよりは複雑です。

```vue:resouces/js/components/ConvertComponent.vue
<template>
  <div>
    <form enctype="multipart/form-data">
      <input
        type="file"
        name="file"
        id="fileRef"
        style="display: none"
        @change="fileSelected"
      />
      <div class="input-group">
        <input
          type="text"
          id="fileShow"
          class="form-control"
          placeholder="select file..."
          readonly
        />
        <div class="input-group-append">
          <span class="input-group-btn">
            <button 
              type="button" 
              class="btn btn-outline-success" 
              onclick="fileRef.click()"
            >
              Browse
            </button>
          </span>
          <button 
            type="button" 
            class="btn btn-success" 
            @click="fileUpload"
          >
            Upload
          </button>
        </div>
      </div>
    </form>
    <div class="pt-3">
      <table class="table table-sm table-striped">
        <thead>
          <tr class="thead-dark">
            <th colspan="2">
              <div class="text-center">ファイル一覧</div>
            </th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="file of files" v-bind:key="file.name">
            <td>
              <span class="pl-3">{{ file.replace("public/", "") }}</span>
            </td>
            <td>
              <div class="text-right">
                <span 
                  class="btn btn-success btn-sm" 
                  @click="toTextgrid(file)" 
                  v-if="file.indexOf('.xls') != -1"
                >
                  to TextGrid
                </span>
                <span 
                  class="btn btn-outline-success btn-sm disabled" 
                  v-else
                >
                  to TextGrid
                </span>
                <span 
                  class="btn btn-success btn-sm" 
                  @click="toExcel(file)" 
                  v-if="file.indexOf('.txt') != -1 || file.indexOf('.TextGrid') != -1"
                >
                  to Excel
                </span>
                <span 
                  class="btn btn-outline-success btn-sm disabled" 
                  v-else
                >
                  to Excel
                </span>
                <a 
                  v-bind:href="'./storage' + file.replace('public', '')" 
                  v-bind:download="file.replace('public', '')"
                >
                  <span class="btn btn-warning btn-sm">
                    download
                  </span>
                </a>
                <span 
                  class="btn btn-danger btn-sm" 
                  @click="deleteFile(file)"
                >
                  delete
                </span>
              </div>
            </td>
          </tr>
        </tbody>
      </table>
    </div>
  </div>
</template>

<script>
export default {
  data: function() {
    return {
      files: [],
      uploadingFileInfo: ""
    };
  },
  methods: {
    fileSelected(event) {
      this.uploadingFileInfo = event.target.files[0];
      fileShow.value = fileRef.value.replace("C:\\fakepath\\", "");
    },
    fileUpload() {
      if (this.uploadingFileInfo) {
        const formData = new FormData();
        formData.append("file", this.uploadingFileInfo);
        axios.post("/api/toolkit/upload", formData).then(res => {
          fileRef.value = "";
          fileShow.value = "";
          this.uploadingFileInfo = "";
          this.getFileList();
        });
      } else {
        alert("アップロードするファイルを選択してください");
      }
    },
    getFileList() {
      axios.get("/api/convert/files").then(res => {
        this.files = res.data;
      });
    },
    to_textgrid(path) {
      axios.post("/api/convert/toTextgrid", { filepath: path }).then(() => {
        this.getFileList();
      });
    },
    to_excel(path) {
      axios.post("/api/convert/toExcel", { filepath: path }).then(() => {
        this.getFileList();
      });
    },
    deleteFile(path) {
      axios.post("/api/convert/delete", { filepath: path }).then(() => {
        this.getFileList();
      });
    }
  },
  mounted() {
    this.getFileList();
  }
};
</script>
```

### ファイル選択フォーム
`file` フォームは bootstrap のみではあまりいい感じになりません。いくつか簡便な手法が考案されていますが、今回は以下のサイトを参考にしました。

- [Bootstrapのfileアップロードフォームがダサいから装飾してみた | vdeep](http://vdeep.net/bootstrap-file)

### 変換ボタン
[to TextGrid] や [to Excel] のボタンはファイルの拡張子によって切り替えて、適切な拡張子のときのみ、クリックで発火するようにしています。TextGrid とかいう形式があるせいで `mimetype` による場合分けが使えないので、単純にファイル名に `.txt` や `.TextGrid` などの文字列が含まれるかどうかで場合分けしています[^validate]。場合分け自体は `v-if`・`v-else` でパパっと。

[^validate]: ほんとうはこんなフロントエンドのなんちゃって検証ではなく、サーバ側でちゃんと入力ファイルを検証しないとダメ。

```vue
<!-- .txt/.TextGrid なら上の有効ボタンを表示する -->
<span
    class="btn btn-success btn-sm"
    @click="toExcel(file)"
    v-if="file.indexOf('.txt') != -1 || file.indexOf('.TextGrid') != -1"
>
    to Excel
</span>
<!-- そうでないなら下の無効ボタンを表示する -->
<span
    class="btn btn-outline-success btn-sm disabled"
    v-else
>
    to Excel
</span>
```

### ダウンロードボタン
各種変換や削除はクリックで関数を実行するようにしていますが、ダウンロードだけはファイルに直接リンクを貼っています。Laravel のサーバーからファイルをダウンロードする方法はいくつかあるのですが、`Storage` ファサードや `response()` を使った手法はどうもうまくいかなかったので（数敗）[^down]、直接リンクを貼る方法を採用しました。

ファイルパスは後述のようなシンプルな方法で取得すると `/storage/app` 以下のパスを返す（＝`/public/filename.ext` のようなパスが返る）ので、適当に置換してシンボリックリンク先の `(/public)/storage/filename.ext` に直接リンクを貼ります。

- [Laravel ファイルのダウンロード方法 | アールエフェクト](https://reffect.co.jp/laravel/laravel-file-download)

```html:downloadリンク
<a 
  v-bind:href="'./storage' + file.replace('public', '')" 
  v-bind:download="file.replace('public', '')"
>
  <span class="btn btn-warning btn-sm">
    download
  </span>
</a>
```

[^down]: パス解決に失敗したり、403エラーが出たり、POSTレスポンスにファイル内容は積まれてくるけどダウンロードまで行けなかったりしました。

## コントローラへのルーティング
すべて `FileController` に実装するので、関数名を適当に考えてルーティングを `api.php` に書いておきます。

```diff:routes/api.php
+ Route::get('/convert/files', 'FileController@getFileList');
+ Route::post('/convert/upload', 'FileController@upload');
+ Route::post('/convert/e_t', 'FileController@toTextgrid');
+ Route::post('/convert/t_e', 'FileController@toExcel');
+ Route::post('/convert/delete', 'FileController@deleteFile');
```

## コントローラの作成
先ほど使用することにした５つの機能を実装します。

```php
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;

class FileController extends Controller{

    // ファイルをアップロードして保存する
    public function upload(Request $request){
        $filename = $request->file('file')->getClientOriginalName();
        $request->file('file')->storeAs('public/',$filename);
    }
    
    // Excel を TextGrid に変換して保存
    public function toTextgrid(Request $request) {
        exec("which python", $pythonpath);

        $scriptpath = app_path('Python/excel_to_textgrid.py');
        $filepath = storage_path('app/' . $request->input('filepath'));

        $command = $pythonpath[0] . ' ' . $scriptpath . ' ' . $filepath;
        exec($command);
    }
    
    // TextGrid を Excel に変換して保存；上とほぼ同じ
    public function toExcel(Request $request) {
        exec("which python", $pythonpath);

        $scriptpath = app_path('Python/textgrid_to_excel.py');
        $filepath = storage_path('app/' . $request->input('filepath'));

        $command = $pythonpath[0] . ' ' . $scriptpath . ' ' . $filepath;
        exec($command);
    }

    // ファイルのリストを取得する
    public function getFileList(){
        // true は .gitignore などの dotfile を除外する
        $files = Storage::allfiles('public/', true);
        // SplFileInfo 型は javascript 上で扱いにくいので、ファイルパス文字列にして返す（悪？）
        $filepaths = explode('#', implode('#', $files));
        return $filepaths;
    }

    // ファイルを削除する
    public function deleteFile(Request $request){
        $filepath = $request->input('filepath');
        Storage::delete($filepath);
    }
}
```

### Python スクリプトの実行
サーバーに Python がインストールされていさえすれば、PHP の `exec` コマンドで Python を動かすことができます。後述しますが、Heroku ビルド時に Python と使用するモジュールを忘れずにインストールしておきましょう。

- [PHP: exec - Manual ](https://www.php.net/manual/ja/function.exec.php)
- [phpからpythonを呼び出し実行結果を利用 | Qiita](https://qiita.com/minamoto_user/items/f90ceb2f88994639ca95)

Heroku は Linux 系なので[^dyno]、Linux コマンドを意識して書いていきます。今回はコンテナ仮想化などはせず Windows10 で開発しましたが、本記事で扱うのはちょっとしたものなので、大きな問題はありませんでした。

[^dyno]: Heroku では Dyno という、Amazon EC2 の巨大インスタンス上で動作する軽量 Linux コンテナが使われています。 

実行までの手順はシンプルです。今回使用するスクリプトは「対象ファイルのパスを与えると、そのファイルを変換して、同ディレクトリに保存する」ものですので、**Python 実行ファイルのパス・スクリプトのパス・対象ファイルのパス**を取得して、それをもとにコマンドを組み立てるだけです。今回、スクリプトは `/app/Python` 下に入れてあるので、`app_path` などのパスヘルパを使用して無難にパスを取得します（ヘルパを使わないとルートのずれに対して不安定になる）。

- [Laravel 7.x ヘルパ | ReaDouble](https://readouble.com/laravel/7.x/ja/helpers.html)

```php
<?php
// Excel を TextGrid に変換して保存
public function toTextgrid(Request $request) {
    // 実行環境での python へのパスを取得
    // Windows cmd なら exec("where python", $pythonpath);
    exec("which python", $pythonpath);

    // 実行したい python スクリプトのパスを取得
    $scriptpath = app_path('Python/excel_to_textgrid.py');

    // POST されてきた filepath を取得して適切な相対パスに変換
    $filepath = storage_path('app/' . $request->input('filepath'));

    // コマンドを組み立てて実行
    // 環境に複数バージョンの Python がある場合は index に注意
    $command = $pythonpath[0] . ' ' . $scriptpath . ' ' . $filepath;
    exec($command);
}
```

なお、ここで使用したスクリプトは入力ファイルと同じディレクトリに出力ファイルを保存する設定になっています。

## 完成図
こんな感じになっているはずです。

`/convert`
![convert.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/526487/83a8ca84-2986-e9d0-c8f9-ab7cbd36af0b.png)


## 改善点
既に言及したエラーハンドリングなどはもとより、セキュリティ上は `exec` が大きな問題です。一般にユーザーが改竄できるデータを PHP の `exec` 関数にそのままぶち込むのは大変危険ですので、適切にエスケープする必要があります。今回はいちおう Laravel のパスヘルパを通しているので大丈夫じゃないかな、と思いますが、ヘルパの正確な挙動を把握していない限りは万全を期したほうがよいでしょう。

- [PHP: escapeshellcmd - Manual ](https://www.php.net/manual/ja/function.escapeshellcmd.php)
- [PHP: escapeshellarg - Manual ](https://www.php.net/manual/ja/function.escapeshellarg.php)

## 次回
Heroku に上げていきます（最終回）。

- 第９回： [日本語諸方言コーパスをDB化して遊ぶ (9) Heroku でデプロイする](https://qiita.com/a_eau_/items/1ae966836d0cf4a6ba0e)