# 使用 Google Apps Script 實現網頁錄音、多檔案與文字上傳至 Google Drive

# 目標
使用者經常需要在網頁上進行錄音、上傳多個檔案（例如圖片、文件），或者上傳一段文字，並希望將這些資料直接儲存到 Google Drive。傳統方法可能需要複雜的後端設定或第三方服務。本筆記旨在提供一個基於 Google Apps Script 的解決方案，讓使用者無需複雜設定，即可透過網頁介面直接完成錄音、檔案和文字上傳，並將資料儲存至指定的 Google Drive 資料夾。

# 核心步驟
1.  **建立 Google Apps Script 專案**:  建立一個新的 Google Apps Script 專案，用於處理網頁前端和 Google Drive API 之間的互動。
2.  **部署網頁應用程式**:  編寫 HTML、CSS 和 JavaScript 程式碼，建立使用者介面，包含錄音、檔案拖曳/選擇、文字輸入等功能，並將其部署為網頁應用程式。
3.  **取得使用者授權**:  首次使用時，需要取得使用者授權，允許 Apps Script 存取其 Google Drive。
4.  **前端錄音與檔案處理**:  使用 JavaScript 的 `MediaRecorder` API 進行錄音，並將錄音資料轉換為 Blob 格式。使用 `FileReader` API 讀取使用者選擇的檔案，並將其轉換為 Data URL 格式。
5.  **前端文字處理**: 取得使用者在 `textarea` 中輸入的文字內容。
6.  **後端資料處理與上傳**:  將前端取得的錄音 Data URL、檔案 Data URL 和文字內容傳送至 Google Apps Script 後端。後端將 Data URL 解碼為二進位資料，並使用 Google Drive API 將其建立為檔案，儲存至指定的資料夾。 建立以時間戳記為名稱的子資料夾，以組織每次上傳。
7. **顯示上傳狀態**: 前端即時顯示上傳狀態和訊息，讓使用者了解目前進度。

# 步驟實現

## 1. 建立 Google Apps Script 專案

   *   前往 Google Drive (drive.google.com)。
   *   點擊「新增」>「更多」>「Google Apps Script」。
   *   將專案重新命名 (例如："WebRecorderUploader")。

## 2. 部署網頁應用程式

   *   在 Apps Script 編輯器中，建立兩個檔案：`Code.gs` (後端程式碼) 和 `index.html` (前端程式碼)。

   **Code.gs:**

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

   // 建立以時間戳記為名稱的子資料夾
   function createSessionFolder() {
     var parentFolderId = "YOUR_PARENT_FOLDER_ID"; // 替換成你的 Google Drive 父資料夾 ID
     var parentFolder = DriveApp.getFolderById(parentFolderId);
     var timestamp = new Date().toISOString();
     var sessionFolder = parentFolder.createFolder(timestamp);
     return sessionFolder.getId();
   }

   function uploadAudio(dataUrl, folderId) {
     try {
       // dataUrl 格式類似 data:audio/webm;base64,xxxx...
       var parts = dataUrl.split(',');
       if (parts.length !== 2) {
         throw new Error('Invalid data URL format.');
       }
       // 解析 MIME 類型，例如 audio/webm
       var contentType = parts[0].match(/:(.*?);/)[1];
       var decoded = Utilities.base64Decode(parts[1]);
       // 產生 Blob 物件，並以 timestamp 當作檔名
       var blob = Utilities.newBlob(decoded, contentType, new Date().toISOString() + ".webm");

       var folder = DriveApp.getFolderById(folderId);
       var file = folder.createFile(blob);
       return 'Audio File uploaded successfully: ' + file.getUrl();
     } catch (error) {
       return 'Error uploading audio file: ' + error.toString();
     }
   }

   function uploadFile(dataUrl, filename, folderId) {
     try {
       var parts = dataUrl.split(',');
        if (parts.length !== 2) {
         throw new Error('Invalid data URL format.');
       }
       var contentType = parts[0].match(/:(.*?);/)[1];
       var decoded = Utilities.base64Decode(parts[1]);
       var blob = Utilities.newBlob(decoded, contentType, filename);

       var folder = DriveApp.getFolderById(folderId);
       var file = folder.createFile(blob);
       return 'File uploaded successfully: ' + file.getUrl();
     } catch (error) {
       return 'Error uploading file: ' + error.toString();
     }
   }

    function uploadText(text, folderId) {
     try {
        var folder = DriveApp.getFolderById(folderId);
        var fileName =  new Date().toISOString() + ".txt"
        var file = folder.createFile(fileName, text, MimeType.PLAIN_TEXT);
        return 'Text uploaded successfully: ' + file.getUrl();
     }
     catch (error){
        return 'Error uploading text file: ' + error.toString();
     }
   }
   
