星期六, 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 移除了。");
}


星期六, 3月 21, 2026

[EFCore] Like

使用 EFCore 10 來整理 Linq to Entity 產生 TSQL like 語法應用

Linq to Entity 語法
using EFCoreLike.Models;
using Microsoft.EntityFrameworkCore;

namespace EFCoreLike
{
    internal class Program
    {
        static void Main(string[] args)
        {
            using var dbContext = new AdventureWorks2022DbContext();

            // StartWith
            var startWithResult = dbContext.Person
                .Where(p => p.LastName.StartsWith("Hen"))
                .ToList();

            // EndWith
            var endWithResult = dbContext.Person
                .Where(p => p.LastName.EndsWith("Hen"))
                .ToList();

            // Containes
            var containResult = dbContext.Person
                .Where(p => p.LastName.Contains("Hen"))
                .ToList();

            // EF.Functions.Like
            var functionsResult = dbContext.Person
                .Where(p => EF.Functions.Like(p.LastName, "H[a-c]n%"))
                .ToList();
        }
    }
}
產生 TSQL Like 語法觀察

StartWith 轉成 關鍵字%
SELECT [p].[BusinessEntityID], [p].[AdditionalContactInfo], [p].[Demographics], [p].[EmailPromotion], [p].[FirstName], [p].[LastName], [p].[MiddleName], [p].[ModifiedDate], [p].[NameStyle], [p].[PersonType], [p].[Suffix], [p].[Title], [p].[rowguid]
FROM [Person].[Person] AS [p]
WHERE [p].[LastName] LIKE N'Hen%'

EndWith 轉成 %關鍵字
SELECT [p].[BusinessEntityID], [p].[AdditionalContactInfo], [p].[Demographics], [p].[EmailPromotion], [p].[FirstName], [p].[LastName], [p].[MiddleName], [p].[ModifiedDate], [p].[NameStyle], [p].[PersonType], [p].[Suffix], [p].[Title], [p].[rowguid]
FROM [Person].[Person] AS [p]
WHERE [p].[LastName] LIKE N'%Hen'

Containes 轉成 %關鍵字%
SELECT [p].[BusinessEntityID], [p].[AdditionalContactInfo], [p].[Demographics], [p].[EmailPromotion], [p].[FirstName], [p].[LastName], [p].[MiddleName], [p].[ModifiedDate], [p].[NameStyle], [p].[PersonType], [p].[Suffix], [p].[Title], [p].[rowguid]
FROM [Person].[Person] AS [p]
WHERE [p].[LastName] LIKE N'%Hen%'

EFCore 2.0 開始支援 EF.Functions.Like 來自訂 TSQL Like 應用
SELECT [p].[BusinessEntityID], [p].[AdditionalContactInfo], [p].[Demographics], [p].[EmailPromotion], [p].[FirstName], [p].[LastName], [p].[MiddleName], [p].[ModifiedDate], [p].[NameStyle], [p].[PersonType], [p].[Suffix], [p].[Title], [p].[rowguid]
FROM [Person].[Person] AS [p]
WHERE [p].[LastName] LIKE N'H[a-c]n%'

EF 6.5.1 StartWith、EndWith 和 Contains 轉出的 TSQL 跟 EFCore 10 是相同的

星期二, 3月 17, 2026

[VS] 在方案總管的檔案下顯示符號

在 VS2026 更版資訊內看見 [在方案總管的檔案下顯示符號] 選項,覺得在 Windows Form 專案上還不錯用

在 .NET10 Window Form 內可以看見 Form1 底下還有很多資訊
VS 選項 => 專案和解決方案 => 一般 => 取消 [在方案總管的檔案下顯示符號],變更設定後需要重啟 VS2026
取消後 Form1 下資訊就不會顯示,但使用方案總管搜尋功能還是會顯示出來的喔
筆記當下 VS2026 版本為 V18.4.0,但之前版本就有才是

星期二, 3月 10, 2026

