memorandums

日々の生活で問題解決したこと、知ってよかったことなどを自分が思い出すために記録しています。

GASで作ったファイルアップロードアプリでアップロードできなかった件について

非常勤先ではGoogle Classroom等を使用していないため、ログインなしでファイルを提出してもらうツールをGAS(Google App Script)で作って使ってきました。作ったといってもネットの記事を寄せ集めただけですが。。。どういうわけかそのときのエントリーが見つからなかったのですが、以下が少し関連しています。

memorandums.hatenablog.com

最近、学生さんから「このツールでアップロードできない」という報告がありました。

スクリプトの処理には特にファイルサイズは制限していなかったはずなので。。。タイムアウトでもしたのかな?と思っていました。調べると、原因は簡単で50MB以上のファイルをアップロードできない仕様だとわかりました。調べたリンクがどこかへ行ってしまったので示せませんが、Drive APIの制約のようでした。

とりあえずアップロードしようとしているファイルが50MB以上だったら警告出して、各自でファイル分割してもらおうかと思ったんです。実装しているうちに、実は50MB以上でもアップロードできることがわかりました。APIのドキュメントは以下のようです。

developers.google.com

でも、このドキュメントを読んだだけでは実装方法がよくわかりません。。。さらに調べると以下の記事を書いてくれている方がいました。しかも動作確認したソースつきで。素晴らしい。御本人がQiitaに記事を書かれています。さすがです。

qiita.com

ソースコードも公開されています。

github.com

ただ、この実装は私には難しくコードを追う気になりませんでした。でも、この機能は欲しいので。。。とりあえずコード.gsとindex.htmlをそのまま動かしてみるとちゃんと動くんですね。。。素晴らしい。

ただ、このスクリプトではアップロードされる場所がマイドライブのトップになってしまうんです。これだと都合が悪いのでなんとかならないかな。。。と思ってググると出てきました。対処は簡単です。2行(というか1行)追加するだけです。APIを呼び出すときのパラメタに親フォルダのIDを指定するだけなんですね。これは恐らくドキュメントに書いてあるんでしょう。。。すいません。

stackoverflow.com

ということで、できあがったスクリプト全体を示します。9割以上は@tanaikeさんのコードです。ありがたく感謝して使わせていただきます🙇‍♂

コード.gs

var folderId = 'フォルダーIDをいれるんだよ〜';

function doGet() {
  return HtmlService.createHtmlOutputFromFile('index');
}

function removeFile(filename) {
  var folder = DriveApp.getFolderById(folderId);
  var fileIterator = folder.getFilesByName(filename);
  if (fileIterator.hasNext()) {
    //console.log(fileIterator);
//    folder.removeFile(fileIterator.next());
    fileIterator.next().setTrashed(true);;
  }
}

function getFiles() {
  var result = [];
  var folder = DriveApp.getFolderById(folderId);
  var files = folder.getFiles()
  while(files.hasNext()){
    var loadFile = files.next();   
    var fileName = loadFile.getName();
    result.push(fileName);
  }
  return result.sort();
}

function getAt() {
  return ScriptApp.getOAuthToken();
}

// This commented line is used for enabling Drive API and adding a scope of "https://www.googleapis.com/auth/drive".
// So please don't remove this.
// DriveApp.createFile();

index.html

