星期四, 10月 30, 2025

[git] ignorecase 區分大小寫

在 VS (2022 17.14.15) 內 F2 更名,但沒想到版控內的檔案名稱大小寫卻還是維持在原本名稱,例如 Abcdef 更名為 AbcDef,在 VS 內是正常,但是 git change 內卻還是一直顯示 Abcdef

下圖為模擬該情況
查資料發現原在 NTFS 檔案系統預設是不區分大小寫,git 預設會沿用該設定值,下 git config --get core.ignorecase 去查詢設定值
最後就直接下 git mv 語法直接變更檔案大小名稱

相關語法整理
# 設為[區分] 大小寫
git config core.ignorecase false

# 設為[忽略] 大小寫(Windows/macOS 預設)
git config core.ignorecase true

# 查詢目前設定值
git config --get core.ignorecase

星期二, 10月 28, 2025

[GAS] LineBot 取得使用者名稱

Line 有提供 profile API 可以取得使用者名稱,實務情況常見於使用者加入好友當下觸發,把 userID 和 displayName 紀錄進好友資訊

profile API
https://api.line.me/v2/bot/profile/{userId}
Response

呼叫成功 status code 會回傳 200 並回傳下列 json
{
  "displayName": "LINE taro",
  "userId": "U4af4980629...",
  "language": "en",
  "pictureUrl": "https://profile.line-scdn.net/ch/v2/p/uf9da5ee2b...",
  "statusMessage": "Hello, LINE!"
}
其中 pictureUrl 和 statusMessage 要使用者有設定才會有該資訊

Error Response

呼叫後的 status code 有 400 和 404
  • 400:不是有效的 userID
  • 404 則有下列四種可能
    • userID 不存在
    • 使用者沒有同意授權可以取得 profile 資訊
    • 該使用者沒有加 LineBot 為好友
    • 該使用者封鎖 LineBot
// 400 json
{
  "message": "The value for the 'userId' parameter is invalid"
}

// 404 json
{
  "message": "Not found"
}

Google Apps Script .gs Code
function doPost(e) {

  try {

    let contents = JSON.parse(e.postData.contents);
    let event = contents.events[0];
    let userID = event.source.userId

    // 加 LineBot 為好友
    if (event.type === 'follow') {
      let profile = getProfile(userID);
      let displayName = profile.displayName;
      WriteLog([new Date(), '加入好友', userID, displayName])
      return;
    }

    // 發送文字訊息
    if (
      event.type === "message" &&
      event.message &&
      event.message.type === "text") {

      let text = contents.events[0].message.text;
      WriteLog([new Date(), "文字訊息", userID, text]);
      return;
    }

    WriteLog([new Date(), "無對應事件"]);
    
  } catch (e) {
    WriteLog([new Date(), "錯誤訊息:", e.toString()]);
  }
}

function getProfile(userId) {

  // 從 PropertiesService 取出 LineChannelAccessToken
  let lineChannelAccessToken = getProperties("LineChannelAccessToken");
  let url = `https://api.line.me/v2/bot/profile/${userId}`;

  let options = {
    'method': 'GET',
    'headers': {
      'Authorization': 'Bearer ' + lineChannelAccessToken,
      'Content-Type': 'application/json'
    },
    'muteHttpExceptions': true
  };

  try {

    let response = UrlFetchApp.fetch(url, options);
    let responseCode = response.getResponseCode();
    let responseText = response.getContentText();

    if (responseCode === 200) {
      return JSON.parse(responseText);
    } else {
      WriteLog([new Date() , `Get Profile API 呼叫失敗。Code: ${responseCode}, Response: ${responseText}`]);
      return null;
    }

  } catch (e) {

    WriteLog([new date() , `執行 UrlFetchApp 發生錯誤: ${e.toString()}`])
    return null;
  }
}

function WriteLog(logData) {

  if (!logData || !Array.isArray(logData)) {
    Logger.log("WriteLog 錯誤:傳入的參數不是有效的陣列。");
    return;
  }

  try {

    // 從 PropertiesService 取出 SheetID 和 SheetName
    let sheetID = getProperties("SheetID");
    let sheetName = getProperties("SheetName");

    let ss = SpreadsheetApp.openById(sheetID);
    let sheet = ss.getSheetByName(sheetName)

    if (!sheet) {
      Logger.log(`WriteLog 錯誤:找不到名稱為 ${SHEET_NAME} 的工作表。`);
      return false;
    }

    sheet.appendRow(logData);
    Logger.log(`成功寫入 Log: ${logData.join(', ')}`);
  } catch (e) {
    Logger.log("WriteLog 寫入失敗: " + e.toString());
  }

}