[C#] 自訂數值字串 - 零

平時都是用標準格式來格式化數值,突然需要應用上自訂格式情況

常用自訂格式
  • 0:零值預留位置
  • #:數字預留位置
  • .:小數點
  • ,:千分位
LinqPad 上執行

用 0 當成對象簡易紀錄一下
void Main()
{
	
	decimal 零 = 0m;

	零.ToString("#,#").Dump($"#,#:顯示空白");

	零.ToString("#,0").Dump("#,0:顯示一個 0");

	零.ToString("#,##0").Dump("#,##0:千分位寫法");
}
基本上用標準格式是最推薦的,能夠自行依據文化特性來進行轉換,並不是每個國家的千分位和小數點都是 , 和 . 的喔

星期四, 3月 05, 2026

[LINQ] TakeWhile、SkipWhile

實務上遇上 TakeWhile、SkipWhile 應用情境,字串資料為 [英文 + 數字] 組合,但英文和數字字元都是隨機長度,使用 TakeWhile 來取出隨機英語字串

使用 LINQPad 來記錄
void Main()
{
  string data = "ABCDE12345".Dump("測試資料");
  (new string(data.TakeWhile(char.IsLetter).ToArray())).Dump("TakeWhile:擷取字母字元,直至字元不是字母停止");
  (new string(data.SkipWhile(char.IsLetter).ToArray())).Dump("SkipWhile:跳過字母字元,直至字元不是字母開始擷取");
}

執行結果


星期三, 3月 04, 2026

[Gmail] 應用程式密碼

之前記錄過 [SQL] Database Mail 搭配 Gmail 發信 - 應用程式密碼 設定應用程式密碼,但現在在網頁上已經找不到滑鼠點擊進入點,可以透過兩種方式進入
  • 直接輸入網址:https://myaccount.google.com/apppasswords
  • 搜尋功能:輸入 [應用程式密碼] 就會出現
最後官方文件 - 使用應用程式密碼登入帳戶 內特別提出警告,自行斟酌使用囉
Important: App passwords aren't recommended and are unnecessary in most cases. To help keep your account secure, use "Sign in with Google" to connect apps to your Google Account.

星期二, 2月 24, 2026

[Docs] Markdown 功能

Google Docs 內有 Markdown 功能可以使用,必須手動去開啟,路徑為 工具 => 偏好設定 => 啟用 Markdowm

開啟後右鍵選單上就會有 Markdown 功能

另外編輯時也支援 Markdown 語法輸入,詳見官方文章介紹 - 在 Google 文件、簡報和繪圖中使用 Markdown 

星期五, 2月 20, 2026

[GAS] 這個應用程式是由 Google Apps Script 的使用者建立

把 [網頁應用程式] 部屬出去執行後,在網頁上方匯出出現 [這個應用程式是由 Google Apps Script 的使用者建立] 安全性警告,這個沒有辦法直接移除,常見繞道隱藏方式為把網頁內嵌在 Google Site 內

直接拿 [JS] 基礎練習 UI 畫面來呈現
XFrameOptionsMode
  • ALLOWALL:No X-Frame-Options header will be set. This will let any site iframe the page, so the developer should implement their own protection against clickjacking.
  • DEFAULT:Sets the default value for the X-Frame-Options header, which preserves normal security assumptions. If a script does not set an X-Frame-Options mode, Apps Script uses this mode as the default.
function doGet() {
  return HtmlService.createTemplateFromFile('index')
    .evaluate()
    .setTitle('圖書館館藏管理系統')
    .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}
一開始嚐試把 GAS 網頁應用程式內嵌在 Google Site 時,並沒有指定 ALLOWALL,但一樣可以正常顯示,大概是 DEFAULT 會把 Google 相關服務設定為白名單或信任服務之類,所以不需要特別指定,萬一是要把 GAS 網頁應用程式內嵌在其他平台,就一定要明確設定 ALLOWALL

GAS 警語隱藏是 Goolge Site 標誌,下面則是內崁的 GAS 網頁應用程式,已經不會出現官方制式警語   

星期三, 2月 18, 2026

[JS] 基礎練習

該筆記會在 GAS 上練習 js
  • 資料來源:陣列、物件概念
  • 基礎函示:push、filter 使用
  • DOM API:使用 API 來顯示資料,而非使用 innerHTML
  • addEventListener 註冊事件:DOMContentLoaded 和 Click 事件

gs code
/**
 * 處理 HTTP GET 請求
 * @return {HtmlOutput}
 */
function doGet() {
  return HtmlService.createTemplateFromFile('index')
    .evaluate()
    .setTitle('圖書館館藏管理系統');
}

/**
 * 嵌入 HTML 檔案內容
 * @param {string} filename 檔案名稱
 * @return {string} 檔案內容
 */
function include(filename) {
  return HtmlService.createHtmlOutputFromFile(filename).getContent();
}

index.html
<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
  </head>
  <body>
    <h2>新增書籍館藏</h2>
    <div class="input-group">
      <select class="bookCategory">
        <option value="程式語言">程式語言</option>
        <option value="文學小說">文學小說</option>
        <option value="商業理財">商業理財</option>
      </select>
      <input type="text" placeholder="書籍名稱" class="bookTitle">
      <input type="text" placeholder="作者" class="bookAuthor">
      <input type="button" class="addBtn" value="加入館藏">
    </div>

    <h2>館藏查詢與篩選</h2>
    <div class="filterGroup">
      <input type="button" value="全部">
      <input type="button" value="程式語言">
      <input type="button" value="文學小說">
      <input type="button" value="商業理財">
    </div>

    <ul class="bookList"></ul>

    <?!= include('javascript'); ?>
  </body>
</html>

js.html
<script>
/**
 * 書籍測試資料
 */
let libraryData = [
  { title: "深入淺出 JavaScript", author: "Eric Freeman", category: "程式語言" },
  { title: "哈利波特", author: "J.K. Rowling", category: "文學小說" },
  { title: "原子習慣", author: "James Clear", category: "商業理財" },
  { title: "Clean Code", author: "Robert C. Martin", category: "程式語言" }
];

// 選取 DOM 元素
const bookList = document.querySelector('.bookList');
const bookTitle = document.querySelector('.bookTitle');
const bookAuthor = document.querySelector('.bookAuthor');
const bookCategory = document.querySelector('.bookCategory');
const addBtn = document.querySelector('.addBtn');
const filterGroup = document.querySelector('.filterGroup');

/**
 * 使用純 DOM API 產生書籍列表
 * @param {Array} data 欲顯示的書籍陣列
 */
function renderLibrary(data) {
  // 清空目前列表
  while (bookList.firstChild) {
    bookList.removeChild(bookList.firstChild);
  }

  // 建立新節點
  data.forEach(function(book) {
    const li = document.createElement('li');
    
    // 建立書籍資訊容器
    const infoSpan = document.createElement('span');
    infoSpan.textContent = `[${book.category}] 《${book.title}》 - 作者:${book.author}`;
    
    li.appendChild(infoSpan);
    bookList.appendChild(li);
  });
}

/**
 * 新增書籍
 */
addBtn.addEventListener('click', function() {
  const title = bookTitle.value.trim();
  const author = bookAuthor.value.trim();
  const category = bookCategory.value;

  if (title === "" || author === "") {
    alert("請完整填寫書名與作者!");
    return;
  }

  const newBook = {
    title: title,
    author: author,
    category: category
  };

  libraryData.push(newBook);
  
  // 重新渲染並清空輸入框
  renderLibrary(libraryData);
  bookTitle.value = "";
  bookAuthor.value = "";
});

/**
 * 篩選資料功能
 */
filterGroup.addEventListener('click', function(e) {
  if (e.target.type !== "button") return;

  const filterType = e.target.value;
  let result;

  if (filterType === "全部") {
    result = libraryData;
  } else {
    result = libraryData.filter(function(book) {
      return book.category === filterType;
    });
  }

  renderLibrary(result);
});

/**
 * 初始化頁面
 */
document.addEventListener('DOMContentLoaded', function() {
  renderLibrary(libraryData);
});

</script>

星期一, 2月 09, 2026

[GAS] Library - 最新程式碼快照 (開發人員模式)

[GAS] 資料庫 (Library) 筆記時有提到版本內有 [最新程式碼快照 (開發人員模式)] 可以使用,但實際應用時發現,原來要存取 [最新程式碼快照 (開發人員模式)] 必須具備 [編輯者] 權限,當初分享 library 給別人使用,因為只授予 [檢視者],就無法存取

星期四, 2月 05, 2026

[GAS] Slide 合併資料

Slide 常見應用場景,在一個範本 Slide 上設定多個 {{文字說明}} 符號,並從 Google Sheet 內抓取資料後,根據實務需求取代 {{文字說明}},該筆記以課程結業證書為例筆記

Slide 範本

gs Code

把 [課程結業證書範本 Slide] 複製至新位置,該筆記是以根目錄內的日期資料夾 (EX:2026-02-05) 去,並把學員資料透過 replaceAllText() 更新至 Slide {{文字說明}}

{{文字說明}} 基本上在 Slide 內是唯一的識別文字就行,前後有沒有符號包起來不是重點,但既然官方教學都使用 {{}} 包起來,就延續該風格囉,Slide 內有三個文字說明,分別為 {{學員姓名}}、{{課程名稱}}、{{日期}}

replaceAllText(findText, replaceText) 函式
  • findText:以該筆記為例,是指 {{文字說明}}
  • replaceText:取代 {{文字說明}} 的內容
function SlideMergeData() {

  const presentation = SlidesApp.getActivePresentation();
  const timeZone = Session.getScriptTimeZone();
  const dateString = Utilities.formatDate(new Date(), timeZone, "yyyy-MM-dd");

  // 設定根 FolderID
  const rootFolderId = GetFolderID();
  const rootFolder = DriveApp.getFolderById(rootFolderId);

  // 檢查並刪除同名的日期資料夾
  const existingFolders = rootFolder.getFoldersByName(dateString);
  while (existingFolders.hasNext()) {
    const oldFolder = existingFolders.next();
    oldFolder.setTrashed(true); // 將舊資料夾移至垃圾桶
  }

  // 建立日期資料夾
  const dateFolder = rootFolder.createFolder(dateString);

  // 複製範本 Slide 來使用
  const templateFile = DriveApp.getFileById(presentation.getId());
  const studentName = "王小強";
  const copyFile = templateFile.makeCopy(studentName, dateFolder);

  // 開啟新 Slide 並進行文字替換
  const newPresentation = SlidesApp.openById(copyFile.getId());
  newPresentation.replaceAllText('{{學員姓名}}', studentName);
  newPresentation.replaceAllText('{{課程名稱}}', "Google Apps Script");
  newPresentation.replaceAllText('{{日期}}', dateString);
  newPresentation.saveAndClose();
}

執行結果

該筆記只有使用 replaceAllText() 取代文字,還有兩個主題分別為
  1. replaceAllShapesWithImage 取代圖片
  2. BatchUpdate:有效能考量使用,需要開啟 Google Drive API 服務
目前沒有使用到,單純紀錄

星期五, 1月 30, 2026

[GAS] Google Slide 轉圖檔

透過 Presentations.Pages.getThumbnail 把 Google Slide 轉成圖檔,精確說是把 Slide 的每一頁轉成縮圖

Request

參數 說明
presentationId Slide ID
pageObjectId Slide 內每頁投影片 ID
thumbnailProperties 縮圖屬性
mimeType 未指定的話,預設為 PNG
thumbnailSize 未指定的話,伺服器會選擇圖片的預設大小。有 LARGE (1600 像素)、MEDIUM (800 像素)、SMALL (200 像素) 可以選擇


Response

參數 說明
width 縮圖圖片的正向寬度 (以像素為單位)
height 縮圖圖片的正向高度 (以像素為單位)
contentUrl
縮圖伺服器網址,該縮圖只會留存 30 分鐘且只有請求者可以存取該圖片


限制

IntelliSense 或是文件上都會提到 Presentations.Pages.getThumbnail 是高成本讀取操作,在 Usage limits 有特別列出存取限制

限制 次數 說明
Per minute per project 300 次/分鐘 所有執行該 Script 使用者加總次數,每分鐘不能超過 300 次
Per minute per user per project 60 次/分鐘 一位使用者每分鐘最多只能請求 60 次


gs Code

使用 Presentations.Pages.getThumbnail 必須在服務內加入 Goolge Slides API 且產生的 Slide 每頁縮圖會存放在伺服器上,必須透過 response.contentUrl 取回圖片

function runSlideExportAndShare() {

  // 從 PropertiesService 取出 presentationId 和 folderId
  const scriptProperties = PropertiesService.getScriptProperties();
  const properties = scriptProperties.getProperties();
  const presentationId = properties["PresentationID"];
  const folderId = GetProperties("FolderID");

  convertSlidesToImagesAndShare(presentationId, folderId);
}

/**
 * 將 Google Slide 內的每一頁轉換為圖片,存儲於 Google Drive 上,並將圖片設定為「知道連結者皆可檢視」
 * * @param {string} presentationId 簡報 ID
 * @param {string} folderId 圖片存放資料夾 ID
 */
function convertSlidesToImagesAndShare(presentationId, folderId) {

  const presentation = SlidesApp.openById(presentationId);
  const slides = presentation.getSlides();
  const folder = DriveApp.getFolderById(folderId);

  slides.forEach((slide, index) => {
    const pageObjectId = slide.getObjectId();

    // 呼叫 Slides API 取得縮圖 URL
    const thumbnailResponse = Slides.Presentations.Pages.getThumbnail(
      presentationId,
      pageObjectId,
      {
        'thumbnailProperties.mimeType': 'PNG',
        'thumbnailProperties.thumbnailSize': 'LARGE'
      }

    );

    const imageUrl = thumbnailResponse.contentUrl;
    const imageName = `Slide_${index + 1}.png`;

    try {
      // 透過 UrlFetchApp 取回圖片
      const response = UrlFetchApp.fetch(imageUrl);
      const blob = response.getBlob().setName(imageName);

      // 在指定資料夾建立檔案
      const file = folder.createFile(blob);

      // 設定權限:知道連結的任何人 (ANYONE_WITH_LINK) 都可以檢視 (VIEW)
      file.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);

    } catch (e) {
      console.error(`處理第 ${index + 1} 頁時發生錯誤: ${e.message}`);
    }
  });
}

