見出し画像

GASで作る自分専用4択単語カードアプリの作り方

 今年の上半期、勉強に必要になったのでGoogle Apps Script(以下GAS)で自分専用の4択単語カードアプリを作りました。せっかく作ったものなので、その作り方を残しておきます。
 このアプリは中身のデータを入れ替えれば好きな内容での4択単語カードを作れるので、これを参考に各々カスタムして作ってもらえると嬉しいです。

 このアプリを作る経緯と運用方法などについては以下の記事にまとめてあります。


どんなアプリなのか

・ 完成品概要

 この4択単語カードアプリは、Googleスプレッドシート上に登録したデータからランダムに4択問題を作成し、それに次々答えていくことで単語カードのように用語などの定着を図るためのものです。

 このアプリは、主に以下のことができるように作られています。

・登録したデータからランダムに4択問題を作成する
 
- 誤答選択肢は登録した他のデータから抽選される
・単語カードの問題面と選択肢面を切り替えられるようにする
・4択の選択肢には近しいカテゴリのものが並ぶ
 - どの選択肢が近しいカテゴリかは手動登録
・問題や選択肢に色を表示できる
 - この機能は当時色の勉強用に作ったので追加されています

 アプリを使うときは、以下の手順を繰り返します。

1. トップメニューで出題する問題に関する設定を行う
2. ランダムに出題される4択問題を次々答える
3. 結果を集計し再度トップメニューに戻る

 問題をランダムに作成する際には、どの情報列を問題と選択肢にするか抽選し、どの行のデータを正解と誤答に使うか抽選して、それらを組み合わせる形で問題を作っています

 このアプリはGASで作成したwebアプリなので、PCやスマホなどからアクセスして使えるようになっています。


・ システム概要

 このアプリは、GoogleスプレッドシートとGoogle Apps Scriptそれぞれで以下のような役割を持たせたシート及びファイルを用意し組み合わせています。

[ Googleスプレッドシート側 ]
・メインシート
 - 各データシートの情報をまとめるシート
・データシート(複数)
 - 単語カードに登場させるデータを管理するシート

[ Google Apps Script側 ]
・コード.gs

 - 主にスプレッドシートとデータをやり取りするスクリプト類
・JavaScript系ファイル
 - 画面の操作などを行うスクリプト類
・HTML、css系ファイル
 - 画面の表示を調整するファイル類

 これらの中身については、次の作り方手順で紹介します。


アプリの作り方手順

 ここからは、このアプリの作り方の手順を説明します。

0. 注意事項

 このアプリについて、及び作り方について以下の注意事項については把握してもらえると幸いです。

・このアプリは「個人専用」利用を前提に実装しているため、その分ユーザの悪意に甘い実装になっています。この実装を不特定多数のユーザがアクセスできるようなものに流用するのはやめた方がいいです。
・このアプリは、とっとと使えるように作ったため、実装はかなりゴリ押しな部分もあります。各々気になる所はカスタムしてください。
・このアプリで利用するデータは、各自で準備する必要があります。
・GASのアップデートなどで作り方手順が変わってしまう可能性があります。この記事の作り方手順は2022年10月時点での情報となっています。
・この作り方は、簡便に使えることを重視して用意されています。そのため詳細な説明や個別の関数の説明は一部省略されています。


1. データシートを用意する

 まずはスプシでデータシートを準備します。

 今回の実装では、いくつかデータシートに入力規則(後述)を設けています。その入力規則の範疇であれば自由にカスタムできますが、データ準備は後でとりあえずまずは動かしたいという方は、以下のサンプルデータをそのまま入力してください。
 以降の説明では、サンプルデータをデータシートに用いているものとしています。

・サンプルデータ
 2種類のサンプルデータを用意しています。以下の画像を参考に入力してください。

シート名:sample_word
4択に表示するデータはテキストのみのサンプル
シート名:sample_color
4択に表示するデータに色が存在するサンプル

・データシート入力規則について
 自身でデータを用意して動かす際は、以下の入力規則に従ってデータシートを準備すれば自由にカスタムして利用することができます。

 データシートは、上部の3行と左側の3列が管理用、それ以外が問題などで表示するための出題用データとなっています。出題用データの行数列数共に可変です。一応バグる可能性があるので、出題用データ行数は4行以上、出題用データ列数は2列以上+備考列1列を推奨です。
 それぞれの役割と入力規則については以下の通りです。

(1) 列名(1行目の全て)
- 役割:シート管理の際に分かりやすくするためのもの
- 入力規則:アプリ側で表示しないので特になし

(2) 列タイプ(2行目の2列目以降)
- 役割:各列の情報をHTMLで描画する際に、その形式を指定するタグ
- 入力規則:「text」「color」「count」「group」のいずれかを推奨
 * 正確には「color」かどうかを判定しているだけなので、color以外は何でもいい

(3) 列出題グループ(3行目の3列目以降から最終列の一つ手前まで)
- 役割:問題列と選択肢列を抽選する際に、同一グループを判別するためのもの(後述)
- 入力規則:「A」「B」を指定した列がそれぞれ必要。残りは1文字以上の英数文字列を推奨。

(4) 管理用ID(1列目の4行目以降)
- 役割:シート管理の際に分かりやすくするためのもの
- 入力規則:アプリ側で表示しないので特になし
 * スプシ側でソートやフィルタを行った時のために連番をつけておくと元の順番に戻しやすい

(5) EXPカウンター(2行目の1列目)
- 役割:累積の実行状況を数えるカウンター
- 入力規則:初期値として「0」を入れておくことを推奨
 * 数値であれば0でなくとも多分OK
 * リングフィットアドベンチャーのEXPにインスパイアされたもので、やればやるほど溜まるおまけ要素

(6) 出題回数カウンター(2列目の4行目以降)
- 役割:各行のデータの出題回数を表すカウンター
- 入力規則:初期値としてそれぞれ「0」を入れておくことを推奨

(7) 行出題グループ(3列目の4行目以降)
- 役割:各行のデータが所属するカテゴリグループ(後述)を表すもの
- 入力規則:英数文字列を推奨。1行に複数のカテゴリを指定する場合は、空白を入れずに「,」で区切る。
 * 例:「R」「YR」「Y」を指定する場合は、「R,YR,Y」とする

(8) 出題用データ(4行目以降の4列目から最終列の一つ手前まで)
- 役割:問題や選択肢に表示するためのデータ
- 入力規則:列タイプに「color」が指定されている列は、「#」始まりのカラーコードを記入。それ以外の列タイプは表示したい文字列を記入。
 * 表示時に改行させたい場合、「<br>」を改行したい箇所に挿入する必要がある。

(9) 備考用データ(4行目以降の最終列)
- 役割:問題や選択肢にはしないが、解説としては表示するデータ
- 入力規則:解説として表示したい内容を記入
 * 表示時に改行させたい場合、「<br>」を改行したい箇所に挿入する必要がある。

・出題グループについて
 今回の実装では、出題用データの行と列それぞれに出題グループを指定する項目を用意しています。それぞれ、問題を抽選して作成する際に用いられるものです。

 列出題グループは、問題となる列と選択肢となる列を抽選する際に用いられます。
 抽選の流れは次の通りです。まず、問題と選択肢に使う列出題グループ名を抽選します。そして、それぞれのグループの中で用いる列を抽選します。
 問題と選択肢に使う列出題グループは異なるものを選ぶようになっているので、同じグループに属する列同士で問題と選択肢が作られることはありません。ですので、問題と選択肢として組み合わされると困るものを同じグループに入れておくことを推奨します。
 よく分からないという場合は、左の列からA、B、C…とアルファベットを順番に一つずつ振っておけば大丈夫です。

 行出題グループは、正解となる行と誤答となる行を抽選する際に用いられます。
 列グループとは抽選の流れは少し違います。まず、正解となる行を抽選します。次に、その正解行が持つ行出題グループから用いるカテゴリを1つ抽選します。そして、そのカテゴリを持つ行の中から誤答となる行を抽選します。
 行出題グループの方は、同じカテゴリグループの中から選択肢を選ぶようになっています。ですので、選択肢として並べたいものを同じグループに、明らかに違うものを異なるグループに入れることを推奨します。
 よく分からないという場合は、全てのデータに同じカテゴリグループ名を一つ(例えば「all」)与えておけば大丈夫です。


