Google Driveのフォルダを再帰的にコピーする

Google Drive (Google One) のファイルを Google Workplace (旧 G Suite)にコピーしようとしたが、階層全体をコピーするコマンドがない。

コピーするファイル量が多くないときは、ローカルのPCにダウンロードして、それを移動先にコピーすることをしていたが、そこそこの量があるので、Google内で終わらせたいと思った。

背景

他のアカウントが所有するファイル群を自分のGoogle Driveで共有しているものがあり、それをGoogle Workplaceにコピーしたい。共有しているフォルダをGoogle Workplaceでも共有してアクセスすることはできるが、他のアカウントは操作できないのでファイルの所有権を渡す方法がとれない。

自分のGoogle Driveにコピーができれば、自分のアカウント間のGoogle Drive から Google Workplaceに共有と所有権移動ができるはず。そこで、Google Drive内でフォルダの再帰的コピーができる機能が必要となった。

調査

検索をして以下のWebを参考にして試してみたが、Google Apps Scriptの実行時間の上限を超えてしまい、コピーが完了しなかった。

Google Drive:フォルダ丸ごとコピー:フォルダ階層下のすべてのファイル・フォルダを同じフォルダ構造のまま一度にコピー(GoogleFormでのメニュー付き)

ファイルのコピー自体に時間がかかっているだろうから、まず、コピーすべきファイルのリストを作成しておいて、実際のファイルのコピーはリストに従って何回かに分けてスクリプトで実行すれば良いのではないかと思って、そのようなGoogle Apps Script を作成してみた。しかし、実行時間制限が厳しすぎてリストの作成がそもそも制限時間内に終わらなかった。

コピー処理の履歴を残して、再開できるプログラムが必要であることがわかった。

そこで、再帰的にコピーし、再開できるGoogle Apps Scriptを作成することにした。

再帰的コピーのためのスクリプト作成

フォルダの階層を再帰的にコピーするスクリプトを作成してみた。

一応動作しているが遅い。Spreadsheetを履歴管理に使っていて、Google Apps Scriptのプログラミングも初めてで効率が悪いスクリプトであろうこともあるが遅い。6分(360秒)の実行制限時間内に1MB弱のファイルを200ほどしかコピーできない。

時間制限があることから、全体を何度も実行する必要があり、あまり実用的ではない。

手間をかけないのは、NASで自動的にダウンロードして、それを自動的にアップロードして、コピーすることのようだ。時間はかかるにしても休みなく働いてくれるので手間は少なそうである。

再帰的にコピーするスクリプト

せっかく作ったので備忘録としてスクリプトを残しておく。

簡単な動作確認をしているが、制限時間でスクリプトが中断された時の扱いが問題ないか十分には検証していない。あくまでも備忘録として記録しておくものであり、このスクリプトを利用した結果に不具合があっても責任はとれない。もしも使うなら At your own riskでお願いする

管理データの構造

ソース側のフォルダのId (+".xlsx")を名前とするスプレッドシートに複製すべきファイルやフォルダのリストとその完了状況を記録する。

列には、ソースの名前(name)、ソースファイルのID(srcFileId)、ソースフォルダのID(srcFolderId)、デスティネーションのID(dstId)、デスティネーションの名前(dstName)、完了時刻(timestamp) を記録する。timestamp が空の場合にはコピー処理が完了していないことを表す。

srcFileId と srcFolderIdはどちらかしか使われず、それによってソースがファイルかフォルダかを区別する。

dstNameはソースの名前と同じはずなので不要であるが、デバッグのために使った。

行が1つのファイルもしくはフォルダを表す。

最終行のname列に "*" が記録されている場合には、そのシート全体、つまり、シートに対応するソースフォルダの処理が完了していることを意味する。

処理の考え方

startCopy を実行することでソースフォルダ内にあるファイルやフォルダをデスティネーションフォルダ内に複製する。

sourceRootFolderName にソースフォルダ名を指定する。
destinationRootFolderName にデスティネーションフォルダ名を指定する。
workFolderName に作業用フォルダ名を指定する。作業用フォルダ内に管理用のスプレッドシートファイルが作成される。

startCopy

startCopyは初期化を行った後に、traverseを呼び出す。

traverse

traverseは、ソースフォルダに対応するスプレッドシートをmakeSpreadsheetFileで作成し、processSpreadsheetFileで処理する。

makeSpreadsheetFile

makeSpreadsheetFileは、ソースフォルダにあるファイルの名前とID (srcFileId) をスプレッドシートに記録する。

次に、ソースフォルダにある子フォルダと同じ名前のフォルダをデスティネーションフォルダに作成し、ソースフォルダの名前とID (srcFolderId)、デスティネーションフォルダのID (dstId)と名前(dstName) をスプレッドシートに記録する。