執行結果

Slide2Imge 內有兩頁投影片,轉出兩張圖片來

星期二, 1月 27, 2026

[GAS] 資料庫 (Library)

Google Apps Script 寫多會遇上不同專案會需要使用相同功能,例如 LineBot 發訊息通知,會希望把功能模組化來引用,在 [部屬] 功能上有 [資料庫 (library)] 來達到模組化效果,除了跨專案外,也可以跨帳號使用

Library 在 [部屬] 內的中文翻譯是資料庫,但官方文件上翻譯為程序庫,該筆記一律以 Library 來記錄

Library 範例 Code

在 JTLibrary 的 gs Code 內以格式化日期當成說明範例,筆記重點為
  • JSDoc 註解:引用該 function 會有對應說明
  • private function:function 名稱最後加上下底線 (_),外部就無法進行呼叫
/**
 * 格式化日期
 * @param {Date} date 
 * @return {string} 格式化後的字串 (yyyy-MM-dd HH:mm:ss)
 */
function formatCurrentDate(date = new Date()) {
  return Utilities.formatDate(date, Session.getScriptTimeZone(), "yyyy-MM-dd HH:mm:ss");
}

/**
 * private function,外部 Project 無法呼叫
 */
function internalHelper_() {
  console.log("該 function 僅內部使用");
}

