はじめに
Googleドライブは便利です。
フォルダを作り、メンバーを招待し、必要なファイルを放り込む。
誰かが新しい資料を作り、別の誰かが編集し、そのまま別のメンバーへ共有する。
小さなチームであれば、これでも仕事は回ります。
しかし、長期間にわたって、
- マイドライブ上のフォルダを複数人で共有する
- 誰が所有者なのかを気にしない
- 各自が自由にファイルやフォルダを追加する
- 個人アカウントと法人アカウントが混在する
- 退職者や外部協力者が作成したファイルも残る
という運用を続けると、ある日突然、困ることになります。
私たちの場合、そのきっかけはISMS取得に向けた運用整理でした。
これまで「アクセスできるから問題ない」と思っていたGoogleドライブを確認すると、共有フォルダ内にさまざまな所有者のファイルが混在していました。
つまり、業務上は会社の資料であっても、Googleドライブ上では会社が管理できる状態になっていなかったのです。
そこで、既存のフォルダ構成を大きく崩さず、移動可能なファイルをGoogle Workspaceの共有ドライブへまとめて移動するGASを作成しました。
この記事では、次の順番で整理します。
- なぜマイドライブ運用が問題になったのか
- マイドライブと共有ドライブの所有権の違い
- 移動できるファイルと移動できないファイル
- GASで移行を効率化する考え方
- 複数アカウントで実行する理由
- 実運用で注意すべきポイント
なお、GASの完成コードは記事中の指定箇所へ後から掲載します。
TL;DR
- この記事には、マイドライブ配下を再帰的に走査し、移動可能なファイルをGoogle Workspaceの共有ドライブへまとめて移動するGASコードを掲載する
- 所有者が複数の法人アカウントに分かれていても、各アカウントで同じGASを承認・実行すれば、ハンドリング可能なファイルを順番に共有ドライブへ集約できる
- 共有ドライブへ移動したファイルは、個人所有ではなく組織管理になる
- GASは、移動成功・スキップ・権限不足などをログへ残すため、手作業で確認すべきファイルも絞り込める
- ただし、個人Gmailや別組織のGoogle Workspaceアカウントが所有するファイルは、そのまま共有ドライブへ移動できない
- 実行には、移動先共有ドライブへのアクセス権、Google Workspace側の共有設定、Apps ScriptのDrive API設定が必要
- 「誰が所有しているか分からない大量のファイルを、一件ずつ調べて移動する」作業を、GASで一気に片付けるための記事
「共有されている」と「会社が所有している」は違う
最初に押さえておきたいのは、次の2つは別物だということです。
- 会社のメンバーがファイルを閲覧・編集できる
- 会社がファイルを継続的に管理できる
マイドライブ上のファイルは、基本的にファイルを作成したアカウントが所有者になります。
共有フォルダの中にあるからといって、そのフォルダの管理者や会社が、配下のファイルを所有しているとは限りません。
たとえば、次のような状態が発生します。
業務資料フォルダ
├── 要件定義書 所有者:法人アカウントA
├── 議事録 所有者:個人Gmail B
├── 見積書 所有者:法人アカウントC
└── 設計資料
├── API仕様書 所有者:外部協力者D
└── 構成図 所有者:法人アカウントA
見た目としては、ひとつの共有フォルダです。
しかし実際には、ファイルごとに所有者が異なります。
この状態では、所有者のアカウントが削除されたり、アクセス不能になったりすると、会社側でファイルを完全に管理できなくなる可能性があります。
これを本記事では、所有者混在問題※1と呼びます。
※1 所有者混在問題
同じ業務フォルダ内に、法人アカウント、個人アカウント、外部協力者など、複数の所有者が混在している状態。
ISMSのためだけの話ではありません。
- 担当者の退職
- 外部委託契約の終了
- Googleアカウントの停止
- ドメイン変更
- 組織再編
- アクセス権の棚卸し
といった場面でも、所有者混在問題は顕在化します。
共有ドライブでは、ファイルを「個人」ではなく「組織」で管理できる
Google Workspaceの共有ドライブは、個人のマイドライブとは所有の考え方が異なります。
共有ドライブ内のファイルやフォルダは、特定の個人ではなく、共有ドライブの所属組織によって管理されます。
そのため、ファイルを追加したメンバーが組織を離れても、ファイルは共有ドライブに残ります。
ここが、今回の移行における重要なポイントです。
同一組織内のGoogle Workspaceアカウントが所有するファイルを共有ドライブへ移動すると、その作成者はファイルのオーナーではなくなり、共有ドライブ側で管理される状態になります。
ただし、作成者の情報そのものが消えるわけではありません。
「誰が作ったか」と「誰が所有・管理するか」は別の情報です。
つまり、今回やりたいことは、単なるフォルダ移動ではありません。
業務ファイルの管理主体を、個人アカウントから組織へ切り替える。
これが本当の目的です。
ただし、個人Gmailのファイルはそのまま移動できない
ここには大きな制約があります。
Google公式ヘルプでは、組織外のユーザーが所有するファイルやフォルダを、組織の共有ドライブへ移動することはできないと説明されています。
これは、その外部ユーザーが共有ドライブのメンバーになっている場合も同様です。
したがって、次の2ケースは分けて考える必要があります。
移動できる可能性があるもの
- 同一Google Workspace組織内のアカウントが所有している
- 実行アカウントが対象ファイルを移動できる
- 移動先の共有ドライブに必要な権限がある
- 管理コンソールの設定で移動が許可されている
そのままでは移動できないもの
- 個人Gmailアカウントが所有している
- 別のGoogle Workspace組織が所有している
- 外部協力者が所有している
- 実行アカウントに移動権限がない
- 共有ドライブ側の設定で移動が制限されている
この違いは、GASを使っても回避できません。
GASやDrive APIは、Googleドライブの権限モデルを無視してファイルを移動する仕組みではないからです。
個人Gmailなど、組織外アカウントが所有するファイルについては、状況に応じて次のような対応が必要になります。
- 所有者へファイルの複製を依頼する
- 法人アカウント側でコピーを作成する
- ダウンロードして共有ドライブへ再アップロードする
- 対応可能な形式でデータをエクスポートする
- 外部所有者へ個別に整理を依頼する
ただし、コピーや再アップロードをすると、更新履歴、コメント、ファイルID、既存URLなどが引き継がれない場合があります。
「あとで移せばよい」と考えず、最初から法人管理の場所でファイルを作るべき理由がここにあります。
手作業で整理しようとすると、すぐに限界が来る
数十件程度であれば、Googleドライブの画面から手作業で移動できます。
しかし、長年運用してきた共有フォルダには、次のようなものが混在しています。
- Googleドキュメント
- Googleスプレッドシート
- Googleスライド
- 画像
- ZIPファイル
- ショートカット
- 多階層のサブフォルダ
- 自分が所有していないファイル
- 外部アカウントが所有するファイル
- すでに削除されたアカウントのファイル
さらに厄介なのが、フォルダの所有者と配下ファイルの所有者が一致しないことです。
親フォルダを見ただけでは、どのアカウントで何を移動できるのか分かりません。
ひとつずつ詳細画面を開き、所有者を確認し、移動し、失敗したファイルを記録する。
これを何百件、何千件と続けるのは現実的ではありません。
そこで、GASで次の処理をまとめて行うことにしました。
- 移行元フォルダを再帰的に走査する
- 配下のファイルとフォルダを取得する
- 実行アカウントで移動できるものを判定する
- 移動先の共有ドライブへ移動する
- 成功・失敗・スキップをログへ記録する
- 移動できなかったアイテムを後から確認できるようにする
ここで重要なのは、すべてを強制的に移動するスクリプトではないという点です。
Googleドライブの権限に従い、現在ログインしているアカウントが扱える範囲を、自動で整理するスクリプトです。
なぜ複数のアカウントで実行する必要があるのか
今回のような環境では、ひとつのフォルダ内に複数の所有者が存在します。
仮に、次の3アカウントがファイルを所有しているとします。
admin@example.jpdeveloper@example.jpsales@example.jp
admin@example.jp でGASを実行しても、他のアカウントが所有するファイルを必ず移動できるとは限りません。
そのため、原則として、対象ファイルをハンドリングできるアカウントごとにGASを承認し、実行する必要があります。
本記事ではこれを、アカウント別実行※2と呼びます。
※2 アカウント別実行
ファイルを所有、または移動可能な複数のGoogleアカウントで、それぞれスクリプトを承認・実行する運用。
処理のイメージは次のとおりです。
1回目:admin@example.jp で実行
└─ adminが移動できるファイルを処理
2回目:developer@example.jp で実行
└─ developerが移動できるファイルを処理
3回目:sales@example.jp で実行
└─ salesが移動できるファイルを処理
最後:
└─ 残ったファイルを確認し、外部所有・権限不足などを切り分ける
同じスクリプトを複数アカウントで実行できるようにしておけば、所有者ごとに別のツールを用意する必要はありません。
一度目の実行で移動済みとなったファイルは、二度目以降の対象から外れるか、移動済みとしてスキップされます。
最終的に残ったものが、個別対応を必要とするファイルです。
つまりGASは、ファイルを移動するだけでなく、手作業で対応すべき例外を絞り込むためのフィルターとしても機能します。
実行前に必要な設定
スクリプトを書く前に、Google Workspaceと共有ドライブ側の設定を確認します。
1. 移動先の共有ドライブを作成する
移行先となる共有ドライブを用意します。
共有ドライブの作成可否や利用可能な機能は、Google Workspaceの契約と管理者設定に依存します。
2. 実行アカウントを共有ドライブのメンバーにする
ファイルを共有ドライブへ移動するには、実行アカウントが移動先へアクセスできなければなりません。
ファイルの移動には、共有ドライブ上で「投稿者」「コンテンツ管理者」「管理者」など、必要な操作を行える権限が必要です。
フォルダそのものをマイドライブから共有ドライブへ移動する場合は、より強い権限が必要になります。
今回のスクリプトがファイル単位で処理するのか、フォルダ単位で処理するのかによって、必要な権限は異なります。
3. Google Workspace管理コンソールの共有設定を確認する
Google Workspace管理者は、次のような項目を制御できます。
- 組織外ユーザーとの共有
- 外部コンテンツの受け入れ
- 他組織の共有ドライブへの移動
- 編集者による共有ドライブへの移動
- 組織部門ごとの共有制限
管理コンソールで禁止されている操作は、GASから実行しても失敗します。
設定を変更した場合、反映まで時間がかかることもあります。
GASのコードを疑う前に、管理コンソール側の設定を確認してください。
4. Apps ScriptでDrive APIを有効にする
今回のコードでは、Apps Script標準のDriveサービスだけでなく、高度なGoogleサービスのDrive API※3を使用します。
※3 高度なGoogleサービスのDrive API
Apps ScriptからGoogle Drive APIを利用するためのサービス。共有ドライブ対応など、標準のDriveサービスだけでは扱いにくい機能を利用できる。
Apps Scriptの編集画面から、次の手順で追加します。
- 左側の「サービス」を開く
- 「サービスを追加」を選択する
- 「Drive API」を選択する
- 使用するバージョンを確認して追加する
Apps Scriptの標準Driveサービスよりも高度な操作や共有ドライブ対応が必要な場合は、Drive APIの利用が推奨されています。
GASコード
以下に、移行元フォルダを走査し、移動可能なファイルを共有ドライブへ移動するGASコードを掲載します。
コード内では、少なくとも次の値を環境に合わせて変更します。
- 移行元フォルダID
- 移動先フォルダID
- ログ出力先
- ドライランの有無
- 一度に処理する件数
- 再実行時の挙動
/**
* My Drive → 共有ドライブ移行ツール
*
* 処理内容:
* 1. 元フォルダを再帰走査
* 2. 宛先に同じフォルダ構造を作成
* 3. 実行アカウント所有のファイルだけ移動
* 4. 他アカウント所有ファイルは残す
* 5. 処理後に空になった元フォルダをゴミ箱へ移動
*
* 再実行可能:
* ・作成済みフォルダは再利用
* ・移動済みファイルは元から消えているため再処理されない
* ・空フォルダは削除済みなら次回の走査対象にならない
*
* 必須:
* Apps Scriptの「サービス」から Drive API v3 を追加してください。
*/
const MIGRATION_CONFIG = {
/**
* 移行元フォルダID。
*
* URL:
* https://drive.google.com/drive/folders/XXXXXXXX
*
* の XXXXXXXX 部分。
*/
SOURCE_ROOT_FOLDER_ID: '移行元フォルダID',
/**
* 共有ドライブ側の移行先基準フォルダID。
*/
TARGET_ROOT_FOLDER_ID: '共有ドライブ側フォルダID',
/**
* 宛先基準フォルダの中に、
* 移行元ルートと同名のフォルダを作るか。
*
* true:
* 移行先/
* └─ 事業/
* └─ 子フォルダ
*
* false:
* 移行先自体を「事業」に対応させる
*/
CREATE_SOURCE_ROOT_FOLDER: true,
/**
* 実行者メールアドレスを自動取得できなかった場合の予備値。
*
* 通常は空文字のままで構いません。
* 必要なら実行するアカウントのメールアドレスを設定します。
*/
OWNER_EMAIL_FALLBACK: '',
/**
* true:
* 実際にはフォルダ作成、ファイル移動、削除をしない
*
* false:
* 実際に処理する
*/
DRY_RUN: true,
/**
* 空になった元フォルダを削除するか。
*
* 実際には完全削除ではなくゴミ箱へ移動します。
*/
DELETE_EMPTY_SOURCE_FOLDERS: true,
/**
* 移行元ルートフォルダ自体も空なら削除するか。
*
* 通常はfalseを推奨します。
*/
DELETE_SOURCE_ROOT_FOLDER: false,
/**
* ショートカットの処理。
*
* SKIP:
* 移動せず残す
*
* MOVE:
* 実行者所有なら移動を試す
*/
SHORTCUT_POLICY: 'SKIP',
/**
* 宛先の同一階層に同名フォルダが複数あった場合。
*
* ERROR:
* その階層をスキップ
*
* USE_FIRST:
* 最初のフォルダを使用
*/
DUPLICATE_TARGET_FOLDER_POLICY: 'ERROR',
/**
* 宛先に同名ファイルがあった場合。
*
* MOVE:
* Google Driveでは同名ファイルを保持できるため移動する
*
* SKIP:
* 移動せず元に残す
*
* ERROR:
* エラーとして元に残す
*/
SAME_NAME_FILE_POLICY: 'MOVE',
/**
* Apps Scriptの強制終了前に自主終了する時間。
*
* 5分で終了し、再実行で続きを処理します。
*/
MAX_EXECUTION_MS: 5 * 60 * 1000,
};
const MIME_FOLDER = 'application/vnd.google-apps.folder';
const MIME_SHORTCUT = 'application/vnd.google-apps.shortcut';
let migrationStartedAt_ = 0;
let migrationStopRequested_ = false;
/**
* メイン処理
*/
function migrateDriveToSharedDrive() {
validateMigrationConfig_();
migrationStartedAt_ = Date.now();
migrationStopRequested_ = false;
const executingEmail = getExecutingUserEmail_();
const sourceRoot = getDriveItem_(
MIGRATION_CONFIG.SOURCE_ROOT_FOLDER_ID
);
const targetBase = getDriveItem_(
MIGRATION_CONFIG.TARGET_ROOT_FOLDER_ID
);
assertFolder_(sourceRoot, '移行元');
assertFolder_(targetBase, '移行先');
if (!targetBase.driveId) {
throw new Error(
'TARGET_ROOT_FOLDER_IDには共有ドライブ内のフォルダを指定してください。'
);
}
const stats = createStatistics_();
console.log('========== 移行処理開始 ==========');
console.log(`実行アカウント: ${executingEmail}`);
console.log(`移行元: ${sourceRoot.name} (${sourceRoot.id})`);
console.log(`移行先: ${targetBase.name} (${targetBase.id})`);
console.log(`DRY_RUN: ${MIGRATION_CONFIG.DRY_RUN}`);
let targetRoot = targetBase;
let targetRootPath = targetBase.name;
if (MIGRATION_CONFIG.CREATE_SOURCE_ROOT_FOLDER) {
targetRoot = findOrCreateTargetFolder_(
targetBase.id,
sourceRoot.name,
targetBase.name,
stats
);
if (!targetRoot) {
throw new Error(
`移行先ルートフォルダ「${sourceRoot.name}」を確定できませんでした。`
);
}
targetRootPath = `${targetBase.name}/${sourceRoot.name}`;
}
processFolder_(
sourceRoot,
targetRoot,
sourceRoot.name,
targetRootPath,
executingEmail,
stats,
true
);
console.log('========== 移行処理終了 ==========');
console.log(JSON.stringify(stats, null, 2));
if (migrationStopRequested_) {
console.warn(
'実行時間上限が近いため自主終了しました。' +
'同じ関数を再実行すると、残りの処理を続行できます。'
);
}
return stats;
}
/**
* 1つの元フォルダを処理します。
*
* 処理順:
* 1. 直下のファイルを移動
* 2. 子フォルダを再帰処理
* 3. 元フォルダが空なら削除
*/
function processFolder_(
sourceFolder,
targetFolder,
sourcePath,
targetPath,
executingEmail,
stats,
isSourceRoot
) {
if (shouldStopMigration_()) {
return;
}
stats.foldersScanned++;
processFilesInFolder_(
sourceFolder.id,
targetFolder.id,
sourcePath,
targetPath,
executingEmail,
stats
);
if (shouldStopMigration_()) {
return;
}
const childFolders = listChildFolders_(sourceFolder.id);
for (const sourceChild of childFolders) {
if (shouldStopMigration_()) {
return;
}
const childSourcePath =
`${sourcePath}/${sourceChild.name}`;
const childTargetPath =
`${targetPath}/${sourceChild.name}`;
try {
const targetChild = findOrCreateTargetFolder_(
targetFolder.id,
sourceChild.name,
targetPath,
stats
);
if (!targetChild) {
stats.foldersSkipped++;
continue;
}
processFolder_(
sourceChild,
targetChild,
childSourcePath,
childTargetPath,
executingEmail,
stats,
false
);
} catch (error) {
stats.errors++;
console.error(
`[フォルダ処理エラー] ${childSourcePath}: ` +
getErrorMessage_(error)
);
}
}
if (shouldStopMigration_()) {
return;
}
if (
MIGRATION_CONFIG.DELETE_EMPTY_SOURCE_FOLDERS &&
(!isSourceRoot ||
MIGRATION_CONFIG.DELETE_SOURCE_ROOT_FOLDER)
) {
trashSourceFolderIfEmpty_(
sourceFolder,
targetFolder,
sourcePath,
targetPath,
executingEmail,
stats
);
}
}
/**
* 元フォルダ直下のファイルを処理します。
*/
function processFilesInFolder_(
sourceFolderId,
targetFolderId,
sourcePath,
targetPath,
executingEmail,
stats
) {
const files = listChildFiles_(sourceFolderId);
for (const file of files) {
if (shouldStopMigration_()) {
return;
}
stats.filesScanned++;
const sourceFilePath =
`${sourcePath}/${file.name}`;
const targetFilePath =
`${targetPath}/${file.name}`;
try {
if (
file.mimeType === MIME_SHORTCUT &&
MIGRATION_CONFIG.SHORTCUT_POLICY === 'SKIP'
) {
stats.filesSkippedShortcut++;
console.log(
`[ショートカットをスキップ] ${sourceFilePath}`
);
continue;
}
const ownerEmail = getOwnerEmail_(file);
if (!ownerEmail) {
stats.filesSkippedOwnerUnknown++;
console.warn(
`[所有者取得不能でスキップ] ${sourceFilePath}`
);
continue;
}
if (
normalizeEmail_(ownerEmail) !==
normalizeEmail_(executingEmail)
) {
stats.filesSkippedDifferentOwner++;
console.log(
`[別所有者のためスキップ] ${sourceFilePath}` +
` / owner=${ownerEmail}`
);
continue;
}
const sameNameFiles = findTargetFilesByName_(
targetFolderId,
file.name
);
if (sameNameFiles.length > 0) {
if (
MIGRATION_CONFIG.SAME_NAME_FILE_POLICY === 'SKIP'
) {
stats.filesSkippedSameName++;
console.warn(
`[宛先に同名ファイルあり・スキップ] ` +
targetFilePath
);
continue;
}
if (
MIGRATION_CONFIG.SAME_NAME_FILE_POLICY === 'ERROR'
) {
throw new Error(
`宛先に同名ファイルが存在します: ${targetFilePath}`
);
}
console.warn(
`[宛先に同名ファイルあり・移動続行] ` +
targetFilePath
);
}
if (MIGRATION_CONFIG.DRY_RUN) {
stats.filesDryRunMoved++;
console.log(
`[移動予定] ${sourceFilePath} -> ${targetFilePath}`
);
continue;
}
moveFileToTarget_(
file.id,
sourceFolderId,
targetFolderId
);
stats.filesMoved++;
console.log(
`[移動完了] ${sourceFilePath} -> ${targetFilePath}`
);
} catch (error) {
stats.errors++;
console.error(
`[ファイル処理エラー] ${sourceFilePath}: ` +
getErrorMessage_(error)
);
}
}
}
/**
* ファイルの親フォルダを変更して共有ドライブへ移動します。
*/
function moveFileToTarget_(
fileId,
sourceParentId,
targetParentId
) {
Drive.Files.update(
{},
fileId,
null,
{
addParents: targetParentId,
removeParents: sourceParentId,
supportsAllDrives: true,
fields: 'id,name,mimeType,parents,driveId',
}
);
}
/**
* 元フォルダが空ならゴミ箱へ移します。
*/
function trashSourceFolderIfEmpty_(
sourceFolder,
targetFolder,
sourcePath,
targetPath,
executingEmail,
stats
) {
if (!isFolderEmpty_(sourceFolder.id)) {
stats.foldersKeptNotEmpty++;
console.log(
`[空ではないため保持] ${sourcePath}`
);
return;
}
/*
* 宛先フォルダが実在するか、削除直前に再確認します。
*/
if (isDryRunFolderId_(targetFolder.id)) {
if (MIGRATION_CONFIG.DRY_RUN) {
stats.foldersDryRunTrashed++;
console.log(
`[空フォルダ削除予定] ${sourcePath}` +
` / 対応先=${targetPath}`
);
}
return;
}
let verifiedTarget;
try {
verifiedTarget = getDriveItem_(targetFolder.id);
} catch (error) {
stats.foldersKeptTargetMissing++;
console.warn(
`[宛先確認不能のため保持] ${sourcePath}`
);
return;
}
if (
verifiedTarget.trashed ||
verifiedTarget.mimeType !== MIME_FOLDER
) {
stats.foldersKeptTargetMissing++;
console.warn(
`[有効な宛先がないため保持] ${sourcePath}`
);
return;
}
/*
* フォルダ所有者が別アカウントの場合、
* 中身が空でも削除できない可能性があります。
*/
const sourceOwnerEmail = getOwnerEmail_(sourceFolder);
if (
sourceOwnerEmail &&
normalizeEmail_(sourceOwnerEmail) !==
normalizeEmail_(executingEmail)
) {
stats.foldersKeptDifferentOwner++;
console.log(
`[フォルダ所有者が異なるため保持] ${sourcePath}` +
` / owner=${sourceOwnerEmail}`
);
return;
}
if (
sourceFolder.capabilities &&
sourceFolder.capabilities.canTrash === false
) {
stats.foldersKeptNoTrashPermission++;
console.log(
`[ゴミ箱移動権限がないため保持] ${sourcePath}`
);
return;
}
if (MIGRATION_CONFIG.DRY_RUN) {
stats.foldersDryRunTrashed++;
console.log(
`[空フォルダ削除予定] ${sourcePath}` +
` / 対応先=${targetPath}`
);
return;
}
/*
* 子処理後に状態が変わる可能性があるため、
* 削除直前にもう一度空を確認します。
*/
if (!isFolderEmpty_(sourceFolder.id)) {
stats.foldersKeptNotEmpty++;
return;
}
Drive.Files.update(
{
trashed: true,
},
sourceFolder.id,
null,
{
supportsAllDrives: true,
fields: 'id,name,trashed',
}
);
stats.foldersTrashed++;
console.log(
`[空フォルダをゴミ箱へ移動] ${sourcePath}`
);
}
/**
* 宛先の同一階層に同名フォルダがあれば再利用し、
* なければ作成します。
*/
function findOrCreateTargetFolder_(
targetParentId,
folderName,
targetParentPath,
stats
) {
if (isDryRunFolderId_(targetParentId)) {
stats.foldersDryRunCreated++;
console.log(
`[フォルダ作成予定] ` +
`${targetParentPath}/${folderName}`
);
return {
id: makeDryRunFolderId_(targetParentId, folderName),
name: folderName,
mimeType: MIME_FOLDER,
driveId: null,
trashed: false,
};
}
const matches = findTargetFoldersByName_(
targetParentId,
folderName
);
if (matches.length > 1) {
const message =
`${targetParentPath} 直下に、` +
`同名フォルダ「${folderName}」が` +
`${matches.length}件あります。`;
if (
MIGRATION_CONFIG.DUPLICATE_TARGET_FOLDER_POLICY ===
'ERROR'
) {
console.error(`[宛先フォルダ重複] ${message}`);
return null;
}
console.warn(
`[宛先フォルダ重複・先頭を使用] ${message}`
);
}
if (matches.length >= 1) {
stats.foldersReused++;
console.log(
`[既存フォルダを再利用] ` +
`${targetParentPath}/${folderName}`
);
return matches[0];
}
if (MIGRATION_CONFIG.DRY_RUN) {
stats.foldersDryRunCreated++;
console.log(
`[フォルダ作成予定] ` +
`${targetParentPath}/${folderName}`
);
return {
id: makeDryRunFolderId_(targetParentId, folderName),
name: folderName,
mimeType: MIME_FOLDER,
driveId: null,
trashed: false,
};
}
const created = Drive.Files.create(
{
name: folderName,
mimeType: MIME_FOLDER,
parents: [targetParentId],
},
null,
{
supportsAllDrives: true,
fields:
'id,name,mimeType,parents,driveId,trashed,' +
'owners(emailAddress),' +
'capabilities(canTrash,canDelete)',
}
);
stats.foldersCreated++;
console.log(
`[フォルダ作成完了] ` +
`${targetParentPath}/${folderName}`
);
return created;
}
/**
* フォルダ直下のファイル一覧。
*/
function listChildFiles_(parentFolderId) {
return listChildren_(
parentFolderId,
`mimeType != '${MIME_FOLDER}'`
);
}
/**
* フォルダ直下の子フォルダ一覧。
*/
function listChildFolders_(parentFolderId) {
return listChildren_(
parentFolderId,
`mimeType = '${MIME_FOLDER}'`
);
}
/**
* 指定した親フォルダ直下のアイテムを一覧取得します。
*/
function listChildren_(parentFolderId, mimeCondition) {
const items = [];
let pageToken = null;
do {
const response = Drive.Files.list({
q: [
`'${escapeDriveQuery_(parentFolderId)}' in parents`,
mimeCondition,
'trashed = false',
].join(' and '),
pageSize: 1000,
pageToken: pageToken,
supportsAllDrives: true,
includeItemsFromAllDrives: true,
fields:
'nextPageToken,' +
'files(' +
'id,' +
'name,' +
'mimeType,' +
'parents,' +
'driveId,' +
'trashed,' +
'owners(emailAddress,displayName),' +
'capabilities(canTrash,canDelete),' +
'shortcutDetails' +
')',
});
items.push(...(response.files || []));
pageToken = response.nextPageToken;
} while (pageToken);
items.sort((a, b) =>
a.name.localeCompare(b.name, 'ja', {
numeric: true,
sensitivity: 'base',
})
);
return items;
}
/**
* 宛先の同一階層にある同名フォルダを探します。
*/
function findTargetFoldersByName_(
targetParentId,
folderName
) {
return findTargetItemsByName_(
targetParentId,
folderName,
`mimeType = '${MIME_FOLDER}'`
);
}
/**
* 宛先の同一階層にある同名ファイルを探します。
*/
function findTargetFilesByName_(
targetParentId,
fileName
) {
if (isDryRunFolderId_(targetParentId)) {
return [];
}
return findTargetItemsByName_(
targetParentId,
fileName,
`mimeType != '${MIME_FOLDER}'`
);
}
/**
* 宛先直下を名前とMIME条件で検索します。
*/
function findTargetItemsByName_(
targetParentId,
itemName,
mimeCondition
) {
if (isDryRunFolderId_(targetParentId)) {
return [];
}
const items = [];
let pageToken = null;
do {
const response = Drive.Files.list({
q: [
`'${escapeDriveQuery_(targetParentId)}' in parents`,
`name = '${escapeDriveQuery_(itemName)}'`,
mimeCondition,
'trashed = false',
].join(' and '),
pageSize: 100,
pageToken: pageToken,
supportsAllDrives: true,
includeItemsFromAllDrives: true,
fields:
'nextPageToken,' +
'files(' +
'id,' +
'name,' +
'mimeType,' +
'parents,' +
'driveId,' +
'trashed,' +
'owners(emailAddress),' +
'capabilities(canTrash,canDelete)' +
')',
});
items.push(...(response.files || []));
pageToken = response.nextPageToken;
} while (pageToken);
return items;
}
/**
* フォルダが空か確認します。
*
* ファイル・フォルダ・ショートカットのいずれかが
* 1件でも残っていれば空ではありません。
*/
function isFolderEmpty_(folderId) {
const response = Drive.Files.list({
q: [
`'${escapeDriveQuery_(folderId)}' in parents`,
'trashed = false',
].join(' and '),
pageSize: 1,
supportsAllDrives: true,
includeItemsFromAllDrives: true,
fields: 'files(id)',
});
return !response.files || response.files.length === 0;
}
/**
* ファイルまたはフォルダのメタデータを取得します。
*/
function getDriveItem_(itemId) {
return Drive.Files.get(itemId, {
supportsAllDrives: true,
fields:
'id,' +
'name,' +
'mimeType,' +
'parents,' +
'driveId,' +
'trashed,' +
'owners(emailAddress,displayName),' +
'capabilities(canTrash,canDelete)',
});
}
/**
* 実行アカウントのメールアドレスを取得します。
*/
function getExecutingUserEmail_() {
const effectiveEmail =
Session.getEffectiveUser().getEmail();
if (effectiveEmail) {
return normalizeEmail_(effectiveEmail);
}
const activeEmail =
Session.getActiveUser().getEmail();
if (activeEmail) {
return normalizeEmail_(activeEmail);
}
if (MIGRATION_CONFIG.OWNER_EMAIL_FALLBACK) {
return normalizeEmail_(
MIGRATION_CONFIG.OWNER_EMAIL_FALLBACK
);
}
throw new Error(
'実行アカウントのメールアドレスを取得できませんでした。' +
'OWNER_EMAIL_FALLBACKへメールアドレスを設定してください。'
);
}
/**
* 実行アカウント確認用。
*
* 移行前にこの関数だけ実行すると、
* 実行ログへ判定されたメールアドレスを表示します。
*/
function showExecutingUserEmail() {
const email = getExecutingUserEmail_();
console.log(`この実行で使用される所有者: ${email}`);
return email;
}
/**
* スプレッドシートに紐づいたGASで使用できる、
* 実行アカウント確認ダイアログ。
*
* スタンドアロンGASではSpreadsheetApp.getUi()を
* 使用できないため、showExecutingUserEmail()を使ってください。
*/
function showExecutingUserDialog() {
const email = getExecutingUserEmail_();
try {
SpreadsheetApp.getUi().alert(
'移行実行アカウント',
`この実行では、次のアカウント所有ファイルを処理します。\n\n${email}`,
SpreadsheetApp.getUi().ButtonSet.OK
);
} catch (error) {
console.log(`実行アカウント: ${email}`);
console.log(
'このGASはスプレッドシートに紐づいていないため、' +
'ダイアログではなく実行ログへ表示しました。'
);
}
}
/**
* タイムアウト前に自主終了します。
*/
function shouldStopMigration_() {
if (migrationStopRequested_) {
return true;
}
const elapsed =
Date.now() - migrationStartedAt_;
if (
elapsed <
MIGRATION_CONFIG.MAX_EXECUTION_MS
) {
return false;
}
migrationStopRequested_ = true;
return true;
}
/**
* 所有者メールを取得します。
*/
function getOwnerEmail_(item) {
if (!item.owners || item.owners.length === 0) {
return '';
}
return normalizeEmail_(
item.owners[0].emailAddress || ''
);
}
function normalizeEmail_(email) {
return String(email || '')
.trim()
.toLowerCase();
}
function assertFolder_(item, label) {
if (item.trashed) {
throw new Error(
`${label}フォルダはゴミ箱にあります。`
);
}
if (item.mimeType !== MIME_FOLDER) {
throw new Error(
`${label}IDはフォルダではありません。`
);
}
}
function makeDryRunFolderId_(
parentId,
folderName
) {
return [
'DRY_RUN',
Utilities.base64EncodeWebSafe(String(parentId)),
Utilities.base64EncodeWebSafe(String(folderName)),
].join(':');
}
function isDryRunFolderId_(folderId) {
return String(folderId).startsWith('DRY_RUN:');
}
function escapeDriveQuery_(value) {
return String(value)
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'");
}
function getErrorMessage_(error) {
return error && error.message
? error.message
: String(error);
}
function createStatistics_() {
return {
foldersScanned: 0,
foldersCreated: 0,
foldersDryRunCreated: 0,
foldersReused: 0,
foldersSkipped: 0,
filesScanned: 0,
filesMoved: 0,
filesDryRunMoved: 0,
filesSkippedDifferentOwner: 0,
filesSkippedOwnerUnknown: 0,
filesSkippedShortcut: 0,
filesSkippedSameName: 0,
foldersTrashed: 0,
foldersDryRunTrashed: 0,
foldersKeptNotEmpty: 0,
foldersKeptDifferentOwner: 0,
foldersKeptNoTrashPermission: 0,
foldersKeptTargetMissing: 0,
errors: 0,
};
}
function validateMigrationConfig_() {
if (
!MIGRATION_CONFIG.SOURCE_ROOT_FOLDER_ID ||
MIGRATION_CONFIG.SOURCE_ROOT_FOLDER_ID ===
'移行元フォルダID'
) {
throw new Error(
'SOURCE_ROOT_FOLDER_IDを設定してください。'
);
}
if (
!MIGRATION_CONFIG.TARGET_ROOT_FOLDER_ID ||
MIGRATION_CONFIG.TARGET_ROOT_FOLDER_ID ===
'共有ドライブ側フォルダID'
) {
throw new Error(
'TARGET_ROOT_FOLDER_IDを設定してください。'
);
}
if (
!['SKIP', 'MOVE'].includes(
MIGRATION_CONFIG.SHORTCUT_POLICY
)
) {
throw new Error(
'SHORTCUT_POLICYはSKIPまたはMOVEにしてください。'
);
}
if (
!['ERROR', 'USE_FIRST'].includes(
MIGRATION_CONFIG
.DUPLICATE_TARGET_FOLDER_POLICY
)
) {
throw new Error(
'DUPLICATE_TARGET_FOLDER_POLICYは' +
'ERRORまたはUSE_FIRSTにしてください。'
);
}
if (
!['MOVE', 'SKIP', 'ERROR'].includes(
MIGRATION_CONFIG.SAME_NAME_FILE_POLICY
)
) {
throw new Error(
'SAME_NAME_FILE_POLICYは' +
'MOVE、SKIP、ERRORのいずれかにしてください。'
);
}
}
いきなり本番実行しない
大量のファイルを扱う処理では、最初から本番移動を実行しない方が安全です。
可能であれば、コードにドライラン※4を用意します。
※4 ドライラン
実際の移動や更新は行わず、「何が処理対象になるか」だけをログへ出力する実行モード。
最初は、次のような小さなテストフォルダを用意してください。
移行テスト
├── 自分が所有するファイル
├── 同一組織の別アカウントが所有するファイル
├── 個人Gmailが所有するファイル
├── サブフォルダ
│ └── 自分が所有するファイル
└── ショートカット
確認したいポイントは次のとおりです。
- 想定したファイルだけが対象になるか
- フォルダ階層が意図どおり扱われるか
- 外部所有ファイルがエラーまたはスキップになるか
- 共有権限が移動後にどう変化するか
- 既存URLから引き続きアクセスできるか
- コメントや更新履歴が維持されているか
- 同じ処理を再実行しても問題が起きないか
共有ドライブへの移動では、直接設定された共有権限が維持される一方、親フォルダから継承されていた権限は引き継がれない場合があります。
「ファイルが移動できた」だけで完了とせず、移動後のアクセス権も確認する必要があります。
Apps Scriptの実行時間制限を考慮する
Apps Scriptには、1回の実行時間やサービス呼び出し回数に上限があります。
大量のファイルを一度に再帰処理すると、途中でタイムアウトする可能性があります。
そのため、実運用では次のような設計が必要です。
- 一度に処理する件数を制限する
- 処理済みの位置を保存する
- 時間主導トリガーで続きを実行する
- ファイルID単位で処理済みを記録する
- エラーが発生しても全体を停止させない
- 再実行可能な設計にする
特に重要なのが、冪等性※5です。
※5 冪等性
同じ処理を複数回実行しても、意図しない重複や破壊が起きない性質。
たとえば、一度目の実行で500件中300件まで移動し、そこで停止したとします。
再実行時に、すでに移動した300件をもう一度処理してエラーになる設計では、運用が難しくなります。
- すでに移動済みならスキップする
- 移動先に同じIDのファイルがある場合は処理しない
- 前回の続きから再開する
- 処理結果を記録する
といった仕組みを入れておくと、安全に繰り返し実行できます。
ログには「失敗した理由」を残す
移行処理では、成功件数よりも、失敗したファイルの情報が重要です。
最低限、次の情報を記録しておくと、後処理が楽になります。
- ファイル名
- ファイルID
- ファイルURL
- MIMEタイプ
- 現在の親フォルダ
- 実行アカウント
- 処理結果
- エラーメッセージ
- 実行日時
処理結果は、たとえば次のように分類できます。
MOVED
SKIPPED_ALREADY_MOVED
SKIPPED_NOT_OWNER
SKIPPED_EXTERNAL_OWNER
SKIPPED_SHORTCUT
FAILED_PERMISSION
FAILED_API
FAILED_UNKNOWN
エラーを単に 失敗 と記録すると、あとから再調査が必要になります。
一方で、失敗理由を分類しておけば、
- 別の法人アカウントで再実行する
- 外部所有者へ依頼する
- 管理者設定を変更する
- 手動でコピーする
- 移行対象外として記録する
といった対応をすぐに判断できます。
ファイルだけでなく、共有権限も棚卸しする
共有ドライブへ移動したからといって、ISMS上の整理がすべて完了するわけではありません。
移動後には、少なくとも次の項目を確認します。
- 共有ドライブのメンバーは適切か
- 不要な外部ユーザーが残っていないか
- ファイル単位の直接共有が残っていないか
- 「リンクを知っている全員」になっていないか
- 管理者権限を持つユーザーが多すぎないか
- 退職者や契約終了者のアクセスが残っていないか
- 閲覧者でよい人に編集権限を与えていないか
共有ドライブは所有者問題を整理する有力な手段ですが、アクセス権を自動的に最適化してくれるわけではありません。
また、共有ドライブ側の共有制限が、ファイルに設定された共有権限より優先されることがあります。
移動前と移動後で、外部ユーザーのアクセス可否が変化する可能性にも注意してください。
実際の移行手順
最終的には、次の順番で進めました。
フェーズ1:現状確認
- 移行対象のルートフォルダを決める
- 想定される所有アカウントを洗い出す
- 個人Gmailや外部所有ファイルの有無を確認する
- 共有ドライブの構成を決める
フェーズ2:環境準備
- 共有ドライブを作成する
- 必要なメンバーと権限を設定する
- Google Workspaceの共有設定を確認する
- Apps ScriptでDrive APIを有効にする
フェーズ3:テスト
- 小規模なテストフォルダを用意する
- ドライランを実行する
- 移動対象とスキップ対象を確認する
- 移動後の共有権限を確認する
フェーズ4:アカウント別実行
- 法人アカウントAで実行する
- 法人アカウントBで実行する
- 法人アカウントCで実行する
- 各アカウントのログを統合する
フェーズ5:例外対応
- 個人Gmail所有ファイルを抽出する
- 外部協力者所有ファイルを抽出する
- 所有者へ移行対応を依頼する
- コピーや再アップロードの可否を判断する
- 移行不能なものを管理台帳へ記録する
フェーズ6:最終確認
- 移行元に残ったファイルを確認する
- 共有ドライブのアクセス権を棚卸しする
- 不要な共有リンクを削除する
- 今後のファイル作成ルールを文書化する
GASで解決できたこと、解決できなかったこと
今回のGASによって、次の作業を大幅に省力化できました。
- 多階層フォルダの走査
- 移動可能なファイルの一括処理
- 複数アカウントでの繰り返し実行
- 移動失敗ファイルの抽出
- 個別対応が必要なファイルの絞り込み
- 作業ログの保存
一方で、次の問題はGASだけでは解決できません。
- 個人Gmailが所有するファイルの直接移動
- 外部組織が所有するファイルの直接移動
- アクセスできないアカウントの代理操作
- 管理コンソールで禁止された操作
- 移行後の共有権限が適切かどうかの判断
- 組織としての運用ルール作成
技術的に自動化できる範囲と、組織的な判断が必要な範囲を分けることが重要です。
最初から共有ドライブで運用していれば、ここまで苦労しなかった
今回、一番強く感じたことです。
Googleドライブは、共有ボタンを押せば簡単に共同作業を始められます。
その便利さゆえに、所有者や管理主体を意識しないまま運用を続けてしまいがちです。
しかし、あとから整理しようとすると、
- 誰が所有しているのか
- どのアカウントなら移動できるのか
- 外部アカウントのファイルをどうするのか
- 元の共有権限をどう維持するのか
- どこまでを法人管理にするのか
を、ひとつずつ確認することになります。
GASを使えば、作業量は減らせます。
それでも、ハンドリングできないアカウントが所有するファイルは、所有者本人へ依頼しなければなりません。
アカウントがすでに使えなくなっていれば、さらに難しくなります。
だからこそ、最初から次のようなルールを設けておくべきでした。
- 業務ファイルは共有ドライブ内で作成する
- マイドライブ上の共有フォルダを業務の正式保管場所にしない
- 個人Gmailで業務ファイルを作成しない
- 外部協力者には会社管理の保存先を案内する
- ファイル単位ではなく、グループ単位で権限を管理する
- 定期的に外部共有とメンバーを棚卸しする
- 契約終了前に所有ファイルを確認する
技術的には、GASでかなり楽にできます。
しかし、もっとも楽なのは、あとから移行作業をしなくてよい運用を最初から作ることです。
まとめ
マイドライブ上で無関心に共有を続けていると、ひとつのフォルダ内に複数の所有者が混在します。
普段は問題なく使えていても、ISMS取得、退職者対応、外部委託の終了、アカウント整理といった場面で、一気に問題が表面化します。
今回のポイントは、次のとおりです。
- マイドライブではファイルごとに所有者が存在する
- 共有フォルダに入っていても、会社所有とは限らない
- 共有ドライブでは組織がファイルを管理できる
- 同一組織内の移動可能なファイルはGASで整理できる
- 個人Gmailなど外部所有ファイルは直接移動できない
- 所有者が混在している場合は、複数アカウントでの実行が必要
- Drive APIとGoogle Workspace側の設定確認が必要
- 移動後は共有権限の棚卸しも必要
- GASは万能な権限突破ツールではなく、整理作業を効率化する道具
Googleドライブは、共有できることと、組織が管理できることが同じではありません。
ファイルが増え切ってから後悔しないように、業務データの保存場所と所有のルールは、できるだけ早い段階で決めておくことをおすすめします。