processSpreadsheetFile

processSpreadsheetFileは、スプレッドシートに記録されている行で、タイムスタンプが空の行を上から順に処理していく。スプレッドシートに記録されているデータを基にファイルのコピーや再帰的な処理を行う。

ソースがファイルの場合には、デスティネーションフォルダにファイルをコピーして、コピーが完了したら、その日時をスプレッドシートのtimestampに記録する。

ソースがフォルダの場合には、traverseを利用して再帰的に処理を行う。

スクリプト

var ColumnIndex = {
  name:  1,
  srcFileId: 2,
  srcFolderId: 3,
  dstId: 4,
  dstName: 5,
  timestamp: 6
}

var sourceRootFolderName = "src";
var destinationRootFolderName = "dst";
var workFolderName = "tmp";

function startCopy() {
  console.log(`sourceRootFolderName=${sourceRootFolderName}`);
  console.log(`destinationRootFolderNamee=${destinationRootFolderName}`);
  console.log(`workFolderName=${workFolderName}`);
  
  // 複製元フォルダ
  var srcFolders = DriveApp.getFoldersByName(sourceRootFolderName);
  var srcFolder = srcFolders.next();
  var srcFolderName = srcFolder.getName();

  // 複製先フォルダ
  var dstFolders = DriveApp.getFoldersByName(destinationRootFolderName);
  var dstFolder = dstFolders.next();
  var dstFolderName = dstFolder.getName();
  
  // 作業用フォルダ
  var workFolders = DriveApp.getFoldersByName(workFolderName);
  var workFolder = workFolders.next();
  
  traverse(srcFolder, dstFolder, workFolder);
}


function traverse(srcFolder, dstFolder, workFolder) {
  console.log(`+traverse`);
  // 再帰的にフォルダを辿ってコピーすべきファイルのリストを作成する共に、複製先フォルダを作成する
  var sheet;
  var srcFolderId = srcFolder.getId();
  
  // ソースフォルダが処理途中か調べる
  var fileName = srcFolderId + ".xlsx"
  var files = workFolder.getFilesByName(fileName);
  var file;
  if ( !files.hasNext() ) { // no file exist
    // 進捗管理ファイル等を作成
    file = makeSpreadsheetFile(fileName, srcFolder, dstFolder, workFolder);
  } else {
    file = files.next();
  }
  if ( !isValidSheet(file) ) { // invalid file
    var fileId = file.getId();
    console.log(`invalid file: fileId=${fileId}`);
    // ファイルを削除
    file.setTrashed(true);  // remove file
    // 進捗管理ファイル等を作成
    file = makeSpreadsheetFile(fileName, srcFolder, dstFolder, workFolder);
  }
  // 進捗管理ファイルを処理する
  processSpreadsheetFile(file, dstFolder, workFolder);
  console.log(`-traverse(${srcFolderId})`);
}

function isValidSheet(spreadsheetFile) {
  var spreadsheet = SpreadsheetApp.open(spreadsheetFile);
  var sheet = spreadsheet.getActiveSheet();
  
  // 最終行にマークがあるか調べる
  var lastRow = sheet.getDataRange().getLastRow();
  var cell = sheet.getRange(lastRow, 1, lastRow, 1);
  var isValid = (cell.getValue() == "*");

  return isValid;
}


function makeSpreadsheetFile(spreadsheetFileName, srcFolder, dstFolder, workFolder) {
  // 進捗管理用スプレッドシートと複製先フォルダを作成する
  console.log(`+makeSpreadsheetFile(spreadsheetFileName=${spreadsheetFileName})`);

  // フォルダ workFolder 直下にスプレッドシートを作成
  var spreadsheet = SpreadsheetApp.create(spreadsheetFileName);
  var spreadsheetId = spreadsheet.getId();
  var file = DriveApp.getFileById(spreadsheetId);
  workFolder.addFile(file);

  // アクティブシートを利用する
  var sheet = spreadsheet.getActiveSheet();
  
  // 見出し行を設定
  addHeaderRow(sheet);
  
  // ファイルの処理(リストの作成)
  var srcFiles = srcFolder.getFiles();
  while(srcFiles.hasNext()) {
    var srcFile = srcFiles.next();
    var srcFileName = srcFile.getName();
    var srcFileId = srcFile.getId();

    // 処理すべきファイルをシートに記録する
    addFileRow(sheet, srcFile);
  }
  
  // フォルダの処理(複製先フォルダの作成とリストへの記録)
  var childSrcFolders = srcFolder.getFolders();
  while(childSrcFolders.hasNext()) {
    var childSrcFolder = childSrcFolders.next();
    var childSrcFolderName = childSrcFolder.getName();
    var childDstFolder = dstFolder.createFolder(childSrcFolderName); // 複製先フォルダの作成
    
    // 処理すべきフォルダをシートに記録する
    addFolderRow(sheet, childSrcFolder, childDstFolder);
  }
  
  // 最後まで処理を終わったマークを付ける
  addTailerRow(sheet);

  console.log(`-makeSpreadsheetFile(spreadsheetFileName=${spreadsheetFileName})`);
  return file;
}