部屬 Library

JTLibrary => 部屬按鈕 => 新增部屬作業 => 選取類型選擇 [資料庫],專案名稱即為 Library 預設名稱
加入 Library

JTLibrary 專案 => 選項 => ID,可以查到該 [指令碼 ID (Script ID)],要特別注意,該 ID 不是上圖 [部屬作業 ID] ,一開始搞錯 ID,一直無法引用

建立新 JTLibraryAction 專案 => 資料庫 => 點選右側加號 (+)

輸入 [指令碼 ID (Sceipt ID)] 後按下 [查詢] 按鈕,有存取權限情況下就會出現
  1. 已成功找到程式庫 JTLibrary 訊息
  2. 可用版本:可透過下拉選單決定要使用哪一個版本
  3. ID:預設為 Library 名稱,萬一該名稱不符合使用習慣,可以直接在這邊更改,不影響引用,事後修改該 ID 名稱的話,gs Code 裡面是不會自動一併修改,要手動修正為新 ID 名稱
存取權限

引用 Library 時萬一 [指令碼 ID (Script ID)] 錯誤或是沒有存取權限,會出現下圖錯誤訊息
授與權限方式即為分享該 Google Apps Script 專案,有兩種方式
  1. 直接把特定使用者、群組加入
  2. 把一般存取權設定為 [知道連結的任何人]
