スマホやPCのカメラでQRを連続して読み取り、スプレッドシートに記録するApps Scriptのウェブアプリ

JavaScriptのライブラリー [html5-qrcode](https://github.com/mebjas/html5-qrcode) を使えば、スマートフォンやPCのカメラを使ってQRコード[^1]を連続して読み取り、スプレッドシートに記録するウェブアプリを無料で作れます。バーコードもOKです🚀

[^1]: QRコードは株式会社デンソーウェーブの登録商標です。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">QRコードを「ぴっ」「ぴっ」と連続して読み取って、スプレッドシートに追記していくApps Scriptを作ってみました！ <a href="https://t.co/lYzWHGKhbh">https://t.co/lYzWHGKhbh</a> <a href="https://t.co/XH3kcYuNqo">pic.twitter.com/XH3kcYuNqo</a></p>&mdash; 高玉 広和 (@takatama_jp) <a href="https://twitter.com/takatama_jp/status/1496338090193199104?ref_src=twsrc%5Etfw">February 23, 2022</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

デモに使ったスプレッドシートとApps Scriptはこちらです。どうぞコピーしてご自由にお使いください[^2]。なお、Google Workspace（for Education）を使っている企業（学校）の方は、ブラウザーをシークレットモードにしてアクセスしてみてください。

[^2]: ただし、著作権は放棄しません。また、このスクリプトによって発生したいかなる不利益についても責任を負うことはできません。

https://docs.google.com/spreadsheets/d/1nRNWxmqc1h4uc7nUDGd3x0fu-sYtj0rXpkDNrLEwyD8/copy?usp=sharing

使っている技術については後述していきます。

ちなみに、GoogleのノーコードツールAppSheetを使えば、連続読み取りはできないものの、QRコードを使うアプリを簡単に作れます。よろしければご覧ください。

https://qiita.com/takatama/items/8c4f7f657d9925cde57f

# 注意事項

QRコードの読み取りは、端末やカメラの性能、光源などによって大きく左右されます。

QRコードリーダーとして使いたいスマホやPCと、読み取らせたいQRコードを準備の上、次のcodepen.ioのURLにアクセスし、期待通りの読み取りができることをお試しください。読み取りが成功すると、音が出ます。

https://codepen.io/takatama/full/dyZeWQJ

# 使い方

スプレッドシートをコピーして、Apps Scriptをウェブアプリとして公開します。

- スプレッドシートにアクセスして、上部メニュー「ファイル」>「コピーを作成」でコピーします。Apps Scriptも一緒にコピーされます。
- コピーしたスプレッドシートの上部メニュー「拡張機能」>「Apps Script」でスクリプトエディターを開きます。
- Apps Scriptのスクリプトエディター右上にある「デプロイ」ボタンから「新しいデプロイ」を選び、アプリの種類で「ウェブアプリ」を選択の上、デプロイします。
- 権限が求められたら付与してください。

URLが払い出されるので、PCもしくはスマートフォンのウェブブラウザーからアクセスしてください。

- アクセスしたら、Request Camera Permissions ボタンを押して、カメラを許可します。
- QRコードを読み取ると、スプレッドシートのシート「Data」に、日付と読み取り結果を追記していきます。
- iOSで音を鳴らす場合は、一度```Sound ON (iOS)```ボタンを押してください。Androidは何もしなくても音が鳴ります。

なお、スクリプトを書き換えた場合は、スクリプトエディターで再デプロイが必要になりますのでご注意ください（後述します）。

# 使っている技術

複数の技術を組み合わせて実現しています。

- バックエンド
    - Google Apps Script 
        - doGet() を使ったウェブアプリ化
        - スプレッドシートへのデータ追加
- フロントエンド
    - html5-qrcode
        - QRコードのスキャン
    - HTMLAudioElement
        - 読み取り音の再生
    - Google Apps Script
        - google.script.run() を使ったバックエンドのAPI呼び出し
- その他
    - IMAGE関数と、Google Charts API
        - QRコードの作成

## バックエンド

Apps Script は次の通りです。

```javascript:Code.gs
const SHEET_NAME = 'Data';

function doGet() {
  return HtmlService.createHtmlOutputFromFile('index')
    .addMetaTag('viewport', 'width=device-width, initial-scale=1');
}

function onScan(text) {
  SpreadsheetApp.getActiveSpreadsheet()
    .getSheetByName(SHEET_NAME).appendRow([new Date(), text]);
}
```

後述しますが、HTMLファイル`index`をApps Scriptに追加しています。

`doGet()`で、アクセスしてきたウェブブラウザーに`index`を渡します。スマートフォンに対応するため、`addMetaTag()`で画面が拡大しないよう調整しています。

`onScan(text)`はフロントエンドから呼び出します。スプレッドシートに日付と受け取ったデータ`text`を追記します。

### ウェブアプリの再デプロイ

ウェブアプリには2種類のURLがあります。「あれ？スクリプトを書き直したのに反映されないな？」と思ったら、URLを確認してみてください。

- 開発用
  - 末尾が`/dev`のURLです。
  - 「新しいデプロイ」の後、「デプロイをテスト」で表示されるURLです。
  - スクリプトエディターで保存したスクリプトがそのまま動きます。
- 本番用
  - 末尾が`/exec`のURLです。
  - 「新しいデプロイ」や「スクリプトを管理」で表示されるURLです。
  - 再デプロイした時点のスクリプトで動作します。

開発用のURLで正しく動作することを確認したら、再デプロイをして、本番用の動作を変更します。再デプロイするには「デプロイを管理」を使います。

- スクリプトエディター右上の「デプロイ」>「デプロイを管理」を選択
- 鉛筆アイコンを押して、バージョンを「新しいバージョン」にして、デプロイ

このやり方なら、新しいデプロイで払い出されたURLはそのままで、動作を変更していくことができます。

https://developers.google.com/apps-script/guides/web

## フロントエンド

Apps Scriptは次の通りです。

```html:index.html
<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
  </head>
  <body>
    <div id="reader" style="width:340px;"></div>
    <button id="sound">Sound ON (iOS)</button>
    <div id="result"></div>
    <script src="https://unpkg.com/html5-qrcode"></script>
    <script>
let CURRENT_TEXT = '';

const append = (text) => {
  document.querySelector('#result').innerHTML += `${text}<br>`; 
};

const onScanSuccess = (decodedText, decodedResult) => {
  console.log('onScanSuccess', decodedText, CURRENT_TEXT);
  if (CURRENT_TEXT !== decodedText) {
    CURRENT_TEXT = decodedText;
    append(decodedText);
    beep();
    google.script.run
      .withFailureHandler(function (error) {
        append(`"${decodedText}"の保存に失敗しました: ${error}`);
      })
      .onScan(decodedText);
  }
};

const html5QrcodeScanner = new Html5QrcodeScanner("reader", { fps: 10, qrbox: 240 });
html5QrcodeScanner.render(onScanSuccess);

const DATA_URL = "data:audio/wav;base64,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qJrx4KOQjahZxWbcZgztj2c49nKmkId44S71j0c8eV9yDK6uPRzx5X18eDvjvQ6yKo9ZSS6l//8elePK/Lf//IInrOF/FvDoADYAGBMGb7FtErm5MXMlmPAJQVgWta7Zx2go+8xJ0UiCb8LHHdftWyLJE0QIAIsI+UbXu67dZMjmgDGCGl1H+vpF4NSDckSIkk7Vd+sxEhBQMRU8j/12UIRhzSaUdQ+rQU5kGeFxm+hb1oh6pWWmv3uvmReDl0UnvtapVaIzo1jZbf/pD6ElLqSX+rUmOQNpJFa/r+sa4e/pBlAABoAAAAA3CUgShLdGIxsY7AUABPRrgCABdDuQ5GC7DqPQCgbbJUAoRSUj+NIEig0YfyWUho1VBBBA//uQZB4ABZx5zfMakeAAAAmwAAAAF5F3P0w9GtAAACfAAAAAwLhMDmAYWMgVEG1U0FIGCBgXBXAtfMH10000EEEEEECUBYln03TTTdNBDZopopYvrTTdNa325mImNg3TTPV9q3pmY0xoO6bv3r00y+IDGid/9aaaZTGMuj9mpu9Mpio1dXrr5HERTZSmqU36A3CumzN/9Robv/Xx4v9ijkSRSNLQhAWumap82WRSBUqXStV/YcS+XVLnSS+WLDroqArFkMEsAS+eWmrUzrO0oEmE40RlMZ5+ODIkAyKAGUwZ3mVKmcamcJnMW26MRPgUw6j+LkhyHGVGYjSUUKNpuJUQoOIAyDvEyG8S5yfK6dhZc0Tx1KI/gviKL6qvvFs1+bWtaz58uUNnryq6kt5RzOCkPWlVqVX2a/EEBUdU1KrXLf40GoiiFXK///qpoiDXrOgqDR38JB0bw7SoL+ZB9o1RCkQjQ2CBYZKd/+VJxZRRZlqSkKiws0WFxUyCwsKiMy7hUVFhIaCrNQsKkTIsLivwKKigsj8XYlwt/WKi2N4d//uQRCSAAjURNIHpMZBGYiaQPSYyAAABLAAAAAAAACWAAAAApUF/Mg+0aohSIRobBAsMlO//Kk4soosy1JSFRYWaLC4qZBYWFRGZdwqKiwkNBVmoWFSJkWFxX4FFRQWR+LsS4W/rFRb/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////VEFHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAU291bmRib3kuZGUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMjAwNGh0dHA6Ly93d3cuc291bmRib3kuZGUAAAAAAAAAACU=";  

const AUDIO = new Audio();
const beep = () => {
  AUDIO.src = DATA_URL;
}

// for iOS audio
// https://stackoverflow.com/questions/31776548/why-cant-javascript-play-audio-files-on-iphone-safari
AUDIO.autoplay = true;
document.querySelector("#sound").addEventListener("click", () => {
  beep();
});
    </script>
  </body>
</html>
```

`<div id="reader">`にQRコードリーダーを表示し、`<div id="result">`に読み取った文字を画面に追記していきます。追記には`append()`を使います。

`<script src="https://unpkg.com/html5-qrcode"></script>`でhtml5-qrcodeを読み込みます。

`CURRENT_TEXT`は2度読み防止のための変数です。

`onScanSuccess()`はQRコードの読み取りが成功したときの処理です。`CURRENT_TEXT`と違う文字だった場合は、新しい読み取りです。`append()`で文字を追記し、`beep()`で音をならし、`google.script.run`で文字をバックエンドに伝えます。

### HTMLAudioElementで音を鳴らす

HTMLAudioElementで音を鳴らせます。音のデータをデータURLとして文字列にエンコードしておき、先に作っておいたAudioオブジェクトのsrc属性にデータURLを代入します。

https://developer.mozilla.org/ja/docs/Web/API/HTMLAudioElement

https://stackoverflow.com/questions/879152/how-do-i-make-javascript-beep

https://stackoverflow.com/questions/15567426/async-play-sound-in-javascript/41317657

当初は`new Audio(DATA_URL).play()`で再生していたのですが（Androidでは再生できるものの）iOSでは再生できないことが分かりました。iOSでは自動再生はできず、ユーザーが操作した時にだけ音を再生します。ボタンが押されたタイミングでAudioオブジェクトで音を鳴らし、そのオブジェクトを使いまわすことで、ボタンが押された以降は音を再生できるようにしています。

https://stackoverflow.com/questions/31776548/why-cant-javascript-play-audio-files-on-iphone-safari

### google.script.runでバックエンドのAPIを呼び出す
`google.script.run`を使って、バックエンドのAPIを非同期に呼び出します。`withFailureHandler()`で呼び出しに失敗したときの処理を書いています。

https://developers.google.com/apps-script/guides/html/reference/run

## その他

知っておくと便利なTipsを紹介します。

### GoogleスプレッドシートにQRコードを表示する

スプレッドシートを使って、QRコードをお手軽に作ることができます。

例えば、A1セルに入った文字をQRコードとして表示するには、次のように書きます。ウェブページにアクセスするためのQRコードを作るときには、ENCODEURL関数を使うようにしてください。


```
=IMAGE("https://chart.googleapis.com/chart?chs=200x200&cht=qr&chl="&ENCODEURL(A1))
```

https://developers.google.com/chart/infographics/docs/qr_codes

https://support.google.com/docs/answer/3093333?hl=ja

https://support.google.com/docs/answer/9199778?hl=ja

# 【応用編】QRコードリーダーにプルダウンメニューを追加して、QRコードと一緒に記録する

QRコードリーダーを改造して機能追加していく様子を、順を追って説明します（最後に完成したスクリプト一式を掲載します）。ここでは、QRコードリーダーにプルダウンメニューを追加し、メニューで選んだ項目を読み取ったQRコードと一緒に記録させてみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6760/7d3ef623-0776-9bfc-4476-73e3ee0dcf04.png)