2. メインシートを用意する

 データシートを準備できたら、同じスプレッドシート上の別シートにメインシートを作成します。

 メインシートはデータシートの内容をまとめるシートです。こちらも実装のために入力規則があるので、以下の画像を参考にして作成してください。自身の作成したシート名を参照する必要があるので、適宜読み換えてください。
 実装では、メインシート名「Main」がデフォルトとして設定されています。「Main」にしておくのが無難です。

サンプルデータを使ったメインシート例

(1) 列名(1行目の全て)
- 役割:シート管理の際に分かりやすくするためのもの
- 入力規則:アプリ側で表示しないので特になし

(2) 合計列(2行目の全て)
- 役割:3行目以降の結果を集計するためのもの
- 入力規則:左から以下のようにする
 * 1列目:アプリ側で表示しないので特になし
 * 2列目:「=SUM(B3:B)」
 * 3列目:「=SUM(C3:C)」
 * 4列目:「=SUM(D3:D)」

 * 5列目:アプリ側で表示しないので特になし
 * 6列目:「=SUM(F3:F)」

(3) データシート名(1列目の3行目以降)
- 役割:利用するデータシートを選択する
- 入力規則:アプリで利用するデータシートのシート名を記入する
 * アプリ側で利用したくないデータシートがある場合は、ここに記入しなければ良い

(4) 問題数(2列目の3行目以降)
- 役割:その行のデータシートの問題数を数える
- 入力規則:どの行も以下を入力
 
* 「=COUNTA(INDIRECT("'"&INDIRECT("A"&ROW())&"'!A3:A"))」

(5) 出題済問題数(3列目の3行目以降)
- 役割:その行のデータシートの出題したことのある問題数を数える
- 入力規則:どの行も以下を入力
 * 「=COUNTIF(INDIRECT("'"&INDIRECT("A"&ROW())&"'!B3:B"), ">0")」

(6) 合計出題回数(4列目の3行目以降)
- 役割:その行のデータシートの各問題の出題回数の合計を数える
- 入力規則:どの行も以下を入力
 * 「=SUM(INDIRECT("'"&INDIRECT("A"&ROW())&"'!B3:B"))」

(7) 出題タイプ(5列目の3行目以降)
- 役割:シートごとに出題列の抽選形式の種類を指定するはずだったもの
- 入力規則:この実装では結局使わず実装内部で全てのシートで同じ抽選形式の種類から選択するようにしたので何でも良い

(8) EXP(6列目の3行目以降)
- 役割:シートごとのEXPをまとめる
- 入力規則:どの行も以下を入力
 * 「=INDIRECT("'"&INDIRECT("A"&ROW())&"'!A2")」

 メインシート3行目以降は、使いたいデータシートを(スプシの許す範囲で)幾つでも追加できるようになっています。後から作成したシートをメインシートに新たに登録することで、アプリ側に追加するという運用も可能です。

 ここまでで、スプシ側の「データシート」と「メインシート」を作成しました。続いてGAS側の作成を行います。


3. GASプロジェクトを作成する

 ここからは、GAS側の実装を行っていきます。そのため、まずGASプロジェクトを作成します。

 スプレッドシート上のメニューバーから、「拡張機能」→「Apps Script」の順で選択します。

 すると以下のようなGASの画面が出てきます。これでGASプロジェクト作成は完了です。プロジェクト名は適宜変更してください。


4. GASで各種ファイルを作成する

 作成したGASのプロジェクトに、以下の5つのファイルを作成します。

 それぞれコピペで大丈夫ですが、実装中のコメントに「Check」とついている部分は、お好みで調整することをオススメする箇所です。また、実装に詳しい方はご自由にカスタムしてください。

・4-1. コード.gs
 この「コード.gs」という名前のファイルは、GASプロジェクトを作成した際に初めから用意されているものです。

 「コード.gs」は、スプレッドシートとアプリ側のデータのやり取りを取り持つものです。ファイルの中身は、以下のように書き換えます。

// GASが呼び出されたときにHTMLを表示する
function doGet() {
  var toppage = HtmlService.createTemplateFromFile('index');
  return toppage
    .evaluate()
    .setSandboxMode(HtmlService.SandboxMode.IFRAME)
    .addMetaTag('viewport', 'width=device-width, initial-scale=1')
    .setTitle('QuizCardTemplate_simple');    // Check: お好みでタイトルを変更する
}


////////////// 環境変数 /////////////////
var N_HEADER_ROW = 3;     // ヘッダー情報の行数
var N_SIDE_COLUMN = 3;    // サイド情報の列数

var IDX_TYPE_ROW = 2;     // ヘッダー情報の表示タイプの行番号
var IDX_GROUP_ROW = 3;    // ヘッダー情報の出題グループの行番号

var IDX_COUNT_COL = 2;    // サイド情報の出題回数の列番号
var IDX_GROUP_COL = 3;    // サイド情報の出題グループの列番号


/////////////// 汎用関数 ////////////////
// argsort: reverse=Trueで大きい順
function argsort(arr, reverse){
  var len = arr.length;
  var indices = new Array(len);
  for (var i = 0; i < len; i++) {
    indices[i] = i;
  }
  
  if (reverse){
    indices.sort(function (a, b) { return arr[a] < arr[b] ? 1 : arr[a] > arr[b] ? -1 : 0; });
  }
  else {
    indices.sort(function (a, b) { return arr[a] < arr[b] ? -1 : arr[a] > arr[b] ? 1 : 0; });
  }
  return indices
}


// random_choice: 0からn_maxまでの中からなるべく重複なくn_choice個選択する
function random_choice(n_max, n_choice)
{
  var result = [];
  var count = 0;
  var n_try = 0;
  if (n_max > n_choice) {
    // 抽選し重複がなかったら追加、抽選回数が一定回数に到達したら重複も許す
    while (count < n_choice) {
      n_try = n_try + 1;
      var rand_num = Math.floor(Math.random() * n_max);
      if (result.indexOf(rand_num) < 0 || n_try > n_choice * 2) {
        result[count] = rand_num;
        count = count + 1;
      }
    }
  } else if (n_max == n_choice) {
    // シャッフルする
    var rand_ids_list = [];
    for (i = 0; i < n_choice; i++) {
      rand_ids_list.push(Math.random());
    }
    result = argsort(rand_ids_list, true);
  } else {
    // n_max < n_choiceは仕方なく重複あり抽選をする
    for (i = 0; i < n_choice; i++) {
      result.push(Math.floor(Math.random() * n_max));
    }
  }
  return result;
}


// random_array_sample: arrからなるべく重複なくn_sample個選択する
function random_array_sample(arr, n_sample){
  var result = [];
  if (n_sample == 1){
    result = [arr[Math.floor(Math.random() * arr.length)]];
  } else {
    var random_idx = random_choice(arr.length, n_sample);
    for (i=0;i<n_sample;i++){
      result.push(arr[random_idx[i]]);
    }
  }
  return result;
}


/////////////// シート操作系 ////////////////
// TOPメニュー作成に必要な情報を取ってくる
function get_top_info(){
  // スプレッドシート処理
  var spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = spreadsheet.getSheetByName('Main');    // Check: 全体情報のシート名を入れる
  var max_r = sheet.getLastRow() - 2;
  var sheet_data = sheet.getRange(3, 1, max_r, 6).getValues();
  var total_data = sheet.getRange(2, 1, 1, 6).getValues();
  
  // 出題シート名のリストを作る
  var sheet_list = [];
  for (i = 0; i < sheet_data.length; i++) {
    sheet_list.push(sheet_data[i][0]);
  }

  // 問題数のリストを返却
  var num_quiz_list = [5, 10, 25, 50, 100];    // Check: お好みの問題数を用意する
  return [sheet_list, num_quiz_list, sheet_data, total_data];
}


// 条件付きで問題のインデックスを取ってくる(not行)
function make_ans_ind_list(sheet, n_ans)
{ 
  var max_r = sheet.getLastRow() - N_HEADER_ROW;
  var ret_list = [];
  var rand_val_list = [];
  for (i = 0; i < max_r; i++) {
    rand_val_list.push(Math.random());
  }
  
  // 出題回数の列を持ってくる
  var sheet_data = sheet.getRange(N_HEADER_ROW + 1, IDX_COUNT_COL, max_r, 1).getValues();
  
  // 出題回数の少ないものをやや優先するように抽選をする
  // Check: お好みで出題に関する抽選の関数を設計する
  for (i = 0; i < rand_val_list.length; i++){
    rand_val_list[i] = rand_val_list[i] - sheet_data[i][0]*0.3;
  }
  var arg_ind_list_other = argsort(rand_val_list, true);
  for (i = 0; i < n_ans; i++){
    var ind_other = arg_ind_list_other[i];
    ret_list.push(ind_other);
  }
  
  return ret_list;
}


