星期四, 4月 16, 2026

[GAS] 網頁應用程式 - Google 帳號

以往把 GAS 發佈為網頁應用程式時,設定值都是
  • 執行身份:我 (gmail 信箱)
  • 誰可以存取:所有人
該筆記使用
  • 執行身份:存取網頁應用程式的使用者
  • 誰可以存取:所有已登入 Google 帳戶的使用者
來記錄,並設定存取名單,不在存取名單內即使有登錄 Google 帳號也無法存取網頁
使用者 Email

透過 getActiveUser() 來存取 Google 帳號 Email
Gets information about the current user. If security policies do not allow access to the user's identity, User.getEmail() returns a blank string. The circumstances in which the email address is available vary: for example, the user's email address is not available in any context that allows a script to run without that user's authorization, like a simple onOpen(e) or onEdit(e) trigger, a custom function in Google Sheets, or a web app deployed to "execute as me" (that is, authorized by the developer instead of the user). However, these restrictions generally do not apply if the developer runs the script themselves or belongs to the same Google Workspace domain as the user.

網頁存取名單

在 GAS PropertyService 內設定存取存取名單,存取名單已逗號 (,) 分隔,例如:123@gmail.com,456@gmail.com,當然也可以放在 Google Sheet 或第三方資料庫來管理存取名單
gs Code
/**
 * 系統配置參數
 * @constant {Object}
 */
const CONFIG = {
  PROPERTY_KEY: 'WHITELIST' // 儲存在指令碼屬性中的鍵名
};

/**
 * 從指令碼屬性 (PropertiesService) 中獲取白名單。
 * @returns {string[]} 允許存取的 Google 帳號信箱陣列。
 */
const getAllowedEmails = () => {
  const whitelistStr = PropertiesService.getScriptProperties().getProperty(CONFIG.PROPERTY_KEY);
  
  if (!whitelistStr) {
    console.warn('目前沒有設定任何白名單。');
    return [];
  }

  // 將字串轉為陣列,過濾空白並轉為小寫
  return whitelistStr.split(',')
    .map(email => email.trim().toLowerCase())
    .filter(email => email !== '');
};

/**
 * 處理 Web App 的 HTTP GET 請求。
 * @param {Object} e - 事件物件。
 * @returns {GoogleAppsScript.HTML.HtmlOutput} 根據使用者權限回傳對應的 HTML 頁面。
 */
const doGet = (e) => {
  const currentUserEmail = Session.getActiveUser().getEmail().toLowerCase();
  const allowedEmails = getAllowedEmails();

  if (allowedEmails.includes(currentUserEmail)) {
    // 驗證通過
    const template = HtmlService.createTemplateFromFile('Index');
    template.userEmail = currentUserEmail; 
    
    return template.evaluate().setTitle('系統主控台');
  } else {
    // 驗證失敗
    const template = HtmlService.createTemplateFromFile('Error');
    template.userEmail = currentUserEmail;
    
    return template.evaluate().setTitle('存取被拒絕');
  }
};
Index.html 和 Error.html 就不特別列出來囉

實際執行

進入網頁時假如沒有登錄 Google 帳號,會出現 Google 登錄畫面

登錄 Google 帳號後則是檢查該帳號是否在存取白名單內



限制

實作發現,原來 getActiveUser() 在 [執行身份:我 (gmail 信箱)] 時,使用者執行網頁時會回傳空值,該情況只有執行身份信箱使用者可以透過 getActiveUser() 取得 gmail 資訊

[執行身份:存取網頁應用程式的使用者] 時,使用者執行網頁時雖然 getActiveUser() 可以取得 gmail 資訊進一步根據存取名單判斷是否有權限,但該身分初次存取網頁需授予權限,授予權限範圍則是依網頁使用相關服務不一,至少會要求存取使用者帳戶資訊,其實部屬畫面上就有簡易文字說明
原以為 [執行身份:我 (gmail 信箱)] 會類似一個服務帳號,該帳號擁有權限存取 Google 相關服務,而 getActiveUser() 則是可以抓到使用者 gmail 資訊,兩者搭配來實作存取名單功能,但在 GAS 內部架構下看起來沒有這麼理想

星期五, 4月 10, 2026

[GAS] Google Doc 轉為 PDF

GAS 內可以透過 getAs() 把 Google Doc 轉換為 PDF

限制
gs code
/**
 * 將指定的 Google Doc 文件轉換為 PDF,並儲存到指定的 Google Drive 資料夾中
 *
 * @param {string} documentId - 欲轉換的 Google Doc 檔案 ID
 * @param {string} folderId - 儲存 PDF 的目標資料夾 ID
 * @returns {string} 新建立的 PDF 檔案 URL
 * @throws {Error} 當缺少必要參數、找不到檔案、權限不足或轉換失敗時拋出錯誤
 */
