Boxアプリ統合でファイル翻訳

# 概要

この記事では、Boxアプリ統合でファイルを翻訳する方法を紹介します。

![BoxFileTranslatorWebAppIntegration-JA.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/895687/cf259177-1333-d2a6-74eb-a0abb4fc0524.gif)


Boxファイルの翻訳を実現するには、3つのコンポーネントを使います：Box API、Google Cloud Functions、 Google Cloud Translation API。ファイルはBoxにあり、翻訳はGoogle Cloud Translation APIで行いますが、Google Cloud Functionsの関数がその2つを繋げます。

流れは下記の通りになります：
1. ユーザーはBox上でファイルを選択し、右クリックで出るアプリ統合メニューからファイル翻訳を開始します。
2. Box Web統合で設定されたコールバックURL（Google Cloud Functions関数のHTTPトリガーURL）にリクエストを送ります。
3. Google Cloud Functionsの関数が言語選択ページを返し、Boxはそのページをユーザーに表示します。
4. ユーザーは言語を選択し、その言語とファイル詳細（ID、形式）をまたGoogle Cloud Functionsの関数に送ります。
5. Google Cloud Functionsの関数はファイルIDを使って、Box API経由でファイルを取得します。
6. Google Cloud Functionsの関数が取得ファイルをGoogle Cloud Translation APIに送ります。
7. Google Cloud Translation APIが翻訳したファイルをGoogle Cloud Functionsの関数に返します。
8. Google Cloud Functionsの関数は翻訳されたファイルを新しいバージョンとしてBox API経由でアップロードします。

<img width="1280" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/895687/c5929249-d240-3408-18ce-dc993f379483.png">