// 指定のシートから問題を作成する関数
function get_quiz_list(sheet_name, n_quiz, col_mode="rand")
{
  // スプレッドシート処理
  var spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = spreadsheet.getSheetByName(sheet_name);
  var max_r = sheet.getLastRow() - N_HEADER_ROW;
  var max_l = sheet.getLastColumn() - N_SIDE_COLUMN;
  
  // 1度に用意する問題数
  var num_quiz = Math.min(n_quiz, max_r);
  
  // 問題インデックスを抽選する
  var ans_ind_list = make_ans_ind_list(sheet, num_quiz);
  
  // シート上の問題と答えのエリアを取ってくる
  var sheet_data = sheet.getRange(N_HEADER_ROW + 1, N_SIDE_COLUMN + 1, max_r, max_l).getValues();
  // シート上の問題出題グループのエリアを取ってくる
  var quiz_group_list = sheet.getRange(N_HEADER_ROW + 1, IDX_GROUP_COL, max_r, 1).getValues();
  // 各行の出題グループごとに行インデックスを割り振っておく
  var quiz_group_dict = {};
  for (qg_ind = 0; qg_ind < quiz_group_list.length; qg_ind++){
    var qg = quiz_group_list[qg_ind][0].split(",");
    for(i = 0; i < qg.length; i++){
      if (qg[i] in quiz_group_dict){
        quiz_group_dict[qg[i]].push(qg_ind);
      } else {
        quiz_group_dict[qg[i]] = [qg_ind];
      }
    }
  }

  // 問題と答えの出題グループ列のデフォルト値を指定
  var quiz_col_id = "A";
  var ans_col_id = "B";

  // 各列のタイプを取得
  var col_type = sheet.getRange(IDX_TYPE_ROW, N_SIDE_COLUMN + 1, 1, max_l).getValues();

  // 各列の問題グループのエリアを取ってくる
  var col_group_list = sheet.getRange(IDX_GROUP_ROW, N_SIDE_COLUMN + 1, 1, max_l).getValues()[0];
  // 各列の出題グループに列インデックスを割り振っておく
  var col_group_dict = {};
  for (cg_ind = 0; cg_ind < col_group_list.length; cg_ind++){
    var cg = col_group_list[cg_ind].split(",");
    for (i = 0; i < cg.length; i++){
      if (cg[i]){
        if (cg[i] in col_group_dict){
          col_group_dict[cg[i]].push(cg_ind);
        } else {
          col_group_dict[cg[i]] = [cg_ind];
        }
      }
    }
  }

  // htmlに渡す問題のリストを用意
  var quiz_list = [];
  for (q_ind = 0; q_ind < num_quiz; q_ind++) {
    // 答えの行インデックスを選択
    var ind_ans = ans_ind_list[q_ind];

    // 出題グループを選択
    var quiz_group = quiz_group_list[ind_ans][0].split(",");
    var choice_quiz_group = quiz_group[Math.floor(Math.random() * quiz_group.length)];
    // 出題グループのインデックスを取得
    var group_ind_list = quiz_group_dict[choice_quiz_group];

    // 出題グループからランダムに4つ行インデックスを選択
    var rands = random_array_sample(group_ind_list, 4);
    
    // randsに答えの行インデックスがない場合
    if (rands.indexOf(ind_ans) < 0){
      // ランダムに1つ選択
      var ans_pos = Math.floor(Math.random() * 4);
      // 選ばれたans_posの場所の行インデックスを事前に選択した答えの行インデックスに入れ替える
      rands[ans_pos] = ind_ans;
    }

    // 問題と答えの出題グループ列をモードに合わせて改めて指定
    if (col_mode == "BA"){
      quiz_col_id = "B";
      ans_col_id = "A";
    } else if (col_mode == "Random"){
      // 1問ごとに出題グループ列の組み合わせをランダムに取得する
      var rand_ids_list = [];
      var col_group_keys = Object.keys(col_group_dict);
      var n_rand_col = 2;
      if (col_group_keys.length > 2){
        n_rand_col = 3;
      }
      for (i = 0; i < n_rand_col; i++) {
        rand_ids_list.push(Math.random());
      }
      var arg_rand_ids_list = argsort(rand_ids_list, true);
      quiz_col_idx = arg_rand_ids_list[0];
      ans_col_idx = arg_rand_ids_list[1];
      if (quiz_col_idx > 1){
        quiz_col_id = random_array_sample(col_group_keys.slice(2), 1)[0];
      } else {
        quiz_col_id = col_group_keys[quiz_col_idx];
      }
      if (ans_col_idx > 1){
        ans_col_id = random_array_sample(col_group_keys.slice(2), 1)[0];
      } else {
        ans_col_id = col_group_keys[ans_col_idx];
      }
    }
    
    idx_quiz_col = random_array_sample(col_group_dict[quiz_col_id], 1)[0];
    idx_ans_col = random_array_sample(col_group_dict[ans_col_id], 1)[0];

    //Logger.log(quiz_group_dict);
    //Logger.log(idx_quiz_col);
    //Logger.log(rands);

    // 問題のデータを作成
    var text_quiz = sheet_data[ind_ans][idx_quiz_col];
    var ans_list = rands.map(x => sheet_data[x][idx_ans_col]); 
    var quiz_type = col_type[0][idx_quiz_col];
    var ans_type = col_type[0][idx_ans_col];
    var ans_info = rands.map(x => sheet_data[x]);
    var quiz_dict = [{
      quiz_id: q_ind,          // 問題番号
      ind_ans: ind_ans,        // 答えの行インデックス
      ind_list: rands,         // 選択肢の行インデックスリスト
      text_quiz: text_quiz,    // 問題の内容
      quiz_type: quiz_type,    // 問題のタイプ
      ans_list: ans_list,      // 選択肢の内容
      ans_type: ans_type,      // 選択肢のタイプ
      ans_info: ans_info       // 選択肢の全ての情報
    }];

    // 全体のリストにこの問題を追加
    quiz_list = quiz_list.concat(quiz_dict);
  }
  
  return quiz_list;
}


// リザルトからスプシを更新する
function send_quiz_result(sheet_name, quiz_result){
  // スプレッドシート処理
  var spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = spreadsheet.getSheetByName(sheet_name); 
  
  // シートの変更するところを取得
  var sheet_data = sheet.getRange(N_HEADER_ROW + 1, IDX_COUNT_COL, sheet.getLastRow()-N_HEADER_ROW, 1).getValues();
  
  // exp集計
  var total_exp = 0;
  
  // スプシに返すデータ
  for (i = 0; i < quiz_result.length; i++) {
    var quiz_id = quiz_result[i][0];
    var q_count = sheet_data[quiz_id][0];

    // 出題されたものに出題カウント+1
    sheet_data[quiz_id][0] = q_count + 1;
    
    // スコアカウント
    // Check: お好みでカウントの数値を調整
    if (quiz_result[i][1] == "False"){
      total_exp = total_exp + 12;
    } else {
      total_exp = total_exp + 60;
    }
  }
  
  // シートの変更するところを更新
  sheet.getRange(N_HEADER_ROW + 1, IDX_COUNT_COL, sheet.getLastRow()-N_HEADER_ROW, 1).setValues(sheet_data);
  
  // exp更新
  sheet.getRange(2,1,1,1).setValue(total_exp + sheet.getRange(2,1,1,1).getValue());
  
  // top作成用の情報を取得
  var top_info = get_top_info();
  
  // 次のtopを作成するための情報を返却
  return top_info;
}

・4-2. index.html
 ここから先は、それぞれ新たにファイルを作成します。今回の実装で作成するファイルは、いずれもファイル欄から「+」→「HTML」を選択し、ファイル名を入力することで作成できます。(JavaScriptファイルも「HTML」を選択して作成します。)

 「index.html」はHTML系ファイルのメインファイルです。ファイルの中身は、以下のように書き換えます。

<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <?!= HtmlService.createHtmlOutputFromFile('stylesheet').getContent(); ?>
</head>

<body>
  <nav class="navbar navbar-expand-lg navbar-org mb-3">
    <div class="navbar-text">
    4択単語帳<!--Check: お好みでヘッダーを変更 -->
    </div>
  </nav>

  <?!= HtmlService.createHtmlOutputFromFile('top_cont').getContent(); ?>

  <div class="container" id="body_cont">
  </div>

  <div class="container" id="result_cont">
  </div>

  <?!= HtmlService.createHtmlOutputFromFile('javascript').getContent(); ?>