function getProperties(key) {
  let scriptProperties = PropertiesService.getScriptProperties();
  let properties = scriptProperties.getProperties();
  return properties[key];
}
使用者把 LineBot 加入好友並傳送文字訊息測試
UrlFetchApp.fetch muteHttpExceptions 參數

預設值為 false,以該範例為例來說明就是當 status code 為 400 或 404 時,GAS 會停止運行,設定為 true 會回傳 status code 來表示
 
設定為 false,回傳 400 時 GAS 停止運行,是停止運行而不是拋出 exception,try catch 是無法阻止停止運行的,如下圖
設定為 true 可以判斷 status code 並把錯誤訊息顯示出來,當然也可以寫進 Log 內

星期六, 10月 25, 2025

[GAS] Google Chart

在 Goolge App Script 內使用 [Google Chart 工具] 來產生圖表,以官方文章 - Chart Service 來練習,該範例是把圖表顯示在網頁上,另外自行加上把圖表嵌在 Email 內寄出

.gs Code 

產生圖表細節就直接寫在 Code 內的備註,基本上就是分兩個步驟
  • 第一步驟:該範例是直接寫死資料,實務上可能是從 Google Sheets 內取得
  • 第二步驟:把資料塞進 chart 去並進行 chart 相關顯示細節設定
function doGet() {

  let areaChart = GetAreaChart();
  let areaChartImage = areaChart.getAs('image/png'); // 回傳 Blob
  let imageData = Utilities.base64Encode(areaChartImage.getBytes());
  let imageUrl = "data:image/png;base64," + encodeURI(imageData);

  let htmlOutput = HtmlService.createHtmlOutput().setTitle('Hello Goolge Chart'); // 網頁 Tab 名稱
  htmlOutput.append("在伺服器端產生圖表 (Render chart server side): <br/>");
  htmlOutput.append("<img border=\"1\" src=\"" + imageUrl + "\">");
  return htmlOutput;
}

function SendEmailWithChart() {

  let areaChart = GetAreaChart();
  let areaChartImage = areaChart.getAs('image/png');
  let subject = `Google Chart 測試 - ${new Date().toLocaleDateString('zh-TW')}`;
  let htmlBody = `
    <html><body>
      <p><img src="cid:chart" style="width:100%; max-width:600px;"></p>
    </body></html>
  `;

  MailApp.sendEmail({
    to: 收件人信箱,
    subject: subject,
    htmlBody: htmlBody,
    inlineImages: {
      chart: areaChartImage
    }
  });
}

function GetAreaChart() {

  let data = Charts.newDataTable()
    .addColumn(Charts.ColumnType.STRING, '月份')
    .addColumn(Charts.ColumnType.NUMBER, '實體販售')
    .addColumn(Charts.ColumnType.NUMBER, '線上商城')
    .addRow(['一月', 10, 1])
    .addRow(['二月', 12, 1])
    .addRow(['三月', 20, 2])
    .addRow(['四月', 25, 3])
    .addRow(['五月', 30, 4])
    .build();

  let chart = Charts.newAreaChart()
    .setDataTable(data)
    .setStacked()           // 堆疊線條 (stacked lines):資料累加 (實體販售 + 線上商城)
    .setRange(0, 40)        // 設定圖表 Y 軸 數值,範圍從 0 到 40
    .setTitle('每月銷售量')  // 設定圖表標題
    .build();

  return chart;
}
網頁顯示
Email 顯示

星期五, 10月 24, 2025

[GAS] 管理部屬作業

在之前學習時都以為 Code 變更後,只能使用 [新增部屬作業] 來部屬 Code,這次上課發現原來 [管理部屬作業] 也是能夠部屬 Code,透過 [管理部屬作業] 最大優點是 url 固定,不因部屬 Code 而改變,該 url 假如是應用在外部服務來進行呼叫,會更顯重要性,例如 LineBot 上 WebHook,每次 Code 變化部屬後,不需要上 Line Developer 上去變更 WebHook 連結

GAS 畫面右上角的 [部屬按鈕] => [管理部屬作業]

