星期六, 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 上的資料

星期三, 4月 01, 2026

[GAS] Realtime Database

透過 Google Apps Script 存取 Firebase Realtime Database (NoSQL)

Firebase 上建立新專案

建立過程依序會詢問專案名稱、是否在 Firebase 上啟用 Gemini、是否啟用 Google Analytics

建立 Firebase RealTime Database

產品類別 => 資料庫和儲存空間 => NoSQL => Realtime Database
點選建立資料庫
選擇資料庫位置,選擇新加玻 (asia-southeadt1)
安全性規則,預設為鎖定模式
Realtime Database 建立完成,紅線為 Readtime Database URL,GAS 會透過它來存取

訂閱方案 - Spark Plan

使用雲端服務最重要的了解帳單費用,Spark Plan 訂閱是 free 的,但 Realtime Database 有使用量限制,萬一到達使用量限制服務就會停止,下圖為 Realtime Database 使用限制,關於 Spark Plan 其他服務使用限制,可以參考官方網站 - Pricing
Google Apps Script CRUD

從 GAS 連線至 Realtime Database 是透過 ScriptApp.getOAuthToken() 取的個人身份來進行驗證,該筆記只著重在 Realtime Database CRUD 操作是沒有問題,但萬一該存取 Realtime Database 會開放,改用 GAS 第三方套件 OAuth2 搭配 Firebase 服務帳號來使用會比較適合

設定 oauth 授權範圍,Google Apps Script Project settings => 啟用 [在編輯器中顯示「appsscript.json」資訊清單檔案]

在編輯器中出現「appsscript.json」資訊清單檔案並加入 oauth 授權範圍

appsscript.json 完整資訊,oauthScopes 為手動加入
{
  "timeZone": "Asia/Taipei",
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "oauthScopes": [
      "https://www.googleapis.com/auth/script.external_request",
      "https://www.googleapis.com/auth/userinfo.email",
      "https://www.googleapis.com/auth/firebase.database"
    ]  
}

在指令碼指令內建立 Realtime Database URL
CRUD Code
/**
 * 從 Script Properties 取得 Firebase 資料庫網址
 * @constant {string}
 */
const FIREBASE_URL = PropertiesService.getScriptProperties().getProperty("FIREBASE_URL");

/**
 * 寫入或覆寫資料 (PUT)
 * 執行此操作會將指定路徑下的資料完全替換為傳入的 data。
 * * @param {string} path - Firebase 資料庫中的目標路徑(不含 .json 後綴)
 * @param {Object} data - 欲寫入或覆寫的 JSON 資料物件
 * @returns {Object} Firebase 伺服器回傳的 JSON 解析物件
 */
function writeDataBuiltIn(path, data) {

  const token = ScriptApp.getOAuthToken();
  const url = `${FIREBASE_URL}${path}.json`;

  const options = {
    method: "put", // 使用 put 進行資料寫入
    contentType: "application/json",
    headers: {
      Authorization: 'Bearer ' + token
    },
    payload: JSON.stringify(data),
    muteHttpExceptions: true
  };

  const response = UrlFetchApp.fetch(url, options);
  console.log("寫入狀態: " + response.getResponseCode());
  return JSON.parse(response.getContentText());
}

/**
 * 讀取資料 (GET)
 * 從指定的資料庫路徑讀取目前的資料。
 * * @param {string} path - Firebase 資料庫中的目標路徑(不含 .json 後綴)
 * @returns {Object|null} Firebase 伺服器回傳的 JSON 解析物件(若該路徑無資料則為 null)
 */
function readDataBuiltIn(path) {

  const token = ScriptApp.getOAuthToken();
  const url = `${FIREBASE_URL}${path}.json`;
  
  const options = {
    method: "get", // 使用 get 進行存取
    headers: {
      Authorization: 'Bearer ' + token
    },
    muteHttpExceptions: true
  };

  const response = UrlFetchApp.fetch(url, options);
  console.log("寫入狀態: " + response.getResponseCode());
  return JSON.parse(response.getContentText());
}

/**
 * 部分更新資料 (PATCH)
 * 只會你傳入的欄位,不會覆寫原本存在的其他欄位。
 * * @param {string} path - Firebase 資料庫中的目標路徑(不含 .json 後綴)
 * @param {Object} data - 要更新的 JSON 資料物件
 * @returns {Object} Firebase 伺服器回傳的 JSON 解析物件(包含已更新的欄位資料)
 */
function updateDataBuiltIn(path, data) {

  const token = ScriptApp.getOAuthToken();
  const url = `${FIREBASE_URL}${path}.json`;

  const options = {
    method: "patch", // 使用 patch 進行部分更新
    contentType: "application/json",
    headers: {
      Authorization: 'Bearer ' + token
    },
    payload: JSON.stringify(data),
    muteHttpExceptions: true
  };

  const response = UrlFetchApp.fetch(url, options);
  console.log("寫入狀態: " + response.getResponseCode());
  return JSON.parse(response.getContentText());
}

/**
 * 刪除資料 (DELETE)
 * 會刪除該路徑下的所有資料與子節點。
 * * @param {string} path - Firebase 資料庫中的目標路徑(不含 .json 後綴)
 * @returns {string} Firebase 伺服器回傳的回應字串(刪除成功通常會回傳 "null" 字串)
 */
function deleteDataBuiltIn(path) {

  const token = ScriptApp.getOAuthToken();
  const url = `${FIREBASE_URL}${path}.json`;

  const options = {
    method: "delete", // 使用 delete 刪除節點
    headers: {
      Authorization: 'Bearer ' + token
    },
    muteHttpExceptions: true
  };

  const response = UrlFetchApp.fetch(url, options);
  console.log("寫入狀態: " + response.getResponseCode());
  return response.getContentText(); 
}
實際測試

寫入
function testWriteDataBuiltIn() {
  const userPath = "customers/user_123";
  const userData = {
    name: "陳大文",
    email: "david@example.com",
    membership: "VIP",
    joinDate: Utilities.formatDate(new Date() , Session.getScriptTimeZone() , 'yyyy-MM-dd HH:mm:ss')
  };
    
  writeDataBuiltIn(userPath, userData);
  console.log("寫入測試完成!請到 Firebase 後台查看結果。");
}
讀取

function testReadDataBuiltIn() {

  const testPath = "customers/user_123";   
  const data = readDataBuiltIn(testPath);
  
  if (data !== null) {
    console.log("讀取成功!資料內容如下:");
    console.log(JSON.stringify(data, null, 2));
  } else {
    console.log("讀取失敗,請檢查權限或路徑。");
  }
}
更新

function testUpdateDataBuiltIn() {

  const userPath = "customers/user_123";
  
  // 假設原本資料有 name, email, membership。
  // 我們現在只想升級他的 membership,並新增一個 points 欄位,保留原本的 name 與 email。
  const updatePayload = {
    membership: "VVIP", // 更新現有欄位
    points: 500,        // 新增原本沒有的欄位
    lastUpdated: Utilities.formatDate(new Date() , Session.getScriptTimeZone() , 'yyyy-MM-dd HH:mm:ss')
  };
  
  updateDataBuiltIn(userPath, updatePayload);
  console.log("更新完成!請去 Firebase 檢查 user_123 的資料,原本的名字應該還在。");
}
刪除
function testDeleteDataBuiltIn() {
  const userPath = "customers/user_123";
  deleteDataBuiltIn(userPath);
  console.log("刪除完成!該節點已經從 Firebase 移除了。");
}