</body>
</html>

・4-3. top_cont.html
 「top_cont.html」はトップメニューに関するHTMLファイルです。ファイルの中身は、以下のように書き換えます。

<div class="container" id="top_cont">

  <!-- ロード画面 -->
  <div class="row" id="top_cont_loading">
    <div class="col-12">
      <div class="card mb-3">
        <div class="card-body">
          <p class="text-center vertical-middle">
          Loading...
          </p>
        </div>
      </div>
    </div>
  </div>

  <!-- メイン画面 -->
  <div class="row" id="top_cont_main" hidden>
    <div class="col-1">
      <button type="button" class="btn btn-topcont-left" onclick="topcont_change()"> ◀︎ </button>
    </div>

    <!-- TOPメニューのcard -->
    <div class="col-10" id="top_card_1">
      <div class="card mb-3">
        <div class="card-header">
          <span class="vertical-middle">
            TOP Menu
          </span>
        </div>
        <div class="card-body">
          <div class="form-group row">
            <label class="col-form-label col-4 text-center" for="toppage">
              問題種類
            </label>
            <div class="col-8">
              <select class="form-control" id="sheet_name">
                <option>A</option>
              </select>
            </div>
          </div>
          <div class="form-group row">
            <label class="col-form-label col-4 text-center" for="toppage">
              パターン
            </label>
            <div class="col-4">
              <select class="form-control" id="col_type">
                <option>AB</option>
              </select>
            </div>
            <div class="col-4">
              <select class="form-control" id="num_quiz">
                <option>5</option>
              </select>
            </div>
          </div>

          <div class="row">
            <div class="col-2">
            </div>
            <div class="col-8">
              <button type="button" class="btn btn-choice" value="1" onclick="start_quiz(this.value)"> start </button>
            </div>
          </div>

          <details>
            <summary>
              実行状況
            </summary>
            <div class="details-contens">
              <div class="details-header">
                Total count: <span id="details_total_count"> xxx </span>, Total Exp:  <span id="details_total_exp"> xxx </span>
              </div>
              <table class="table table-sm table-details" id="details_table">
              </table>
            </div>
          </details>

        </div>
      </div>
    </div>

    <!-- 設定のcard -->
    <div class="col-10" id="top_card_2" hidden>
      <div class="card mb-3">
        <div class="card-header">
          <span class="vertical-middle">
            Setting
          </span>
        </div>
        <div class="card-body">
          <div class="row" id="setting_main_color">
            <div class="col-6">
              <p class="text-center vertical-middle">
                メインカラー
              </p>
            </div>
            <div class="col-2">
              <button type="button" class="btn btn-color-box" style="background-color: var(--key-color-1);" value="1" onclick="set_main_color(this.value)"> 1 </button>
            </div>
            <div class="col-2">
              <button type="button" class="btn btn-color-box" style="background-color: var(--key-color-2);" value="2" onclick="set_main_color(this.value)"> 2 </button>
            </div>
            <div class="col-2">
              <button type="button" class="btn btn-color-box" style="background-color: var(--key-color-3);" value="3" onclick="set_main_color(this.value)"> 3 </button>
            </div>
          </div>

          <div class="row" id="setting_color_mode">
            <div class="col-6">
              <p class="text-center vertical-middle">
                カラーモード
              </p>
            </div>
            <div class="col-3">
              <button type="button" class="btn btn-color-box" style="background-color: var(--light-back-dark-color);" value="light" onclick="set_color_mode(this.value)"> 1 </button>
            </div>
            <div class="col-3">
              <button type="button" class="btn btn-color-box" style="background-color: var(--dark-back-dark-color);" value="dark" onclick="set_color_mode(this.value)"> 2 </button>
            </div>
          </div>

        </div>
      </div>
    </div>

    <div class="col-1">
      <button type="button" class="btn btn-topcont-right" onclick="topcont_change()"> ▶︎ </button>
    </div>
  </div>

</div>

・4-4. stylesheet.html
 「stylesheet.html」はcssに関するファイルです。お好みでカスタムしてください。ファイルの中身は、以下をベースにするのをオススメします。

<!-- bootstrap var4-->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-select/1.12.1/css/bootstrap-select.min.css">
<!-- toaster -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/toastr.js/latest/toastr.min.css" crossorigin="anonymous">
<link href="https://fonts.googleapis.com/css?family=Lato:400,700|Noto+Sans+JP:400,700" rel="stylesheet">

<style>
:root{
  --text-main-color: var(--light-text-main-color);
  --text-disable-color: var(--light-text-disable-color);
  --frame-color: var(--light-frame-color);
  --frame-text-color: var(--light-frame-text-color);
  --frame-high-light-color: var(--light-frame-high-light-color);
  --back-light-color: var(--light-back-light-color);
  --back-dark-color: var(--light-back-dark-color);
  --high-light-color: var(--light-high-light-color);
  --footer-color: var(--light-footer-color);
  --alpha-gray-color: var(--light-alpha-gray-color);
  --action-text-color: var(--light-action-text-color);
  --action-text-hover-color: var(--light-action-text-hover-color);
  --key-color-1: var(--light-key-color-1);
  --key-color-2: var(--light-key-color-2);
  --key-color-3: var(--light-key-color-3);

  --light-key-color: var(--light-key-color-1);
  --light-text-main-color: #4f2807;
  --light-text-disable-color: #aaaaaa;
  --light-frame-color: var(--light-key-color);
  --light-frame-text-color: #ffffff;
  --light-frame-high-light-color: var(--light-frame-text-color);
  --light-back-light-color: #fafafa;
  --light-back-dark-color: #eeeeee;
  --light-high-light-color: #ffffff;
  --light-footer-color: var(--light-text-main-color);
  --light-alpha-gray-color: 52,52,52;
  --light-action-text-color: var(--light-frame-text-color);
  --light-action-text-hover-color: var(--light-text-main-color);
  --light-key-color-1: #f6d706;
  --light-key-color-2: #d84058;
  --light-key-color-3: #0ec1b6;

  --dark-key-color: var(--dark-key-color-1);
  --dark-text-main-color: #dee6e6;
  --dark-text-disable-color: var(--dark-frame-high-light-color);
  --dark-frame-color: #384444;
  --dark-frame-text-color: var(--dark-key-color);
  --dark-frame-high-light-color: #777a7a;
  --dark-back-light-color: #202a2a;
  --dark-back-dark-color: #162424;
  --dark-high-light-color: #263030;
  --dark-footer-color: var(--dark-back-light-color);
  --dark-alpha-gray-color: 202,202,202;
  --dark-action-text-color: #777a7a;
  --dark-action-text-hover-color: var(--dark-frame-text-color);
  --dark-key-color-1: #e1c505;
  --dark-key-color-2: #cc3c53;
  --dark-key-color-3: #11b4a9;
}


body{
    font-family: 'Lato', 'Noto Sans JP', '游ゴシック Medium', '游ゴシック体', 'Yu Gothic Medium', YuGothic, 'ヒラギノ角ゴ ProN', 'Hiragino Kaku Gothic ProN', 'メイリオ', Meiryo, 'MS Pゴシック', 'MS PGothic', sans-serif;
    background-color: var(--back-dark-color);
    overflow-y: scroll;
    transform: translateZ(0); 
    height: 100vh;
}


.container{
    padding-top: 20px;
    padding-bottom: 20px;
}


#footer{
    font-size:12px;
    color: rgba(255, 255, 255, 0.3);
    text-align:center;
    border-top:0px solid #cccccc;
    padding:10px 0 20px;
    background-color: var(--footer-color);
}


.navbar-org{
    color: var(--frame-text-color);
    background-color: var(--frame-color);
    border-bottom: 2px solid var(--high-light-color);
}


.card{
    color: var(--text-main-color);
    background-color: var(--back-light-color);
    margin-bottom: 20px;
    border-width: 0;
    box-shadow: 3.3px 2.0px 0 rgba(52, 52, 52, 0.5);
    animation: fadeIn 0.5s ease-out 0.0s 1 normal forwards;
    position: relative;
    border-radius: 20px;
    z-index: 1;
}