管理部屬作業內目前有一個部屬為 [21 版-Demo Init],左方 [進行中] 只會顯示 [Demo Init] 字樣 
點擊 [編輯] => [版本] 內選擇 [建立新版本] => 部屬,[版本] 選項內,一進入可編輯時,會只顯示 [21 版],必須點選後往上拉,才可以看見 [建立新版本] 選項

上步驟在 [版本] 內選擇 [建立新版本] 後,在說明內打上 [部屬後 URL 不改變],方便辨識

部屬後可以在  [進行中] 看見 [22 版-部屬後 URL 不改變],而在 [已封存] 內看見 [21 版-Demo Init]

星期二, 10月 21, 2025

[GAS] 搜尋資料

以會員登錄系統來記錄,登錄時搜尋使用者 email 是否存在 Google Sheets 內並回傳整筆資料或更新該筆資料

Google Sheets 資料

寫法一:find

使用 getDataRange 搭配 getValues 讀取全部資料 (包含標題) 進記憶體,再透過 find 來找出該筆資料
function 寫法一(email) {

  let sheet = GetSheet_Member();
  let dataRange = sheet.getDataRange();
  let values = dataRange.getValues();
  let row = values.find(r => r[1] === email);
  if (!row) {
    return {
      success: false,
      error: "該 email 不存在"
    };
  }

  return {
    success: true,
    data: {
      姓名: row[0],
      電子信箱: row[1]
    }
  }
}

寫法二:findIndex

使用 getRange() 搭配 getLastRow() 明確指定範圍來讀取全部資料 (不包含標題) 進記憶體,再透過 findIndex 來找出該筆資料,因為是抓取全部資料,所以 findIndex 找出該筆資料的 rowIndex 後, rowIndex + [標題 Row] + [rowIndex 和 rowPosition 起始點差值] ,即為該筆資料在 Sheet 內的 RowPosition
function 寫法二(email) {
  let sheet = GetSheet_Member();
  let lastRow = sheet.getLastRow();
  let limitedRange = sheet.getRange(`A2:B${lastRow}`);
  let values = limitedRange.getValues();
  let rowIndex = values.findIndex(r => r[1] === email);
  if (rowIndex === -1) {
    return {
      success: false,
      error: "該 email 不存在"
    };
  }

  let rowPosition = rowIndex + 2; // 2 為 [標題 Row] + [rowIndex 和 rowPosition 起始差值]
  let columnC = 3;
  sheet.getRange(rowPosition, columnC).setValue("是");
  return {
    success: true,
    error: "更新成功"
  };
}

寫法三:createTextFinder

使用 createTextFinder() 直接搜尋 Google Sheet 內資料,沒有把全部資料拉進記憶體內進行搜尋,詳見 [GAS] TextFinder 筆記
function 寫法三(email) {

  let sheet = GetSheet_Member();
  let columnB = sheet.getRange("B:B");
  let textFinder = columnB.createTextFinder(email);
  let foundRange = textFinder
    .matchEntireCell(true)
    .findNext();

  if (!foundRange) {
    return {
      success: false,
      error: "該 email 不存在"
    };
  }

  let rowPosition = foundRange.getRow();
  let columnA = 1;
  let rangeA = sheet.getRange(rowPosition, columnA);

  return {
    success: true,
    data: {
      姓名: rangeA.getValue(),
      電子信箱: email
    }
  }
}

星期四, 10月 16, 2025

[ADO.NET] Copy

之前使用 DataTable.Copy() 時,整個 DataTable 的 DataRow 都是處於 unchange 狀態,剛好遇上資料修改狀態下要使用 DataTable.Copy(),驗證 DataTable.Copy() 會連 DataRow 狀態會一併複製過去
namespace CopyLab
{
    internal class Program
    {

        static void Main(string[] args)
        {
            DataTable dt = new DataTable("手動建立資料的 DataTable");
            dt.Columns.Add("ID", typeof(int));
            dt.Columns.Add("Data", typeof(string));
            dt.Rows.Add("1", "原有資料");
            dt.Rows.Add("2", "原有資料");
            dt.AcceptChanges();

            // 刪除第一筆資料
            dt.Rows[0].Delete();

            // 修改第二筆資料
            dt.Rows[1]["Data"] = "更新資料";

            // 新增第三筆資料
            dt.Rows.Add("3", "新增資料");

            ShowData(dt);

            DataTable copyDataTable = dt.Copy();
            copyDataTable.TableName = "透過 Copy() 產生";
            ShowData(copyDataTable);
        }

