使用 NotebookLM 工作室的輸出功能,EX:報告內的簡介文件,每次都得去調整語言選項,發現在設定內是可以直接變更
路徑為右上角設定 => 輸出語言
輸出語言內更改為 [中文 (繁體)] 即可,該選項預設就叫 [預設],但似乎沒有吃到 Google 帳號的語言設定
星期四, 5月 28, 2026
[SQL] Unique 條件約束
以往要在 Table 上建立 Unique 條件約束,都是以建立 Unqiue Index 來達成,在 Line 社群上看見討論提到,建立 Unique 條件約束,也會自動建立一個 Unique Index 出來,基本上從 Index 或是 Constraint 來建立,底層都會是 Unique Index
建立 Unique Constraint 語法
建立 Unique Constraint 語法
ALTER TABLE TableName
ADD CONSTRAINT AK_TableName_ColName UNIQUE (ColName);
GO
建立 Unique Index 語法CREATE UNIQUE INDEX UQ_TableName_ColName
ON dbo.TableName (ColName)
GO
完整範例
USE AdventureWorks2025;
GO
DROP TABLE IF EXISTS tblUniqueDemo
GO
CREATE TABLE tblUniqueDemo
(
ID int NOT NULL CONSTRAINT PK_tblUniqueDemo PRIMARY KEY,
Email VARCHAR(100),
Phone VARCHAR(20)
);
GO
-- 建立 Unique Constraint 語法
ALTER TABLE tblUniqueDemo
ADD CONSTRAINT AK_tblUniqueDemo_Email UNIQUE (Email);
GO
-- 建立 Unique Index 語法
CREATE UNIQUE INDEX UQ_tblUniqueDemo_Phone
ON dbo.tblUniqueDemo (Phone)
GO
-- 透過 sys.Indexes 查詢
SELECT
[name] ,
[type] ,
[type_desc] ,
is_primary_key ,
is_unique ,
is_unique_constraint
FROM sys.indexes
WHERE object_id = object_id('tblUniqueDemo')
sys.indexes 內有兩個 Unique 相關欄位資訊,分別為- is_unique:Index is unique.
- is_unique_constraint:Index is part of a UNIQUE constraint.,白話翻譯就是是否從 Constraint 語法建立出來的
星期一, 5月 25, 2026
[Gemini] 連結的應用程式
在網路上看見有人提到 Gemini 沒有辦法直接讀取 Google Workspace 服務,要在 Gemini 內開啟 [連結的應用程式] 才可以讀取
以讀取 Gmail 存取為例
星期四, 5月 21, 2026
[C#] Math.DivRem
以商業邏輯出貨包裝數量為例子來記錄不同計算寫法
namespace ConsoleApp1
{
internal class Program
{
static void Main(string[] args)
{
int 總出貨數量 = 24;
int 預計包裝數量 = 5;
// 傳統寫法一:/ 搭配 %
int 實際包裝數量_1 = 總出貨數量 / 預計包裝數量;
int 剩餘數量_1 = 總出貨數量 % 預計包裝數量;
if (剩餘數量_1 > 0)
實際包裝數量_1++;
// 傳統寫法二:Math.Ceiling 無條件進位
int 實際包裝數量2 = (int)Math.Ceiling((double)總出貨數量 / 預計包裝數量);
// .NET Framework:透過 out 回傳剩餘數量
int 實際包裝數量_3 = Math.DivRem(總出貨數量, 預計包裝數量, out int 剩餘數量_3);
if (剩餘數量_3 > 0)
實際包裝數量_3++;
// .NET 6 新增:使用 Tuple 回傳
var (實際包裝數量_4, 剩餘數量_4) = Math.DivRem(總出貨數量, 預計包裝數量);
if (剩餘數量_4 > 0)
實際包裝數量_4++;
Console.WriteLine($"/ 搭配 %: {實際包裝數量_1}");
Console.WriteLine($"Math.Ceiling 無條件進位: {實際包裝數量2}");
Console.WriteLine($"透過 out 回傳剩餘數量: {實際包裝數量_3}");
Console.WriteLine($"使用 Tuple 回傳: {實際包裝數量_4}");
}
}
}
星期二, 5月 19, 2026
[GAS] Utilities.getUuid()
AI 產出 Code 在 js 刪除 Google Sheet 資料時,常常會看見把資料反向排序後,再依序刪除做法,避免破壞 Google Sheet RowPosition,想說要給在新增資料時給定唯一識別碼,發現 GAS 內建就有 Utilities.getUuid() 可以使用,這樣就可以明確直接刪除特定資料
星期六, 5月 16, 2026
[GAS] OTP 登錄
在了解 [GAS] CacheService 如何使用後,該筆記範例是 OTP 登錄範例,OTP (One Time Password) 相關操作都會在 CacheService 上進行
gs Code
收到 OTP email
使用 OTP 密碼登錄
Index.html 不是該筆記重點,就不放在文章上囉
gs Code
/**
* 處理 Web App 的 HTTP GET 請求,回傳使用者介面。
* @param {Object} e - 事件物件 (GET 參數)。
* @return {HtmlOutput} 渲染後的 HTML 畫面。
*/
function doGet(e) {
return HtmlService.createHtmlOutputFromFile('Index')
.setTitle('OTP 登入')
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}
/**
* 產生並發送 OTP 至指定 Email,同時將 OTP 寫入 CacheService。
* @param {string} email - 使用者的電子郵件地址。
* @return {Object} 包含執行結果的狀態物件 {success: boolean, message: string}。
*/
function generateAndSendOTP(email) {
try {
if (!email || !email.includes('@')) {
return { success: false, message: '請提供有效的 Email 地址格式' };
}
// 產生 6 位數隨機 OTP
const otp = Math.floor(100000 + Math.random() * 900000).toString();
// 取得 Script 層級的 Cache,將 email 當作 Key,OTP 當作 Value
const cache = CacheService.getScriptCache();
// 設定快取保留時間為 300 秒 (5 分鐘)
cache.put(email, otp, 300);
// 寄送包含 OTP 的電子郵件
const subject = '您的系統登入一次性密碼 (OTP)';
const body = '您好,\n\n您的登入驗證碼為:' + otp + '\n此驗證碼將於 5 分鐘後失效。\n\n若非本人操作請忽略此信件。';
GmailApp.sendEmail(email, subject, body);
return { success: true, message: 'OTP 已發送至您的信箱,請於 5 分鐘內輸入。' };
} catch (error) {
return { success: false, message: '系統錯誤:' + error.message };
}
}
/**
* 驗證使用者輸入的 OTP 是否與 CacheService 中的記錄相符。
* @param {string} email - 使用者的電子郵件地址。
* @param {string} otp - 使用者輸入的 OTP。
* @return {Object} 包含驗證結果的狀態物件 {success: boolean, message: string}。
*/
function verifyOTP(email, otp) {
try {
const cache = CacheService.getScriptCache();
const cachedOtp = cache.get(email);
if (!cachedOtp) {
return { success: false, message: 'OTP 已過期或不存在,請重新獲取。' };
}
if (cachedOtp === otp) {
// 驗證成功後,基於安全性應立即移除 Cache 中的 OTP
cache.remove(email);
return { success: true, message: '驗證成功!歡迎登入系統。' };
} else {
return { success: false, message: 'OTP 錯誤,請重新輸入。' };
}
} catch (error) {
return { success: false, message: '驗證時發生系統錯誤:' + error.message };
}
}
執行結果
收到 OTP email
使用 OTP 密碼登錄
Index.html 不是該筆記重點,就不放在文章上囉
星期二, 5月 12, 2026
[SQL] 轉型為 bit
無意中在自家系統內看見下述 TSQL 語法,整數 2 轉型 bit 竟然不會出現轉型錯誤
SELECT CAST(2 as bit)
在官方文件 - bit 內有這段說明
The bit data type can be used to store Boolean values. The string values TRUE and FALSE can be converted to bit values: TRUE is converted to 1, and FALSE is converted to 0.基本上只要不是 0 就是轉型為 1 就是,測試範例如下
Converting to bit promotes any nonzero value to 1.
The bit data type supports the COUNT function. However, other standard aggregate functions, like SUM, AVG, MIN, and MAX, don't directly support the bit data type.
SELECT
source as 整數 ,
CAST(source as bit) AS 整數轉型bit
FROM
(
SELECT 0 AS source UNION ALL
SELECT 1 UNION ALL
SELECT 2 UNION ALL
SELECT 100 UNION ALL
SELECT -1
) AS T
星期六, 5月 09, 2026
[GAS] clasp deploy
部屬官方文件說明
A release that makes a specific version of your script available for users. A deployment has a unique URL or ID.
GAS IDE 部屬有三種模式,分別為
- 新增部屬作業:詳見 [GAS] 部屬網頁程式
- 管理部屬作業:詳見 [GAS] 管理部屬作業
- 測試部屬作業:未部屬狀態下可以執行 Code,會得到一個 dev 結尾網址,該位址不會變化,Code 有進行任何更改的話,可以在瀏覽器上按 F5 refresh 執行程式
而 claps deploy 有兩種語法應用
- clasp deploy 對應 [新增部屬作業],部屬後每次都會取得一個新的網址
- clasp deploy --deploymentId <deployment-id> 對應 [管理部屬作業],部屬後網址不變
星期二, 5月 05, 2026
[GAS] 專案紀錄
學習 clasp 時發現有 clasp version 語法可以使用,但是腦海裡對應不上是 GAS IDE 上哪一個功能
官方文件 - 部署作業與版本 說明
官方文件 - 部署作業與版本 說明
A static snapshot of your script project's code. Once created, a version is immutable. Think of a version as a "save point" in your development history.仔細去查發現,原來在 GAS IDE 上繁體中文是 [專案紀錄],想說會不會是翻譯過來時差太多,特別切成英語版本來查看,還真的是 Project History
星期一, 5月 04, 2026
[GAS] Template 解析異常
請 AI 寫小範例驗證功能時,發生下述錯誤訊息
部屬執行後都出現下面錯誤訊息
錯誤竟然在 Index.html 內的註解,傻眼
正確應該是下圖才是,這樣才會正確解析 < 和 > 符號,但這是註解說,本來就沒有要顯示,真的打在有趣的點
SyntaxError: Unexpected token ';' (第 18 行,檔案名稱:程式碼)該範例就單純在 code.gs 內的參數傳遞至 Index.html 而已,因為錯誤訊息一直指向 code.gs 內,所以第一時間也沒有意識到錯誤是在 Index.html 內,都修錯方向
部屬執行後都出現下面錯誤訊息
錯誤竟然在 Index.html 內的註解,傻眼
正確應該是下圖才是,這樣才會正確解析 < 和 > 符號,但這是註解說,本來就沒有要顯示,真的打在有趣的點
星期二, 4月 28, 2026
[GAS] CacheService
Google Apps Script 本身有快取服務 CacheService 可以直接使用,分別為
CRUD Sample Code
直接請 AI 產出紀錄
- getDocumentCache()
- getScriptCache()
- getUserCache()
Gets the cache instance scoped to the script. Script caches are common to all users of the script. Use these to store information that is not specific to the current user.CacheServide 最多儲存 1,000 筆,超過 1,000 筆的話會保留 expirationInSeconds 最長的 900 筆資料
CRUD Sample Code
直接請 AI 產出紀錄
/**
* 示範 Google Apps Script 的 CacheService 混合寫入操作
* 包含 putAll (批量新增)、put (單筆新增)、getAll (批量讀取)、刪除與驗證
* * @returns {void}
*/
function demoCacheServiceMixedCRUD() {
// 1. 初始化 ScriptCache
const cache = CacheService.getScriptCache();
// 2. 使用 putAll() 批量新增三筆資料
// 將快取資料整理成一個 Object (KeyValue)
const batchData = {
'user_01': 'Alice',
'user_02': 'Bob',
'user_03': 'Charlie'
};
// 寫入批次資料,設定存活時間為 600 秒 (預設值)
cache.putAll(batchData, 600);
console.log('✅ 已使用 putAll() 成功新增三筆資料至 Cache。');
// 3. 使用 put() 追加新增單一筆資料
const singleKey = 'user_04';
cache.put(singleKey, 'David', 600);
console.log(`✅ 已使用 put() 成功追加一筆資料至 Cache:${singleKey}`);
// 4. 取得全部資料 (Read)
// 將批次寫入的 keys 與單筆寫入的 key 合併成一個陣列來查詢
const allKeysToFetch = ['user_01', 'user_02', 'user_03', 'user_04'];
const allData = cache.getAll(allKeysToFetch);
console.log('🔍 取得目前的資料狀態:', allData);
// 5. 移除一筆資料 (Delete)
const targetToRemove = 'user_02'; // 準備移除 Bob
cache.remove(targetToRemove);
console.log(`🗑️ 已執行移除動作,目標:${targetToRemove}`);
// 6. 取得上一筆移除資料,驗證該筆資料已不存在
const removedData = cache.get(targetToRemove);
if (removedData === null) {
console.log(`⚠️ 驗證成功:${targetToRemove} 已經被移除 (查詢結果為 null)。`);
} else {
console.log(`❌ 錯誤:${targetToRemove} 依然存在,值為:`, removedData);
}
}
put(key, value, expirationInSeconds)- key:最多 250 字元
- value:每一筆資料最大是 100KB
- expirationInSeconds:預設值為 600 秒 (10 分鐘) ,最少為 1 秒、最長為 21,600 秒 (6 小時),指定的到期時間僅供參考,如果快取大量資料,系統可能會提前移除快取資料 (有 1,000 筆快取限制)
星期一, 4月 20, 2026
[SQL] 稀疏欄位 (Sparse Column)
稀疏欄位 (Sparse Column) 是 MS SQL 2008 功能,官方文件說明
重構某單位 AP 時發現 Table 整併後,配合商業邏輯而新增欄位內有 90% 資料會是 null,原本是想要多拆一個 Table 來存放節省空間,但該情境似乎蠻符合 Sparse Column 就來使用看看囉
Sparse columns are ordinary columns that have an optimized storage for null values. Sparse columns reduce the space requirements for null values at the cost of more overhead to retrieve non-NULL values. Consider using sparse columns when the space saved is at least 20 percent to 40 percent.Sparse 關鍵字
USE AdventureWorks2025;
GO
CREATE TABLE DocumentStore
(DocID int PRIMARY KEY,
Title varchar(200) NOT NULL,
ProductionSpecification varchar(20) SPARSE NULL, -- SPARSE Column
ProductionLocation smallint SPARSE NULL, -- SPARSE Column
MarketingSurveyGroup varchar(20) SPARSE NULL ) ; -- SPARSE Column
GO
Sparse Column 查詢
use AdventureWorks2025
go
SELECT
[name] AS ColumnName ,
is_nullable ,
is_sparse ,
is_column_set
FROM sys.columns
WHERE object_name(object_id) = 'DocumentStore'
限制- Sparse columns are a property of the storage layer, rather than the logical table. Therefore a SELECT ... INTO statement does not copy over the sparse column property into a new table.
- A sparse column must be nullable and cannot have the ROWGUIDCOL or IDENTITY properties. A sparse column cannot be of the following data types: text, ntext, image, timestamp, user-defined data type, geometry, or geography; or have the FILESTREAM attribute.
- A sparse column cannot have a default value.
- Sparse columns are incompatible with data compression. Therefore sparse columns cannot be added to compressed tables, nor can any tables containing sparse columns be compressed.
- Changing a column from sparse to non-sparse, or non-sparse to sparse, requires changing the storage format of the column. The SQL Server Database Engine uses the following procedure to accomplish this change:
- Adds a new column to the table in the new storage size and format.
- For each row in the table, updates and copies the value stored in the old column to the new column.
- Removes the old column from the table schema.
- Rebuilds the table (if there is no clustered index) or rebuilds the clustered index to reclaim space used by the old column.
重構某單位 AP 時發現 Table 整併後,配合商業邏輯而新增欄位內有 90% 資料會是 null,原本是想要多拆一個 Table 來存放節省空間,但該情境似乎蠻符合 Sparse Column 就來使用看看囉
星期四, 4月 16, 2026
[GAS] 網頁應用程式 - Google 帳號
以往把 GAS 發佈為網頁應用程式時,設定值都是
使用者 Email
透過 getActiveUser() 來存取 Google 帳號 Email
網頁存取名單
在 GAS PropertyService 內設定存取存取名單,存取名單已逗號 (,) 分隔,例如:123@gmail.com,456@gmail.com,當然也可以放在 Google Sheet 或第三方資料庫來管理存取名單
gs Code
實際執行
進入網頁時假如沒有登錄 Google 帳號,會出現 Google 登錄畫面
登錄 Google 帳號後則是檢查該帳號是否在存取白名單內
限制
實作發現,原來 getActiveUser() 在 [執行身份:我 (gmail 信箱)] 時,使用者執行網頁時會回傳空值,該情況只有執行身份信箱使用者可以透過 getActiveUser() 取得 gmail 資訊
- 執行身份:我 (gmail 信箱)
- 誰可以存取:所有人
- 執行身份:存取網頁應用程式的使用者
- 誰可以存取:所有已登入 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
限制
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);
}
}
- 延伸閱讀
- [GAS] Slide 合併資料
- 參考資料
- D26 自動產生文件 & PDF
星期二, 4月 07, 2026
[Forms] 設定截止日或回覆限制
看 AI 教學時,無意中看到老師操作畫面上 Google Forms 有表單截止設定,沒想到自動關閉表單已經是內建功能囉
把表單發佈出去後就可以看見 [設定截止日或回覆限制],有兩種設定方式,分別為
把表單發佈出去後就可以看見 [設定截止日或回覆限制],有兩種設定方式,分別為
- 指定日期
- 直到指定回覆數之後
輸入 3 則回覆後表單就會自動關閉,再進入表單就會看見下圖
之前介紹過 Google Forms 外掛 [Forms] 外掛-formLimiter 就只剩下 spreadSheet cell value 特定條件結束表單功能囉
之前介紹過 Google Forms 外掛 [Forms] 外掛-formLimiter 就只剩下 spreadSheet cell value 特定條件結束表單功能囉
星期一, 4月 06, 2026
[Line] 使用預設瀏覽器開啟連結
在 iphone 上使用診所 Line 官方帳號要進行預約時發現,網頁沒有用手機上預設瀏覽器開啟,Line 有內建瀏覽器,之前完全沒有發現這點
在手機版本 Line 上,設定 => Line Labs => 使用預設瀏覽器開啟連結,該選項預設是開啟的
在手機版本 Line 上,設定 => Line Labs => 使用預設瀏覽器開啟連結,該選項預設是開啟的
星期六, 4月 04, 2026
[JS] localStorage
在 GAS 上實作待辦事項,並使用 localStorage 來存取資料,該筆記有下列重點
Index.html
Stylesheet.html
Code.gs
觀察儲存在 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 為手動加入
在指令碼指令內建立 Realtime Database URL
CRUD Code
寫入
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 移除了。");
}

















































