GAS(Google Apps Script)でフォルダコピー(サブフォルダ含む)をする(タイムアウト対策有)

# GAS(Google Apps Script)でフォルダコピー(サブフォルダ含む)をする(タイムアウト対策有)

# まえがき
訳あってGoogleDriveアプリを使用せずにGoogleDriveの共有ドライブ間でフォルダごとコピーする必要があり、フォルダコピー用のGAS(Google Apps Script)を作成しました。

WEB版のGoogleDriveでは仕様でフォルダの移動は出来てもフォルダのコピーは出来ないので、クラウド内で操作を完結させるにはGASを使用する必要があります。

GASには処理時間に制限があり、無料版アカウントなら6分、有料版アカウントなら30分でタイムアウトしてしまいます。
ネットで調べた情報より、指定時間(3分とか)で処理を自動終了させてコピー処理の進捗を
「PropertiesService.getScriptProperties().setProperty」で保存して、
次回実行時に
「PropertiesService.getScriptProperties().getProperty」で呼び出す事でコピー処理を途中から再開させる処理を
トリガーで定期的(1分おき)に実行させる事で実現可能だと分かりました。

作成はネットの情報とChatGTPのアドバイスで進めていましたが、
どうしても自力で解決出来ないエラーが発生し、同じ会社の人に助けて頂き、作成に5日間を要したのでここで完成版のスクリプトを公開します。


# スクリプトの説明
***
1.コピー元のsourceFolderIdとコピー先のtargetFolderIdを指定するとサブフォルダ以下も含めてコピーする。
2.コピー元のsourceFolderIdはActiveSpreadsheetの'フォルダコピー'シートの'B3'セルを読み込んで指定する。
3.コピー先のtargetFolderIdはActiveSpreadsheetの'フォルダコピー'シートの'B6'セルを読み込んで指定する。
4.初回の処理開始日時をActiveSpreadsheetの'フォルダコピー'シートの'E3'セルに設定する。
5.全ての処理が完了したら処理完了日時をActiveSpreadsheetの'フォルダコピー'シートの'E4'セルに設定する。
6.コピー処理中にGASを実行しても処理が実行できないようにLockService.getScriptLock()でロックを掛ける。
7.処理時間が指定時間(デフォルト3分)を経過すると処理を中断してコピーの処理状態をPropertiesService.getScriptProperties().setPropertyで保存してロックを解放して終了する。
8.GASを再実行するとPropertiesService.getScriptProperties().getPropertyで中断していたコピー処理を再開する。
9.実行が初回の場合はコンソールログに"Starting new session."と表示するようにする。
10.実行が2回目以降の場合はコンソールログに"Resuming session."と表示するようにする。
***

var MAX_RUNNING_TIME_MS = 3 * 60 * 1000;  //GASの処理時間はデフォルト3分
ScriptApp.newTrigger("executeCopyProcess").timeBased().everyMinutes(1).create();  //トリガーは1分毎に実行

と設定しています。
有料版アカウントですと、処理は30分まで設定する事が出来るのですが、
var MAX_RUNNING_TIME_MS = 10 * 60 * 1000;
ScriptApp.newTrigger("executeCopyProcess").timeBased().everyMinutes(5).create();
トリガー5分毎、処理時間10分としたところ、
コピー処理中の10分間は次の実行ではロックが掛かっている為に処理がスキップされるはずが処理がスタートしてしまいました。
この事からおそらく、
LockService.getScriptLock()
を使用してのロックは10分以内に解除されてしまうのだと思います。
ですので、
トリガー1分毎、処理時間3分での設定としました。

# GASの使い方
コピー元フォルダとコピー先フォルダのアイテムIDを入力して
「フォルダコピーGAS実行ボタン」を押します。
コピー処理が始まると「StartTime」に処理の開始日時が表示され、
しばらくすると「Status」が実行中を示す「Running」になります。
コピー処理が終了すると「EndTIme」に処理の終了日時が表示されて
「Status」が「Success」に変わります。

(※コピー処理中にブラウザを閉じても処理は継続されます。)

テスト実行したところ、
フォルダ総サイズ：914MB
ファイル数: 3,628、フォルダー数: 151
のコピーで
処理時間：約2時間30分
でした。


## スプレットシートのイメージ
![folder_copy_GAS_sheet_01.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2729552/1dd39947-3e7e-7c35-a651-125ccfaaf772.jpeg)

「フォルダコピーGAS実行ボタン」には
function startTrigger()
を割り当てます。

実行中の画像
3分(180秒)実行が繰り返されています。
![folder_copy_GAS_running_01.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2729552/e81e3f0e-ef71-3b19-a0d5-9b75d1383b92.jpeg)