        private static void ShowData(DataTable dt)
        {
            string info = string.Empty;

            info = $"Table:{dt.TableName}" + Environment.NewLine;
            DataRowVersion version;
            foreach (DataRow dr in dt.Rows)
            {
                version = dr.RowState == DataRowState.Deleted ? DataRowVersion.Original : DataRowVersion.Current;
                info += $"ID:{dr["ID", version]} - Data:{dr["Data", version]} - RowState:{dr.RowState}" + Environment.NewLine;
            }
            Console.WriteLine(info);
        }
    }
}
驗證結果是會連 DataRow 狀態都複製過去

星期三, 10月 15, 2025

[GAS] TextFinder

在 Google Apps Script 內可以透過 TextFinder 來尋找或取代 Google Sheets 內資料

SampleData
.gs Code

總共紀錄 5 個使用範例,後續會一一紀錄
let searchText = "蘋果";
let sheetName = "SampleData"
let columnBRange = "B1:B10";
let highlightColor = "lightgreen";

function FindFirstData() {

  let range = SpreadsheetApp.getActiveSpreadsheet()
    .getSheetByName(sheetName)
    .getRange(columnBRange)
    .createTextFinder(searchText)
    .findNext();

  let exist = range ? true : false
  Logger.log(exist);

  if (exist)
    Logger.log(range.getA1Notation());
}

function AppleInColumnB() {
  
  let rangeList = SpreadsheetApp.getActiveSpreadsheet()
    .getSheetByName(sheetName)
    .getRange(columnBRange)
    .createTextFinder(searchText)
    .matchEntireCell(true)
    .findAll()
    .map((r) => r.getA1Notation());

  Logger.log(rangeList);
}

function FullTextSearch() {

  let rangeList = SpreadsheetApp.getActiveSpreadsheet()
    .getSheetByName(sheetName)
    .createTextFinder(searchText)
    .findAll();

  rangeList.forEach(range => {
    range.setBackground(highlightColor);
  });
}

function Regular() {
  
  let regex = "紅|綠"; 

  let rangeList = SpreadsheetApp.getActiveSpreadsheet()
    .getSheetByName(sheetName)
    .getRange(columnBRange)
    .createTextFinder(regex)
    .useRegularExpression(true)
    .findAll()
    .map((r) => r.getA1Notation());

  Logger.log(rangeList);
}

function Update() {

  let sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(sheetName);
  if (!sheet) {
    Logger.log("找不到名為 SampleDate 的工作表。請檢查工作表名稱。");
    return;
  }

  let rangeToSearch = sheet.getRange(columnBRange);
  let replaceText = "Apple";
  let textFinder = rangeToSearch.createTextFinder(searchText);

  let rangesFound = textFinder.findAll();
  if (rangesFound.length === 0) {
    Logger.log(`在範圍 "${columnBRange}" 中沒有找到 "${searchText}"。`);
    return;
  }

  let replacementsCount = textFinder.replaceAllWith(replaceText);
  Logger.log(`已完成更新,總共更新 ${replacementsCount} 筆資料`);
  
  rangesFound.forEach(range => {
    range.setBackground(highlightColor);
  });
}
尋找第一筆資料 (模糊搜尋)

在指定範圍內透過 findNext 進行模糊搜尋來搜尋第一筆相符合資料

findNext
Returns the next cell matching the search criteria.
function FindFirstData() {

  let range = SpreadsheetApp.getActiveSpreadsheet()
    .getSheetByName(sheetName)
    .getRange(columnBRange)
    .createTextFinder(searchText)
    .findNext();

  let exist = range ? true : false
  Logger.log(exist);

  if (exist)
    Logger.log(range.getA1Notation());
}
精確搜尋

在指定範圍內尋找全部 (findAll) 完全相符 (matchEntireCell) 資料

matchEntrieCell
If true, configures the search to match the entire contents of a cell; otherwise, the search defaults to partial matching.
findAll
Returns all cells matching the search criteria.
function AppleInColumnB() {
  
  let rangeList = SpreadsheetApp.getActiveSpreadsheet()
    .getSheetByName(sheetName)
    .getRange(columnBRange)
    .createTextFinder(searchText)
    .matchEntireCell(true)
    .findAll()
    .map((r) => r.getA1Notation());

  Logger.log(rangeList);
}
全文檢索