function processSpreadsheetFile(spreadsheetFile, dstParentFolder, workFolder) {
  console.log(`+processSpreadsheetFile(spreadsheetFile=${spreadsheetFile})`);
  var spreadsheet = SpreadsheetApp.open(spreadsheetFile);
  var sheet = spreadsheet.getActiveSheet();
  var lastRow = sheet.getDataRange().getLastRow(); // 最終行

  // 未処理のファイルのある行(timestampが空の行)を探す
  var startRow;
  for ( startRow = 1; startRow <= lastRow ; startRow++ ) {
    if ( sheet.getRange(startRow,ColumnIndex.timestamp).getValue() == "" ) {
      break;
    }
  }
  console.log(`startRow=${startRow}, lastRow=${lastRow}`);
  
  // 未処理のファイルを順次処理する
  for ( var i = startRow ; i < lastRow; i++){
    var name = sheet.getRange(i,ColumnIndex.name).getValue();
    var srcFileId = sheet.getRange(i,ColumnIndex.srcFileId).getValue();
    
    if ( srcFileId != "" ) { // ファイル処理(複製)
      //console.log(`file name=${name}, srcFileId=${srcFileId}, i=${i}`);
      // ファイル複製
      copyFile(name, srcFileId, dstParentFolder);
    } else { // フォルダ処理(再帰)
      var srcFolderId = sheet.getRange(i,ColumnIndex.srcFolderId).getValue();
      var srcFolder = DriveApp.getFolderById(srcFolderId);
      var dstFolderId = sheet.getRange(i,ColumnIndex.dstId).getValue();
      var dstFolder = DriveApp.getFolderById(dstFolderId);
      
      // フォルダを再帰的にたどる
      //console.log(`folder name=${name}, srcFolderId=${srcFolderId}, dstFolderId=${dstFolderId}, i=${i}`);
      traverse(srcFolder, dstFolder, workFolder);
    }
    // 処理が終わったら完了時刻を記録する
    sheet.getRange(i, ColumnIndex.timestamp).setValue(new Date());
  }
  console.log(`-processSpreadsheetFile(spreadsheetFile=${spreadsheetFile})`);
}

// ファイルを複製する
function copyFile(name, srcFileId, dstParentFolder) {
  console.log(`copyFile(name=${name}, srcFileId=${srcFileId})`);
  // 複製先に既にファイルがあったら不完全な可能性があるので削除しておく
  var dstFiles = dstParentFolder.getFilesByName(name);
  if ( dstFiles.hasNext() ) { // file exists
    var dstFile = dstFiles.next();
    var dstFileId = dstFile.getId();
    console.log(`remove dstFileId=${dstFileId} in copyFile(name=${name}, srcFileId=${srcFileId})`);
    // ファイルを削除
    dstFile.setTrashed(true);  // remove file
  }
  
  // ファイルの複製
  var srcFile = DriveApp.getFileById(srcFileId);
  srcFile.makeCopy(name, dstParentFolder);
}


function addHeaderRow(sheet) {
  var rowContents = [ "name", "srcFileId", "srcFolderId","dstId",  "dstName", "timestamp"];
  sheet.appendRow(rowContents);
  
  // 背景色を変更
  var range=sheet.getRange("A1:F1");
  var color="#0FFFFF";
  range.setBackground(color);
}

function addFileRow(sheet, srcFile) {
  // 複製すべきファイルの記録
  var srcFileName = srcFile.getName();
  var srcFileId = srcFile.getId();

  var rowContents = [ srcFileName, srcFileId ];
  sheet.appendRow(rowContents);
}

function addFolderRow(sheet, childSrcFolder, childDstFolder) {
  // 複製したフォルダの記録
  var childSrcFolderId = childSrcFolder.getId();
  var childSrcFolderName = childSrcFolder.getName();
  var childDstFolderId = childDstFolder.getId();
  var childDstFolderName = childDstFolder.getName();

  var rowContents = [ childSrcFolderName, "", childSrcFolderId, childDstFolderId, childDstFolderName];
  sheet.appendRow(rowContents);
}

