はじめに
Googleフォームは質問項目をランダムに並べ替えてくれる機能はあるが、セクション単位でランダマイズすることは出来ない。ってことでセクションをランダマイズできないか、ということでやってみた。
ただ、結論を言うと、質問項目のランダマイズのように、フォームの回答画面にアクセスする度に自動でセクションの並びをランダマイズする、というのは出来ません。
誰もアクセスしていないときにセクション・ランダマイズが実行される分には良いのですが、誰かがフォームにアクセスしてる最中にセクションのランダマイズが実行されると、アクセス中の人のフォームのセクションがダブったり、飛んだりといったバグが生じます。
回答フォームにアクセスする毎にセクションの並びを変えるのは、HTMLとCSSとjQueryとjavascriptを使えばできるので、Googleフォームでやるのはあきらめて、自分でHTMLを書きましょう。
ただ、例えばランダマイズは1日単位の実施でよい(確実にだれもフォームにアクセスしないであろう時間(夜中0時とか)にフォームの並びをランダムに変える)というのであれば出来ます。
そういうもので良ければ以下をご参照ください。
ではスタート
Googleフォームはセクション内で質問の順序をランダマイズする機能はデフォルトであるけど、セクションの並びをランダマイズしてくれる機能は残念ながら入ってない。
ってことで、それを実現するためにGoogleAppScriptを色々といじってみる。
まずは事始め
とりあえずGoogleフォームを作ってスクリプトを開く。
作ったフォームは関な感じで、最初からある先頭の1つのセクションに加えて、4つのセクションを追加。
function myFunction() {
}
現在のフォームを取得する。
FormAppという変数でいろいろ操作できるようだ。とりあえず、現在のフォームを取得して、フォームの構成要素を取得する。
function myFunction() {
const form = FormApp.getActiveForm();
var Test = form.getItems();
}
getActiveForm()
は関数名のまんま現在のフォームを取得してくれるメソッド。
getItems()
はフォームを構成している要素を配列で返してくれる。
どんな配列が返ってくるのかチェックするために、デバックモードで実行。
正直思ってたんと違った。なんだ?この長さ8っていうのは?
で、さらにそれぞれの配列要素の中身を見てみたんだけど、あくまでこの要素はGoogleフォームの各パーツを管理するクラスのインスタンスになっていて、タイトルとかが直接書かれてるわけではない。
フォーム要素の中身を弄る
続けて
Test[1].setTitle("書き換えテスト");
ってのをやってみる。
結果はこちら。
1つ目(トップのセクションから数えると2つ目)のセクションの中に含まれる質問項目のタイトルが書き変わってる。
ほう、そこが書き換わるのか、という感じ。
じゃあ、ということで次のスクリプトを実施。
Test[0].setTitle("セクション1書き換え");
Test[2].setTitle("セクション2書き換え");
ってことで、なんとなく、配列の中身が分かってきた。多分、セクションであったり、質問であったりの、一つ一つのフォーム要素が配列として入っているっていうことじゃないか。
つまり,セクションって,Google フォームの編集画面上では,セクションの属する質問を束ねる上位要素のように見えるけど,実際には,質問とかと同じ位置づけで配列に入ってるだけってことか.
ということは、セクション単位での並び替えは、配列要素をセクション単位で並び替えればよいということなんじゃないか??
セクションの並べ替え
うん、それって簡単じゃないぞ。。。
セクション単位で配列をまとめとかないといけない=セクションの切れ目をキチンと把握しとかないといけない12
ただ、セクションの切れ目さえわかれば、配列をその切れ目で分割して、分割したものを所望の順番に並べ替えて、結合させればよい。
さてどうする??
要素のタイプの識別
配列要素がフォームのどのタイプの要素なのかが識別できれば、要素がセクションタイトル要素なのかそうでないかを識別して、その手前で切り分けられる。
ということで、要素タイプへのアクセス方法を調べてみる。
とりあえず、エディタ上でTest[0].get
とか打ち込んでみて、それっぽいメソッドがないかチェック。getType
というメソッドがにおう。
ということでTest[0].getType()
と打ち込んでみる。
すると、こんな感じのメソッドの簡単な説明が表示されたので、そのなかのリンクを叩てみる。
すると要素のタイプをチェックする例文を示してくれてる。
おー、これ使えるんちゃうん?
ってことで、
function myFunction() {
const form = FormApp.getActiveForm();
var Test = form.getItems();
for(var i in Test){
if(Test[i].getType() == FormApp.ItemType.SECTION_HEADER){
Logger.log(i);
}
}
}
として、セクションの切れ目のインデックスが取れるかを確認。
。。。。
できひんやん!!
なんで?
ってことで、今度はこういうのをしてみる。
for(var i in Test){
Logger.log(Test[i].getType());
}
すると、セクションの切れ目はSECTION_HEADERではなくてPAGE_BREAKというタイプであることが判明。
ってことで、if文をちょっと変えて実行
for(var i in Test){
Logger.log(Test[i].getType());
if(Test[i].getType() == FormApp.ItemType.PAGE_BREAK){
Logger.log(i);
}
}
きちんとインデックス番号を取れることが確認できた!
ってことで、このインデックス番号を手掛かりに配列を切り分けて、並べ替えればよいわけだ。
ってことで,以下のコードでセクションの切れ目を配列としてSectionという変数に保存.
//セクション(PageBreak)の切れ目の確認
var Section = new Array();
for(var i in Test){
if(Test[i].getType() == FormApp.ItemType.PAGE_BREAK){
Section.push(i);
}
}
セクション単位で配列を切り分け
Testという配列にItemが入っているので,slice(start, end)
を使って切り分け.
//セクションの切り分け。SecArrayに、切り分けたセクションごとに、二次元配列の形で保存。
var SecArray = new Array();
for( var i=0; i < Section.length; i++){
if( i < Section.length-1){
SecArray.push( Test.slice(Section[i],Section[i+1])); //n番目からn+1番目まで
}
else{
SecArray.push(Test.slice(Section[i]));//n番目から最後まで。最後の要素を取り出すための分岐
}
}
ということで,切り分けた.あとはこれを並べ直して,もとのフォームに戻してやれば良い.
へ?どうやって戻すの?
あかん。配列で切り分けたところで、それを元のフォームに戻す方法が無かった・・・。
getItems()
があるんやから,てっきりsetItems()
とかあるんやと思ってた.
使えそうなのはmoveItem(from, to)
メソッドやけど、これはあくまで1個ずつ移動させるというもの。
うーん、一個ずつ動かすの、できなくは無いけど面倒くさいぞ・・・。
あ,でもmoveItem(Item, toIndex)
ってのもあるのか.
ってことは、別にIndex番号だけで並び替えを作って、そのIndex番号に基づいて並び替えるということか。。。
と,ここでふと思った.
今getItemsで取得して、それを配列で切り分けたりしたけど、それってもしかしてポインタが配列化されてる?
そうだとしたら、配列から要素ををいじると、フォームもちゃんと変わるはず。
逆に,本当にItemのインスタンスがコピーされて配列化されてるんだとしたら,SecArray上でなにかをいじってもフォームそのものには反映されない.
ってことで,試してみよう。
SecArray[0][0].setTitle("クション1をSecArrayで書き換え");
あ、できた。
実際にGASのマニュアルを見てみると,Itemはあくまでアイテム要素へアクセスするためのインタフェースということらしい.まあつまりはポインタっちゅうことやな.
ってことは,SecArray上で並べ替えて,その順番にSecArrayから要素をポップさせて,Itemとして,moveItem(Item, toIndex)の第1引数に与えてやればいけるんじゃないか?
完成
ってことで,最終的に以下のようなスクリプト.ちなみに、乱数列の作成部分は以下のページから拝借。
https://www.sejuku.net/blog/22432
const form = FormApp.getActiveForm();
var Items = form.getItems();
//Items.setTitle("セクション1書き換え")
//Items.setTitle("セクション2書き換え")
//セクション(PageBreak)の切れ目を確認して,そのIndex番号をSecction配列に格納.
var Section = new Array();
for(var i=0; i<Items.length; i++){
if(Items[i].getType() == FormApp.ItemType.PAGE_BREAK){
Section.push(i);
}
}
//Logger.log(Section);
//セクションの切り分け。SecArray配列に、切り分けたセクション単位で、二次元配列の形で保存。
var SecArray = new Array();
for( var i=0; i < Section.length; i++){
if( i < Section.length-1){
SecArray.push( Items.slice(Section[i],Section[i+1])); //n番目からn+1番目まで
}
else{
SecArray.push(Items.slice(Section[i]));//n番目から最後まで。最後の要素を取り出すための分岐
}
}
// //アイテムがきちんと切り分けられてるかの確認
// for(var i=0; i<SecArray.length;i++){
// for(var j=0; j<SecArray[i].length; j++){
// Logger.log((i+1)+"個目のセクションの"+(j+1)+"個目の要素のタイトル: " + SecArray[i][j].getTitle());
// }
// }
//SecArray[0][0].setTitle("クション1をSecArrayで書き換え");
//乱数列を作成
var randArray = new Array(SecArray.length);
for(var i=0; i<randArray.length; i++){
randArray[i] = i;
}
var a = randArray.length;
while(a){
var j= Math.floor(Math.random()*a);
var t = randArray[--a];
randArray[a] = randArray[j];
randArray[j] = t;
}
//Logger.log(randArray);
//
var count = 0;
for(var i=0 ; i<randArray.length ; i++){
//Logger.log(SecArray[randArray[i]].length);
for(var j=0; j<SecArray[randArray[i]].length; j++){
form.moveItem(SecArray[randArray[i]][j],count);
count++;
}
}
// //アイテムがランダマイズされてるかの確認.
// Items = form.getItems();
// for(var i=0; i<Items.length;i++){
// Logger.log((i+1)+"個目の要素のタイトル: " + Items[i].getTitle());
// }
仕上げ:トリガー設定
さてスクリプトができたということで,このスクリプトを実行するタイミングの設定が必要.
うまく動かず・・・.
「実行する関数の選択」は,自分が作成さした関数名.今回はmyFunction.
「実行するデプロイを選択」はそのままいじらず.
「イベントのソースを選択」は「フォームから」を選択.
で,
「イベントの種類を選択」を,「起動時」に設定.
イメージしてたは,フォームが読み込まれるたびにスクリプトが実行されて,セクションがシャッフルされる,というもの.
なので「起動時」という設定でよいハズ.
しかし!!
なぜか,フォームを何度読み込んでもスクリプトが実行されず.
ブラウザでShift + F5で完全再読み込みでも効かず.
調べてみると,あくまでFormのエディター画面を読み込んだときに実行されるのであって,フォームの回答画面を開くタイミングでは実行されないようだ.
Google 公式アナウンスでもそういうアナウンスらしい.
https://www.pre-practice.net/2019/09/google-formonopen.html
どうする?
うーん....
対応方針は2つで,時間ドリブンにしてやる方法.ただし,この場合1分ごとの実行が最小単位.
もう一つは,フォーム送信後に実施することはできるので,そちらに結びつける.
どうしよう.
今回やろうとしていることは,研究の中でアンケートを取るときに回答者ごとに質問順をセクション単位でランダマイズしたいっていうのが目的.
なので,上記の2つだといずれも,一斉にアンケートをしてもらうシチュエーションでは難しそう.
PAGE_BREAKだと使えない!!
しかもちょっと色々と試していると,時間トリガーにせよ,送信後実行にせよ,別の人がフォームにアクセス中にスクリプトが実行されると,別の人がアクセスしているフォームが崩れてしまう...
どうやら,セクション分割(PAGE_BREAK)を入れると,そこでHTMLが切れるので,前のページの質問に回答して,「次へ」をクリックしない限り,次のHTMLが読み込まれない.またHTMLの生成は「次へ」をクリックするタイミングで行っている.
なので,ある人がアクセス中にスクリプトが実行されると,その人のフォームが崩れる.具体的には、その時点で表示させているページは良いけど,「次へ」を押したときに,スクリプト実行前のセクションの並びのものとは異なる並びのフォームからページが生成されるので,セクション番号がダブったものが表示されたり,セクションが飛んだりしてしまう.
この問題に対応するためには,つまるところPAGE_BREAKを使わないこと.単なるテキスト要素を追加するもの(SECTION_HEADER)を使ってセクション切り分けを行うことにして,すべてのセクションを1ページ内に表示してしまうことしかない.
次善策として,SECTION_HEADERを使う
これだと,ページ読み込みは最初の1回だけなので,最初のアクセスのあとは,フォームが変わろうが何しようが関係なし.
なので,別の関数として,以下のような,インターバルを1秒にしてmyFunctionを繰り返し呼び出す関数myFunction2を作って,1分ごとの時間トリガーをかけるようにすると,延々とフォームのランダマイズが実行され続けることになる.
function myFunction() {
const form = FormApp.getActiveForm();
var Items = form.getItems();
//Items.setTitle("セクション1書き換え")
//Items.setTitle("セクション2書き換え")
//セクション(PageBreak)の切れ目を確認して,そのIndex番号をSecction配列に格納.
var Section = new Array();
for(var i=0; i<Items.length; i++){
if(Items[i].getType() == FormApp.ItemType.SECTION_HEADER){
Section.push(i);
}
}
// Logger.log(Section);
//セクションの切り分け。SecArray配列に、切り分けたセクション単位で、二次元配列の形で保存。
var SecArray = new Array();
for( var i=0; i < Section.length; i++){
if( i < Section.length-1){
SecArray.push( Items.slice(Section[i],Section[i+1])); //n番目からn+1番目まで
}
else{
SecArray.push(Items.slice(Section[i]));//n番目から最後まで。最後の要素を取り出すための分岐
}
}
// //アイテムがきちんと切り分けられてるかの確認
// for(var i=0; i<SecArray.length;i++){
// for(var j=0; j<SecArray[i].length; j++){
// Logger.log((i+1)+"個目のセクションの"+(j+1)+"個目の要素のタイトル: " + SecArray[i][j].getTitle());
// }
// }
//SecArray[0][0].setTitle("クション1をSecArrayで書き換え");
//乱数列を作成
var randArray = new Array(SecArray.length);
for(var i=0; i<randArray.length; i++){
randArray[i] = i;
}
var a = randArray.length;
while(a){
var j= Math.floor(Math.random()*a);
var t = randArray[--a];
randArray[a] = randArray[j];
randArray[j] = t;
}
//Logger.log(randArray);
//
var count = 0;
for(var i=0 ; i<randArray.length ; i++){
//Logger.log(SecArray[randArray[i]].length);
for(var j=0; j<SecArray[randArray[i]].length; j++){
form.moveItem(SecArray[randArray[i]][j],count);
count++;
}
}
// //アイテムがランダマイズされてるかの確認.
// Items = form.getItems();
// for(var i=0; i<Items.length;i++){
// Logger.log((i+1)+"個目の要素のタイトル: " + Items[i].getTitle());
// }
}
function myFunction2(){
var date = new Date();
var diff = 0;
while(diff < 55*1000){//55秒でループ終了.60にしなかったのはトリガー起動を1分ごとにしてるので,それと当たるとどうなるかが読めないので,バッティングするのを避けるため.
myFunction();
Utilities.sleep(10*1000)
var d = new Date();
diff = d - date;
}
}
うーん,,,なんかもうHTMLとCSSとjQueryとjavascriptを使って,自分でアンケートフォーム画面を作って,スプレッドシートとリンクさせるほうが簡単な気がしてきた・・・.