針對指定 SheetName 內全部欄位進行模糊搜尋,想針對一個 Sheets 內的全部 SheetName 進行模糊搜尋的話,就把 getSheetByName 移除就行
function FullTextSearch() {

  let rangeList = SpreadsheetApp.getActiveSpreadsheet()
    .getSheetByName(sheetName)
    .createTextFinder(searchText)
    .findAll();

  rangeList.forEach(range => {
    range.setBackground(highlightColor);
  });
}
正則表示式

在指定範圍內透過正則表示式來自定搜尋文字,一定要開啟 useRegularExpression 才會用上正則表示式,要不然就當成文字來搜尋,該範例要尋找紅或綠文字

useRegularExpression
If true, configures the search to interpret the search string as a regular expression; otherwise the search interprets the search string as normal text.
function Regular() {
  
  let regex = "紅|綠"; 

  let rangeList = SpreadsheetApp.getActiveSpreadsheet()
    .getSheetByName(sheetName)
    .getRange(columnBRange)
    .createTextFinder(regex)
    .useRegularExpression(true)
    .findAll()
    .map((r) => r.getA1Notation());

  Logger.log(rangeList);
}
取代資料

該範例把每一個步驟都註記文字說明,要把 [蘋果] 更新為 [Apple] 並把 cell 背景顏色設定為淺綠色

replaceAllWith
Replaces all matches with the specified text.
function Update() {

  let sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(sheetName);
  if (!sheet) {
    Logger.log("找不到名為 SampleDate 的工作表。請檢查工作表名稱");
    return;
  }

  let rangeToSearch = sheet.getRange(columnBRange);

  // 取代文字
  let replaceText = "Apple";

  // 建立 TextFinder
  let textFinder = rangeToSearch.createTextFinder(searchText);

  // 尋找所有符合條件的的儲存格
  let rangesFound = textFinder.findAll();

  if (rangesFound.length === 0) {
    Logger.log(`在範圍 "${columnBRange}" 中沒有找到 "${searchText}"。`);
    return;
  }

  // 取代所有搜尋到的文字
  let replacementsCount = textFinder.replaceAllWith(replaceText);
  Logger.log(`已完成更新,總共更新 ${replacementsCount} 筆資料`);

  // 標記有取代的儲存格
  rangesFound.forEach(range => {
    range.setBackground(highlightColor);
  });
}

星期日, 10月 12, 2025