.card::after{
    border-color: var(--frame-color);
    border-width: 2.0px;
    border-style: solid;
    border-radius: 18.0px;
    width: calc(100% - 4.0px);
    height: calc(100% - 4.0px);
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    content: "";
    box-sizing: border-box;
    box-shadow: 0 0 0 2.0px var(--high-light-color);
    z-index: 3;
}


.card-header{
    color: var(--frame-text-color);
    background-color: var(--frame-color);
    border: 2.0px solid var(--high-light-color);
    border-radius: 18px 18px 0 0 !important;
    text-align: center;
    position: relative;
    z-index: 2;
}

.card-header::before, .card-header::after{
    content: "★";
    color: var(--frame-high-light-color);
    position: absolute;
    width: 1rem;
    height: 100%;
    top: 50%;
    margin-top: -0.75rem;
    box-sizing: border-box;
}

.card-header::before{
    left: 0.75rem;
}

.card-header::after{
    right: 0.75rem;
}


.card-body{
    padding: 15px 20px 25px 20px;
    z-index: 4;
}


.vertical-middle{
    vertical-align: middle;
}


.btn-topcont-left, .btn-topcont-right{
    font-size: 10px;
    text-align: center;
    padding: 0 0 0 0 ;
    color: var(--action-text-color);
    background-color: rgba(var(--alpha-gray-color), 0.1);
    border-radius: 30%;
    position: relative;
    width: 30px;
    height: 30px;
    top: calc(50% - 15px);
}

.btn-topcont-left{
    left: calc(100% - 15px);
}

.btn-topcont-right{
    left: calc(0% - 15px);
}

.btn-topcont-left:hover, .btn-topcont-right:hover{
    color: var(--action-text-hover-color);
    background-color: rgba(var(--alpha-gray-color), 0.2);
}


.btn-color-box{
    font-size: 10px;
    text-align: center;
    padding: 0 0 0 0 ;
    color: var(--light-frame-text-color);
    border-radius: 10px;
    position: relative;
    width: 100%;
    height: 80%;
}

.btn-color-box:hover{
    color: var(--dark-back-dark-color);
}


.row-color-box{
    height: 4rem;
}


.choice-color-box{
    background-color: var(--key-color-1);
    padding: 0 0 0 0 ;
    border-radius: 4px;
    position: relative;
    width: 100%;
    height: 80%;
}

.choice-color-box p{
    text-align: center;
    font-size: 10px;
    color: rgba(32, 32, 32, 0.6);
    line-height: 18px;
    background-color: rgba(232, 232, 232, 0.4);
    padding: 0 0 0 0;
    border-radius: 4px;
    position: relative;
    width: 20px;
    height: 20px;
    top: 4px;
    left: 6px;
}


.btn-choice{
    color: var(--text-main-color);
    background-color: var(--back-light-color);
    position: relative;
    border-color: var(--frame-color);
    border-width: 2.0px;
    border-style: dotted;
    border-radius: 10px;
    width: 100%;
}

.btn-choice:disabled, .btn-choice.disabled{
    color: var(--text-disable-color);
}

.btn-choice:not(:disabled):not(.disabled):hover{
    color: var(--text-main-color);
    background-color: var(--frame-color);
}

.btn-choice:focus, .btn-choice.focus,
.btn-choice:not(:disabled):not(.disabled):active:focus, .btn-choice:not(:disabled):not(.disabled).active:focus,
.show > .btn-choice.dropdown-toggle:focus {
    box-shadow: 0 0 0 0px rgba(58, 167, 252, 0.9); /* クリック後の枠線を消す */
}

.btn-choice.success.disabled, .btn-choice.success:disabled {
    background-color: rgba(116, 236, 122, 0.2);
    border-color: #74ec7a;
}

.btn-choice.danger.disabled, .btn-choice.danger:disabled {
    background-color: rgba(252, 88, 32, 0.3);
    border-color: #fc5820;
}


.row-btns-double{
    padding-left: 20px;
    padding-right: 20px;
}

.row-btns-double div.col{
    padding-left: 0px;
    padding-right: 0px;
}


details{
    color: var(--text-main-color);
    background-color: transparent;
    margin: 0px 20px;
    font-size: 0.9rem;
    transition: 0.5s;
}


summary{
    outline: none;
    padding: 10px 0% 2px 10%;
}


.details-contens{
   padding: 0px 0px 12px 0px;
}


.table-answer{
    color: var(--text-main-color);
    background-color: var(--back-light-color);
    position: relative;
    margin: 0px 0px 0px 0px;
    padding: 0px 0px 0px 0px;
    font-size: 0.8rem;
    border-collapse: collapse;
    width: 100%;
    white-space: nowrap;
}


.table-answer td{
    position: relative;
    vertical-align: middle;
    padding: 0px 15px 0px 15px;
    line-height: 1;
    height: 1.6rem;
    border: dotted 1px var(--frame-color);
}


.form-control, .form-control:focus, .form-control.focus {
    border: 0px solid var(--frame-color);
    background-color: transparent;
    color: var(--text-main-color);
}


.details-header{
    text-align: center;
    font-size: 0.8rem;
}


.table-details{
    color: var(--text-main-color);
    background-color: var(--back-light-color);
    position: relative;
    margin: 0px 0px 0px 0px;
    padding: 0px 0px 0px 0px;
    font-size: 0.8rem;
    border-collapse: collapse;
    width: 100%;
    white-space: nowrap;
}

.table-details th{
    position: relative;
    vertical-align: middle;
    padding: 0px 15px 0px 15px;
    line-height: 1;
    height: 1.6rem;
    border: dotted 1px var(--frame-color);
}

.table-details td{
    position: relative;
    vertical-align: middle;
    padding: 0px 15px 0px 15px;
    line-height: 1;
    height: 1.6rem;
    border: dotted 1px var(--frame-color);
}


.td-index{
    color: rgba(116, 236, 122, 0.8);
}


p{
    margin-top: 2px;
    margin-bottom: 10px;
}


.border-clear{
    border-width: 0px;
}


.no-gutters>.col {
    padding-right: 6px;
    padding-left: 6px;
}


@keyframes fadeIn { /*animetion-nameで設定した値を書く*/
  0% {opacity: 0} /*アニメーション開始時は不透明度0%*/
  100% {opacity: 1} /*アニメーション終了時は不透明度100%*/
}
</style>

・4-5. javascript.html
 「javascript.html」はスプシから受け取ったデータを使って画面を操作したりするスクリプトなどがあるファイルです。ファイルの中身は、以下のように書き換えます。

<script>
// 画面の色の設定フラグ
var key_color_id = 1;
var color_mode = "light"

// 解答結果を記録するリスト まとめて最後にgasに返却する
var result_list = [];

// アクティブな問題シート名
var quiz_sheet_name = "";


///////////////////////////////////////////////////////////////
/// top cont のボタン系 ///

// 問題開始ボタンを押した時の挙動
function start_quiz(){
  // selectの値を取ってくる
  var sheet_name = document.getElementById("sheet_name").value;
  var col_type = document.getElementById("col_type").value;
  var num_quiz = document.getElementById("num_quiz").value;
  quiz_sheet_name = sheet_name;

  new_quiz(quiz_sheet_name, num_quiz, col_type);
}


// topメニューの切り替えボタンを押した時の挙動
function topcont_change(){
  var card1 = document.getElementById("top_card_1");
  var card2 = document.getElementById("top_card_2");
  var items = [card1, card2];

  for (let i=0;i<items.length;i++){
      if (items[i].hasAttribute("hidden")){
        items[i].removeAttribute("hidden");
      } else {
        items[i].setAttribute("hidden", "");
      }
  }
}


// メインカラーボタンを押した時の挙動
function set_main_color(color_id){
  var cssrule = document.styleSheets[4].cssRules[0];

  key_color_id = color_id;
  if (color_mode == "light"){
    cssrule.style.setProperty("--light-key-color", "var(--light-key-color-"+key_color_id+")");
  } else if (color_mode == "dark"){
    cssrule.style.setProperty("--dark-key-color", "var(--dark-key-color-"+key_color_id+")");
  }
}


