Googleドライブのフォルダ階層ごとコピーできない問題を、Gemini×GASで解決した話

クライアントからGoogleドライブで共有された資料一式を、自分のアカウントにバックアップしたい。Web制作やマーケティング支援の仕事をしていると、わりと頻繁にこの場面が出てくる。

「普通にコピーすればよくない?」
「MacのGoogleドライブアプリで、Finder上からコピペすれば済む話でしょ?」

軽く考えると、そんな結論に至りそうなものだ。しかし、これがまったくうまくいかない。Googleドライブには、ローカルファイルシステムの常識が通用しない厄介な仕様がいくつも潜んでいる。

本記事では、Googleドライブでファイルをコピーする時のあるあると、それを解決するツールをGeminiとの対話によってほんの数分で作り上げた話を紹介する。

実際に動くツールのソースコードを公開しているので、よかったら試してみてね。
(注意事項はよく読んで!自己責任で)

目次

Googleドライブのフォルダコピーで踏む3つの罠

1. Finderでコピペすると、ファイルの「実体」が消える

Googleドライブアプリを用いたときにFinder上に表示される .gdoc.gsheet は、実はWebへのショートカット(メタデータ)に過ぎない。ドキュメントの実体はGoogleのサーバー上にある。

これを別のワークスペースにFinder上でコピペすると、一瞬アップロードされたように見えて、すぐにファイルが消える。参照元とアカウントの権限スコープが異なる場合に、無効なリンクとして弾かれてしまうのだ。

「じゃあブラウザにドラッグ&ドロップで上げ直せば?」と思って試したこともあるが、結果はただのテキストファイル(中にURLが書かれただけのもの)になり、Googleドキュメントとしては二度と開けない。

2. ブラウザでダウンロードすると、Word形式に強制変換される

ローカルアプリが駄目なら、ブラウザからフォルダごと一括ダウンロードだ――と実行すると、Googleが「親切に」ドキュメントを .docx 形式に変換してZIP圧縮してくれる。

問題は、これを解凍して自分のドライブに上げ直したとき。緻密に組んだレイアウトや書式がまるごと崩壊する。見出しのレベルが変わる、表のセル幅が飛ぶ、インデントが消える。Google ドキュメント → Word → Google ドキュメントの往復変換で、書式情報が2回変換の過程でロスするのだから当然だ。

3. 「コピーを作成」するとファイル名に「〜のコピー」がつく

ブラウザ上で右クリック →「コピーを作成」の方法もある。しかしこれをやると 「提案書_20260309 のコピー」のように、ファイル名の末尾にお節介な接尾辞がつく。

数ファイルならまだしも、フォルダ内に数十〜百ファイルある場合、後からひとつずつリネームするのは現実的ではない。

ファイルを開いてから「コピーを作成」という手段をとると、保存場所を指定して名前をつけて保存することができるが、これも数ファイルの対応が限界。手間と時間がかかり、数件すすめたあたりで投げ出したくなる。

解決策:GeminiにGASアプリを作ってもらった

この3つの問題を一発で解決するツールが欲しくなり、Gemini に頼んでみた。

最初のプロンプトは単純で、「フォルダURLを入れたら、階層構造を維持したまま、同名で別フォルダにコピーするGoogle Apps Scriptを書いて」という趣旨のもの。ここから何度かやりとりを重ねてブラッシュアップしていった結果、単なるスクリプトではなく、エクスプローラー風のWebアプリに仕上がった。

Gemini×GAS Drive File Copier

完成したツールの構成

技術的には GAS(Google Apps Script)のWebアプリで、サーバーサイドの コード.gs とフロントエンドの index.html の2ファイル構成。GASの HtmlService でHTMLをホストし、google.script.run でサーバーサイド関数を非同期呼び出しするオーソドックスな構成だ。