上述兩者都給 [檢視者] 即可,下圖是採取設定為 [知道連結的任何人] 且為 [檢視者]
呼叫 Library 內 function

在 JTLibraryAction 專案內的 gs Code 內輸入時,不會出現 private function internalHelper_

輸入 formatCurrentDate 時,因為有設定 JSDoc 所以會出現文字說明

實際執行
最新程式碼快照 (開發人員模式)

Library 內有個特殊版控版本 - 最新程式碼快照 (開發人員模式) Head (Development mode),不需要部屬出去就可以進行測試使用
刻意在 formatCurrentDate 內呼叫 internalHelper_ function 並只儲存專案未部屬
function formatCurrentDate(date = new Date()) {

  // 新增:呼叫 private function
  internalHelper_();

  return Utilities.formatDate(date, Session.getScriptTimeZone(), "yyyy-MM-dd HH:mm:ss");
}
實際執行
版本選擇

把 JTLibrary 部屬為新版本,可以在下圖中 [進行中] 看見兩個版本
JTLibraryAction Project 並不會自動套用最新 JTLibrary 部屬版本,是否需要進行升級就看個人需求
JSDoc 文件

JSDoc 說明文件除了 Code 輸入時可以看到,在 Library 內也提供一覽表可以參考

會用網頁形式來呈現文字說明