// カラーモードボタンを押した時の挙動
function set_color_mode(cmode){
  var cssrule = document.styleSheets[4].cssRules[0];

  color_mode = cmode;
  if (cmode == "light"){
    cssrule.style.setProperty("--text-main-color", "var(--light-text-main-color)");
    cssrule.style.setProperty("--frame-color", "var(--light-frame-color)");
    cssrule.style.setProperty("--frame-text-color", "var(--light-frame-text-color)");
    cssrule.style.setProperty("--text-disable-color", "var(--light-text-disable-color)");
    cssrule.style.setProperty("--frame-high-light-color", "var(--light-frame-high-light-color)");
    cssrule.style.setProperty("--back-light-color", "var(--light-back-light-color)");
    cssrule.style.setProperty("--back-dark-color", "var(--light-back-dark-color)");
    cssrule.style.setProperty("--high-light-color", "var(--light-high-light-color)");
    cssrule.style.setProperty("--footer-color", "var(--light-footer-color)");
    cssrule.style.setProperty("--alpha-gray-color", "var(--light-alpha-gray-color)");
    cssrule.style.setProperty("--action-text-color", "var(--light-action-text-color)");
    cssrule.style.setProperty("--action-text-hover-color", "var(--light-action-text-hover-color)");
    cssrule.style.setProperty("--key-color-1", "var(--light-key-color-1)");
    cssrule.style.setProperty("--key-color-2", "var(--light-key-color-2)");
    cssrule.style.setProperty("--key-color-3", "var(--light-key-color-3)");
    cssrule.style.setProperty("--light-key-color", "var(--light-key-color-"+key_color_id+")");
  } else if (cmode == "dark"){
    cssrule.style.setProperty("--text-main-color", "var(--dark-text-main-color)");
    cssrule.style.setProperty("--frame-color", "var(--dark-frame-color)");
    cssrule.style.setProperty("--frame-text-color", "var(--dark-frame-text-color)");
    cssrule.style.setProperty("--text-disable-color", "var(--dark-text-disable-color)");
    cssrule.style.setProperty("--frame-high-light-color", "var(--dark-frame-high-light-color)");
    cssrule.style.setProperty("--back-light-color", "var(--dark-back-light-color)");
    cssrule.style.setProperty("--back-dark-color", "var(--dark-back-dark-color)");
    cssrule.style.setProperty("--high-light-color", "var(--dark-high-light-color)");
    cssrule.style.setProperty("--footer-color", "var(--dark-footer-color)");
    cssrule.style.setProperty("--alpha-gray-color", "var(--dark-alpha-gray-color)");
    cssrule.style.setProperty("--action-text-color", "var(--dark-action-text-color)");
    cssrule.style.setProperty("--action-text-hover-color", "var(--dark-action-text-hover-color)");
    cssrule.style.setProperty("--key-color-1", "var(--dark-key-color-1)");
    cssrule.style.setProperty("--key-color-2", "var(--dark-key-color-2)");
    cssrule.style.setProperty("--key-color-3", "var(--dark-key-color-3)");
    cssrule.style.setProperty("--dark-key-color", "var(--dark-key-color-"+key_color_id+")");
  }
}


///////////////////////////////////////////////////////////////
/// body_cont のボタン系 ///

// 回答ボタン押した時の正誤判定
function judge(ans, id_prefix){
  var answer1 = document.getElementById("answer1" + id_prefix);
  var answer2 = document.getElementById("answer2" + id_prefix);
  var answer3 = document.getElementById("answer3" + id_prefix);
  var answer4 = document.getElementById("answer4" + id_prefix);
  var answers = [answer1, answer2, answer3, answer4];
  
  if (!answer1.hasAttribute("disabled")){
    // 正誤判定と正誤による表示切り替え
    if (ans.value == "True") {
      // 正解のみ書式変更
      ans.classList.value="btn btn-choice success"
    } else {
      // 誤答を書式変更
      ans.classList.value="btn btn-choice danger"
      // 正解を書式変更
      for (let i = 0; i < answers.length; i++) {
        if (answers[i].value == "True") {
          answers[i].classList.value="btn btn-choice success"
        }
      }
    }
    
    // 結果をresult_listに入れる
    var quiz = document.getElementById("quiz" + id_prefix);
    result_list.push([quiz.dataset.indans, ans.value]);
  
    // 全部押せなくする
    for (let i = 0; i < answers.length; i++) {
      answers[i].setAttribute("disabled", true);
    }
  }
  
  // 解説表示
  var details = document.getElementById("details" + id_prefix);
  if (details.hasAttribute("hidden")){
    details.removeAttribute("hidden");
  }
}



///////////////////////////////////////////////////////////////
/// result_cont のボタン系 ///

// リザルトを集計
function get_result(){
  var res_text = document.getElementById("result");
  var total_num = result_list.length;
  
  // 結果を集計して表示
  var correct_num = 0;
  for (let i = 0; i < total_num; i++){
    var res_i = result_list[i];
    if (res_i[1] == "True"){
      correct_num = correct_num + 1;
    }
  }
  res_text.innerHTML = correct_num + " <small>(correct)</small>/ " + total_num + " <small>(total)</small>";
  
  // 問題を押せなくする
  var selectObj = document.getElementsByTagName('button');
  var matchObj= new RegExp("answer");
  for (i = 0; i < selectObj.length; i++){
    if (selectObj[i].id.match(matchObj)){
      if (!selectObj[i].hasAttribute("disabled")){
        selectObj[i].setAttribute("disabled", true);
      }
    }
  }
  
  // 送信ボタンを押せるようにする
  var send_btn = document.getElementById("btn_send");
  if (send_btn.hasAttribute("disabled")){
    send_btn.removeAttribute("disabled");
  }
}


// 集計結果を送信->トップページに戻る
function send_result(){
  var bcont = document.getElementById("body_cont");
  while (bcont.firstChild) bcont.removeChild(bcont.firstChild);
  
  // 送信ボタンを押せなくする
  var send_btn = document.getElementById("btn_send");
  send_btn.innerHTML = "...";
  if (!send_btn.hasAttribute("disabled")){
    send_btn.setAttribute("disabled", true);
  }
  var res_btn = document.getElementById("btn_result");
  if (!res_btn.hasAttribute("disabled")){
    res_btn.setAttribute("disabled", true);
  }
  
  // 送信中表示カードを作る
  child = make_simplecard("Sending...");
  bcont.appendChild(child);  
  
  // result_listの結果をgasを経由してスプシに渡す
  google.script.run
    .withSuccessHandler(function(top_info){
      var send_btn = document.getElementById("btn_send");
      send_btn.classList.value="btn btn-success col-3";
      send_btn.innerHTML = "Send fin";
      
      var tcont = document.getElementById("top_cont");
      tcont.removeAttribute("hidden");
      var bcont = document.getElementById("body_cont");
      while (bcont.firstChild) bcont.removeChild(bcont.firstChild);
      
      update_topcard(top_info);
      
    })
    .withFailureHandler(function(top_info){
      var send_btn = document.getElementById("btn_send");
      send_btn.classList.value="btn btn-danger col-3";
      send_btn.innerHTML = "Send Error";
    })
    .send_quiz_result(quiz_sheet_name, result_list);
}


///////////////////////////////////////////////////////////////
/// htmlを更新する系 ///

// ロード画面などのカードを作る
function make_simplecard(text){
  // カード全体
  var card = document.createElement("div");
  card.className = "card border-clear mb-3";
  
  // カードのbody
  var cardbody = document.createElement("div");
  cardbody.className = "card-body";
  
  // テキスト部分
  var p_text = document.createElement("p");
  p_text.className = "text-center vertical-middle";
  p_text.innerHTML = text;
  cardbody.appendChild(p_text);
  
  // 結合する
  card.appendChild(cardbody);
  
  return card;
}