核になっているのは DriveApp.makeCopy() メソッド。これはGoogleドライブのAPI経由でネイティブにファイルを複製するため、Finderを経由する.gdocショートカット問題も、ブラウザダウンロード時のWord変換問題も発生しない。コピー先のフォルダにGoogleドキュメント形式のまま、同名で複製される。

GASで完結するので、Google Cloud(GCP)の有料契約やクレジットカードの登録は一切不要だし、動かすためのサーバーも不要。デプロイしたアプリのURLだけブックマークに保存しておけばよい。必要なものはGoogleアカウント(無料プランでもOK)だけ。

できること

  • フォルダURLを貼るだけで、中身をツリー構造でスキャン・表示。ファイルサイズと更新日時も確認できる
  • チェックボックスで個別選択が可能。親フォルダのチェックを外すと配下も連動して外れる(部分選択状態にも対応)
  • Googleドキュメント・スプレッドシートの形式を維持したまま複製。→Word変換による書式崩壊が起きない
  • 「〜のコピー」をつけずに同名で複製
  • 同名ファイルが既に存在する場合の処理を選択可能。「上書き(古い方をゴミ箱へ)」か「リネーム(末尾にタイムスタンプを付与)」の2択
  • リアルタイムの進捗バーキャンセル機能(コピー済みファイルをゴミ箱へ移動)

プログラミングの深い知識がなくても、AIとやりとりするだけでこの規模のUIを備えたツールが形になる。「こういう機能が欲しい」を自分の言葉で伝えて、出力を検証して、足りなければ追加で指示する。この繰り返しで実用レベルのものができてしまう時代になった。

導入手順とソースコード

手順

  1. Google Apps Script にアクセスし、「新しいプロジェクト」を作成
  2. デフォルトの コード.gs に、下記の コード.gs の内容を貼り付ける
  3. エディタ左のファイル一覧で「+」→「HTML」を選択し、ファイル名を index にする(.htmlは自動で付く)
  4. 作成された index.html に、下記の index.html の内容を貼り付ける
  5. 「デプロイ」→「新しいデプロイ」→ 種類を「ウェブアプリ」に設定
  6. 「アクセスできるユーザー」は必ず「自分のみ」にする(後述のセキュリティ注意を参照)
  7. 「デプロイ」を押してURLを取得。ブックマークしておくと便利

コード.gs

function doGet() {
  return HtmlService.createHtmlOutputFromFile('index')
      .setTitle('Drive File Copier');
      // ※意図しない外部サイトへの埋め込み(クリックジャッキング)を防ぐため、XFrameOptionsはデフォルトのまま
}

function extractId_(input) {
  const match = input.match(/[-\w]{25,}/);
  if (!match) throw new Error('URLからフォルダIDが見つかりません。');
  return match[0];
}

function getCopyPlan(sourceUrl, includeSubfolders) {
  const sourceId = extractId_(sourceUrl);
  const sourceFolder = DriveApp.getFolderById(sourceId);
  const tasks = [];
  let counter = 0;

  function traverse(folder, parentId, depth, ancestors) {
    if (includeSubfolders) {
      const subfolders = folder.getFolders();
      while (subfolders.hasNext()) {
        const subfolder = subfolders.next();
        const nodeId = 'node_' + (++counter);
        tasks.push({
          id: nodeId, parentId: parentId, ancestors: ancestors.join(' '),
          type: 'folder', name: subfolder.getName(), depth: depth, size: '-', lastUpdated: '-'
        });
        traverse(subfolder, nodeId, depth + 1, [...ancestors, nodeId]);
      }
    }
    const files = folder.getFiles();
    while (files.hasNext()) {
      const file = files.next();
      let sizeStr = "-";
      try {
        const bytes = file.getSize();
        if (bytes > 0) {
          const kb = Math.round(bytes / 1024);
          sizeStr = kb >= 1024 ? (kb / 1024).toFixed(1) + " MB" : kb + " KB";
        } else if (bytes === 0) sizeStr = "Google形式";
      } catch(e) {}
      const dateStr = Utilities.formatDate(file.getLastUpdated(), Session.getScriptTimeZone(), "yyyy/MM/dd HH:mm");
      tasks.push({
        id: 'node_' + (++counter), parentId: parentId, ancestors: ancestors.join(' '),
        type: 'file', sourceId: file.getId(), name: file.getName(), depth: depth, size: sizeStr, lastUpdated: dateStr
      });
    }
  }
  traverse(sourceFolder, 'root', 0, []);
  return tasks;
}