function convertDocToPdf(documentId, folderId) {

  if (!documentId || !folderId) {
    throw new Error("執行失敗:必須同時提供 documentId (文件 ID) 與 folderId (目標資料夾 ID)。");
  }

  try {
    // 1. 透過 ID 取得指定的 Google 文件
    const docFile = DriveApp.getFileById(documentId);

    // 2. 將檔案轉換為 PDF 格式的 Blob (二進位大型物件)
    const pdfBlob = docFile.getAs(MimeType.PDF);

    // 3. 設定產出的 PDF 檔名 (預設為原檔名加上 .pdf)
    const newFileName = `${docFile.getName()}.pdf`;
    pdfBlob.setName(newFileName);

    // 4. 取得目標資料夾實體,並在該資料夾內直接建立 PDF 檔案
    const folder = DriveApp.getFolderById(folderId);
    const newPdfFile = folder.createFile(pdfBlob);
    return newPdfFile.getUrl();

  } catch (error) {
    throw new Error(`PDF 轉換程序發生錯誤: ${error.message}`);
  }
}
執行
function testConvertDocToPdf() {
  const targetDocumentId = "1x17o_MEpEU-S2jKzFhdVqfQ3bswGVPKf2ottem8TSRE"; 
  const targetFolderId = "1Odq3pbNO63RxXmrxWNM9vSGWuGWyBmbZ";

  try {
    const pdfUrl = convertDocToPdf(targetDocumentId, targetFolderId);
    console.log(`轉換完成!PDF 檔案已儲存至指定資料夾,連結請見: ${pdfUrl}`);
  } catch (error) {
    console.error(error);
  }
}

星期二, 4月 07, 2026

[Forms] 設定截止日或回覆限制

看 AI 教學時,無意中看到老師操作畫面上 Google Forms 有表單截止設定,沒想到自動關閉表單已經是內建功能囉

把表單發佈出去後就可以看見 [設定截止日或回覆限制],有兩種設定方式,分別為
  • 指定日期
  • 直到指定回覆數之後
指定日期:設定完成後,日期時間有包含時區
直到指定回覆數之後: 設定 3 則回覆,並自訂 [若表單不再接受回覆,作答者就會看到這則訊息] 為 [已達報名人數上限,無法繼續報名]
輸入 3 則回覆後表單就會自動關閉,再進入表單就會看見下圖
之前介紹過 Google Forms 外掛 [Forms] 外掛-formLimiter 就只剩下 spreadSheet cell value 特定條件結束表單功能囉

星期一, 4月 06, 2026

[Line] 使用預設瀏覽器開啟連結

在 iphone 上使用診所 Line 官方帳號要進行預約時發現,網頁沒有用手機上預設瀏覽器開啟,Line 有內建瀏覽器,之前完全沒有發現這點

在手機版本 Line 上,設定 => Line Labs => 使用預設瀏覽器開啟連結,該選項預設是開啟的

星期六, 4月 04, 2026

[JS] localStorage

在 GAS 上實作待辦事項,並使用 localStorage 來存取資料,該筆記有下列重點
  • localStorage 只能儲存字串資料,該練習資料來源是陣列和物件,會使用 JSON.stringify() 轉成 json 後才進行儲存,讀取資料則是透過 JSON.parse() 轉回
  • render 時會透過自訂 attribute 來儲存識別資料
  • render 資料時,並不是逐一註冊每一個 li click 事件,而是網頁 Loading 時註冊 ul click 事件,再透過 event.target.closest 來確定抓到 li 並進行刪除資料

Index.html
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
  <base target="_top">
  <meta charset="utf-8">
  <?!= include('Stylesheet'); ?>
