複数のGoogleスプレッドシートにスクリプトを仕込むスクリプト

ソースはこちら。
https://github.com/kuinaein/gas-deploy

# やりたいこと
Googleドライブ上の下記のような階層にある「XX管理ファイル」に一度にスクリプトを仕込みたい。

- ○○管理フォルダ ※共通の親
  - 事業所A
      - <font color="blue">**2018_事業所A_XX管理ファイル**</font>
  - 事業所B
      - <font color="blue">**2018_事業所B_XX管理ファイル**</font>
  - 事業所C
      - <font color="blue">**2018_事業所C_XX管理ファイル**</font>

# ペインポイント
- 事業所毎（10箇所ほど）に作成・管理しているスプレッドシートがあり（以下略）
  - 業務上の ~~愚痴~~ 要件については[前回（Google Apps Script でライブラリからサイドバーを表示させる）](https://qiita.com/kuinaein/items/835b877a593065495e27)と同じなので省略
- 2018-09-02現在、「他のドキュメントに紐付いているスクリプト」のIDを外から取得する方法がないらしい
  - Drive API、Sheets API、Apps Script APIのいずれからも取得できない
  - clasp（Google謹製のApps Script操作用コマンドラインツール）の[このあたりのissue](https://github.com/google/clasp/issues/208)参照
- スクリプト（というかApps Scriptプロジェクト）のIDさえわかっていればApps Script APIで操作できるので、どうにかして紐付いているドキュメントにスクリプトのIDを書き込む必要がある
  - あるいはドキュメントに紐づく新しいScriptプロジェクトを作成してそいつのIDを得ることは可能
- Googleドライブ上のファイルにはプロパティ（メタデータ）が存在するが、ドライブの画面上からは確認できないし、Apps Scriptでは拡張サービスを使う必要がある。（DriveAppでは読み書きできない）
- Apps Script APIはApps Scriptからは拡張サービスとしても提供されていないので、URLFetchAppで直接叩く必要がある
  - （API認証に`ScriptApp.getOAuthToken()`を使う場合は）マニフェストのoauthScopesを自分で書くことになる
- 社内でNode.jsなんて使っているのが自分しかいないので、基本Apps Scriptとして組む必要がある ← New!
  - この条件がなければ全面的にclaspを使ったほうが楽そうな気も。。

# やること
初期状態のApps Scriptで呼び出せないAPIを使うので多少準備がいりますが、やることは単純です。

1. 仕込みたいスクリプトを、接頭辞`dist/`を付けて仕込み用スクリプトのプロジェクトに入れておく
  - `appsscript.json`は必須ですが、そのまま作成できないのでHTMLファイル`dist/appsscript.json.html`として置いておきます。
  - プロジェクト内ではフォルダなんて概念はありません。
1. 仕込み用スクリプトのプロジェクトでDrive API、Apps Script APIを有効にする
1. DriveApp を使って対象ドキュメント（スプレッドシート）を列挙
  - `getFolderById() `から子を辿るだけの簡単なお仕事なので省略。
1. Apps Script APIで対象ドキュメントにスクリプトのプロジェクトを作成し、そのIDを当該ドキュメントのプロパティに書き込んでおく
  - 2度目以降はそのプロパティを見てスクリプトプロジェクトを更新
  - なおAPIからだと一つのドキュメント内に複数のスクリプトプロジェクトを作成することが可能です。
1. Apps Script APIで上記スクリプトプロジェクトの内容（ファイル群）を更新

## APIの有効化
G Suite（法人向け）の場合は管理者への申請が必要になるかもしれません。

- Google Drive API
- Apps Script API

まずCloud Platform側で上記APIを有効にする必要があります。メニュー「リソース＞Cloud Platform」で開かれるダイアログの右下、「APIコンソールを表示」というボタンから飛ぶ先で設定できます。

次にスクリプト側の設定を行いますが、今のところApps Script APIはApps Scriptから拡張サービス等として呼び出すことができないため、これの呼び出し権限を手で設定してやる必要があります。

メニューの「表示＞マニフェストファイルを表示」という項目を有効にすると`appsscript.json`というファイルが出てくるので、それを編集します。

```json:appsscript.json
{
  "timeZone": "Asia/Tokyo",
  "dependencies": {
    "enabledAdvancedServices": [
      {
        "userSymbol": "Drive",
        "serviceId": "drive",
        "version": "v2"
      }
    ]
  },
  "exceptionLogging": "STACKDRIVER",
  "oauthScopes": [
    "https://www.googleapis.com/auth/script.projects",
    "https://www.googleapis.com/auth/drive",
    "https://www.googleapis.com/auth/script.external_request"
  ]
}
```

（説明が面倒なので）上記でDrive拡張サービスの設定も一緒にしてしまっています。

## スクリプトプロジェクトの作成と、ドキュメントのプロパティへのID書き込み
ファイルの列挙はDriveAppで難なく終わるので省略します。

プロジェクトの作成自体は簡単で、`https://script.googleapis.com/v1/projects`にPOSTリクエストを投げ込むだけです。

```js
var proj = callScriptApi('projects', 'POST', {
  title: doc.getName(),
  parentId: doc.getId(),
  // スプレッドシート等でなくフォルダ直下に独立したファイルして作成することも可能
});

// ...

function callScriptApi(path, method, payload) {
  var opts = {
    method: method || 'GET',
    headers: {Authorization: 'Bearer ' + ScriptApp.getOAuthToken()},
  };
  if (payload) {
    opts.payload = JSON.stringify(payload);
    opts.contentType = 'application/json';
  }
  var res =
    '' + UrlFetchApp.fetch('https://script.googleapis.com/v1/' + path, opts);
  return JSON.parse(res);
}
```

プロジェクト作成が成功するとそのデータが返ってくるので、スクリプトIDを親ドキュメントのプロパティに書き込みます。

```js
var newProp = {key: PROPERTY_KEY, value: proj.scriptId, visibility: 'PUBLIC'};
if (currentProp) {
  Drive.Properties.update(newProp, doc.getId(), 
  PROPERTY_KEY, {
    visibility: 'PUBLIC',
  });
} else {
  Drive.Properties.insert(newProp, doc.getId());
}
```

## 対象スクリプトプロジェクト内へのファイル群のプッシュ
スクリプトを作成しただけでは中身は空なのでファイルを書き込みます。

まず書き込む内容をまとめます。実は`HtmlService().createTemplateFromFile(...).getRawContent()`でも読み込めるのですが、今回は自分自身を対象にApps Script APIを投げて内容を取得してみます。

```js
var contents = callScriptApi(
  'projects/' + ScriptApp.getScriptId() + '/content'
);
var files = [];
var DIST_PATTERN = /^dist\//;
for (var i = 0; i < contents.files.length; ++i) {
  var f = contents.files[i];
  if (DIST_PATTERN.test(f.name)) {
    var name = f.name.replace(DIST_PATTERN, '');
    // APIの構造体では拡張子はついていない
    var isManifest = 'appsscript.json' === name;
    files.push({
      name: isManifest ? 'appsscript' : name,
      // typeは基本的に'SERVER_JS'か'HTML'以外エラー
      type: isManifest ? 'JSON' : f.type,
      source: f.source,
    });
  }
}
```

あとはこれを更新用APIに投げ込めばOK。更新時はPOSTじゃなくてPUTリクエストというところに注意。POSTで投げると404が返ってきます。

```js

callScriptApi('projects/' + proj.scriptId + '/versions', 'POST', {
  description: 'デプロイスクリプト: 変更前の状態を保存',
});
callScriptApi('projects/' + proj.scriptId + '/content', 'PUT', {
  files: files,
});
```

なお、Script APIではプロジェクト全体書き換えしかできないようです。リクエストボディに含めなかったファイルは消えるので、もし「一部のファイルだけ更新したい」という場合は直前のデータを取得して自分でマージする必要があります。。