# GAS本文
```Google Apps Script:Google Apps Script
  var LOCK_KEY = "COPY_PROCESS_LOCK";
  var MAX_RUNNING_TIME_MS = 3 * 60 * 1000;   //GASの処理時間はデフォルト3分に設定
  var RECURSIVE_ITERATOR_KEY = "RECURSIVE_ITERATOR_KEY";

function startTrigger() {
  //この関数を1日一回のクーロンなどで登録しておく
  
  // ステータスクリア
  StatusClear();

  // 開始日時をセルに記録
  getStartDateTime();
  
  // 終了日時のセルをクリアー
  ENDDateTimeClear();

  

  // セッション情報を削除して新しいセッションを開始
  PropertiesService.getScriptProperties().deleteProperty(RECURSIVE_ITERATOR_KEY);
  
  //バックアップトリガーを設定
  ScriptApp.newTrigger("executeCopyProcess").timeBased().everyMinutes(1).create();
}

function executeCopyProcess() {
  
  var lock = LockService.getScriptLock();

  //  スプレッドシートの読み込み
  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  //  シートの選択
  const sheet = spreadsheet.getSheetByName('フォルダコピー');
  //  セルの選択
  const range1 = sheet.getRange('B3');
  //  セルの値を取得する
  const sourceFolderId = range1.getValue();
  //  セルの選択
  const range2 = sheet.getRange('B6');
  //  セルの値を取得する
  const targetFolderId = range2.getValue();

  var range3 = sheet.getRange('E6');
  //  セルの値を取得する
  var runStatus = range3.getValue();

  
  try {
    // Try to acquire the lock and proceed if successful
    if (lock.tryLock(5000)) {
      console.info("Lock acquired, starting copy process.");

      copyFolderRecursively(sourceFolderId, targetFolderId );
      // 一応スリープ
      Utilities.sleep(2000);
      // Release the lock when done
      lock.releaseLock();
    } else {
      console.info("Lock is already taken, skipping this run.");
    }
  } finally {
     
   //終了
    
   
  }
}

function endTrigger() {
  //executeCopyProcessトリガーを削除
  
  var triggers = ScriptApp.getProjectTriggers();
  
  for (var i = 0; i < triggers.length; i++) {
    var trigger = triggers[i];
    
    if (trigger.getHandlerFunction() == "executeCopyProcess") {
      ScriptApp.deleteTrigger(trigger);
    }
  }
}

function copyFolderRecursively(sourceFolderId, targetFolderId) {
  
  var sourceFolder = DriveApp.getFolderById(sourceFolderId);
  var targetFolder = DriveApp.getFolderById(targetFolderId);

  var startTime = new Date().getTime();
  
  var recursiveIterator;


// [{folderName: String, fileIteratorContinuationToken: String?, folderIteratorContinuationToken: String}]
  var recursiveIterator = JSON.parse(PropertiesService.getScriptProperties().getProperty(RECURSIVE_ITERATOR_KEY));
  if (recursiveIterator !== null) {
    // verify that it's actually for the same folder
    if (sourceFolder.getName() !== recursiveIterator[0].folderName) {
      console.warn("Looks like this is a new folder. Clearing out the old iterator.");
      recursiveIterator = null;
    } else {
      console.info("Resuming session.");
    }
  }
  
  if (recursiveIterator === null) {
    console.info("Starting new session.");
    recursiveIterator = [];
    recursiveIterator.push(makeIterationFromFolders(sourceFolder, targetFolder));
    PropertiesService.getScriptProperties().setProperty(RECURSIVE_ITERATOR_KEY, JSON.stringify(recursiveIterator));
  }

  while (recursiveIterator.length > 0) {
    recursiveIterator = nextIteration(recursiveIterator, startTime);

    var currTime = new Date().getTime();
    var elapsedTimeInMS = currTime - startTime;
    var timeLimitExceeded = elapsedTimeInMS >= MAX_RUNNING_TIME_MS;
    if (timeLimitExceeded) {
      PropertiesService.getScriptProperties().setProperty(RECURSIVE_ITERATOR_KEY, JSON.stringify(recursiveIterator));
      console.info("Stopping loop after '%d' milliseconds. Please continue running.", elapsedTimeInMS);
      //ステータスをRunningにセットする
      StatusRUN();
      return;
    }
  }

  console.info("Done copying");
  getENDDateTime();
  //ステータスを終了にする
  StatusEND();
  PropertiesService.getScriptProperties().deleteProperty(RECURSIVE_ITERATOR_KEY);

  // executeCopyProcessのトリガーを削除
  endTrigger();
}

function makeIterationFromFolders(sourceFolder, targetFolder) {

  return {
    folderName: sourceFolder.getName(),
    targetFolderName: targetFolder.getName(), 
    fileIteratorContinuationToken: sourceFolder.getFiles().getContinuationToken(),
    folderIteratorContinuationToken: sourceFolder.getFolders().getContinuationToken(),
    sourceFolder: sourceFolder,
    targetFolderId:targetFolder.getId(),

  };
}

function nextIteration(recursiveIterator, startTime) {

  var currentIteration = recursiveIterator[recursiveIterator.length -1]; //配列の最後の要素（現在のイテレーション）を取得
  if (currentIteration.fileIteratorContinuationToken !== null) {
    var fileIterator = DriveApp.continueFileIterator(currentIteration.fileIteratorContinuationToken);
    if (fileIterator.hasNext()) {
      // process the next file
      var path = recursiveIterator.map(function(iteration) { return iteration.folderName; }).join("/");
      var nextFile = fileIterator.next();
      processFile(nextFile, path);
      
      nextFile.makeCopy(nextFile.getName(), DriveApp.getFolderById(currentIteration.targetFolderId));
      
      currentIteration.fileIteratorContinuationToken = fileIterator.getContinuationToken();
      recursiveIterator[recursiveIterator.length -1] = currentIteration;
      return recursiveIterator;
    } else {
      // done processing files
      currentIteration.fileIteratorContinuationToken = null;
      recursiveIterator[recursiveIterator.length- 1] = currentIteration;
      return recursiveIterator;
    }
  }

  if (currentIteration.folderIteratorContinuationToken !== null) {
    var folderIterator = DriveApp.continueFolderIterator(currentIteration.folderIteratorContinuationToken);
    if (folderIterator.hasNext()) {
      // process the next folder
      var folder = folderIterator.next();
      recursiveIterator[recursiveIterator.length-1].folderIteratorContinuationToken = folderIterator.getContinuationToken();
      var newTargetSubfolder = DriveApp.getFolderById(currentIteration.targetFolderId).createFolder(folder.getName()); // 新しいサブフォルダを作成
      recursiveIterator.push(makeIterationFromFolders(folder, newTargetSubfolder)); // ターゲットフォルダを更新した新しいイテレーションを追加
      
      return recursiveIterator;
    } else {
      // done processing subfolders
      recursiveIterator.pop();
      return recursiveIterator;
    }
  }
throw "should never get here";
}


function processFile(file, path) {
  console.log(path + "/" + file.getName());
}



function getStartDateTime() {
//現在時刻を取得
  var now = new Date();
  var year = now.getFullYear();
  var month = now.getMonth()+1;
  var date = now.getDate();
  var hour = now.getHours();
  var minute = now.getMinutes();
  var second = now.getSeconds();
 //入力したい文字列に整形する 
  var nowDate = year + "年" + month + "月" + date + "日" + hour + "時" + minute + "分" + second + "秒";
//  スプレッドシートの読み込み
  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  //  シートの選択
  const sheet = spreadsheet.getSheetByName('フォルダコピー');
 //E3セルに時刻を入力
  sheet.getRange('E3').setValue(nowDate);
  
}

function getENDDateTime() {
//現在時刻を取得
  var nowE = new Date();
  var yearE = nowE.getFullYear();
  var monthE = nowE.getMonth()+1;
  var dateE = nowE.getDate();
  var hourE = nowE.getHours();
  var minuteE = nowE.getMinutes();
  var secondE = nowE.getSeconds();
 //入力したい文字列に整形する 
  var nowEDate = yearE + "年" + monthE + "月" + dateE + "日" + hourE + "時" + minuteE + "分" + secondE + "秒";
 //  スプレッドシートの読み込み
  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  //  シートの選択
  const sheet = spreadsheet.getSheetByName('フォルダコピー');
  //E4セルに時刻を入力
  sheet.getRange('E4').setValue(nowEDate);
}

function ENDDateTimeClear() {
 //  スプレッドシートの読み込み
  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  //  シートの選択
  const sheet = spreadsheet.getSheetByName('フォルダコピー');
  //E4セルに時刻を入力
  sheet.getRange('E4').clearContent();
}

function StatusClear() {
 //  スプレッドシートの読み込み
  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  //  シートの選択
  const sheet = spreadsheet.getSheetByName('フォルダコピー');
  //E4セルに時刻を入力
  sheet.getRange('E6').clearContent();
}

function StatusRUN() {
 //  スプレッドシートの読み込み
  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  //  シートの選択
  const sheet = spreadsheet.getSheetByName('フォルダコピー');
  //E4セルに時刻を入力
  sheet.getRange('E6').setValue("Running");
}

function StatusEND() {
 //  スプレッドシートの読み込み
  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  //  シートの選択
  const sheet = spreadsheet.getSheetByName('フォルダコピー');
  //E4セルに時刻を入力
  sheet.getRange('E6').setValue("Success");
}
```

# あとがき
処理に時間がかかるのでGoogleDriveアプリをインストールしてエクスプローラでコピーするなりrobocopyを使うなりした方が早いのですが、WEBで操作を完結したい場合に使えると思います。
作成はChatGPTの支援を受けたのですが、人の助言を受けなければ解決できなかった部分もあり、CHatGPTが人間の能力を超えることは無いのだと改めて感じました。