サードパーティーを用いない Google Apps Script と Javascript によるファイルピッカー

# 概要

この投稿ではサードパーティーを用いない Google Apps Script と Javascript によるファイルピッカーを紹介します。これまでの問い合わせで、開発者側の仕様でサードバーティを使用しないファイルピッカーがあれば便利とのご意見が多かったため、このようなサンプルを作成しました。このファイルピッカーの特徴は、次の通りです。

1. 特定のフォルダ下のファイルを取得できる。
2. 自分のアカウントの Google Drive だけでなく、サービスアカウントの Drive も利用できる。

リポジトリは[こちら](https://github.com/tanaikech/File_Picker_using_Google_Apps_Script_and_Javascript_without_3rd_party)です。

# デモ

![fig1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/133383/e52a3463-43fa-df19-0b85-6966ac55b7eb.gif)


# 使用方法

この場合、サンプルスクリプトを実行するための使用方法です。実際に使用される際は、それに合わせて調整してください。

## 1. スプレッドシートを新規作成する

このサンプルスクリプトでは、スプレッドシートのサイドバーを利用しますので、新規でスプレッドシートを作成します。そして、スクリプトエディタを開きます。

## 2. Drive API を有効にする

Advanced Google services で Drive API を有効にしてください。 [Ref](https://developers.google.com/apps-script/guides/services/advanced#enable_advanced_services)

## 3. サンプルスクリプト

### `Google Apps Script side: Code.gs`

下記のスクリプトを Google Apps Script ファイルへコピーペーストしてください。このサンプルでは、自分のGoogle Driveのファイルが表示されます。

```javascript
function getFiles(e, rootFolderId, mimeType) {
  const accessToken = ScriptApp.getOAuthToken(); // In this case, the files and folders are retrieved from your own Google Drive.
  mimeType = mimeType || "*";
  const data = {};
  const idn = e || "root";
  const url1 = `https://www.googleapis.com/drive/v3/files/${e}?fields=id%2Cname%2Cparents`;
  const folderObj = JSON.parse(
    UrlFetchApp.fetch(url1, {
      headers: { authorization: `Bearer ${accessToken}` },
    }).getContentText()
  );
  if (e == "root") e = folderObj.id;
  data[e] = {
    keyname: folderObj.name,
    keyparent: idn == rootFolderId ? null : folderObj.parents[0],
    files: [],
  };
  let pageToken = "";
  do {
    const q =
      mimeType == "*"
        ? `'${e}' in parents and trashed=false`
        : `'${e}' in parents and (mimeType = 'application/vnd.google-apps.folder' or mimeType = '${mimeType}') and trashed=false`;
    const url2 = `https://www.googleapis.com/drive/v3/files?fields=nextPageToken%2Cfiles%28id%2Cname%2CmimeType%29&q=${encodeURIComponent(
      q
    )}&orderBy=name&pageSize=1000&pageToken=${pageToken}`;
    const res = UrlFetchApp.fetch(url2, {
      headers: { authorization: `Bearer ${accessToken}` },
    });
    const fileList = JSON.parse(res.getContentText());
    if (fileList.files.length > 0) {
      data[e].files = data[e].files.concat(
        fileList.files.map(({ id, name, mimeType }) => ({
          name,
          id,
          mimeType: mimeType == MimeType.FOLDER ? "folder" : mimeType,
        }))
      );
    }
    pageToken = fileList.nextPageToken;
  } while (pageToken);
  return data;
}

// DriveApp.getFiles()  // This is used for automatically detecting the scope of "https://www.googleapis.com/auth/drive.readonly". Also, you can use the scope of "https://www.googleapis.com/auth/drive".

// When the file is selected, this function is run from `work(value)` in Javascript side.
function doSomething(id) {
  // do something
  var res = id;
  return res;
}

// Please run this function.
function main() {
  SpreadsheetApp.getUi().showSidebar(
    HtmlService.createTemplateFromFile("index")
      .evaluate()
      .setTitle("Sample File Picker")
  );
}
```

### `HTML & Javascript side: index.html`

下記のスクリプトを HTML ファイルへコピーペーストしてください。

```html
<style>
  #filePicker {
    text-align: left;
    width: 95%;
    font-size: 1em;
    margin: auto;
    height: 3em;
  }
</style>

<select id="filePicker"></select>
<div id="result"></div>

<script>
  const rootFolderId = "root"; // Please set the top folder ID here.
  const emimeType = "*"; // In this case, all files are shown.
  //   const emimeType = "application/pdf"; // When you use this line, you can show only files with this mimeType.

  // --- When you want to run the script for the selected file, please modify this function.
  const work = (...args) =>
    google.script.run.withSuccessHandler(output).doSomething(...args);

  function output(res) {
    document.getElementById("result").innerHTML = res;
  }
  // ---

  const alldata = {};
  const getFileList = (...args) =>
    google.script.run.withSuccessHandler(importData).getFiles(...args);
  getFileList(rootFolderId, rootFolderId, emimeType);

  document.getElementById("filePicker").addEventListener("change", function () {
    const { value, options } = this;
    const disp = options[options.selectedIndex].text;
    if (disp.includes("Folder") || disp.includes("../")) {
      while (this.lastChild) this.removeChild(this.lastChild);
      if (alldata[value]) {
        importData({ [value]: alldata[value] });
        return;
      }
      getFileList(value, rootFolderId, emimeType);
      return;
    }
    work(value);
  });

  function importData(e) {
    const key = Object.keys(e)[0];
    if (!alldata[key]) alldata[key] = e[key];
    const select = document.getElementById("filePicker");
    const obj = e[key]["keyparent"]
      ? [
          { text: `./${e[key]["keyname"]}`, value: key },
          { text: "../", value: e[key]["keyparent"] },
        ]
      : [{ text: `./${e[key]["keyname"]}`, value: key }];
    obj.forEach(({ text, value }) => {
      const option = document.createElement("option");
      option.textContent = text;
      option.value = value;
      select.appendChild(option);
    });
    e[key]["files"].forEach(({ mimeType, name, id }) => {
      const option = document.createElement("option");
      option.textContent = mimeType == "folder" ? "[Folder]" + name : name;
      option.value = id;
      select.appendChild(option);
    });
  }