function processTask(task, targetUrl, folderMap, conflictMode) {
  try {
    let parentFolderId = folderMap[task.parentId];
    if (!parentFolderId) parentFolderId = extractId_(targetUrl);
    const targetFolder = DriveApp.getFolderById(parentFolderId);
    
    if (task.type === 'folder') {
      const existing = targetFolder.getFoldersByName(task.name);
      let newFolder = existing.hasNext() ? existing.next() : targetFolder.createFolder(task.name);
      return { status: 'success', newId: newFolder.getId() };
    } else if (task.type === 'file') {
      const sourceFile = DriveApp.getFileById(task.sourceId);
      let finalName = task.name;
      const existingFiles = targetFolder.getFilesByName(task.name);
      if (existingFiles.hasNext()) {
        if (conflictMode === 'overwrite') {
          while (existingFiles.hasNext()) existingFiles.next().setTrashed(true);
        } else if (conflictMode === 'rename') {
          const timestamp = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), "yyyyMMdd_HHmmss");
          finalName = `${task.name}_${timestamp}`;
        }
      }
      const newFile = sourceFile.makeCopy(finalName, targetFolder);
      return { status: 'success', newId: newFile.getId() };
    }
  } catch (e) { return { status: 'error', message: e.message }; }
}

function rollbackCopy(copiedIds) {
  copiedIds.forEach(id => {
    try { DriveApp.getFileById(id).setTrashed(true); } 
    catch(e) { try { DriveApp.getFolderById(id).setTrashed(true); } catch(e2) {} }
  });
}

index.html