:::note warn
Google Cloud Translationを使うとき、料金がかかる場合があります。現時点で、テキストファイル以外を翻訳するには、ページごとに料金がかかります。詳細は[Google Cloud Translationの料金ページ](https://cloud.google.com/translate/pricing )で確認できます。
:::

:::note warn
この記事では、なるべくわかりやすくするため、コードを極力省いています。
セキュリティ面での行うべき考慮を省略し、エラーハンドリングなども実装されていません。
具体的な実装時には非機能面の考慮と実装方法を開発者が行う必要があります。
:::

# 準備

Google Cloud Platform (GCP)とBox上の必要な準備をここで説明します。

## Google Cloud Platform (GCP)
Google Translate APIを利用する前に下記の手順が必要です：
1. [プロジェクトを作成または選択する](https://cloud.google.com/translate/docs/setup#project)
2. [課金を有効にする](https://cloud.google.com/translate/docs/setup#billing)
3. [APIを有効化する](https://cloud.google.com/translate/docs/setup#api)
4. [認証を設定する](https://cloud.google.com/translate/docs/setup#auth)

詳細は[オフィシャル設定ガイド](https://cloud.google.com/translate/docs/setup)を参考にしてください。

## Box

[オフィシャル設定ガイド](https://ja.developer.box.com/guides/applications/web-app-integrations/configure/)に従って、BoxのWebアプリ統合を作ることができますが、このデモで使用される設定を説明します。

まずは、[開発者コンソール](https://app.box.com/developers/console)で[OAuth 2.0認証](https://ja.developer.box.com/guides/authentication/oauth2/oauth2-setup/)を利用する[カスタムアプリ](https://ja.developer.box.com/guides/applications/custom-apps/)を作成します。そして、変更が必要な設定は下記通りです。

- アプリの「構成」タブの「アプリケーションスコープ」セクションで、「Boxに格納されているすべてのファイルとフォルダへの書き込み」をチェックします。自動的に、「Boxに格納されているすべてのファイルとフォルダの読み取り」もチェックされます。他の項目にチェックが付いていないままで、「変更を保存」ボタンをクリックします。これで、Webアプリ統合がファイルの新バージョンを作成するこたできるようになります。

<img width="1280" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/895687/60bb52e3-8458-fa7f-3e54-1c57b6db2a9c.png">

- アプリの「統合」タブで、「新しいウェブアプリ統合を作成」ボタンをクリックすると、アプリ統合が作成されます。作成されたWebアプリ統合の設定を次のようにします：
  - Google Cloud Translationが対応するファイル形式のみWebアプリ統合を表示するには「サポートされるファイル拡張子」で「特定のファイル拡張子のみサポートする」を選択し、`docx,pdf,pptx,xlsx,txt`を入力します。
  - 新バージョンのアップロードもするので、「必要な権限」で「すべての権限が必要」を選択します。
  - 翻訳するファイルだけを使うので、「統合の範囲」で「この統合の対象となるファイル/フォルダ」を選択します。そして、翻訳中でファイルが編集されないように「ロックして、この統合を使用したファイルの上書きを現在のユーザーにのみ許可」をオンにします。
  - ファイル1つずつ翻訳する仕組みになっているので、「統合の種類」を「ファイル」を選択します。

<img width="1280" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/895687/d4d86836-bd59-048e-ba19-bd2222acbb88.png">

- 「コールバック構成」セクションで「クライアントコールバックのURL」をGoogle Cloud Functionsの関数の[HTTPトリガー](https://cloud.google.com/functions/docs/calling/http)URLを入力します。現時点で、そのURLのフォーマットは`https://[リージョン]-[GCPプロジェクトID].cloudfunctions.net/[関数名]`です。Cloud Functionsの関数を[デプロイ](https://cloud.google.com/functions/docs/deploy#gcloud)したら、関数のHTTPトリガーURLがGCPコンソールで確認できますが、とりあえず作成したGCPプロジェクト名だけを置き換えて、`https://us-central1-my-project-id.cloudfunctions.net/box_file_translator_web_app_integration`のように設定します。
- 「コールバックパラメータ」セクションで、BoxがGoogle Cloud Functionsの関数に送るデータを設定します：

| メソッド | パラメータ名 | パラメータ値 | 説明 |
|:-:|:-:|:-:|:-:|
| Get  | fileId        | #file_id#         | ファイルIDはファイルをダウンロード・アップロードするために使います。  |
| Get  | fileExtension  | #file_extension#  | ファイル形式は利用するGoogle Cloud Translationのメソッド（「テキスト翻訳」または「ドキュメント翻訳」）を決めるために使います。  |
| Get  | authCode       | #auth_code#       | 承認コードはBox APIとの接続に必要なアクセスコードを発行するために使います。  |

<img width="1280" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/895687/9a7875e5-ec88-65ba-9629-fe16ca634327.png">

設定ができたら、「変更を保存」ボタンをクリックします。


# 実装

このデモをNode.jsで実装しますので、プロジェクトの初期化とパッケージのインストールをします：
```sh
# デモプロジェクトフォルダを作成する
mkdir box_file_translator_web_app_integration
cd box_file_translator_web_app_integration

# プロジェクトを初期化する
npm init -y  

# 必要なパッケージをインストールする
npm install @google-cloud/functions-framework @google-cloud/translate box-node-sdk

# 実際のソースコードファイルを作成する
touch index.js
touch languageSelectionPageTemplate.html
```

ソースコードの流れは次のように分かれています：
1. 翻訳言語がパラメータはHTTPクエリにある場合は、`fileTranslator`関数がファイルをBoxからダウンロードし、Google Cloud Translation APIに投げます。翻訳されたファイルを新バージョンとしてBoxにアップロードします。
2. 翻訳言語がパラメータはHTTPクエリにない場合は、`languageSelector`関数が言語選択用のHTMLページテンプレートを読み込み、そのページから関数を呼び出せるようにいくつかの値を置き換え、HTTPレスポンスとしてHTMLを返します。

実際に翻訳する関数（`translateFile`）で、ファイル形式によってGoogle Cloud Translation APIメソッドが決まります：
1. テキストファイルであれば、[テキストの翻訳](https://cloud.google.com/translate/docs/advanced/translating-text-v3)は使用されます。
2. `xlsx`、`docx`、`pptx`、`pdf`であれば、[ドキュメントの翻訳](https://cloud.google.com/translate/docs/advanced/translate-documents)は使用されます。繰り替えになりますが、現時点でドキュメントを翻訳するにはページごとに料金がかかります。

```javascript:index.js
const { TranslationServiceClient } = require("@google-cloud/translate").v3;
const fs = require("fs");
const BoxSDK = require("box-node-sdk");
const Readable = require("stream").Readable;

// Google Cloud Translationが対応するファイル形式：
// https://cloud.google.com/translate/docs/advanced/translate-documents
const mimeTypes = {
  txt: "text/plain",
  docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
  pdf: "application/pdf",
  pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
  xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
};

// このデモはあまり複雑ならないように、1つのエントリーポイントで言語選択とファイル翻訳を対応する
exports.box_file_translator_web_app_integration = async function (req, res) {
  if (!req.query.targetLanguage) {
    languageSelector(req, res);
  } else {
    fileTranslator(req, res);
  }
};

// Boxからパラメータを使い、言語選択ページを用意し、HTMLを返す
async function languageSelector(req, res) {
  try {
    const languageSelectionPageTemplate = fs
      .readFileSync("languageSelectionPageTemplate.html")
      .toString();
    const languageSelectionPage = languageSelectionPageTemplate
      .replaceAll("FILE_ID", req.query.fileId)
      .replaceAll("FILE_EXTENSION", req.query.fileExtension)
      .replaceAll("AUTH_CODE", req.query.authCode)
      .replaceAll("FUNCTION_LOCATION", process.env.FUNCTION_LOCATION)
      .replaceAll("PROJECT_ID", process.env.PROJECT_ID)
      .replaceAll("FUNCTION_NAME", process.env.FUNCTION_NAME);

    res.status(200).send(languageSelectionPage);
  } catch (error) {
    res
      .status(500)
      .send(`言語選択ページを表示することができませんでした.`);
    throw error;
  }
}


// ファイルのダウンロード・翻訳・アップロードする
async function fileTranslator(req, res) {
  try {
    const targetLanguage = req.query.targetLanguage;
    const fileId = req.query.fileId;
    const fileExtension = req.query.fileExtension;
    const authCode = req.query.authCode;

    // Box APIクライアント初期化
    const sdk = new BoxSDK({
      clientID: process.env.CLIENT_ID,
      clientSecret: process.env.CLIENT_SECRET,
    });
    const tokenInfo = await sdk.getTokensAuthorizationCodeGrant(authCode);
    const client = sdk.getBasicClient(tokenInfo.accessToken);

    // Box APIからファイルストリームを取得
    const fileStream = await client.files.getReadStream(fileId);

    // ファイルを翻訳
    const translatedFileContents = await translateFile(
      fileStream,
      fileExtension,
      targetLanguage
    );

    // 大きなファイルの場合、分割アップロードは推薦される：
    // http://opensource.box.com/box-node-sdk/jsdoc/Files.html#getNewVersionChunkedUploader
    await client.files.uploadNewFileVersion(
      fileId,
      Readable.from(translatedFileContents),
      {
        content_length: Buffer.byteLength(translatedFileContents),
      }
    );

    res.status(200).send(`ファイルは翻訳されました。`);
  } catch (error) {
    res.status(500).send(`ファイルを翻訳することができませんでした。`);
    throw error;
  }
}

// ファイルをGoogle Cloud Translationに送って、翻訳が返される
async function translateFile(fileStream, fileExtension, targetLanguage) {
  // 翻訳クライアントを初期化する
  const translationClient = new TranslationServiceClient();
  const request = {
    parent: translationClient.locationPath(
      process.env.PROJECT_ID,
      process.env.FUNCTION_LOCATION
    ),
    targetLanguageCode: targetLanguage,
  };

  // 利用する翻訳メソッドはファイル形式で決まります
  switch (fileExtension) {
    case "txt": {
      request.contents = [await streamToBytesArray(fileStream)];
      request.mimeType = mimeTypes[fileExtension];

      const response = await translationClient.translateText(request);

      for (const element of response) {
        if (element == null) continue;
        return element["translations"][0]["translatedText"];
      }
    }

    case "docx":
    case "xlsx":
    case "pptx":
    case "pdf": {
      request.documentInputConfig = {
        content: await streamToBytesArray(fileStream),
        mimeType: mimeTypes[fileExtension],
      };
      const response = await translationClient.translateDocument(request);
      return response[0].documentTranslation.byteStreamOutputs[0];
    }

    default:
      throw new Error(`${fileExtension}形式ファイルを対応できません。`);
  }
}

// ストリームをバイト列に変換する
function streamToBytesArray(stream) {
  const chunks = [];
  return new Promise((resolve, reject) => {
    stream.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
    stream.on("error", (err) => reject(err));
    stream.on("end", () => resolve(Buffer.concat(chunks)));
  });
}
```
```html:languageSelectionPageTemplate.html
<!DOCTYPE html>
<html>
  <head>
    <base target="_top" />
    <link
      rel="stylesheet"
      href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css"
    />
    <style>
      .container {
        width: 55%;
      }
    </style>
  </head>

  <body>
    <div id="container" class="container">
      <div id="section" class="section">
        <h5>Boxアプリ統合デモ - Google Cloud Translation連携</h5>
        <p>
          言語を選択してください。
        </p>
      </div>
      <div class="row">
        <form
          id="form"
          onsubmit="event.preventDefault(); submitForm(this)"
          class="col s12"
        >
          <div class="row">
            <div class="input-field col s12">
              <label class="active" for="targetLanguage">言語</label>
              <select id="targetLanguage" name="targetLanguage" required>
                <option value="en">English</option>
                <option value="ja">日本語</option>
                <option value="pt">Português</option>
                <option value="es">Español</option>
                <option value="el">Ελληνικά</option>
              </select>
            </div>
          </div>
          <div class="row">
            <div class="center-align">
              <button class="btn" type="submit">翻訳</button>
            </div>
          </div>
        </form>
      </div>
    </div>
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
    <script src="https://code.jquery.com/ui/1.13.0/jquery-ui.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script>
    <script>
      $(document).ready(function () {
        $("select").formSelect();
      });

      function submitForm(form) {
        $.post(
          "https://FUNCTION_LOCATION-PROJECT_ID.cloudfunctions.net/FUNCTION_NAME?" +
            "targetLanguage=" +
            $("#targetLanguage").val() +
            "&fileId=FILE_ID" +
            "&fileExtension=FILE_EXTENSION" +
            "&authCode=AUTH_CODE"
        );
        window.top.close();
      }
    </script>
  </body>
</html>
```
:::note warn
言語選択ページは普通のHTMLで、`<select>`を使ってます。このデモで固定値として定義しましたが、Google Cloud Translation APIが対応する[言語を動的に取得できます](https://cloud.google.com/translate/docs/languages)。
:::

# デプロイ
関数をデプロイするには[CLIまたはGoogle Cloud Consoleが使えます](https://cloud.google.com/functions/docs/deploy)。

運用をしやすくするために、ソースコードで環境変数を使っている（例えば、`process.env.CLIENT_ID`）ので、デプロイする時に環境変数の設定が必要です。ソースコードで参照される変数は下記の通りです：
- *CLIENT_ID*: BoxアプリのクライアントIDです。
- *CLIENT_SECRET*: Boxアプリのクライアントシークレットです。
- *FUNCTION_LOCATION*: [リージョン一覧](https://cloud.google.com/functions/docs/locations)でいくつかローケーションがありますが、現時点でGoogle Cloud Translation APIが`global`と`us-central1`しか対応できないみたいです。他のリージョンを設定してみると、エラーが発生します。今回は`us-central1`を使います。
- *FUNCTION_NAME*: 関数の名前で、ソースコードで定義する関数名（`box_file_translator_web_app_integration`）と一致することが必要あります。
- *PROJECT_ID*: GCPのプロジェクトIDです。

:::note warn
Box APIとの接続に、Boxの開発者コンソールで作成したカスタムアプリのクライアントIDとクライアントシークレットを使います。それらのセキュリティを保護するために、[Secrets Manager](https://cloud.google.com/functions/docs/configuring/secrets)は推薦されますが、このデモの準備をあまり複雑にならないように、Cloud Functionsの[環境変数](https://cloud.google.com/functions/docs/configuring/env-var)として設定します。
:::

環境変数はCloud Functionの設定で定義できます：
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/895687/8ee4ee2d-5413-0a2f-f779-af31d28936b4.png">

CLIを使う場合は、`.env.yaml`ファイルを作成し、環境変数を定義することもできます：
```yaml:.env.yaml
CLIENT_ID: abc123
CLIENT_SECRET: xyz456
PROJECT_ID: my-project-id
FUNCTION_LOCATION: us-central1
FUNCTION_NAME: box_file_translator_web_app_integration
```
そして、次のコマンドでデプロイができます：
```
gcloud functions deploy box_file_translator_web_app_integration --trigger-http --runtime nodejs16 --allow-unauthenticated --region us-central1 --env-vars-file .env.yaml
```
# まとめ

Box上で選択したファイルを自動的に翻訳する方法を紹介しました。これでBoxの画面から、色々な言語の資料（PDFやWordやPowerPointなど）を簡単に自分の言語にすることができます。特に、ドキュメント翻訳によってフォーマットを変更せずに文書だけは翻訳され、便利です。

今回はGoogle Cloud Platformの製品（Google Cloud FunctionsとGoogle Cloud Translation）を使い実装しましたが、同じ仕組みで他のクラウドプロバイダーの製品（例えば、AWS LambdaとAmazon Translate）あるいは普通のWebアプリケーションを利用することも可能です。その場合は、Box開発者コンソールで統合のクライアントコールバックのURLだけを変更すればWebアプリ統合が動きます。

このように、ファイル翻訳機能だけではなくWebアプリ統合によって色々なSaaS製品と連携し、Boxでできることが広がります。