[C#] FastMember-ObjectReader

FastMember github 上看見的使用方式,要把 List<T> 轉為 DataTable,重點在於 ObjectAccessor 使用

C# Code
namespace FastMemberDemo
{
    class Program
    {
        static void Main(string[] args)
        {

            List<Employee> dataSource = GenerateEmployees();

            // 範例一:ObjectReader.Create 搭配 DataTable.Load 產生 DataTable,
            //      因為 Employee Property 上有設定 OrdinalAttribute,
            //      所以 DataTable.DataColumn 會依設定順序產生
            DataTable dt = new DataTable(typeof(Employee).Name);

            using (var reader = ObjectReader.Create(dataSource))
                dt.Load(reader);

            PrintDataTableToConsole(dt);

            // 範例二:ObjectReader.Create 多載,指定特定欄位
            DataTable dtCols = new DataTable(typeof(Employee).Name);
            List<string> cols = new List<string>()
            {
                nameof(Employee.EmployeeID),
                nameof(Employee.EmployeeName)
            };

            using (var reader = ObjectReader.Create(dataSource, cols.ToArray()))
                dtCols.Load(reader);

            PrintDataTableToConsole(dtCols);
        }

        private static List<Employee> GenerateEmployees()
        {
            return new List<Employee>
            {
                new Employee
                {
                    EmployeeID = 1001,
                    EmployeeName = "王小明",
                    Birthday = new DateTime(1990, 5, 15),
                    Age = 35
                },
                new Employee
                {
                    EmployeeID = 1002,
                    EmployeeName = "陳美麗",
                    Birthday = new DateTime(1985, 12, 1),
                    Age = 40
                },
                new Employee
                {
                    EmployeeID = 1003,
                    EmployeeName = "林大華",
                    Birthday = new DateTime(2000, 1, 20),
                    Age = 25
                },
                new Employee
                {
                    EmployeeID = 1005,
                    EmployeeName = "黃建國",
                    Birthday = new DateTime(1975, 3, 30),
                    Age = 50
                },
                new Employee
                {
                    EmployeeID = 1006,
                    EmployeeName = "吳心怡",
                    Birthday = new DateTime(2002, 11, 11),
                    Age = 23
                },
                new Employee
                {
                    EmployeeID = 1007,
                    EmployeeName = "劉文傑",
                    Birthday = new DateTime(1988, 9, 25),
                    Age = 37
                },
                new Employee
                {
                    EmployeeID = 1009,
                    EmployeeName = "曾志偉",
                    Birthday = new DateTime(1980, 6, 8),
                    Age = 45
                },
                new Employee
                {
                    EmployeeID = 1010,
                    EmployeeName = "高慧君",
                    Birthday = new DateTime(2001, 4, 1),
                    Age = 24
                }
            };
        }

        public static void PrintDataTableToConsole(DataTable dt)
        {
            int totalWidth = 15;

            // --- 標題列 (欄位名稱) ---
            string header = "";

            foreach (DataColumn column in dt.Columns)
                header += column.ColumnName.PadRight(totalWidth);

            Console.WriteLine(header);

            // 分隔線
            Console.WriteLine(new string('-', header.Length));

            // --- 資料內容 ---
            foreach (DataRow row in dt.Rows)
            {
                string cellValue = string.Empty;
                string rowOutput = string.Empty;

                for (int i = 0; i < dt.Columns.Count; i++)
                {
                    cellValue = row[i].ToString();
                    rowOutput += cellValue.PadRight(totalWidth);
                }

                Console.WriteLine(rowOutput);
            }
        }
    }

    public class Employee
    {
        [Ordinal(3)]
        public int EmployeeID { get; set; }

        [Ordinal(2)]
        public string EmployeeName { get; set; }

        [Ordinal(4)]
        public DateTime Birthday { get; set; }

        [Ordinal(1)]
        public int? Age { get; set; }
    }
}
Sample1 執行結果,根據 OrdinalAttribute 決定欄位順序
Sample2 執行結果,指定特定欄位

OrdinalAttribute SourceCode

實務上剛好有指定 DataTable 欄位順序需求,查 Code 時發現這段語法才知道有 FastMember.OrdinalAttribute 存在,省下不少麻煩

星期三, 10月 08, 2025

[C#] 根據條件設定 DataGridViewCell 唯讀

在 DataGridView 內根據條件來決定 DataGridViewCell 唯讀、可編輯。

該範例會在 CellBeginEdit 事件內,根據 [結案] 欄位資料,來決定 [價格] 欄位是否唯讀,並在CellFormatting 事件內,把唯讀的 Column、Cell 設定為淺灰色方便識別。

UI 設計

C# Code

在 CellBeginEdit 事件內,把 把 DataGridViewCellCancelEventArgs.Cancel 設定為 true 即取消編輯該 cell,就可以達到唯讀效果。
namespace CellEditableCore
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();

            dataGridView1.AutoGenerateColumns = false;

            // 把 ColNO 設為唯讀
            ColNO.ReadOnly = true;
            ColNO.DefaultCellStyle.BackColor = Color.LightGray;
        }

        private void Form1_Load(object sender, EventArgs e)
        {

            List<Data> source = new List<Data>();
            source.Add(new Data() { NO = "001", Price = 100, Done = true });
            source.Add(new Data() { NO = "002", Price = 789, Done = false });
            source.Add(new Data() { NO = "003", Price = 500, Done = true });
            source.Add(new Data() { NO = "004", Price = 1000, Done = false });
            source.Add(new Data() { NO = "005", Price = 2000, Done = true });

            dataGridView1.DataSource = source;
        }

        private void dataGridView1_CellBeginEdit(object sender, DataGridViewCellCancelEventArgs e)
        {
            if (e.ColumnIndex != ColPrice.Index)
                return;

            bool done = (bool)dataGridView1.Rows[e.RowIndex].Cells[ColDone.Index].Value;
            
            // 把 e.Cancel = true 即取消編輯該 cell
            e.Cancel = done;
        }

        private void dataGridView1_CellFormatting(object sender, DataGridViewCellFormattingEventArgs e)
        {
            if (e.ColumnIndex != ColPrice.Index)
                return;

            bool done = (bool)dataGridView1.Rows[e.RowIndex].Cells[ColDone.Index].Value;
            e.CellStyle.BackColor = done ? Color.LightGray : dataGridView1.DefaultCellStyle.BackColor;
        }
    }

    public class Data
    {
        [Description("編號")]
        public string NO { get; set; }

        [Description("價格")]
        public decimal Price { get; set; }

        [Description("結案")]
        public bool Done { get; set; }
    }
}
執行結果
自動生成欄位