```

   **index.html:**

   
```html
   <!DOCTYPE html>
   <html>
   <head>
     <title>Record, Upload Files & Text to Google Drive</title>
     <style>
       /* 訊息面板樣式 */
       #log {
         position: fixed;
         bottom: 0;
         left: 0;
         width: 100%;
         background: #f0f0f0;
         max-height: 150px;
         overflow-y: auto;
         padding: 10px;
         font-size: 14px;
         border-top: 1px solid #ccc;
       }
       /* 按鈕樣式 */
       button {
         padding: 10px 20px;
         margin: 5px;
         font-size: 16px;
       }
       /* Drag & drop 區域樣式 */
       #drop-area {
         border: 2px dashed #ccc;
         padding: 20px;
         text-align: center;
         margin-top: 20px;
       }
       /* Highlight state for drag & drop */
       #drop-area.highlight {
         border-color: #333;
         background-color: #f9f9f9;
       }
       /* Text upload 區域樣式 */
       #text-upload {
         margin-top: 20px;
       }
       textarea {
         width: 100%;
         height: 100px;
         font-size: 16px;
       }
     </style>
   </head>
   <body>
     <h1>Record, Upload Files & Text to Google Drive</h1>

     <!-- Audio recording buttons -->
     <button id="start">Start recording</button>
     <button id="stop" disabled>Stop recording</button>

     <!-- File upload area -->
     <div id="drop-area">
       <p>Drag and drop files here</p>
       <p>or</p>
       <input type="file" id="file-input" multiple style="display: none;">
       <button id="select-files">Select Files</button>
     </div>

      <!-- Text upload area -->
     <div id="text-upload">
       <textarea id="text-input" placeholder="Enter text here..."></textarea>
       <button id="upload-text">Upload Text</button>
     </div>

     <!-- 狀態訊息面板 -->
     <div id="log">
       <p>Status Message: </p>
     </div>

     <script>
       let folderId;

       // 建立上傳資料夾
       google.script.run.withSuccessHandler(
         id => {
           folderId = id;
           logMessage('Session folder created with ID: ' + folderId);
         }
       ).createSessionFolder();

       let mediaRecorder;
       let recordedChunks = [];

       // 在頁面上顯示狀態訊息
       function logMessage(message) {
         const logDiv = document.getElementById('log');
         const p = document.createElement('p');
         p.textContent = message;
         logDiv.appendChild(p);
         logDiv.scrollTop = logDiv.scrollHeight;
       }

       // Audio recording logic
       if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
         navigator.mediaDevices.getUserMedia({ audio: true })
         .then(stream => {
           mediaRecorder = new MediaRecorder(stream);

           mediaRecorder.ondataavailable = function(e) {
             if (e.data && e.data.size > 0) {
               recordedChunks.push(e.data);
             }
           };

           mediaRecorder.onstop = function() {
             let blob = new Blob(recordedChunks, { type: 'audio/webm' });
             recordedChunks = [];
             logMessage('Recording stopped. Preparing to upload...');
             uploadAudio(blob);
           };

           document.getElementById('start').onclick = function() {
             mediaRecorder.start();
             logMessage('Recording started...');
             this.disabled = true;
             document.getElementById('stop').disabled = false;
           };

           document.getElementById('stop').onclick = function() {
             mediaRecorder.stop();
             logMessage('Stopping recording...');
             this.disabled = true;
             document.getElementById('start').disabled = false;
           };
         })
         .catch(err => {
           logMessage('Error accessing microphone: ' + err);
         });
       } else {
         logMessage('getUserMedia not supported.');
       }

       // 將錄音 Blob 轉成 Data URL 並呼叫伺服器端上傳
       function uploadAudio(blob) {
         let reader = new FileReader();
         reader.onloadend = function() {
           let dataUrl = reader.result;
           logMessage('Uploading audio...');
           google.script.run.withSuccessHandler(logMessage).uploadAudio(dataUrl, folderId);
         };
         reader.readAsDataURL(blob);
       }

       // File upload logic
       document.getElementById('select-files').addEventListener('click', () => {
         document.getElementById('file-input').click();
       });

       const dropArea = document.getElementById('drop-area');

       ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
         dropArea.addEventListener(eventName, preventDefaults, false);
       });

       function preventDefaults(e) {
         e.preventDefault();
         e.stopPropagation();
       }

       ['dragenter', 'dragover'].forEach(eventName => {
         dropArea.addEventListener(eventName, highlight, false);
       });

       ['dragleave', 'drop'].forEach(eventName => {
         dropArea.addEventListener(eventName, unhighlight, false);
       });

       function highlight(e) {
         dropArea.classList.add('highlight');
       }

       function unhighlight(e) {
         dropArea.classList.remove('highlight');
       }

       dropArea.addEventListener('drop', handleDrop, false);

       function handleDrop(e) {
         let dt = e.dataTransfer;
         let files = dt.files;
         handleFiles(files);
       }

       document.getElementById('file-input').addEventListener('change', function(e) {
         handleFiles(this.files);
       });

       function handleFiles(files) {
         ([...files]).forEach(uploadFile);
       }

       function uploadFile(file) {
         let reader = new FileReader();
         reader.onloadend = function() {
           let dataUrl = reader.result;
           logMessage('Uploading file: ' + file.name);
           google.script.run.withSuccessHandler(logMessage).uploadFile(dataUrl, file.name, folderId);
         }
         reader.readAsDataURL(file);
       }

        // Text upload logic
        document.getElementById('upload-text').addEventListener('click', () => {
            const text = document.getElementById('text-input').value;
            if (text.trim() !== '') {
                logMessage('Uploading text...');
                google.script.run.withSuccessHandler(logMessage).uploadText(text, folderId);
            } else {
                logMessage('Please enter some text to upload.');
            }
        });
     </script>
   </body>
   </html>
   