上図の場合、読み取ったQRコード```https://ja.wikipedia.org```と```メニュー1```をスプレッドシートに追記します。

## フロントエンドにメニューを直接書き込む

まず、フロントエンドの```index.html```にプルダウンメニューを追加し、項目として、```メニュー1```、```メニュー2```、```メニュー3```を表示します。なお、これまでは読み取ったQRコードの二度読みを禁止してきましたが、メニューを変更した場合は二度読みを可能にします。

```html:index.html
    ...
    <div id="result"></div>

    <!-- メニューを変更したら読み取り結果をリセットして二度読みを可能にする -->
    <select id="menu" onchange="CURRENT_TEXT = '';">
      <option>メニュー1</option>
      <option>メニュー2</option>
      <option>メニュー3</option>
    </select>

    <script src="https://unpkg.com/html5-qrcode"></script>
    ...
```

また、選択した項目を、読み取ったQRコードと一緒にバックエンドに送信します。バックエンド側の```onScan()```は、引数を1つ増やします。

```javascript:index.html
const onScanSuccess = (decodedText, decodedResult) => {
  ...
    beep();
    // 選択したメニューを取得
    const menu = document.querySelector('#menu option:checked').innerText;
    google.script.run
      .withFailureHandler(function (error) {
        append(`"${decodedText}"の保存に失敗しました: ${error}`);
      })
      .onScan(decodedText, menu); // 選択したメニューをスプレッドシートに送信
  }
};
```