// 問題カードのテンプレを作る
function make_card(id_prefix){
  // カード全体
  var card = document.createElement("div");
  card.className = "card mb-3";
  
  // カードのheader
  var cardheader = document.createElement("div");
  cardheader.className = "card-header";
  var cardheader_text = document.createElement("span");
  cardheader_text.className = "vertical-middle" ;
  cardheader_text.innerHTML = "No : " + id_prefix ;
  cardheader.appendChild(cardheader_text);
  card.appendChild(cardheader);
  
  // カードのbody
  var cardbody = document.createElement("div");
  cardbody.className = "card-body";
  
  // テキスト部分
  var quiz_text = document.createElement("p");
  quiz_text.className = "text-center vertical-middle";
  quiz_text.innerHTML = "quiz_text";
  quiz_text.id = "quiz" + id_prefix;
  quiz_text.setAttribute("data-indans", -1);
  cardbody.appendChild(quiz_text);
  
  // ボタン
  var btn_group = document.createElement("div");
  btn_group.className = "row row-cols-2 row-cols-sm-2 row-cols-md-2 row-btns-double";
  btn_group.id = "ansbtns" + id_prefix;
  
  var btn_1 = document.createElement("button");
  var btn_2 = document.createElement("button");
  var btn_3 = document.createElement("button");
  var btn_4 = document.createElement("button");
  btn_1.className = "btn btn-choice col";
  btn_2.className = "btn btn-choice col";
  btn_3.className = "btn btn-choice col";
  btn_4.className = "btn btn-choice col";
  btn_1.id = "answer1" + id_prefix;
  btn_2.id = "answer2" + id_prefix;
  btn_3.id = "answer3" + id_prefix;
  btn_4.id = "answer4" + id_prefix;
  btn_1.setAttribute("onclick", "judge(this,'" + id_prefix + "')");
  btn_2.setAttribute("onclick", "judge(this,'" + id_prefix + "')");
  btn_3.setAttribute("onclick", "judge(this,'" + id_prefix + "')");
  btn_4.setAttribute("onclick", "judge(this,'" + id_prefix + "')");
  
  btn_group.appendChild(btn_1);
  btn_group.appendChild(btn_2);
  btn_group.appendChild(btn_3);
  btn_group.appendChild(btn_4);
  
  // 解説
  var details = document.createElement("details");
  details.setAttribute("hidden", "");
  details.id = "details" + id_prefix;
  var details_summary = document.createElement("summary");
  details_summary.innerHTML = "<small>解説</small>"
  details.appendChild(details_summary);
  
  var div_contents = document.createElement("div");
  div_contents.className = "details-contens table-responsive";
  div_contents.id = "div_contents" + id_prefix;
  
  details.appendChild(div_contents);
  
  // 結合する
  cardbody.appendChild(btn_group);
  cardbody.appendChild(details);
  card.appendChild(cardbody);
  
  return card;
}


function update_quiz(q, id_prefix, isloading) {
  // html読み取り
  var quiz = document.getElementById("quiz" + id_prefix);
  var ansbtns = document.getElementById("ansbtns" + id_prefix);
  var answer1 = document.getElementById("answer1" + id_prefix);
  var answer2 = document.getElementById("answer2" + id_prefix);
  var answer3 = document.getElementById("answer3" + id_prefix);
  var answer4 = document.getElementById("answer4" + id_prefix);
  var answers = [answer1, answer2, answer3, answer4];
  
  // 問題のセット
  if (q.quiz_type == "color"){
    quiz.innerHTML = "";
    var color_quiz_row = document.createElement("div");
    color_quiz_row.className = "row row-color-box";

    var color_quiz_row_1 =document.createElement("div");
    color_quiz_row_1.className = "col-3";

    var color_quiz_row_2 =document.createElement("div");
    color_quiz_row_2.className = "col-6";

    var color_quiz_box = document.createElement("div");
    color_quiz_box.className = "choice-color-box";
    color_quiz_box.setAttribute("style", "background-color: "+q.text_quiz+";");
    
    color_quiz_row_2.appendChild(color_quiz_box);
    color_quiz_row.appendChild(color_quiz_row_1);
    color_quiz_row.appendChild(color_quiz_row_2);
    quiz.appendChild(color_quiz_row);
  } else {
    quiz.innerHTML = q.text_quiz;
  }

  if (q.ans_type == "color"){
    //不要なものを削除
    ansbtns.classList.value = "";
    while (ansbtns.firstChild) ansbtns.removeChild(ansbtns.firstChild);

    //色表示を作成
    var color_box_row = document.createElement("div");
    color_box_row.className = "row row-color-box";
    var color_caps = ["A", "B", "C", "D"];
    for (let i = 0; i < 4; i++) {
      var color_box_col = document.createElement("div");
      color_box_col.className = "col-3";

      var color_box_i = document.createElement("div");
      color_box_i.className = "choice-color-box";
      color_box_i.setAttribute("style", "background-color: "+q.ans_list[i]+";");

      var color_box_cap = document.createElement("p");
      color_box_cap.innerHTML = color_caps[i];

      color_box_i.appendChild(color_box_cap);
      color_box_col.appendChild(color_box_i);
      color_box_row.appendChild(color_box_col);
    }

    // 回答ボタンを作成
    var color_choice_row = document.createElement("div");
    color_choice_row.className = "row row-btns-single";
    for (let i = 0; i < 4; i++) {
      var color_choice_col = document.createElement("div");
      color_choice_col.className = "col-3";

      var color_choice_i = document.createElement("button");
      color_choice_i.className = "btn btn-choice";
      color_choice_i.innerHTML = color_caps[i];
      color_choice_i.id = "answer" + String(i+1) + id_prefix;
      color_choice_i.setAttribute("onclick", "judge(this,'" + id_prefix + "')");

      color_choice_col.appendChild(color_choice_i);
      color_choice_row.appendChild(color_choice_col);
    }
    ansbtns.appendChild(color_box_row);
    ansbtns.appendChild(color_choice_row);
  }

  var answer1 = document.getElementById("answer1" + id_prefix);
  var answer2 = document.getElementById("answer2" + id_prefix);
  var answer3 = document.getElementById("answer3" + id_prefix);
  var answer4 = document.getElementById("answer4" + id_prefix);
  var answers = [answer1, answer2, answer3, answer4];

  if (q.ans_type == "color"){
    answer1.innerHTML = color_caps[0];
    answer2.innerHTML = color_caps[1];
    answer3.innerHTML = color_caps[2];
    answer4.innerHTML = color_caps[3];
  } else {
  // 回答のセット
    answer1.innerHTML = "[A] "+q.ans_list[0];
    answer2.innerHTML = "[B] "+q.ans_list[1];
    answer3.innerHTML = "[C] "+q.ans_list[2];
    answer4.innerHTML = "[D] "+q.ans_list[3];
  }

  if (isloading){
    // ロード中の処理
    // 書式をリセット
    for (let i = 0; i < answers.length; i++) {
      answers[i].classList.value = "btn btn-choice";
    }
  } else {
    // ロード終了後の処理
    // indansをセット
    quiz.dataset.indans = q.ind_ans;
    for (let i = 0; i < answers.length; i++) {
      // ボタンを押せるようにする
      if (answers[i].hasAttribute("disabled")){
        answers[i].removeAttribute("disabled");
      }
      // valueをセット
      if (q.ind_ans == q.ind_list[i]){
        answers[i].value = "True"
      } else {
        answers[i].value = "False"
      }
    }
  } 
  
  // 解説
  var div_contents = document.getElementById("div_contents" + id_prefix);
  var res_table = document.createElement("table");
  res_table.className = "table table-answer"
  
  for (var i = 0; i < 4; i++) {
    var table_tr = document.createElement("tr");
    table_tr.setAttribute("align", "center");

    for (var k = 0; k < q.ans_info[i].length; k++){
      var table_td_k = document.createElement("td");
      if (q.ans_info[i][k].startsWith("#")){
        table_td_k.innerHTML = "";
        var color_box_td_k = document.createElement("div");
        color_box_td_k.className = "choice-color-box";
        color_box_td_k.setAttribute("style", "background-color: "+q.ans_info[i][k]+";"+"width: 3.5rem;");
        table_td_k.appendChild(color_box_td_k);
      } else {
        table_td_k.innerHTML = "<small>" + q.ans_info[i][k] + "</small>";
      }
      table_tr.appendChild(table_td_k);
    }
    res_table.appendChild(table_tr);
  }
  div_contents.appendChild(res_table);
}


// リザルト画面を作る
function make_resultcard(){
  // カード全体
  var card = document.createElement("div");
  card.className = "card mb-3";
  
  // カードのheader
  var cardheader = document.createElement("div");
  cardheader.className = "card-header";
  var cardheader_text = document.createElement("span");
  cardheader_text.className = "vertical-middle" ;
  cardheader_text.innerHTML = "Result";
  cardheader.appendChild(cardheader_text);
  card.appendChild(cardheader);
  
  // カードのbody
  var cardbody = document.createElement("div");
  cardbody.className = "card-body";
  
  // row
  var bodyrow = document.createElement("div");
  bodyrow.className = "row row-btns-double"
  
  // テキスト部分
  var result_text = document.createElement("span");
  result_text.className = "col-6 text-center vertical-middle";
  result_text.innerHTML = "... / ...";
  result_text.id = "result";
  
  // ボタン
  var btn_1 = document.createElement("button");
  var btn_2 = document.createElement("button");
  btn_1.className = "btn btn-choice col-3";
  btn_2.className = "btn btn-choice col-3";
  btn_1.id = "btn_result";
  btn_2.id = "btn_send";
  btn_1.setAttribute("onclick", "get_result()");
  btn_2.setAttribute("onclick", "send_result()");
  btn_1.innerHTML = "Result"
  btn_2.innerHTML = "Send"
  btn_2.setAttribute("disabled", true);
  
  // 結合する
  bodyrow.appendChild(btn_1);
  bodyrow.appendChild(result_text);
  bodyrow.appendChild(btn_2);
  cardbody.appendChild(bodyrow);
  card.appendChild(cardbody);
  
  return card;
}


