reveal.jsのスライド内容を管理するGASツールを作ってみた

こんにちは。メカヲタ。です。

FUN Advent Calender 12/17担当ということですが、就活忙しいので、とあるLT用に準備してたmarkdownあったので、それをそのまま投げます。

先日、[「GASはいいぞ」](https://qiita.com/mecaota/items/1823f68f556500f4f7b0)という記事を先日投稿しましたが、この度、前々から作っていたreveal.jsでスライドやるときに簡単にスライドの内容を切り替え出来る便利ツールが完成したので、そのへんの話をしようと思います。

GASは中身JSなのでちょっとしたプログラミングの練習にもなるし、技量がなくとも使いこなせば結構便利なもの作れるので、ぜひ使ってみてください。以下、駄文。

# 概要

GoogleDriveに用意したフォルダにmarkdownを投げれば、githubpagesにホストしておいたreveal.js改から、専用の文字列が入ったリクエストがあったときに、リクエストどおりのmarkdownと、実名かハンドルネームのプロフィール情報を返し、それを元にDOM生成、reveal.js起動。非常に簡単にスライドがデプロイでき、プロフィール情報を本名表示するか、ハンネ表示するかも指定できる便利ツール。

# 何故作ったか

reveal.jsかっこいいし、簡単にオンライン公開できるけど、スライドごとにホスティングするのは面倒だし非効率だと思ったから。

あと、逆スカウト就活ばっかやってると結構使うんですよ。面接用、面談用、逆スカウトプレゼン用などなど・・・そのたびにスライド1枚1枚作ってたらしゃーないし、データ忘れたら怖いし。ゆうて公開しちゃ困る情報は会社に見せたくないから公開しても問題ないし・・・みたいな。

# 実装

## GAS側
### 取得する情報
- ファイルid → slideid
- ファイル名(拡張子有り) → filename
- ファイル名(拡張子なし) → mode(リクエストパラメータ判別文字列一覧)
- GoogleDriveからダウンロードする際のURL → url

### 起動時「onOpen(e)」の処理
```javascript:code.gs
// 起動時に自動発火する関数
function onOpen(e){
  var ui = SpreadsheetApp.getUi();
  // Or DocumentApp or FormApp.
  ui.createMenu('GAS')
      .addItem('自動登録処理', 'autofill')
      .addToUi();
}

function autofill() {
  var ss = SpreadsheetApp.getActiveSheet();
  var slidesource = DriveApp.getFolderById("ターゲットフォルダのフォルダid").getFiles();
  addSlideList(ss, slidesource);
}

function addSlideList(ss, slidesource){
  var range = ss.getRange(1, 1, ss.getLastRow(), ss.getLastColumn()).getValues();
  var idNum = getColumnIndexByName(range, "slideid");
  var filenameNum = getColumnIndexByName(range, "filename");
  while(slidesource.hasNext()){
    var slide = slidesource.next();
    var slideid = slide.getId();
    var filename = slide.getName();
    if(!boolDataColumn(range, slideid, idNum) && (filename.search(/.*(\.md)$/g) >= 0)){
      var setrange = ss.getRange(ss.getLastRow()+1, 1, 1, ss.getLastColumn());
      setrange.getCell(1, idNum+1).setValue(slideid);
      setrange.getCell(1, filenameNum+1).setValue(filename);
      setrange.getCell(1, getColumnIndexByName(range, "url")+1).setValue(slide.getDownloadUrl());
      setrange.getCell(1, getColumnIndexByName(range, "mode")+1).setValue(filename.match(/^.*(?=\.md)/g));
      var rule = SpreadsheetApp.newDataValidation().requireValueInList(['TRUE', 'FALSE'], true).build();
      setrange.getCell(1, getColumnIndexByName(range, "realname")+1).setDataValidation(rule).setValue(false);
    }
  }
}

function getColumnIndexByName(range, name){
  var result = -1;
  range[0].forEach(function(value, index){
    if(value == name){
      result = index;
    }
  });
  return result ;
}

function boolDataColumn(range, slideid, idNum){
  var flag = 0;
  range.forEach(function(value){
    if(value[idNum] == slideid){
      flag++;
    }
  });
  if(flag > 0){
    return true;
  }else{
    return false;
  }
}
```

起動時にUIボタンをメニューへ追加(手動でも処理できるようにする為)、UIボタン押下時と同じ動作をそのまま起動時に実行。

1. UIボタンを追加
2. 指定したフォルダのmarkdownファイルを走査
3. スプレッドシート上に記載されていないファイルidを検知
4. スプレッドシートに指定のフォーマットで追加

### postリクエスト時「doPost」の処理

※あまりAPIのURLを公開したくなかったので・・・

```javascript:code.gs
function doPost(request) {
  Logger.log(request);
  var mode = JSON.parse(request.postData.contents).mode;
  var output = ContentService.createTextOutput().setContent(getMydata(mode)).setMimeType(ContentService.MimeType.JSON);
  Logger.log(output);
  return output;
}

function getMydata(mode){
  var slidelist = init(decodeURI(mode));
  var data = getName(slidelist["realname"]);
  data["slideurl"] = getSlide(slidelist["url"]);
  data["title"] = slidelist["title"];
  data["collge"] = "大学名";
  data["department"] = "学科とか";
  data["cource"] = "配属コース";
  data["grade"] = "学年";
  data["description"] = "その他";
  data["url"] = "https://introduce.mecaota.work?mode=" + slidelist["title"];
  data["born"] = "出身地"; 
  var today = new Date();
  data["date"] = today.getFullYear() + "/" +  today.getMonth() + "/"+ today.getDate() + "/" + today.getHours() + ":" + today.getMinutes();
  data = JSON.stringify(data);
  return data;
}

function init(mode){
  var ss = SpreadsheetApp.getActiveSheet();
  var range = ss.getRange(1, 1, ss.getLastRow(), ss.getLastColumn()).getValues();
  var slidelist = getSlideList(range, mode);
  return slidelist;
}

function getSlideList(range, mode) {
  var slidelist = [];
  var header = range[0];
  range = range.slice(1);
  range.forEach(function(values){
    var slideinfo = {};
    values.forEach(function(value, index){
      slideinfo[header[index]] = value;
    });
    if(mode){
      if(slideinfo["mode"].indexOf(mode) >= 0){
        slidelist = slideinfo;
      }
    }else{
      slidelist.push(slideinfo);
    }
  });
  return slidelist;
}

function getSlide(slides, mode){
  slides.forEach(function(value){
    if(value.indexOf(mode) >= 0){
      return value;
    }
  });
  return {};
}

function getName(realname){
  var data = new Object();
  if(realname){
    data["name"] = "本名";
    data["image"]= "自画像のリンク";
  }else{
    data["name"] = "ハンドルネーム";
    data["image"]= "SNSのアイコンの画像リンク";
  }
  return data;
}

function getSlide(url){
  var result = url + "&access_token=" + ScriptApp.getOAuthToken();
  return result;
}
```
1. リクエストを受付
2. リクエストパラメータをパースして、スライドプロフィール情報を構成開始
3. スプレッドシートに登録しているプロフィール情報の実名表示可否フラグやmarkdownダウンロードURLを元にobject生成
4. jsonにエンコードして、返す


## スプレッドシート側

スプレッドシート側はこんな感じで管理出来るようにしています。

![キャプチャ.PNG](https://qiita-image-store.s3.amazonaws.com/0/104876/f3017dff-6324-ec67-2ec1-5af8549514d1.png)

(写真の白帯が雑で申し訳ない)

modeにはreveal.js側からのリクエストパラメータ(私はmodeにしました)、titleにはスライド表示時のタイトル名、filenameはファイル名(管理用)、slideidはGoogleDriveのファイルid、urlはmarkdownファイルのダウンロードURLにそれぞれ対応しています。

例えば、指定したフォルダにmarkdownファイルを投げると、スプレッドシート開いたときに自動で処理が走ります。

自動で処理が走るので、こちらで指定するのは、追加のパラメーター文字列や、実名表示にしたい時だけ対応する行を書き換えればOK

## reveal.js(改)側

```html:index.html
<!doctype html>
<html>
	<head>
		<meta charset="utf-8">
		<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">


		<title>自己紹介LT</title>

        <script src="js/makedom.js"></script>
		<link rel="stylesheet" href="css/reveal.css">
		<link rel="stylesheet" href="css/theme/black.css">

		<!-- Theme used for syntax highlighting of code -->
		<link rel="stylesheet" href="lib/css/zenburn.css">

		<!-- Printing and PDF exports -->
		<script>
			var link = document.createElement( 'link' );
			link.rel = 'stylesheet';
			link.type = 'text/css';
			link.href = window.location.search.match( /print-pdf/gi ) ? 'css/print/pdf.css' : 'css/print/paper.css';
			document.getElementsByTagName( 'head' )[0].appendChild( link );
		</script>
	</head>
	<body>
        <!-- ファーストロードでサーバーからのファイル受信をを順序指定非同期で開始  -->
		<div class="reveal">
			<div class="slides">
                <section>
                    <h2 class="title"></h2>
                    <h3 class="name"></h3>
                    <h4 class="date"></h4>
                    <span></span>
                    <a class="url"></a>
                    <p><small>Powered by Reveal.js, GoogleAppScript</small></p>
                </section>
                <section>
                    <div class="image"></div>
                    <h3 class="name"></h3>
                    <div class="collge"></div>
                    <div class="department"></div>
                    <div class="cource grade description"></div>
                    <div class="born"></div>


                </section>
				<section data-markdown data-separator="---$" data-separator-vertical=">>>$">
                    <script id = "slideurl" type="text/template"></script>
                </section>
                
                <section>
                    <p>End</p>
                    <p></p>
                    <p>このスライドのURL</p>
                    <a class="url"></a>
                </section>
			</div>
		</div>

		<script src="lib/js/head.min.js"></script>
		<script src="js/reveal.js"></script>
		<script>
			// More info about config & dependencies:
			// - https://github.com/hakimel/reveal.js#configuration
			// - https://github.com/hakimel/reveal.js#dependencies
            ///URL parameter取得して配列へin
            getInfo(function(){
                console.log("reveal initializing///");
                Reveal.initialize(
                    {
                        dependencies: [
                            { src: 'plugin/markdown/marked.js' },
                            { src: 'plugin/markdown/markdown.js' },
                            { src: 'plugin/notes/notes.js', async: true },
                            { src: 'plugin/highlight/highlight.js', async: true, callback: function() { hljs.initHighlightingOnLoad(); } }
                        ]
                    }
                );
                return "reveal.js Initializing finished";
            })
        </script>
	</body>
</html>
```

```javascript:makedom.js
function getURLparam(pair){
    var arg = new Object;
    for(var i = 0;pair[i];i++) {
        var kv = pair[i].split('=');
        arg[kv[0]] = kv[1];
    }
    return arg;
}

function getInfo(callback){
    var arg = getURLparam(location.search.substring(1).split('&'));
    ///プロフィールAPI(自作GASアプリ)より取得
    var url = "GASアプリURL(APIサーバー)";
    var param = {};
    param["mode"]=arg["mode"];
    param = JSON.stringify(param);
    return fetch(url,{
        method: 'POST',
        mode: 'cors',
        body: param
    }).then(function(response) {
        return response.text();
    }).then(function(json) {
        var json = JSON.parse(json||"null");
        return json;
    }).then(json => createDom(json)
    ).then(function(json){
        fetch(json["slideurl"]).then(function(response) {
            return response.text();
        }).then(function(slidebody){
            document.getElementById("slideurl").insertAdjacentHTML("beforeend", ""+ slidebody +"");
            return true;
        }).then(callback);
    });
    return true;
}

function selectDom(key, value){
    ///各メタデータ配置箇所のclass要素をget
    var domList = document.getElementsByClassName(key);
    return Promise.all(Object.keys(domList).map(function (i) {
        if(key == "image"){
            domList.item(i).insertAdjacentHTML("beforeend", "<img alt='こ↑こ↓僕のサムネ' style='height:20rem;' src='"+ value +"'></img>");
        }else if(key == "url"){
            domList.item(i).insertAdjacentHTML("beforeend", ""+ value+ "");
            domList.item(i).href = value;
        }else{
            domList.item(i).insertAdjacentHTML("beforeend", ""+ value +"");
        }
    }));
}

function createDom(meta_json){
    //json内のキーを探査して、キーと同値のクラスへdom書き込み
    return Promise.all(Object.keys(meta_json).map(function(key){
        return selectDom(key, meta_json[key]);
    })).then(function(){
        return meta_json;
    });
}
```

1. reveal.jsのindex.htmlでReceal.init()が走る前に、URLパラメーターを基に用意したAPIへアクセス
2. レスポンスを確認して、そのレスポンスを基にプロフィール情報をDOM追加
3. 先程のレスポンスに含まれているmarkdownダウンロードURLにfetchして、拾ってきたデータを、スライドデータを読みこむ領域にDOM追加
3. 上の処理が完了してからReveal.init()する。

# 結果

これで、[https://introduce.mecaota.work?mode=hoge](https://introduce.mecaota.work?mode=)にアクセスして、hogeにこっちで用意した文字列入れさえすれば、スライドが稼働。めっちゃ便利。

とても楽ちんになった。みんなもGASでこんな便利ツールをクイックに作ってみよう。

# 及第点

フロント側のGAS連携機構部分であるmakedom.jsがPromiseオブジェクトとかコールバックとかJSのその辺の話全く知らなかったので、めっちゃ苦労した結果汚いコードになったので、良い子は真似しないでね。