バックエンド側の```onScan()```です。スプレッドシートに追加する行は、1列目にタイムスタンプ、2列目に選択したメニュー項目、3列目に読み取ったQRコード、としています。

```javascript:Code.gs
// 選択したメニューの値も送信する
function onScan(text, menu) {
  SpreadsheetApp.getActiveSpreadsheet()
    .getSheetByName(SHEET_NAME).appendRow([new Date(), menu, text]);
}
```

ここまでに追加したコードで、メニューで選んだ項目を読み取ったQRコードと一緒に記録できるようになりました。開発用のURLで正しく動作することを確認したら、再デプロイをして、本番用の動作を変更します。

## メニュー項目をシートで管理する

ただ、表示するメニューを変更するたびに、再デプロイをするのは面倒です。そこで、スプレッドシートにメニュー項目一覧シートを追加し、シートを変更したらQRコードリーダーのメニューに反映するようにしてみましょう。

まず、スプレッドシートに```Menu```シートを追加して、メニュー項目を1行ごとに記述します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6760/9da237cc-9c0c-0a18-4366-3f209a45ffe5.png)

そして、バックエンド側にメニュー一覧を取得する```fetchMenus()```を追加します。

```javascript:Code.gs
const MENU_SHEET_NAME = 'Menu';

// メニュー一覧を取得する
function fetchMenus() {
  return SpreadsheetApp.getActiveSpreadsheet()
    .getSheetByName(MENU_SHEET_NAME).getDataRange().getValues().map(row => row[0]);
} 
```