<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
    <style>
      body { font-family: "Segoe UI", Meiryo, sans-serif; padding: 20px; max-width: 750px; margin: 0 auto; background-color: #f4f5f7; color: #333; }
      .container { background: white; padding: 25px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
      label { font-weight: bold; display: block; margin-top: 15px; font-size: 14px; }
      input[type="text"], select { width: 100%; padding: 10px; margin-top: 5px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; }
      .checkbox-wrapper { margin-top: 15px; }
      
      .btn-group { display: flex; gap: 10px; margin-top: 25px; }
      button { flex: 1; padding: 12px; border: none; border-radius: 4px; font-size: 16px; cursor: pointer; font-weight: bold; color: white; transition: background 0.2s; }
      button:disabled { background-color: #ccc !important; cursor: not-allowed; }
      
      #scanBtn { background-color: #1a73e8; }
      #scanBtn:hover:not(:disabled) { background-color: #1557b0; }
      #runBtn { background-color: #34a853; display: none; }
      #runBtn:hover:not(:disabled) { background-color: #2d8c46; }
      #cancelBtn { background-color: #dc3545; display: none; }
      #cancelBtn:hover:not(:disabled) { background-color: #c82333; }

      #progress-area { display: none; margin-top: 25px; padding-top: 20px; border-top: 1px solid #eee; }
      .progress-text { display: flex; justify-content: space-between; font-weight: bold; margin-bottom: 5px; font-size: 14px; }
      .progress-bar-bg { width: 100%; background-color: #e0e0e0; border-radius: 4px; height: 12px; overflow: hidden; }
      .progress-bar-fill { height: 100%; background-color: #34a853; width: 0%; transition: width 0.3s; }
      
      #select-all-container { margin-top: 15px; padding: 10px; background: #f1f3f4; border-radius: 4px; display: none; }
      
      #task-list { list-style: none; padding: 0; margin-top: 10px; max-height: 450px; overflow-y: auto; border: 1px solid #ddd; border-radius: 4px; background: #fff; }
      .task-item { display: flex; align-items: center; justify-content: space-between; padding: 6px 12px; border-bottom: 1px solid #f8f9fa; font-size: 13px; transition: background 0.1s; }
      .task-item:hover { background-color: #eef2f5; }
      .task-item:last-child { border-bottom: none; }
      
      .task-main { display: flex; align-items: center; flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
      .task-meta { color: #888; font-size: 11px; display: flex; gap: 15px; min-width: 170px; justify-content: flex-end; }
      
      /* ツリーUI用デザイン */
      .toggle-icon { cursor: pointer; width: 20px; text-align: center; color: #666; font-size: 10px; display: inline-block; flex-shrink: 0; user-select: none; }
      .toggle-icon:hover { color: #000; }
      .spacer { display: inline-block; width: 20px; flex-shrink: 0; }
      .status-icon { width: 22px; margin-right: 2px; text-align: center; }
      .item-check { margin-right: 6px; cursor: pointer; transform: scale(1.1); }
      .type-icon { margin-right: 6px; font-size: 15px; }
      .task-name { overflow: hidden; text-overflow: ellipsis; }
      .size-col { width: 60px; text-align: right; }
      .date-col { width: 100px; text-align: right; }

      .task-item[class*="hidden-by-"] { display: none !important; }
    </style>
  </head>
  <body>
    <div class="container">
      <h2 style="margin-top: 0; color: #1a73e8;">Drive File Copier</h2>
      
      <label>コピー元フォルダURL</label>
      <input type="text" id="source" placeholder="https://drive.google.com/...">

      <label>コピー先フォルダURL</label>
      <input type="text" id="target" placeholder="https://drive.google.com/...">

      <div class="checkbox-wrapper">
        <label style="font-weight: normal; cursor: pointer;">
          <input type="checkbox" id="subfolders" checked> サブフォルダもスキャンする
        </label>
      </div>

      <label>同名ファイルがある場合</label>
      <select id="conflict">
        <option value="overwrite">上書きする(古い方をゴミ箱へ)</option>
        <option value="rename">リネームしてコピー(末尾に日時を追加)</option>
      </select>

      <div class="btn-group">
        <button id="scanBtn" onclick="startScan()">1. ファイル一覧を取得</button>
        <button id="runBtn" onclick="startCopy()">2. 選択した項目をコピー</button>
        <button id="cancelBtn" onclick="cancelProcess()">キャンセル</button>
      </div>
      
      <div id="select-all-container">
        <label style="margin: 0; font-weight: normal; cursor: pointer;">
          <input type="checkbox" id="selectAll" checked onchange="toggleAll()"> 全選択 / 全解除
        </label>
      </div>

      <div id="progress-area">
        <div class="progress-text">
          <span id="status-msg">待機中...</span>
          <span id="progress-percent">0%</span>
        </div>
        <div class="progress-bar-bg">
          <div class="progress-bar-fill" id="progress-fill"></div>
        </div>
        <ul id="task-list"></ul>
      </div>
    </div>

    <script>
      let allTasks = []; 
      let taskQueue = []; 
      let currentTaskIndex = 0;
      let targetUrlGlobal = "";
      let conflictModeGlobal = "";
      let folderMap = {}; 
      let copiedItemIds = []; 
      let isCancelled = false;

      function startScan() {
        const src = document.getElementById('source').value;
        const sub = document.getElementById('subfolders').checked;
        if (!src) { alert('コピー元のURLを入力してください。'); return; }

        resetUIForScan();
        updateStatus('🔍 フォルダ内をスキャンしています...', '0%');

        google.script.run
          .withSuccessHandler(onScanComplete)
          .withFailureHandler(err => {
            updateStatus('❌ エラー: ' + err.message, '0%');
            document.getElementById('scanBtn').disabled = false;
          })
          .getCopyPlan(src, sub);
      }

      function onScanComplete(tasks) {
        allTasks = tasks;
        if (tasks.length === 0) {
          updateStatus('対象のファイルが見つかりませんでした。', '0%');
          document.getElementById('scanBtn').disabled = false;
          return;
        }

        document.getElementById('scanBtn').style.display = 'none';
        document.getElementById('runBtn').style.display = 'block';
        document.getElementById('select-all-container').style.display = 'block';
        updateStatus('📝 コピーする項目を選択し、「コピー」ボタンを押してください', '-');
        
        renderTaskList();
      }

      function renderTaskList() {
        const listUl = document.getElementById('task-list');
        listUl.innerHTML = '';
        
        allTasks.forEach(task => {
          const li = document.createElement('li');
          li.className = 'task-item';
          li.id = 'task-' + task.id;
          li.dataset.ancestors = task.ancestors;
          
          const indent = task.depth * 22; 
          const typeIcon = task.type === 'folder' ? '📁' : '📄';
          
          let toggleHtml = '<span class="spacer"></span>';
          if (task.type === 'folder') {
            const hasChildren = allTasks.some(t => t.parentId === task.id);
            if (hasChildren) {
              toggleHtml = `<span class="toggle-icon" onclick="toggleFolder('${task.id}')" id="toggle-${task.id}">▼</span>`;
            }
          }

          li.innerHTML = `
            <div class="task-main" style="padding-left: ${indent}px;">
              ${toggleHtml}
              <span class="status-icon" id="status-${task.id}" style="display:none;"></span>
              <input type="checkbox" class="item-check" value="${task.id}" id="check-${task.id}" checked onchange="handleCheck('${task.id}')">
              <span class="type-icon">${typeIcon}</span>
              <span class="task-name" title="${task.name}">${task.name}</span>
            </div>
            <div class="task-meta">
              <span class="size-col">${task.size}</span>
              <span class="date-col">${task.lastUpdated}</span>
            </div>
          `;
          listUl.appendChild(li);
        });
      }

      // --- 選択・開閉の制御 ---
      function toggleFolder(folderId) {
        const icon = document.getElementById('toggle-' + folderId);
        const isExpanded = icon.innerText === '▼';
        icon.innerText = isExpanded ? '▶' : '▼';

        const rows = document.querySelectorAll(`.task-item[data-ancestors~="${folderId}"]`);
        rows.forEach(row => {
          if (isExpanded) {
            row.classList.add(`hidden-by-${folderId}`);
          } else {
            row.classList.remove(`hidden-by-${folderId}`);
          }
        });
      }

      function handleCheck(nodeId) {
        const isChecked = document.getElementById(`check-${nodeId}`).checked;
        
        // 1. 子要素をすべて同じ状態にする
        const descendants = document.querySelectorAll(`.task-item[data-ancestors~="${nodeId}"] .item-check`);
        descendants.forEach(cb => {
          cb.checked = isChecked;
          cb.indeterminate = false; 
        });

        // 2. 親要素の状態を再計算する
        const task = allTasks.find(t => t.id === nodeId);
        if (task && task.parentId !== 'root') {
          updateAncestors(task.parentId);
        }

        checkSelectAllState();
      }

      // 親フォルダの「部分選択(indeterminate)」を計算する裏方関数
      function updateAncestors(parentId) {
        if (!parentId || parentId === 'root') return;

        const children = allTasks.filter(t => t.parentId === parentId);
        let allChecked = true;
        let noneChecked = true;
        let someIndeterminate = false;

        children.forEach(child => {
          const cb = document.getElementById(`check-${child.id}`);
          if (cb.checked) {
            noneChecked = false;
          } else if (cb.indeterminate) {
            noneChecked = false;
            allChecked = false;
            someIndeterminate = true;
          } else {
            allChecked = false;
          }
        });

        const parentCb = document.getElementById(`check-${parentId}`);
        if (parentCb) {
          if (allChecked) {
            parentCb.checked = true;
            parentCb.indeterminate = false;
          } else if (noneChecked && !someIndeterminate) {
            parentCb.checked = false;
            parentCb.indeterminate = false;
          } else {
            parentCb.checked = false;
            parentCb.indeterminate = true; // ここで「部分選択」マークにする
          }
        }

        const parentTask = allTasks.find(t => t.id === parentId);
        if (parentTask && parentTask.parentId !== 'root') {
          updateAncestors(parentTask.parentId);
        }
      }

      function toggleAll() {
        const isChecked = document.getElementById('selectAll').checked;
        document.querySelectorAll('.item-check').forEach(cb => {
          cb.checked = isChecked;
          cb.indeterminate = false;
        });
      }

      function checkSelectAllState() {
        const checkboxes = document.querySelectorAll('.item-check');
        const total = checkboxes.length;
        let checkedCount = 0;
        let indeterminateCount = 0;

        checkboxes.forEach(cb => {
          if (cb.checked) checkedCount++;
          if (cb.indeterminate) indeterminateCount++;
        });

        const selectAllCb = document.getElementById('selectAll');
        if (checkedCount === total && total > 0) {
          selectAllCb.checked = true;
          selectAllCb.indeterminate = false;
        } else if (checkedCount === 0 && indeterminateCount === 0) {
          selectAllCb.checked = false;
          selectAllCb.indeterminate = false;
        } else {
          selectAllCb.checked = false;
          selectAllCb.indeterminate = true;
        }
      }

      // --- コピー実行 ---
      function startCopy() {
        const tgt = document.getElementById('target').value;
        if (!tgt) { alert('コピー先のURLを入力してください。'); return; }

        const selectedIds = [];
        // チェックが入っているもの + 「部分選択(indeterminate)」のフォルダも対象に含める
        document.querySelectorAll('.item-check').forEach(cb => {
          if (cb.checked || cb.indeterminate) {
            selectedIds.push(cb.value);
          }
        });

        if (selectedIds.length === 0) {
          alert('コピーする項目が選択されていません。');
          return;
        }

        taskQueue = allTasks.filter(t => selectedIds.includes(t.id));
        currentTaskIndex = 0;
        targetUrlGlobal = tgt;
        conflictModeGlobal = document.getElementById('conflict').value;
        folderMap = {}; 
        copiedItemIds = [];
        isCancelled = false;

        document.getElementById('runBtn').style.display = 'none';
        document.getElementById('cancelBtn').style.display = 'block';
        document.getElementById('select-all-container').style.display = 'none';
        
        allTasks.forEach(task => {
          document.getElementById(`check-${task.id}`).style.display = 'none';
          if (selectedIds.includes(task.id)) {
            const statusEl = document.getElementById(`status-${task.id}`);
            statusEl.style.display = 'inline-block';
            statusEl.innerText = '⬜️';
          }
        });

        processNextTask();
      }

      function processNextTask() {
        if (isCancelled) { handleCancellation(); return; }
        updateProgressUI();

        if (currentTaskIndex >= taskQueue.length) {
          updateStatus('✅ すべての処理が完了しました!', '100%');
          resetUIForFinish();
          return;
        }

        const task = taskQueue[currentTaskIndex];
        document.getElementById(`status-${task.id}`).innerText = '⏳';
        
        const taskEl = document.getElementById(`task-${task.id}`);
        taskEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
        
        updateStatus(`🔄 コピー中: ${task.name}`, null);

        google.script.run
          .withSuccessHandler(result => onTaskComplete(result, task))
          .withFailureHandler(err => onTaskError(err, task))
          .processTask(task, targetUrlGlobal, folderMap, conflictModeGlobal);
      }

      function onTaskComplete(result, task) {
        if (isCancelled) { handleCancellation(); return; }

        const statusEl = document.getElementById(`status-${task.id}`);
        if (result.status === 'success') {
          statusEl.innerText = '✅';
          if (result.newId) copiedItemIds.push(result.newId);
          if (task.type === 'folder' && result.newId) {
            folderMap[task.id] = result.newId; 
          }
        } else {
          statusEl.innerText = '❌';
        }
        currentTaskIndex++;
        processNextTask();
      }

      function onTaskError(err, task) {
        if (!isCancelled) {
          document.getElementById(`status-${task.id}`).innerText = '❌';
          currentTaskIndex++;
          processNextTask(); 
        }
      }

      // --- キャンセル処理 ---
      function cancelProcess() {
        if (!confirm("コピー処理を中止し、すでにコピーされたファイルを削除しますか?")) return;
        isCancelled = true;
        document.getElementById('cancelBtn').innerText = '中止処理中...';
        document.getElementById('cancelBtn').disabled = true;
        updateStatus('🛑 キャンセル処理中...コピー済みファイルを削除しています', null);
      }

      function handleCancellation() {
        if (copiedItemIds.length > 0) {
          google.script.run
            .withSuccessHandler(() => {
              updateStatus('🛑 キャンセルされました。コピー済みのファイルはゴミ箱へ移動しました。', '-');
              resetUIForFinish();
            })
            .rollbackCopy(copiedItemIds);
        } else {
          updateStatus('🛑 キャンセルされました。', '-');
          resetUIForFinish();
        }
      }

      // --- UI更新ユーティリティ ---
      function updateStatus(msg, percentStr) {
        if (msg) document.getElementById('status-msg').innerText = msg;
        if (percentStr) {
          document.getElementById('progress-percent').innerText = percentStr;
          document.getElementById('progress-fill').style.width = percentStr;
        }
      }

      function updateProgressUI() {
        if (taskQueue.length === 0) return;
        const percent = Math.floor((currentTaskIndex / taskQueue.length) * 100);
        updateStatus(null, `${percent}%`);
        document.getElementById('progress-percent').innerText = `${currentTaskIndex} / ${taskQueue.length} (${percent}%)`;
      }

      function resetUIForScan() {
        document.getElementById('scanBtn').disabled = true;
        document.getElementById('runBtn').style.display = 'none';
        document.getElementById('cancelBtn').style.display = 'none';
        document.getElementById('select-all-container').style.display = 'none';
        document.getElementById('progress-area').style.display = 'block';
        document.getElementById('task-list').innerHTML = '';
        document.getElementById('progress-fill').style.width = '0%';
      }

      function resetUIForFinish() {
        document.getElementById('cancelBtn').style.display = 'none';
        document.getElementById('cancelBtn').innerText = 'キャンセル';
        document.getElementById('cancelBtn').disabled = false;
        
        document.getElementById('scanBtn').style.display = 'block';
        document.getElementById('scanBtn').innerText = 'もう一度実行する';
        document.getElementById('scanBtn').disabled = false;
      }
    </script>
  </body>
</html>

使用上の注意とセキュリティリスク

便利なツールだが、強力なファイル操作権限を持つため、運用にあたって押さえておくべきポイントがある。

組織のセキュリティポリシーを確認する

会社のGoogle Workspace(共有ドライブ)から個人のGmailアカウントや別組織の環境へファイルをコピーする行為は、企業のセキュリティポリシー(情報持ち出し禁止規定など)に抵触する可能性が高い。

今どきの組織では DLP(Data Loss Prevention / データ損失防止)機能が有効になっている場合もあり、誰がどのファイルを外部へコピーしたかは管理者側で検知・記録される。無断で業務データを別環境へ複製すると、重大なインシデントになり得る。必ず所属組織のルールと管理者の許可を確認してから使ってほしい。

デプロイ時のアクセス権限は「自分のみ」に

ウェブアプリとして公開する際、「アクセスできるユーザー」を「全員」にしてしまうと、URLを知っている第三者があなたの権限でファイルを操作できてしまう。必ず「自分のみ」に設定し、自分専用のプライベートツールとして運用すること。

GASの「6分ルール」

Google Apps Scriptには1回の実行が最大6分という制限がある。数ギガバイトの動画ファイルや、数千ファイルの一括コピーでは途中でタイムアウトする可能性がある。その場合はフォルダを小分けにして実行すればいい。

初回実行時の警告画面

初回実行時に「このアプリはGoogleで確認されていません」という警告が出る。自分で作ったスクリプトなので、左下の「詳細」→「〇〇(プロジェクト名)に移動」へ進んで許可して問題ない。

実際にかかった時間と使ったツール

参考までに、今回の開発にかかった時間を記録しておく。

STEP
当初の要件を満たす動作版:約5分

「フォルダURLを入れたら階層ごとコピーして」という最初のプロンプトから、基本機能が動くものが出てくるまでの時間

STEP
使い勝手の改善:追加15分

ツリー表示、チェックボックス連動、キャンセル機能など、実際に触ってみて「ここはこうしたい」を伝えるやりとりの合計

つまり、トータル約20分。手作業でリネームしながらコピーしていた時間を考えると、1回使うだけで元が取れる。

使った生成AIツール(LLM)はGemini。

  • Gemini Pro(Google Workspace版に含まれるGemini)
  • Google AI Pro(個人版、月額2,900円 / 2TBストレージ付き/ ファミリー共有+5人まで )

なお、2TBものストレージが不要であれば、2026年1月に登場した Google AI Plus(月額1,200円)がコスパに優れている。ストレージは200GBだが、Gemini 3 Proが使えるので、今回のようなGASコード生成には十分すぎる。ChatGPT Goの月額1,500円よりも安く、ストレージまでついてくるので、AIを試してみたい人にはちょうどいい入口だと思う。

こちらのリンクから登録すると、個人版4ヶ月無料の特典が使えるよ!(先着10名)

まとめ

Googleドライブのフォルダコピーは、見た目のシンプルさに反して、Finderのショートカット問題・ブラウザのWord強制変換・ファイル名の接尾辞と、地味に面倒な罠が多い。今回はGeminiとの対話でGASのWebアプリを作ることで、これらをまとめて解決できた。

AIとの共同開発で個人的に感じたのは、「最初から完璧な指示を出す必要はない」ということ。大まかな要件を伝えて、動くものが出てきたら実際に触ってみて、「ここはこうしたい」を追加で伝える。この繰り返しで、想像以上に実用的なものができあがる。

そして、この「AIと対話しながら自分の業務を効率化する」スキルは、今後あらゆるビジネスパーソンにとって基礎教養になっていくだろうと感じている。プログラミングの知識そのものではなく、「自分が何に困っていて、どう解決したいのか」を言語化してAIに伝える力。これは職種を問わず求められるようになるはずだ。

コードを書けなくても、業務フローの非効率に気づける人は、AIの力を借りてそれを自動化できる時代になった。日々の「これ、手作業でやるの面倒だな」に対して、まずはAIに相談してみる。その一歩が、仕事の質を変える起点になると思う。

  • URLをコピーしました!
  • URLをコピーしました!

WHO WROTE

Shinpei Okadaのアバター Shinpei Okada COO / AIエンジニア

地方テレビ局、歯科コンサル、中堅SIerを経て独立。ダイヤルアップ接続の時代にHTMLに魅せられ、なんだかんだ10年以上WEB制作に関わり続けている。近年はNotionとn8nを軸にしたワークフロー構築に注力。生活しているだけでユーザーの哲学や日々の情報を抽出・蓄積し、AIによるデータ活用が可能になるシステム「MIMIR」の開発に取り組んでいます。

目次