0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-08-17

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

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

事前準備

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

cmd
php artisan storage:link

画面遷移図

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

func_3.png

コンポーネントのルーティング

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

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

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

コンポーネントの作成

1画面ではありますが、たくさん機能をつけるので前回までよりは複雑です。

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 のみではあまりいい感じになりません。いくつか簡便な手法が考案されていますが、今回は以下のサイトを参考にしました。

変換ボタン

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

<!-- .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() を使った手法はどうもうまくいかなかったので(数敗)3、直接リンクを貼る方法を採用しました。

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

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>

コントローラへのルーティング

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

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');

コントローラの作成

先ほど使用することにした5つの機能を実装します。

<?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 と使用するモジュールを忘れずにインストールしておきましょう。

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

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

<?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

改善点

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

次回

Heroku に上げていきます(最終回)。

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

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

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

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?