function addTailerRow(sheet) {
  var rowContents = [ "*", "*", "*","*", "*", "*"];
  sheet.appendRow(rowContents);
}

テスト実行

ファイル階層

次の図ような構造のフォルダをコピーしてみた。「+」がフォルダを表し、「-」がファイルを表す。

生成されるスプレッドシートファイル

実行が完了すると作業フォルダの tmp に以下のようなファイルができている。それぞれのファイルがフォルダの管理情報を含んでいる。

実行が終わった後のスプレッドシートの内容は以下の通りである。

src に対応するスプレッドシート

child1 に対応するスプレッドシート

child2 に対応するスプレッドシート

ログ出力

スクリプト実行時のログは次の通り。

[20-10-10 01:19:13:559 PDT] sourceRootFolderName=src
[20-10-10 01:19:13:561 PDT] destinationRootFolderNamee=dst
[20-10-10 01:19:13:563 PDT] workFolderName=tmp
[20-10-10 01:19:14:009 PDT] +traverse
[20-10-10 01:19:14:152 PDT] +makeSpreadsheetFile(spreadsheetFileName=1rlC36-3-RumaMdeV4nEUGO7BfQpPWUa1.xlsx)
[20-10-10 01:19:19:323 PDT] -makeSpreadsheetFile(spreadsheetFileName=1rlC36-3-RumaMdeV4nEUGO7BfQpPWUa1.xlsx)
[20-10-10 01:19:19:732 PDT] +processSpreadsheetFile(spreadsheetFile=1rlC36-3-RumaMdeV4nEUGO7BfQpPWUa1.xlsx)
[20-10-10 01:19:20:028 PDT] startRow=2, lastRow=6
[20-10-10 01:19:20:130 PDT] copyFile(name=testsheet.xlsx, srcFileId=1V31OR_VSmqgT7DRq2niSMHRBjpMKbMK-)
[20-10-10 01:19:21:523 PDT] copyFile(name=testdoc.docx, srcFileId=1zd1iTFFYNJJP0FNAjqIk1akw8njvTnGe)
[20-10-10 01:19:23:138 PDT] +traverse
[20-10-10 01:19:23:277 PDT] +makeSpreadsheetFile(spreadsheetFileName=1W-IWO-9fndorHGWnx4rTfKCXSGtDx8uh.xlsx)
[20-10-10 01:19:25:742 PDT] -makeSpreadsheetFile(spreadsheetFileName=1W-IWO-9fndorHGWnx4rTfKCXSGtDx8uh.xlsx)
[20-10-10 01:19:26:153 PDT] +processSpreadsheetFile(spreadsheetFile=1W-IWO-9fndorHGWnx4rTfKCXSGtDx8uh.xlsx)
[20-10-10 01:19:26:453 PDT] startRow=3, lastRow=2
[20-10-10 01:19:26:455 PDT] -processSpreadsheetFile(spreadsheetFile=1W-IWO-9fndorHGWnx4rTfKCXSGtDx8uh.xlsx)
[20-10-10 01:19:26:457 PDT] -traverse(1W-IWO-9fndorHGWnx4rTfKCXSGtDx8uh)
[20-10-10 01:19:27:291 PDT] +traverse
[20-10-10 01:19:27:405 PDT] +makeSpreadsheetFile(spreadsheetFileName=1yBu18fsUCPSrcInZZ0g2sw9916qR27eA.xlsx)
[20-10-10 01:19:30:369 PDT] -makeSpreadsheetFile(spreadsheetFileName=1yBu18fsUCPSrcInZZ0g2sw9916qR27eA.xlsx)
[20-10-10 01:19:30:871 PDT] +processSpreadsheetFile(spreadsheetFile=1yBu18fsUCPSrcInZZ0g2sw9916qR27eA.xlsx)
[20-10-10 01:19:31:197 PDT] startRow=2, lastRow=3
[20-10-10 01:19:31:292 PDT] copyFile(name=testPresen.pptx, srcFileId=14eKJNvev7-r0NdrV4PmXVF-2Q6mzQtvZ)
[20-10-10 01:19:32:568 PDT] -processSpreadsheetFile(spreadsheetFile=1yBu18fsUCPSrcInZZ0g2sw9916qR27eA.xlsx)
[20-10-10 01:19:32:570 PDT] -traverse(1yBu18fsUCPSrcInZZ0g2sw9916qR27eA)
[20-10-10 01:19:32:579 PDT] -processSpreadsheetFile(spreadsheetFile=1rlC36-3-RumaMdeV4nEUGO7BfQpPWUa1.xlsx)
[20-10-10 01:19:32:580 PDT] -traverse(1rlC36-3-RumaMdeV4nEUGO7BfQpPWUa1)