預設拉一個 DataGridView 來使用,AutoGenerateColumns 預設為 true,忘記把它設定為 false,導致在 CellBeginEdit 和 CellFormatting 事件內鬼打牆,在 CellFomatting 內寫個簡單範例來說明
bool trigger = false;
private void dataGridView1_CellFormatting(object sender, DataGridViewCellFormattingEventArgs e)
{
    if (trigger == false)
    {
        foreach (DataGridViewColumn col in dataGridView1.Columns)
            textBox1.Text += $"ColumnName:{col.Name}-ColumnIndex:{col.Index}" + Environment.NewLine;

        textBox2.Text += $"ColNO:{ColNO.Index}" + Environment.NewLine;
        textBox2.Text += $"ColPrice:{ColPrice.Index}" + Environment.NewLine;
        textBox2.Text += $"ColDone:{ColDone.Index}" + Environment.NewLine;
    }

    trigger = true;
}
從下圖可以看見,AutoGenerateColumns = true 情況下,[直接顯示 ColumnIndex] 會全部都是 0,造成欄位判斷異常,永遠都是第一欄位的背景顏色變化

星期日, 10月 05, 2025

[GAS] 存取 Google Sheets

在 Google Apps Script 內,透過 GetRange()、GetValue() 和 SetValue() 等相關函數,來針對 Google Sheets 進行 CRUD

Sample Data

請 AI 生成資料,故意把 RowPosition = 7 資料清除,變成空白資料

完整 .gs Code

後續會各別拆解說明
function CRUD() {

  // 取得目前作用中的試算表
  let sheetID = GetSheetID();
  let sheetName = 'Data';
  let ss = SpreadsheetApp.openById(sheetID);
  let sheet = ss.getSheetByName(sheetName);

  // 1. 顯示全部資料範圍內的資料

  // 會把第一 Row 標題一併顯示出來
  let dataRange = sheet.getDataRange().getValues();
  ShowData(dataRange);

  // 可以明確指定起始 Row,就可以避開第 1 row 的標題
  let range = sheet.getRange(2, 1, sheet.getLastRow() - 1, sheet.getLastColumn()).getValues();
  Logger.log("getLastColumn():" + sheet.getLastColumn());
  Logger.log("getLastRow():" + sheet.getLastRow());
  ShowData(range);

  // 2. 取得特定 Row、Cell 資料

  // 取得特定 Row 資料,使用 getSheetValues 來取值
  let rowRange = sheet.getSheetValues(3, 1, 1, -1);
  ShowData(rowRange);

  // 取得特定 Cell 資料
  let cellRange = sheet.getRange(8, 2).getValues();
  ShowData(cellRange);

  // 3. 新增一筆資料:
  var insertData = ["GAS", "gas@gmail.com", "2025/10/05", "0909-010-101"];
  sheet.appendRow(insertData);
  let insertRange = sheet.getSheetValues(sheet.getLastRow(), 1, 1, -1);
  ShowData(insertRange);

  // 4. 更新資料:更新指定 cell 內資料

  let updateRange = sheet.getRange(8, 4);
  
  // 顯示更新前資料
  ShowData(updateRange.getValues());

  // 更新資料
  updateRange.setValue("從 GAS 進行更新");

  // 顯示更新後資料
  ShowData(updateRange.getValues());

  // 5. 刪除資料:刪除指定 RowPosition 的 Row
  let deleteRange = sheet.getRange(7, 1);
  sheet.deleteRow(deleteRange.getRow());

}

function ShowData(values) {

  for (let i = 0; i < values.length; i++) {

    let row = '';

    for (let j = 0; j < values[i].length; j++) {
      if (values[i][j]) {
        row = row + values[i][j];
      }

      row = `${row},`;
    }

    Logger.log(row);
  }
}

顯示全部資料範圍內的資料