フロントエンド側で、この```fetchMenus()```を呼び出して、シートに記載されたメニュー項目一覧をHTMLにして表示します。Templated HTML（テンプレート化されたHTML）を使います。

https://developers.google.com/apps-script/guides/html/templates

フロントエンド側のコードは次のようになります。

```html:index.html
   ...
    <div id="result"></div>

    <!-- メニューを表示。メニューを変更したら読み取り結果をリセットして二度読みを可能にする -->
    <select id="menu" onchange="CURRENT_TEXT = '';">
      <? for(let menu of fetchMenus()) { ?>
        <option><?= menu ?></option>
      <? } ?>
    </select>

    <script src="https://unpkg.com/html5-qrcode"></script>
    ...
```

また、Templated HTMLを使うために、バックエンド側の```doGet()```を忘れずに変更します。

```javascript:Code.gs
function doGet() {
  // HTMLの中で fetchMenus()を呼び出ため、テンプレートを使う
  const html = HtmlService.createTemplateFromFile('index').evaluate();
  html.addMetaTag('viewport', 'width=device-width, initial-scale=1');
  return html;
}
```

開発用のURLで正しく動作することを確認したら、再デプロイをして、本番用の動作を変更しましょう。

## ソースコード

完成したスプレッドシートとApps Scriptはこちらです。どうぞコピーしてご自由にお使いください[^2]。なお、Google Workspace（for Education）を使っている企業（学校）の方は、ブラウザーをシークレットモードにしてアクセスしてみてください。