// 問題ロード中の画面を作成
function quiz_loading(){
  var tcont = document.getElementById("top_cont");
  tcont.setAttribute("hidden", "");
  var bcont = document.getElementById("body_cont");
  while (bcont.firstChild) bcont.removeChild(bcont.firstChild);
  var rcont = document.getElementById("result_cont");
  while (rcont.firstChild) rcont.removeChild(rcont.firstChild);
  // ローディングカードを作成
  child = make_simplecard("Loading...");
  bcont.appendChild(child);  
}


// トップメニューを作成する
function update_topcard(top_info){
  // シート名をoptionsに入れる
  var form_sheet_name = document.getElementById("sheet_name");
  while (form_sheet_name.firstChild) form_sheet_name.removeChild(form_sheet_name.firstChild);
  var sheet_list = top_info[0];
  for (let i = 0; i < sheet_list.length; i++){
    var form_opt_sheet = document.createElement("option");
    form_opt_sheet.innerHTML = sheet_list[i];
    form_opt_sheet.value = sheet_list[i];
    form_sheet_name.appendChild(form_opt_sheet);
  }

  // 出題列をoptionsに入れる
  var form_col_type = document.getElementById("col_type");
  while (form_col_type.firstChild) form_col_type.removeChild(form_col_type.firstChild);
  var col_type_list = ["AB", "BA", "Random"];
  for (let i = 0; i < col_type_list.length; i++){
    var form_opt_col_type = document.createElement("option");
    form_opt_col_type.innerHTML = col_type_list[i];
    form_opt_col_type.value = col_type_list[i];
    form_col_type.appendChild(form_opt_col_type);
  }

  // 問題数をoptionsに入れる
  var form_num_quiz = document.getElementById("num_quiz");
  while (form_num_quiz.firstChild) form_num_quiz.removeChild(form_num_quiz.firstChild);
  var num_quiz_list = top_info[1];
  for (let i = 0; i < num_quiz_list.length; i++){
    var form_opt_num_quiz = document.createElement("option");
    form_opt_num_quiz.innerHTML = num_quiz_list[i];
    form_opt_num_quiz.value = num_quiz_list[i];
    form_num_quiz.appendChild(form_opt_num_quiz);
  }

  // リザルト表を作る
  var details_total_count = document.getElementById("details_total_count");
  details_total_count.innerHTML = top_info[3][0][3];
  var details_total_exp = document.getElementById("details_total_exp");
  details_total_exp.innerHTML = top_info[3][0][5];

  var details_table = document.getElementById("details_table");
  while (details_table.firstChild) details_table.removeChild(details_table.firstChild);
  var table_header = document.createElement("tr");
  table_header.setAttribute("align", "center");
  var table_header_text = ["問題種類", "出題済み", "累計回数", "<small>Exp.</small>"];
  for (var i = 0; i < table_header_text.length; i++) {
    var table_header_th = document.createElement("th");
    table_header_th.innerHTML = table_header_text[i];
    table_header.appendChild(table_header_th);
  }
  details_table.appendChild(table_header);

  var table_data = top_info[2];
  for (var i = 0; i < table_data.length; i++) {
    var table_tr = document.createElement("tr");
    table_tr.setAttribute("align", "center");
    var table_td_1 = document.createElement("td");
    table_td_1.innerHTML = table_data[i][0];
    var table_td_2 = document.createElement("td");
    table_td_2.innerHTML = table_data[i][2] + " /<small> "+ table_data[i][1] + "</small>";
    var table_td_3 = document.createElement("td");
    table_td_3.innerHTML = table_data[i][3];
    var table_td_4 = document.createElement("td");
    table_td_4.innerHTML = "<small>" + table_data[i][5] + "</small>";
    
    table_tr.appendChild(table_td_1);
    table_tr.appendChild(table_td_2);
    table_tr.appendChild(table_td_3);
    table_tr.appendChild(table_td_4);
    details_table.appendChild(table_tr);
  }
}


// GASの問題取得成功した場合
function onquizSuccess(res){
  // 結果格納用のリストをリセット
  result_list = [];
  
  // 出題用コンテナの更新
  // 既にあるカードをリセット
  var bcont = document.getElementById("body_cont");
  while (bcont.firstChild) bcont.removeChild(bcont.firstChild);
  
  for (i = 0; i < res.length; i++) {
    // カードの枠を作成
    child = make_card(res[i].quiz_id);
    bcont.appendChild(child);
    // カードの中身を作成
    update_quiz(res[i], res[i].quiz_id, false);
  }
  
  // リザルト用コンテナの更新
  var rcont = document.getElementById("result_cont");
  while (rcont.firstChild) rcont.removeChild(rcont.firstChild);
  r_child = make_resultcard();
  rcont.appendChild(r_child);
}


// GASの通信失敗した場合
function onFailure(res) {
  // 既にあるカードをリセット
  var bcont = document.getElementById("body_cont");
  while (bcont.firstChild) bcont.removeChild(bcont.firstChild);
  // ローディングカードを作成
  child = make_simplecard("Load failed");
  bcont.appendChild(child);  
}


///////////////////////////////////////////////////////////////
/// GAS通信する系 ///

// GAS関数から問題・答えを取得する関数
function new_quiz(sheet_name, num_quiz, col_type) {
  quiz_loading();
  
  google.script.run.withSuccessHandler(onquizSuccess).withFailureHandler(onFailure).get_quiz_list(sheet_name, num_quiz, col_type);
}


// 開始時にtop_contをロード画面から切り替える
document.addEventListener("DOMContentLoaded", function() {  
  // 夜はダークモードにする
  var now = new Date();
  var hour = now.getHours();

  if (hour < 7 || 21 < hour){
    set_color_mode("dark");
  }
  
  // topページ作成情報のためGASを呼ぶ
  google.script.run
    .withSuccessHandler(function(top_info){
      // GAS通信成功した場合
      var tcont_loading = document.getElementById("top_cont_loading");
      var tcont_main = document.getElementById("top_cont_main");

      // TOP画面の内容を更新する
      update_topcard(top_info);

      // load画面を隠す
      tcont_loading.setAttribute("hidden", "");
      // TOP画面を映す
      tcont_main.removeAttribute("hidden");
    })
    .withFailureHandler(onFailure) // GAS通信失敗した場合
    .get_top_info();
});


</script>

 以上でファイル作成が完了です。


5. アプリをデプロイする

 ファイルが作成できたら、アプリを使える状態にするためにデプロイします。

 まずは「デプロイ」→「新しいデプロイ」を選択します

 次に「歯車のマーク」→「ウェブアプリ」を選択します。

 続いて設定を入力します。以下のように入力してください。

・新しい説明文
 - お好みの内容
・次のユーザーとして実行
 - 「自分」又は「ウェブアプリケーションにアクセスしているユーザー」
・アクセスできるユーザー
 - 「自分のみ」

 入力できたら「デプロイ」を選択してデプロイ完了です。

 完了した際に表示されるウェブアプリのURLにアクセスすれば、アプリを使うことができます。デプロイ後も、「デプロイ」→「デプロイを管理」からURLを確認することができます。
 このURLを、ブラウザ上でブックマークするなり、スマホでショートカット設定するなりしてアクセスしやすいようにしておくのがオススメです。

 これにてGASで作る自分専用4択カードアプリが完成です。お疲れ様でした。どんどんアプリをやって知識定着を図るなり、自分好みにゴリゴリカスタムするなりしてください。


おわりに

 勉強に必要になって作成したこのGASの4択カードアプリ、今後自分が再利用するときの備忘録も兼ねて作り方を残しておきましたが、誰かの役に立っていれば幸いです。もし、自分で実装やデザインをカスタムした人がいたら、どんな感じにしたのか教えてもらえると喜びます。

 このアプリの作るきっかけとなった話や作った後の話はこちらの記事にまとめてあります。


この記事が参加している募集

この記事が気に入ったらサポートをしてみませんか?