```

   *   **重要:** 將 `Code.gs` 中的 `"YOUR_PARENT_FOLDER_ID"` 替換成你 Google Drive 中想要存放上傳檔案的父資料夾 ID。  (取得資料夾 ID 的方法：開啟該資料夾，網址列中 `folders/` 後面的那一串字元即為資料夾 ID。)

   *   點擊「部署」>「新增部署作業」。
   *   在「設定」中，選擇「網頁應用程式」。
   *   「網頁應用程式」設定：
      *   「執行身分」：選擇「我」。
      *   「誰可以存取」：選擇「任何人」。
   *   點擊「部署」。
   *   複製「網頁應用程式」的網址，這就是你的網頁應用程式的網址。

## 3. 取得使用者授權

   *   首次在瀏覽器中開啟網頁應用程式網址時，系統會要求你授權該應用程式存取你的 Google Drive。請按照指示完成授權。

## 4. 前端錄音與檔案處理 (已在 `index.html` 程式碼中)

   *   **錄音:** 使用 `navigator.mediaDevices.getUserMedia` 取得麥克風權限，並建立 `MediaRecorder` 物件。點擊「Start recording」按鈕開始錄音，點擊「Stop recording」按鈕停止錄音。錄音資料會以 `audio/webm` 格式的 Blob 儲存在 `recordedChunks` 陣列中。停止錄音後，會將 `recordedChunks` 中的 Blob 合併成一個 Blob，並呼叫 `uploadAudio` 函式。
   *   **檔案上傳:** 使用者可以透過拖曳檔案到指定區域，或點擊「Select Files」按鈕選擇檔案。`handleFiles` 函式會處理選擇的檔案，並對每個檔案呼叫 `uploadFile` 函式。
   *   **Data URL 轉換:** `uploadAudio` 和 `uploadFile` 函式中使用 `FileReader` 的 `readAsDataURL` 方法將 Blob 或 File 物件轉換為 Data URL。

## 5. 前端文字處理 (已在 `index.html` 程式碼中)
    * 使用者在`<textarea>`中輸入文字。
    * 點擊 "Upload Text" 按鈕後，會取得`textarea`的值，並傳送至`uploadText`。

## 6. 後端資料處理與上傳 (已在 `Code.gs` 程式碼中)

   *   **`createSessionFolder` 函式:** 建立一個以目前時間戳記為名稱的子資料夾，用於存放本次上傳的所有檔案。
   *   **`uploadAudio` 函式:** 接收前端傳來的 Data URL，將其解碼為二進位資料，建立 `audio/webm` 格式的 Blob 物件，並使用 `DriveApp.getFolderById(folderId).createFile(blob)` 將其上傳到 Google Drive 指定的資料夾。
   *   **`uploadFile` 函式:** 接收前端傳來的 Data URL 和檔名，將其解碼為二進位資料，建立 Blob 物件，並使用 `DriveApp.getFolderById(folderId).createFile(blob)` 將其上傳到 Google Drive 指定的資料夾。
   *   **`uploadText` 函式:** 接收前端傳來的文字內容和資料夾 ID，並使用 `DriveApp.getFolderById(folderId).createFile(filename, text, MimeType.PLAIN_TEXT)` 將其上傳到 Google Drive 指定的資料夾，檔案類型為純文字。

## 7. 顯示上傳狀態 (已在 `index.html` 程式碼中)

   *   `logMessage` 函式用於在網頁下方的訊息面板中顯示狀態訊息。
   *   前端和後端的各個步驟都會呼叫 `logMessage` 函式，顯示目前的操作狀態 (例如：「Recording started...」、「Uploading audio...」、「Audio File uploaded successfully: ...」)。
   *   使用 `google.script.run.withSuccessHandler(logMessage)` 將後端函式的回傳值 (上傳成功訊息或錯誤訊息) 顯示在前端。

# 結論

本筆記提供了一個完整的解決方案，讓使用者可以透過簡單的網頁介面，直接進行錄音、上傳多個檔案和文字，並將資料儲存到 Google Drive。此方案基於 Google Apps Script，無需複雜的後端設定，易於部署和使用。

**優點:**

*   **簡單易用:** 使用者只需透過網頁介面即可操作，無需安裝任何軟體或插件。
*   **無需後端設定:** 所有程式碼都在 Google Apps Script 中執行，無需架設伺服器。
*   **直接整合 Google Drive:** 資料直接儲存到 Google Drive，方便管理和分享。
*   **支援多檔案上傳:** 可以一次上傳多個檔案，提高效率。