https://docs.google.com/spreadsheets/d/13WQVpmxBkhD6GflHQMjmy5qFPnEAtJfGT7sDJnTZwsY/copy?usp=sharing

### フロントエンド
```html:index.html
<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
  </head>
  <body>
    <div id="reader" style="width:340px;"></div>
    <button id="sound">Sound ON (iOS)</button>
    <div id="result"></div>

    <!-- メニューを表示ためにCode.gsのgetMenus()を呼び出す -->
    <!-- メニューを変更したら読み取り結果をリセットして二度読みを可能にする -->
    <select id="menu" onchange="CURRENT_TEXT = '';">
      <? for(let menu of fetchMenus()) { ?>
        <option><?= menu ?></option>
      <? } ?>
    </select>

    <script src="https://unpkg.com/html5-qrcode"></script>
    <script>
let CURRENT_TEXT = '';

const append = (text) => {
  document.querySelector('#result').innerHTML += `${text}<br>`; 
};

const onScanSuccess = (decodedText, decodedResult) => {
  console.log('onScanSuccess', decodedText, CURRENT_TEXT);
  if (CURRENT_TEXT !== decodedText) {
    CURRENT_TEXT = decodedText;
    append(decodedText);
    beep();
    // 選択したメニューを取得
    const menu = document.querySelector('#menu option:checked').innerText;
    google.script.run
      .withFailureHandler(function (error) {
        append(`"${decodedText}"の保存に失敗しました: ${error}`);
      })
      .onScan(decodedText, menu); // 選択したメニューをスプレッドシートに送信
  }
};

const html5QrcodeScanner = new Html5QrcodeScanner("reader", { fps: 10, qrbox: 240 });
html5QrcodeScanner.render(onScanSuccess);

// https://stackoverflow.com/questions/879152/how-do-i-make-javascript-beep
const DATA_URL = "data:audio/wav;base64,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qJrx4KOQjahZxWbcZgztj2c49nKmkId44S71j0c8eV9yDK6uPRzx5X18eDvjvQ6yKo9ZSS6l//8elePK/Lf//IInrOF/FvDoADYAGBMGb7FtErm5MXMlmPAJQVgWta7Zx2go+8xJ0UiCb8LHHdftWyLJE0QIAIsI+UbXu67dZMjmgDGCGl1H+vpF4NSDckSIkk7Vd+sxEhBQMRU8j/12UIRhzSaUdQ+rQU5kGeFxm+hb1oh6pWWmv3uvmReDl0UnvtapVaIzo1jZbf/pD6ElLqSX+rUmOQNpJFa/r+sa4e/pBlAABoAAAAA3CUgShLdGIxsY7AUABPRrgCABdDuQ5GC7DqPQCgbbJUAoRSUj+NIEig0YfyWUho1VBBBA//uQZB4ABZx5zfMakeAAAAmwAAAAF5F3P0w9GtAAACfAAAAAwLhMDmAYWMgVEG1U0FIGCBgXBXAtfMH10000EEEEEECUBYln03TTTdNBDZopopYvrTTdNa325mImNg3TTPV9q3pmY0xoO6bv3r00y+IDGid/9aaaZTGMuj9mpu9Mpio1dXrr5HERTZSmqU36A3CumzN/9Robv/Xx4v9ijkSRSNLQhAWumap82WRSBUqXStV/YcS+XVLnSS+WLDroqArFkMEsAS+eWmrUzrO0oEmE40RlMZ5+ODIkAyKAGUwZ3mVKmcamcJnMW26MRPgUw6j+LkhyHGVGYjSUUKNpuJUQoOIAyDvEyG8S5yfK6dhZc0Tx1KI/gviKL6qvvFs1+bWtaz58uUNnryq6kt5RzOCkPWlVqVX2a/EEBUdU1KrXLf40GoiiFXK///qpoiDXrOgqDR38JB0bw7SoL+ZB9o1RCkQjQ2CBYZKd/+VJxZRRZlqSkKiws0WFxUyCwsKiMy7hUVFhIaCrNQsKkTIsLivwKKigsj8XYlwt/WKi2N4d//uQRCSAAjURNIHpMZBGYiaQPSYyAAABLAAAAAAAACWAAAAApUF/Mg+0aohSIRobBAsMlO//Kk4soosy1JSFRYWaLC4qZBYWFRGZdwqKiwkNBVmoWFSJkWFxX4FFRQWR+LsS4W/rFRb/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////VEFHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAU291bmRib3kuZGUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMjAwNGh0dHA6Ly93d3cuc291bmRib3kuZGUAAAAAAAAAACU=";  

const beep = () => {
  AUDIO.src = DATA_URL;
}

// for iOS audio
// https://stackoverflow.com/questions/31776548/why-cant-javascript-play-audio-files-on-iphone-safari
const AUDIO = new Audio();
AUDIO.autoplay = true;
document.querySelector("#sound").addEventListener("click", () => {
  beep();
});
    </script>
  </body>
</html>
```