官方文件 Libraries 內注意事項

執行速度,直接把 Code 放在專案內比呼叫 library 來的快
Warning: A script that uses a library doesn't run as quickly as it would if all the code were contained within a single script project. Although libraries can make development and maintenance more convenient, use them sparingly in projects where speed is critical. Because of this issue, library use should be limited in add-ons.

library 命名,萬一已經存在同名 library,例如也取名 MailApp 的話,library 會覆蓋
If you use an identifier name that matches the name of an already existing service, such as MailApp, or a previously added library, then the library you have added most recently overrides the existing service or library.

星期五, 1月 16, 2026

[OneNote] 快捷鍵 (Accelerator Keys)

使用畫面剪輯功能要把截圖直接放進 OneNote 內,常常要去點擊按鈕才能使用,研究是否有快捷鍵可以直接使用,發現 OneNote 上快捷鍵是可以在軟體本身上查詢,以畫面剪輯功能為紀錄,該筆記使用環境為
  • Windows 11 24H2 26100.7623
  • OneNote 2016 桌機板
Step1:alt 按鍵

按下 alt 按鍵會顯示上方選單的提示字母
Step2:[插入] tab 標示為 N

按 N 進入 [插入] tab 並顯示各功能提示字母
Step3:[畫面剪輯] 功能表示為 R

按 R 觸發畫面剪輯功能,OneNote 視窗最小化,畫面會變成灰色,選擇要擷取畫面會直接插入筆記中

以該範例來說快捷鍵是 alt => n => r,有其他需求依循以上步驟就能找到 OneNote 畫面上的功能快捷鍵