<html lang="ja">
  <head>
    <meta charset="utf-8">
    <base target="_top">
    <title>課題提出用ページ</title>

    <script>
      function loadFileList() {
        google.script.run.withSuccessHandler(updateFiles).getFiles();
      }
      window.addEventListener('load', loadFileList);
    </script>
  </head>
  
  <body>

    <h1>課題提出用ページ</h1>
    <span style="background-color:yellow;">「ファイルを選択」ボタンを押して、アップロードしたいファイルを選択してください。提出済みファイル一覧に表示されたら提出完了となります。</span>
    <div style="margin:20px; border-style: dotted; border-color:red; padding:20px;">
      <form id="myForm">
        <input id="myFile" name="myFile" type="file" value="" style=" font-size: 150%;"/>
        <div id="progress"></div>
      </form>
    </div>
    
    <h2>提出済ファイル一覧</h2>
    <div id="files"></div>

    <script>
      document.getElementById("myFile").onchange = function() {
        var file = this.files[0];
        if (file.name != "") {
            google.script.run.removeFile(file.name); //←アップロードしようとしたファイル名が既にあったら削除してしまう恐ろしい処理です。最新版のファイルだけ残したかったのでこうしていますが他人のファイルでも構わず上書きします。危険な場合はコメントアウトしてください。
            var fr = new FileReader();
            fr.fileName = file.name;
            fr.fileSize = file.size;
            fr.fileType = file.type;
            fr.onload = init;
            fr.readAsArrayBuffer(file);
        }
      }

      const chunkSize = 5242880;
      var folderId = 'フォルダーIDをいれるんだよ〜'; //クライアントコードは暗号化されるのでまあいいかという感じでしょうか。

      function init() {
        document.getElementById("progress").textContent = "初期化中";
        var fileName = this.fileName;
        var fileSize = this.fileSize;
        var fileType = this.fileType;
        console.log({fileName: fileName, fileSize: fileSize, fileType: fileType});
        var buf = this.result;
        var chunkpot = getChunkpot(chunkSize, fileSize);
        var uint8Array = new Uint8Array(buf);
        var chunks = chunkpot.chunks.map(function(e) {
          return {
            data: uint8Array.slice(e.startByte, e.endByte + 1),
            length: e.numByte,
            range: "bytes " + e.startByte + "-" + e.endByte + "/" + chunkpot.total,
          };
        });
        google.script.run.withSuccessHandler(function(at) {
          var xhr = new XMLHttpRequest();
          xhr.open("POST", "https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable");
          xhr.setRequestHeader('Authorization', "Bearer " + at);
          xhr.setRequestHeader('Content-Type', "application/json");
          xhr.send(JSON.stringify({
            mimeType: fileType,
            name: fileName,
            parents: [folderId] //ここがアップロードされるフォルダをしているところです。
          }));
          xhr.onload = function() {
            doUpload({
              location: xhr.getResponseHeader("location"),
              chunks: chunks,
            });
          };
          xhr.onerror = function() {
            console.log(xhr.response);
          };
        }).getAt();
      }

      function doUpload(e) {
        var chunks = e.chunks;
        var location = e.location;
        var cnt = 0;
        var end = chunks.length;
        var temp = function callback(cnt) {
          var e = chunks[cnt];
          var xhr = new XMLHttpRequest();
          xhr.open("PUT", location, true);
          xhr.setRequestHeader('Content-Range', e.range);
          xhr.send(e.data);
          xhr.onloadend = function() {
            var status = xhr.status;
            cnt += 1;
            console.log("Uploading: " + status + " (" + cnt + " / " + end + ")");
            document.getElementById("progress").textContent = "アップロード中: " + Math.floor(100 * cnt / end) + "%";
            if (status == 308) {
              callback(cnt);
            } else if (status == 200) {
              loadFileList(); //アップロードが正常完了したらファイル一覧を表示更新します。
              document.getElementById("progress").textContent = "アップロード完了しました。";
            } else {
              document.getElementById("progress").textContent = "アップロード失敗しました:" + xhr.response;
            }
          };
        }(cnt);
      }

      function getChunkpot(chunkSize, fileSize) {
        var chunkPot = {};
        chunkPot.total = fileSize;
        chunkPot.chunks = [];
        if (fileSize > chunkSize) {
          var numE = chunkSize;
          var endS = function(f, n) {
            var c = f % n;
            if (c == 0) {
              return 0;
            } else {
              return c;
            }
          }(fileSize, numE);
          var repeat = Math.floor(fileSize / numE);
          for (var i = 0; i <= repeat; i++) {
            var startAddress = i * numE;
            var c = {};
            c.startByte = startAddress;
            if (i < repeat) {
              c.endByte = startAddress + numE - 1;
              c.numByte = numE;
              chunkPot.chunks.push(c);
            } else if (i == repeat && endS > 0) {
              c.endByte = startAddress + endS - 1;
              c.numByte = endS;
              chunkPot.chunks.push(c);
            }
          }
        } else {
          var chunk = {
            startByte: 0,
            endByte: fileSize - 1,
            numByte: fileSize,
          };
          chunkPot.chunks.push(chunk);
        }
        return chunkPot;
      }
      
      function updateFiles(msg) {
        var div = document.getElementById("files");
        var rst = "";
        for (var i = 0; i < msg.length; i++) {
          if (msg[i] == "upload") continue;
          rst += msg[i] + "<br/>";
        }
        div.innerHTML = rst;
      }
    </script>
  </body>
</html>

以上です。とりあえず動くことは確認しています。

さ、ねよ。明日から月曜日というのに。。。また夜ふかししてしまった💦