### バックエンド
```javascript:Code.gs
const SHEET_NAME = 'Data';
const MENU_SHEET_NAME = 'Menu';

// 選択したメニューの値も送信する
function onScan(text, menu) {
  SpreadsheetApp.getActiveSpreadsheet()
    .getSheetByName(SHEET_NAME).appendRow([new Date(), menu, text]);
}

// メニュー一覧を取得する
function fetchMenus() {
  return SpreadsheetApp.getActiveSpreadsheet()
    .getSheetByName(MENU_SHEET_NAME).getDataRange().getValues().map(row => row[0]);
}

function doGet() {
  // HTMLの中で fetchMenus()を呼び出ため、テンプレートを使う
  const html = HtmlService.createTemplateFromFile('index').evaluate();
  html.addMetaTag('viewport', 'width=device-width, initial-scale=1');
  return html;
}
```


# トラブルシューティング

## Q. スプレッドシートをコピーして流用したいのですが、アクセスできないようです。

- 所属組織のGoogle Workspaceの設定で、組織外のスプレッドシートへのアクセスが禁止されている場合があります。ブラウザーのシークレットモードだとアクセスできることがあります。お試しください。

## Q. スクリプトを書き換えて、ブラウザーからアクセスし直しているのですが、書き換えた機能が反映されていないようです。

- 開発用と本番用のURLがあります。開発用は最新のスクリプトが反映されていますが、本番用だと再デプロイが必要です。

## Q. バックエンドからフロントエンドに値を渡すよう、スクリプトを書き換えてみました。Stringだけなら渡せるのですが、objectだとうまく渡せずnullになってしまいます。

- google.script.runを使ったやり取りですが、バックエンドから渡せる値に制限があります。特にエラーが出るわけではないので気が付きにくいです。特にDate型のやりとりが制限されていることにご注意ください。

https://stackoverflow.com/questions/56480228/google-script-run-withsuccesshandler-does-not-return-value

- Date型をstring型に変換するのに、`Utilities.formatDate()`が便利です。

https://developers.google.com/apps-script/reference/utilities/utilities#formatdatedate,-timezone,-format