</head>
<body>

  <div class="container">
    <h2 class="header-title">GAS LocalStorage 練習</h2>
    
    <div class="input-group">
      <input type="text" id="todoInput" class="todo-input" placeholder="請輸入待辦事項...">
      <button type="button" id="addBtn" class="btn-submit">新增</button>
    </div>

    <ul id="todoList" class="todo-list">
    </ul>
  </div>

  <script>
    /**
     * 從 localStorage 讀取並解析待辦事項
     * @returns {Array<{id: number, text: string}>}
     */
    const getTodos = () => {
      const todosData = localStorage.getItem('my_todos');
      return todosData ? JSON.parse(todosData) : [];
    };

    /**
     * 將待辦事項陣列轉換為 JSON 字串並儲存至 localStorage
     * @param {Array<{id: number, text: string}>} todos 
     */
    const saveTodos = (todos) => {
      localStorage.setItem('my_todos', JSON.stringify(todos));
    };

    /**
     * 負責將資料渲染至畫面 (讀取)
     */
    const renderTodos = () => {
      const todos = getTodos();
      const listContainer = document.getElementById('todoList');
      
      listContainer.innerHTML = ''; 

      todos.forEach(todo => {
        const liElement = document.createElement('li');
        liElement.className = 'todo-item';
        liElement.setAttribute('data-id', todo.id);
        
        const textSpan = document.createElement('span');
        textSpan.textContent = todo.text;
        
        const hintSpan = document.createElement('span');
        hintSpan.className = 'hint-text';
        hintSpan.textContent = '刪除';

        liElement.appendChild(textSpan);
        liElement.appendChild(hintSpan);

        listContainer.appendChild(liElement);
      });
    };

    /**
     * 處理新增待辦事項邏輯 (新增)
     */
    const handleAddTodo = () => {
      const inputElement = document.getElementById('todoInput');
      const textValue = inputElement.value.trim();

      if (!textValue) {
        alert('請先輸入待辦事項內容!');
        return;
      }

      const todos = getTodos();
      const newTodo = {
        id: Date.now(), 
        text: textValue
      };

      todos.push(newTodo);
      saveTodos(todos);
      
      inputElement.value = ''; 
      renderTodos(); 
    };

    /**
     * 處理刪除待辦事項邏輯 (刪除)
     * @param {number} id
     */
    const deleteTodo = (id) => {
      const todos = getTodos();
      const filteredTodos = todos.filter(todo => todo.id !== id);
      saveTodos(filteredTodos);
      renderTodos(); 
    };

    /**
     * 處理待辦清單的點擊事件
     * @param {Event} event 
     */
    const handleListClick = (event) => {
      const clickedItem = event.target.closest('.todo-item');
      if (clickedItem) {
        const todoId = Number(clickedItem.getAttribute('data-id'));
        deleteTodo(todoId);
      }
    };

    document.addEventListener('DOMContentLoaded', () => {
      const addBtn = document.getElementById('addBtn');
      const todoInput = document.getElementById('todoInput');
      const todoList = document.getElementById('todoList');

      addBtn.addEventListener('click', handleAddTodo);

      todoInput.addEventListener('keypress', (event) => {
        if (event.key === 'Enter') {
          handleAddTodo();
        }
      });
      
      todoList.addEventListener('click', handleListClick);

      renderTodos();
    });
  </script>
</body>
</html>

Stylesheet.html
<style>
  body {
    font-family: sans-serif;
    background-color: #f4f7f6;
    color: #333;
    padding: 20px;
  }
  .container {
    width: 500px; 
    margin: 0 auto;
    background: #ffffff;
    padding: 24px;
    border-radius: 8px;
    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  }
  .header-title {
    text-align: center;
    color: #2c3e50;
    margin-top: 0;
  }
  .input-group {
    display: flex;
    gap: 10px; 
    margin-bottom: 20px;
  }
  .todo-input {
    flex-grow: 1;
    padding: 10px;
    border: 1px solid #ddd;
    border-radius: 4px;
    font-size: 16px;
  }
  .btn-submit {
    padding: 10px 20px;
    background-color: #3498db;
    color: #ffffff;
    border: none;
    border-radius: 4px;
    font-size: 16px;
    cursor: pointer;
  }
  .btn-submit:hover {
    background-color: #2980b9;
  }
  .todo-list {
    list-style-type: none;
    padding: 0;
    margin: 0;
  }
  .todo-item {
    background-color: #f8f9fa;
    padding: 12px;
    margin-bottom: 8px;
    border-radius: 4px;
    border-left: 4px solid #3498db;
    display: flex;
    justify-content: space-between;
    align-items: center;
    cursor: pointer;
  }
  .todo-item:hover {
    background-color: #e9ecef;
  }
  .hint-text {
    font-size: 12px;
    color: #e74c3c;
  }
</style>

Code.gs
/**
 * 處理 Web App 的 GET 請求,透過模板引擎渲染前端 HTML 頁面
 * @param {Object} e - GET 請求的事件物件
 * @returns {GoogleAppsScript.HTML.HtmlOutput} 回傳渲染後的 HTML 視圖
 */
function doGet(e) {
  // 使用 createTemplateFromFile 才能解析 HTML 檔案中的 GAS 標籤
  return HtmlService.createTemplateFromFile('Index')
    .evaluate()
    .setTitle('待辦事項練習')
}

/**
 * 將指定的 HTML 檔案內容載入到當前頁面中 (用於抽離 CSS 或 JS)
 * @param {string} filename - 要載入的檔案名稱
 * @returns {string} 檔案內容字串
 */
function include(filename) {
  return HtmlService.createHtmlOutputFromFile(filename).getContent();
}

觀察儲存在 localStorage 上的資料