</script>
```

- トップフォルダを変更する場合は、`const rootFolderId = "root";`を`const rootFolderId = "### folder ID ###";`のように変更してください。

- また、mimeType でフィルタをしたい場合は、`const emimeType = "*";`を`const emimeType = "### mimeType ###";`に変更してください。

## サービスアカウントを使用する場合

サービスアカウントを使用する場合は、Google Apps Script を下記のように変更してください。

```javascript
// This script is from https://gist.github.com/tanaikech/20ea127a8e23a7c609f8d764c8b7ed7c
function getAccessTokenFromServiceAccount(scopes) {
  const private_key =
    "-----BEGIN PRIVATE KEY-----\n-----END PRIVATE KEY-----\n"; // private_key of JSON file retrieved by creating Service Account
  const client_email = "###"; // client_email of JSON file retrieved by creating Service Account
  const url = "https://www.googleapis.com/oauth2/v3/token";
  const header = { alg: "RS256", typ: "JWT" };
  const now = Math.floor(Date.now() / 1000);
  const claim = {
    iss: client_email,
    scope: scopes.join(" "),
    aud: url,
    exp: (now + 3600).toString(),
    iat: now.toString(),
  };
  const signature =
    Utilities.base64Encode(JSON.stringify(header)) +
    "." +
    Utilities.base64Encode(JSON.stringify(claim));
  const jwt =
    signature +
    "." +
    Utilities.base64Encode(
      Utilities.computeRsaSha256Signature(signature, private_key)
    );
  const params = {
    method: "post",
    payload: {
      assertion: jwt,
      grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
    },
  };
  const data = UrlFetchApp.fetch(url, params).getContentText();
  const obj = JSON.parse(data);
  return obj.access_token;
}

function getFiles(e, rootFolderId, mimeType) {
  const scopes = ["https://www.googleapis.com/auth/drive.readonly"];
  const accessToken = getAccessTokenFromServiceAccount(scopes); // In this case, the files and folders are retrieved from Google Drive of the service account.

  mimeType = mimeType || "*";
  const data = {};
  const idn = e || "root";
  const url1 = `https://www.googleapis.com/drive/v3/files/${e}?fields=id%2Cname%2Cparents`;
  const folderObj = JSON.parse(
    UrlFetchApp.fetch(url1, {
      headers: { authorization: `Bearer ${accessToken}` },
    }).getContentText()
  );
  if (e == "root") e = folderObj.id;
  data[e] = {
    keyname: folderObj.name,
    keyparent: idn == rootFolderId ? null : folderObj.parents[0],
    files: [],
  };
  let pageToken = "";
  do {
    const q =
      mimeType == "*"
        ? `'${e}' in parents and trashed=false`
        : `'${e}' in parents and (mimeType = 'application/vnd.google-apps.folder' or mimeType = '${mimeType}') and trashed=false`;
    const url2 = `https://www.googleapis.com/drive/v3/files?fields=nextPageToken%2Cfiles%28id%2Cname%2CmimeType%29&q=${encodeURIComponent(
      q
    )}&orderBy=name&pageSize=1000&pageToken=${pageToken}`;
    const res = UrlFetchApp.fetch(url2, {
      headers: { authorization: `Bearer ${accessToken}` },
    });
    const fileList = JSON.parse(res.getContentText());
    if (fileList.files.length > 0) {
      data[e].files = data[e].files.concat(
        fileList.files.map(({ id, name, mimeType }) => ({
          name,
          id,
          mimeType: mimeType == MimeType.FOLDER ? "folder" : mimeType,
        }))
      );
    }
    pageToken = fileList.nextPageToken;
  } while (pageToken);
  return data;
}

// DriveApp.getFiles()  // This is used for automatically detecting the scope of "https://www.googleapis.com/auth/drive.readonly". Also, you can use the scope of "https://www.googleapis.com/auth/drive".

// When the file is selected, this function is run from `work(value)` in Javascript side.
function doSomething(id) {
  // do something
  var res = id;
  return res;
}

// Please run this function.
function main() {
  SpreadsheetApp.getUi().showSidebar(
    HtmlService.createTemplateFromFile("index")
      .evaluate()
      .setTitle("Sample File Picker")
  );
}
```

# スコープについて

このサンプルスクリプトでは、`// DriveApp.getFiles()`のコメント行によって、`https://www.googleapis.com/auth/drive.readonly`のスコープを自動検出するように設定しています。この場合、`UrlFetchApp`によって、`https://www.googleapis.com/auth/script.external_request`のスコープも自動追加されます。

もしもさらに制限したスコープを使用したい場合は、Manifest ファイル(`appsscript.json`)へ`https://www.googleapis.com/auth/drive.metadata.readonly`と`https://www.googleapis.com/auth/script.external_request`のスコープを設定すると、このサンプルスクリプトは動作します。

もちろん、選択したファイルを他のスコープを使用するメソッドで使用する場合は、それぞれのスコープを追加してください。