使用 GetDataRange() 或是 GetRange() 來取得全部資料範圍後,再透過 GetValues() 來取得資料
  • GetRange(row , column ,  numRows , numColumns):指定起始 cell ( row  和 column) 並回傳指定的資料筆數 (numRows) 和欄位數 (numColumns)
  • GetDataRange:取得對應資料的 Range,等同於 GetRange(1 , 1 , Sheet.getLastRow() , Sheet.getLastColumn())
GetDataRange() 會包含第一 Row 標題,希望 Range 不包含第一 Row 的話,可以使用 GetRange(2 , 1 , Sheet.getLastRow() - 1 , Sheet.getLastColumn())
let range = sheet.getRange(2, 1, sheet.getLastRow() - 1, sheet.getLastColumn()).getValues();
Logger.log("getLastColumn():" + sheet.getLastColumn());
Logger.log("getLastRow():" + sheet.getLastRow());
ShowData(range);
getDataRange() 和 getRange() 都會把範圍內的空白 Row 一併顯示出來,不會自動過濾掉


取得特定 Row 或 Cell 資料


取得指定起始 cell ( startRow  和 startColumn) 並回傳指定的資料筆數 (numRows) 和欄位數 (numColumns) 的值,比較特別的是 numRows 和 numColumns 設定為 -1 的話,代表最後一 Row 和最後一 Column,可以不透過 Sheet.getLastRow()、Sheet.getLastColumn() 取得

基本上 getSheetValues() 等同於 getRange() + getValues()
let rowRange = sheet.getSheetValues(3, 1, 1, -1);
ShowData(rowRange);


新增資料

appendRow(rowContents):在 Sheet 目前資料區域底部新增一筆 Row,萬一需要 insert 在特定 Row,請參考 InsertRow 相關函數
let insertData = ["GAS", "gas@gmail.com", "2025/10/05", "0909-010-101"];
sheet.appendRow(insertData);
let insertRange = sheet.getSheetValues(sheet.getLastRow(), 1, 1, -1);
ShowData(insertRange);

更新資料

setValue(value):設定指定 Range 值,值可以是數字、字串、布林值或日期
let updateRange = sheet.getRange(8, 4);
  
// 顯示更新前資料
ShowData(updateRange.getValues());

// 更新資料
updateRange.setValue("從 GAS 進行更新");

// 顯示更新後資料
ShowData(updateRange.getValues());

刪除資料

deleteRow(rowPosition):刪除指定 RowPostion 的 Row

rowPosition:Row 位置,第一 Row 從 1 開始,不是 0 喔

從文件上發現 getRow()getRowIndex() 都是取得 RowPosition,但 getRowIndex() 在名稱上容易引起誤會
let deleteRange = sheet.getRange(7, 1);
sheet.deleteRow(deleteRange.getRow());

星期六, 10月 04, 2025

[Sheets] Filter() - 多欄位只存在一個值

突然收到的需求,下述資料欄位一、欄位二、欄位三只能有一個欄位能填入[是],但拿到資料時已經亂掉,存在多個欄位同時填入[是],要找出這些異常資料
解法一:Filter 篩選資料

透過 Filter 來進行篩選,公式如下 

=FILTER(A:D,
    (B:B = "是") + (C:C = "是") + (D:D = "是") >= 2
)

結果如下
解法二:CountIf() + Filter()

先在 E 欄位輸入 =COUNTIF(B2:D2,"是") >=2,判斷三個欄位資料是否正確,之後再使用 Filter 來判斷 =FILTER(A2:E8,E2:E8 = true)
效能問題

(B:B = "是") + (C:C = "是") + (D:D = "是") 語法,很明顯是布林值被隱含轉換為 false => 0、true => 1 相加,涉及隱含轉換就直覺會有效能問題,詢問 AI 是說不會,初學 Sheets 還沒能力識別真偽,倒是偷懶資料範圍直接寫 A:D,在官方文件-將資料參照最佳化以改善試算表的效能 內有說明,要使用封閉式範圍參照 (A2:D8),不要使用開放式範圍參照 (A:D),也就是解法二資料範圍寫法

星期四, 10月 02, 2025

[Sheets] IsBlank()

在 Google Sheets 內可以透過 IsBlank() 來判斷是否為空值 (Empty)

IsBlank 語法

ISBLANK(value)
  • value:參照儲存格是否空值
應用範例

紅色線條和紅色方框為儲存格 A8