星期五, 12月 26, 2025

[GAS] 上傳檔案至 Google Drive - Excel to Sheet

在 Google Apps Script 內操作檔案有兩種方式,分別為 DriveApp 和 Drive API,要把 Excel 轉為 Sheet 必須使用 Drive API 才能進行轉換

Drvie API 服務

要使用 Drive API 必須先在服務內加入,該筆記是加入 Drive API V3,該筆記記錄當下 V2 還是存在的




index.html
<!DOCTYPE html>
<html>

<head>
  <base target="_top">
  <?!= include('css') ?>
</head>

<body>

  <div class="app-container">
      <!-- return false:避免 form onsubmit 預設 redirect 行為 -->
      <form onsubmit="handleFormSubmit(this); return false;">

      <label class="field-label">電子郵件:</label>
      <input type="email" name="userEmail" class="input-field" placeholder="example@mail.com" required>

      <label class="field-label">選擇檔案:</label>
      <!-- 有限定檔案為 xls 和 xlsx -->
      <input type="file" name="myFile" class="input-field"
                  accept=".xls,.xlsx, application/vnd.ms-excel, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
                  required>

      <button type="submit" class="btn-submit">上傳</button>
    </form>

    <div class="result-message">等待操作</div>
  </div>

  <script>
    function onSuccessHandler(response) {
        const display = document.querySelector('.result-message');
        display.innerText = "【成功】\n" + response;
        display.className = "result-message text-success";
    }

    function onFailureHandler(error) {
        const display = document.querySelector('.result-message');
        display.innerText = "【失敗】\n" + error.message;
        display.className = "result-message text-failure";
    }

    function handleFormSubmit(formObject) {

        let display = document.querySelector('.result-message');
        
        let fileInput = formObject.myFile; 
                
        // 透過 .files 屬性取得檔案
        let file = fileInput.files[0];
        let fileName = file.name.toLowerCase();

        // 檢查副檔名
        if (!fileName.endsWith('.xls') && !fileName.endsWith('.xlsx')) {
            display.innerText = "【格式錯誤】\n檔案格式不正確。\n請只上傳 Excel 檔案 (.xls, .xlsx)";
            display.className = "result-message text-failure";
            return;
        }

        display.innerText = "正在處理檔案,請勿重整頁面";
        display.className = "result-message";

        // 直接傳遞 formObject 
        google.script.run 
          .withSuccessHandler(onSuccessHandler)
          .withFailureHandler(onFailureHandler)
          .processForm(formObject);
      }

  </script>
</body>

</html>
code.gs
function doGet() {
  return HtmlService.createTemplateFromFile('index')
    .evaluate()
    .setTitle('[GAS] 上傳檔案至 Google Drive - Excel to Sheet');
}

function include(fileName) {
  return HtmlService.createHtmlOutputFromFile(fileName).getContent();
}

function processForm(formObject) {

  try {
    let parentFoldID = getFoldID(); 
    let parentFolder = DriveApp.getFolderById(parentFoldID);

    let timestamp = Utilities.formatDate(new Date(), "GMT+8", "yyyyMMdd_HHmmss");
    let subFolder = parentFolder.createFolder(timestamp);
    let subFolderId = subFolder.getId(); // 取得 ID 供後續 Drive API 使用

    // 儲存 Excel 檔案
    let excelFileBlob = formObject.myFile; 
    subFolder.createFile(excelFileBlob);

    let fileMetaData = {
      name: excelFileBlob.getName(),
      mimeType: MimeType.GOOGLE_SHEETS, // 指定檔案類型
      parents: [subFolderId] // 指定父資料夾,必須是資料夾ID (FoldID) 的陣列格式
    };

    // 透過 Drvie API 把 Excel 轉換為 Sheet
    let sheetFileByAPI = Drive.Files.create(fileMetaData, excelFileBlob);
    
    // 檔案 ID 是相同,單純方便操作檔案,才透過 DriveApp.getFileById() 再轉回 File Class 來使用
    let sheetFileByApp = DriveApp.getFileById(sheetFileByAPI.id);
    let url = sheetFileByApp.getUrl();
    sheetFileByApp.setDescription("透過 DriveApp 設定:上傳者為 " + formObject.userEmail)

    let message = `
      上傳時間:${timestamp}
      Email:${formObject.userEmail}
      上傳檔案 URL:${url}`;

    return message;    

  } catch (error) {
    throw new Error("系統錯誤:" + error.toString());
  }
}
CSS
<style>
  .app-container {
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
    padding: 30px;
    line-height: 1.6;
  }

  .field-label {
    display: block;
    margin-bottom: 5px;
    font-weight: bold; 
  }

  .input-field {
    width: 280px;
    padding: 8px;
    margin-bottom: 15px;
    border: 1px solid #ccc; 
    border-radius: 4px;
    display: block;
  }

  .btn-submit {
    display: block;
    padding: 8px 20px;
    background-color: #007bff;
    color: white;
    border: none;
    border-radius: 4px;
    cursor: pointer;
  }

  .btn-submit:hover {
    background-color: #0056b3;
  }

  .result-message {
    margin-top: 20px;
    padding: 15px; 
    border-left: 5px solid #eee;
    background-color: #f9f9f9;
    white-space: pre-wrap;
  }

  .text-success {
    border-left-color: #28a745;
    color: #155724;
  }

  .text-failure {
    border-left-color: #dc3545;
    color: #721c24;
  }
</style>
執行結果
上傳一個 Excel 和轉換後的 Sheet 並列呈現

星期五, 12月 19, 2025

[GAS] 上傳檔案至 Google Drive

在官方文件 在 HTML 服務:與伺服器函式通訊 - 表單 上有特別提到,當把 html 上的 form 當成參數傳遞至 gs code 內的函數時,form 會變成一個 key-value object,form field names 為 key、form field values 為 value,值會轉成 string,只有 file 會轉成 blob,該筆記紀錄該 GAS 特色功能

index.html
<!DOCTYPE html>
<html>

<head>
  <base target="_top">
  <?!= include('css') ?>
</head>

<body>

  <div class="app-container">
    <form onsubmit="handleFormSubmit(event)">

      <label class="field-label">電子郵件:</label>
      <input type="email" name="userEmail" class="input-field" placeholder="example@mail.com" required>

      <label class="field-label">選擇檔案:</label>
      <input type="file" name="myFile" class="input-field" required>

      <button type="submit" class="btn-submit">上傳</button>
    </form>

    <div class="result-message">等待操作</div>
  </div>

  <script>
    function onSuccessHandler(response) {
        const display = document.querySelector('.result-message');
        display.innerText = "【成功】\n" + response;
        display.className = "result-message text-success";
    }

    function onFailureHandler(error) {
        const display = document.querySelector('.result-message');
        display.innerText = "【失敗】\n" + error.message;
        display.className = "result-message text-failure";
    }

    function handleFormSubmit(event) {

        // 避免 form onsubmit 預設 redirect 行為
        event.preventDefault(); 

        // 取得 form
        let formObject = event.target;
        
        let display = document.querySelector('.result-message');    
        display.innerText = "正在上傳至 Google Drive";
        display.className = "result-message";

        // 呼叫 gs Code 內的 processForm() 並把 form 當成參數
        google.script.run 
          .withSuccessHandler(onSuccessHandler)
          .withFailureHandler(onFailureHandler)
          .processForm(formObject);
    }

  </script>
</body>

</html>

css.html
<style>
  .app-container {
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
    padding: 30px;
    line-height: 1.6;
  }

  .field-label {
    display: block;
    margin-bottom: 5px;
    font-weight: bold; 
  }

  .input-field {
    width: 280px;
    padding: 8px;
    margin-bottom: 15px;
    border: 1px solid #ccc; 
    border-radius: 4px;
    display: block;
  }

  .btn-submit {
    display: block;
    padding: 8px 20px;
    background-color: #007bff;
    color: white;
    border: none;
    border-radius: 4px;
    cursor: pointer;
  }

  .btn-submit:hover {
    background-color: #0056b3;
  }

  .result-message {
    margin-top: 20px;
    padding: 15px; 
    border-left: 5px solid #eee;
    background-color: #f9f9f9;
    white-space: pre-wrap;
  }

  .text-success {
    border-left-color: #28a745;
    color: #155724;
  }

  .text-failure {
    border-left-color: #dc3545;
    color: #721c24;
  }
</style>

gs code
function doGet() {
  return HtmlService.createTemplateFromFile('index')
    .evaluate()
    .setTitle('[GAS] 上傳檔案至 Google Drive');
}

function include(fileName) {
  return HtmlService.createHtmlOutputFromFile(fileName).getContent();
}

/**
 * 處理表單提交
 */
function processForm(formObject) {

  try {
    
    let parentFoldID = getFoldID();
    let parentFolder = DriveApp.getFolderById(parentFoldID);

    // 建立時間戳記子資料夾
    let timestamp = Utilities.formatDate(new Date(), "GMT+8", "yyyyMMdd_HHmmss");
    let subFolder = parentFolder.createFolder(timestamp);

    // 儲存檔案 (formObject.myFile 會自動被 GAS 轉為 Blob)
    let fileBlob = formObject.myFile; 
    let file = subFolder.createFile(fileBlob);
    file.setDescription("上傳者:" + formObject.userEmail)

    let message = `
      上傳時間:${timestamp}
      Email:${formObject.userEmail}
      上傳檔案 URL:${file.getUrl()}`;

    return message;

  } catch (error) {

    throw new Error("系統錯誤:" + error.toString());

  }

}

執行結果

星期四, 12月 18, 2025

[GAS] google.script.run

google.script.run 是非同步用戶端 JavaScript API,可呼叫伺服器端 Apps Script 內函式,當呼叫 Apps Script 內函式時並不會直接傳回值,而是透過 withSuccessHandler、withFailureHandler 搭配對應 callback function 來處理回傳值

範例 Code 說明
 
使用 google.script.run 非同步呼叫 .gs Code 內的 processData(),processData() 執行
  • 成功:透過 withSuccessHandler 執行自訂函數 onSuccessHandler
  • 失敗:透過 withFailureHandler 執行自訂函數 onFailureHandler
google.script.run
	.withSuccessHandler(onSuccessHandler)
	.withFailureHandler(onFailureHandler)
	.processData(emailValue);
index.html
<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
    <?!= include('css') ?>
  </head>
  <body>

    <div class="app-container">

      <label class="field-label">電子郵件:</label>

      <input type="email" class="input-email" placeholder="example@mail.com">
      
      <button class="btn-submit" onclick="runUpload()">提交測試</button>
      
      <div class="result-message">等待操作</div>
    </div>

    <script>
      function onSuccessHandler(response) {
        const display = document.querySelector('.result-message');
        display.innerText = "【SuccessHandler 觸發】\n" + response;
        display.className = "result-message text-success";
      }

      // withFailureHandler 回傳值為 Error 物件
      function onFailureHandler(error) {
        const display = document.querySelector('.result-message');
        display.innerText = "【FailureHandler 觸發】\n" + error.message;
        display.className = "result-message text-failure";
      }

      function runUpload() {
        let emailValue = document.querySelector('.input-email').value;
        let display = document.querySelector('.result-message');
        
        display.innerText = "呼叫 .gs Code 中,請稍後";

        // 使用 google.script.run 呼叫 .gs Code 內的 processData()
        google.script.run
          .withSuccessHandler(onSuccessHandler)
          .withFailureHandler(onFailureHandler)
          .processData(emailValue);
      }
    </script>
  </body>
</html>
Code.gs

奇數分鐘回傳成功,偶數分數回傳失敗
function doGet() {
  return HtmlService.createTemplateFromFile('index')
    .evaluate()
    .setTitle('[GAS] google.script.run');
}

function include(fileName)
{
  return HtmlService.createHtmlOutputFromFile(fileName).getContent();
}

function processData(email) {

  let now = new Date();
  let minutes = now.getMinutes();

  // 奇數分鐘:正常回傳
  if (minutes % 2 !== 0) {
    return "成功!Email 為:" + email;
  }

  // 偶數分鐘:拋出錯誤
  throw new Error("失敗!Email 為:" + email);
}
css
<style>
  .app-container {
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
    padding: 30px;
    line-height: 1.6;
  }

  .field-label {
    display: block;
    margin-bottom: 5px;
    font-weight: bold;
  }

  .input-email {
    width: 250px;
    padding: 8px;
    margin-bottom: 15px;
    border: 1px solid #ccc;
    border-radius: 4px;
  }

  .btn-submit {
    display: block;
    padding: 8px 20px;
    background-color: #007bff;
    color: white;
    border: none;
    border-radius: 4px;
    cursor: pointer;
  }

  .btn-submit:hover {
    background-color: #0056b3;
  }

  .result-message {
    margin-top: 20px;
    padding: 15px;
    border-left: 5px solid #eee;
    background-color: #f9f9f9;
    white-space: pre-wrap;
  }

  .text-success {
    border-left-color: #28a745;
    color: #155724;
  }

  .text-failure {
    border-left-color: #dc3545;
    color: #721c24;
  }
</style>
執行結果

星期六, 12月 13, 2025

[GAS] 引用 css、javascript

學習前端開發時,習慣會把 html、css、javascript 檔案拆開來維護,但 Vibe Coding 產生的 Code 都是全部放在一起,該筆記是要記錄如何拆分檔案且能正常執行,基本上是透過 HtmlService.createTemplateFromFile() 來達成該需求

html、css、javascript 全部在一起
.gs Code

使用 HtmlService.createHtmlOutputFromFile 直接呼叫 index.html
function doGet() {
  return HtmlService.createHtmlOutputFromFile('index')
       .setTitle('Google Apps Script 引用 css、javascript 檔案');     
}
index.html
<!DOCTYPE html>
<html>

<head>
  <base target="_top">
  <style>
    h2 {
      background-color: gray;
      text-align: left;
      padding: 10px;
    }
  </style>
</head>

<body>

  <h2>Google Apps Script 引用 css、javascript 檔案</h2>

  <button id="btnUpload" onclick="disableButton()">
    上傳檔案
  </button>

  <script>
    function disableButton() {
      const button = document.getElementById('btnUpload');

      if (button) { 
        button.innerText = "上傳處理中,請稍候... (From index.html)";

        button.disabled = true;

        button.style.backgroundColor = '#cccccc';
        button.style.color = '#666666';
        button.style.cursor = 'not-allowed';
      }      

    }
  </script>
</body>

</html>
執行結果

h2 以灰色背景呈現,按鈕按下去文字會變成 [上傳處理中,請稍候... (From index.html)]
拆分檔案

Google Apps Script 內只有 .html 和 .gs 兩種檔案格式,css 和 javascript 內容也是放在 html 檔案格式內,拆分後如下圖

.gs Code

因為 index.html 會呼叫 include() 函數來抓取 css.html 和 js.html 檔案,所以必須使用 HtmlService.createTemplateFromFile() 來呼叫 index.html
function doGet() {
  return HtmlService.createTemplateFromFile('index') // 回傳 HtmlTemplate
    .evaluate() // 把 HtmlTemplate 轉回 HtmlOuput
    .setTitle('Google Apps Script 引用 css、javascript 檔案');       
}

/**
 * index.html 內會透過呼叫 include() 來把 css.html 和 js.html 內容塞進 index.html 
 */
function include(fileName)
{
  return HtmlService.createHtmlOutputFromFile(fileName).getContent();
}
index.html

<?!= include("css") ?> 意思分兩個部分解釋
  • <? 函式 ?>:呼叫 .gs 檔案內的函式,即為 include()
  • !=:不進行 HTML 跳脫 (no escaping),會當成 html 語法的意思
<!DOCTYPE html>
<html>

<head>
  <base target="_top">
  <!-- 載入 css -->
  <?!= include("css") ?>
</head>

<body>

  <h2>Google Apps Script 引用 css、javascript 檔案</h2>

  <button id="btnUpload" onclick="disableButton()">
    上傳檔案
  </button>

  <!-- 載入 Javascript -->
  <?!= include("js") ?>
</body>

</html>
css.html

css 語法必須用 style 標籤包起來
<style>
h2 {
  background-color: green;
  text-align: left;
  padding: 10px;
}
</style>
js.html

javascript 語法必須用 script 標籤包起來
<script>
function disableButton() {
  const button = document.getElementById('btnUpload');

  if (button) { 

    button.innerText = "上傳處理中,請稍候... (From js html)";
    
    button.disabled = true;
    
    button.style.backgroundColor = '#cccccc'; 
    button.style.color = '#666666';
    button.style.cursor = 'not-allowed';
  }      
}
</script>
執行結果

h2 以綠色背景呈現,按鈕按下去文字會變成 [上傳處理中,請稍候... (From js.html)]

星期五, 12月 12, 2025

GodexRT730x-紙張偵測 sensor

公司內有 [一 Row 兩張] 的標籤紙,廠商來公司進行維修時請教對方,這類標籤紙在偵測結束後,按 [出紙鍵] 不會作動,直覺是偵測異常,經對方解釋後才理解,標籤機內 [紙張偵測 sensor] 是可以調整的,sensor 不要剛好置中就可以提高紙張偵測成功度

[一 Row 兩張] 標籤紙,不知道正式名稱該怎麼稱呼它,就隨興取個名字
下圖紅色箭頭為 [紙張偵測 sensor] 可以左右移動,[一 Row 兩張] 正中間剛好是空白,導致偵測成功率降低,往兩邊移動偵測,讓 sensor 可以實際偵測到紙張大小就是

星期三, 12月 10, 2025

[CSS] Blogger 排版設計

模擬 Blogger 手機版排版設計,電腦版排版設計右側 sidebar 會同時有多個區塊 (關於我、文章分類、依日期分類等),但在手機板上瀏覽,只會有 [關於我] 區塊沉在主要文章區塊下方

index.html
<!DOCTYPE html>
<html lang="zh-Hant-TW">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="styles.css">
    <title>[CSS] Blogger 排版設計</title>
</head>

<body>
    <div class="wrap">

        <div class="item">
            <img src="https://125408140_c9204a223e_n.jpg" alt="台北大直橋">
            <h2>台北大直橋</h2>
            <p>台北大直橋是橫跨基隆河的一座醒目地標,以其獨特的白色拱形鋼纜設計而聞名,流線型的橋身優雅地劃過水面,極具現代感。夜幕降臨後,橋體會亮起多變的燈光秀,與周圍的內湖科技園區和美麗華摩天輪夜景相互輝映,是攝影愛好者的熱點。它不僅是重要的交通樞紐,更是一座融合工程與藝術的城市景觀橋。
            </p>
        </div>

        <div class="siderbar">
            <div class="aboutme">
                <h2>關於我</h2>
                <p>以下省略一萬字</p>
            </div>
            <div class="mobile-hide">
                <h2>文章分類</h2>
                <p>HTML</p>
                <p>CSS</p>
                <p>Javascript</p>
            </div>
            <div class="mobile-hide">
                <h2>依日期分類</h2>
                <p>2025 (77)</p>
                <p>2024 (108)</p>
            </div>
        </div>
    </div>
</body>

</html>
style.css
*,
*::before,
*::after {
    box-sizing: border-box;
}

.wrap {
    max-width: 1024px;
    margin: 0 auto;
    display: flex;
}

.content {
    display: flex;
    flex-direction: column;
    gap: 10px;
    flex: 1;
}

.item {
    padding: 10px;
    border: lightgray 1px solid;
}

.item img {
    display: block;
    margin: 0 auto;
}

.item h2 {
    text-align: center;
    font-size: 25px;
}

.siderbar {
    flex: 0 0 200px;
    padding: 20px;
    border: lightgray 1px solid;
}

@media(max-width: 992px) {

    /* 讓 content 和 sidebar 折行 */
    .wrap {
        flex-wrap: wrap;
    }

    .content,
    .siderbar {
        width: 100%; /* 確保 content 和 sidebar 佔滿 wrap  */
        flex: auto; /* 取消 flex 相關設定 */
    }

    /* sidebar 折行後,和 content 有底部間距 */
    .content {
        margin-bottom: 20px;
    }

    /* 移除 sidebar 的邊界和內距,不凸顯它的存在 */
    .siderbar {
        border: none;
        padding: 0;
    }

    /* 隱藏 mobile-hide */
    .mobile-hide {
        display: none;
    }

    /* 讓 aboutme 更像一個獨立區塊 */
    .aboutme {
        padding: 10px;
        border: lightgray 1px solid;
    }
}
效果呈現

星期六, 12月 06, 2025

[CSS] 雙欄流體式設計

透過 flex 屬性設計,讓雙欄式版面,達到 [右側固定、左側流體式設計],flex 屬性還可以細分為 flex-grow (預設值為 0)、flex-shrink (預設值為 1) 和 flex-basis 

右側:flex: 0 0 200px 代表
  • flex-grow: 0 (不成長)
  • flex-shrink: 0 (不收縮)
  • flex-basis: 200px (基礎寬度為 200px)
左側:flex: 1 代表
  • flex-grow: 1
  • flex-shrink: 1 (預設值)
  • flex-basis: 0%
該設定會自動填滿除了右側 200 px 外的所有空間

index.html
<!DOCTYPE html>
<html lang="zh-Hant-TW">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="styles.css">
    <title>[CSS] 雙欄式設計</title>
</head>

<body>
    <div class="wrap">

        <div class="content">
            <div class="item">
                <img src="https://82534363_4f518a102e_n.jpg" alt="高雄愛河">
                <h2>高雄愛河</h2>
                <p>高雄愛河是穿梭都市的生命之水,兩岸綠樹成蔭,微風徐徐。夜間尤其迷人,河畔燈光倒映水面,波光粼粼,如同一條璀璨的銀帶。遊客可漫步親水步道,或搭乘愛之船,感受迎面而來的浪漫氣息。週末更有市集和街頭藝人,為這片靜謐的河景增添熱鬧與活力,是高雄最具代表性的休憩與觀光勝地。</p>
            </div>
        </div>

        <div class="siderbar">
                <div class="aboutme">
                    <h2>關於我</h2>
                    <p>以下省略一萬字</p>
                </div>            
                <div class="mobile-hide">
                    <h2>文章分類</h2>
                    <p>HTML</p>
                    <p>CSS</p>
                    <p>Javascript</p>
                </div>              
                <div class="mobile-hide">
                    <h2>依日期分類</h2>
                    <p>2025 (77)</p>
                    <p>2024 (108)</p>
                </div>                           
        </div>

    </div>
</body>
</html>
style.css
*,*::before,*::after {
    box-sizing: border-box;
}

.wrap{
    max-width: 1024px;
    margin: 0 auto;
    display: flex;
}

.content{
    display: flex;
    flex-direction: column;
    gap: 10px; 
    flex:1; 
}

.item{
    padding: 10px;
    border: lightgray 1px solid;
}

.item img{
    display: block;
    margin: 0 auto;
}

.item h2{
    text-align: center;
    font-size: 25px;
}

.siderbar{
    flex: 0 0 200px;
    padding: 20px;
    border: lightgray 1px solid;
}
效果呈現

星期一, 12月 01, 2025

[CSS] 三欄流體式設計

延續 [CSS] Flex 基礎練習 練習內容,該筆記要記錄三欄顯示會隨著瀏覽器縮小而變成兩欄、一欄,模擬在不同行動裝置上的 UI 顯示,會用上重點為
  • box-sizing:預設為 content-box,設定為 border-box 方便計算和應用
  • max-width:以父元素 (上一層) 寬度為判斷目標
    • 當父元素寬度高於設定值 (比較寬) 時:保持該寬度
    • 當父元素寬度低於設定值 (比較窄) 時:隨父元素寬度自動調整
  • flex-wrap:預設為 no-wrap,會設定 wrap,隨著瀏覽器縮小而折行
  • Media Query:判斷瀏覽器大小
    • 大螢幕:三欄 (約 1024px 以上)
    • 中螢幕:兩欄 (約 768px 到 992px 之間)
    • 小螢幕:一欄 (約 767px 以下)
Index.html
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="styles.css">
    <title>[CSS] 三欄流體式設計</title>
</head>

<body>
    <div class="wrap">
        <div class="content">
            <div class="item">
                <img src="https://82534363_4f518a102e_n.jpg" alt="高雄愛河">
                <h2>高雄愛河</h2>
                <p>高雄愛河是穿梭都市的生命之水,兩岸綠樹成蔭,微風徐徐。夜間尤其迷人,河畔燈光倒映水面,波光粼粼,如同一條璀璨的銀帶。遊客可漫步親水步道,或搭乘愛之船,感受迎面而來的浪漫氣息。週末更有市集和街頭藝人,為這片靜謐的河景增添熱鬧與活力,是高雄最具代表性的休憩與觀光勝地。</p>
            </div>
            <div class="item">
                <img src="https://1776884718_0ed0417709_n.jpg" alt="溫哥華市中心">
                <h2>溫哥華市中心</h2>
                <p>溫哥華市中心坐擁壯麗的海灣景色,現代化的玻璃帷幕高樓直插雲霄,與湛藍的英吉利灣和布拉德內灣形成鮮明對比。街道充滿活力,從時尚的羅布森街到歷史悠久的蓋士鎮,處處可見繁忙的購物人潮、多元的餐飲文化。市區緊鄰史丹利公園,讓這座都會在繁華之餘,仍保有親近大自然的綠色魅力。</p>
            </div>
            <div class="item">
                <img src="https://125408140_c9204a223e_n.jpg" alt="台北大直橋">
                <h2>台北大直橋</h2>
                <p>台北大直橋是橫跨基隆河的一座醒目地標,以其獨特的白色拱形鋼纜設計而聞名,流線型的橋身優雅地劃過水面,極具現代感。夜幕降臨後,橋體會亮起多變的燈光秀,與周圍的內湖科技園區和美麗華摩天輪夜景相互輝映,是攝影愛好者的熱點。它不僅是重要的交通樞紐,更是一座融合工程與藝術的城市景觀橋。</p>
            </div>
        </div>
    </div>
</body>
</html>
style.css
*{
    box-sizing: border-box; /* 讓 padding 和 border 包含在 width 內 */
}

.wrap{
    max-width: 1024px;
    margin: 0 auto;
}

.content{
    display: flex;
    justify-content: center;
    gap: 10px; /* item 之間的間距 */
    flex-wrap: wrap; /* 允許折行 */
}

.item{
    width: 30%;
    padding: 10px;
    border: lightgray 1px solid;
}

.item img{
    width: 100%;
}

.item h2{
    text-align: center;
    font-size: 25px;
}

/* 中螢幕 */
@media (max-width:992px) {
	.item{
		width: 48%;
        margin-bottom: 10px;
	}
}

/* 小螢幕 */
@media (max-width:767px) {
	.item{
		width: 80%
	}
}
效果呈現

星期一, 11月 24, 2025

[CSS] CSS 基礎練習

課程練習,說明如下
  • CSS Reset:使用 CSS Tools: Reset CSS
  • 使用 div 來進行排版:網頁水平置中
  • header:一張圖片(img)
  • body:
    • 使用後代選擇器來進行設定
    • p 段落:兩個段落,顏色為藍色,使用 Lorem 快速產生文字
    • 留白:使用 padding (往內推)、margin (往外推)
    • a 連結:修正為塊狀 (display:inline -> display:block) 且滑鼠滑過去後背景變成黑色
  • footer:
    • 文字置中
index.html
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>[CSS] CSS 基礎練習</title>
    <link rel="stylesheet" href="styles.css">
</head>

<body>
    <div class="page">        
        <div class="header"><img src="https://54725596729_d130df08f7_n.jpg" alt="Blog logo"></div>

        <div class="body">
            <p>Lorem ipsum dolor sit amet consectetur, adipisicing elit. Quia nemo alias magnam perferendis saepe rem,
                voluptatum quaerat neque similique vero obcaecati quas a vitae commodi laboriosam earum animi provident
                nulla.</p>

            <p>Lorem ipsum dolor sit amet consectetur, adipisicing elit. Quia nemo alias magnam perferendis saepe rem,
                voluptatum quaerat neque similique vero obcaecati quas a vitae commodi laboriosam earum animi provident
                nulla.</p>

            <a href="https://jengting.blogspot.com/" target="_blank">~楓花雪岳~</a>
        </div>

        <div class="footer">地址:OO 市 XX 區 OO 路 XX 號</div>
    </div>
</body>
</html>
style.cs
/* http://meyerweb.com/eric/tools/css/reset/ 
   v2.0 | 20110126
   License: none (public domain)
*/

html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, u, i, center,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed, 
figure, figcaption, footer, header, hgroup, 
menu, nav, output, ruby, section, summary,
time, mark, audio, video {
	margin: 0;
	padding: 0;
	border: 0;
	font-size: 100%;
	font: inherit;
	vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure, 
footer, header, hgroup, menu, nav, section {
	display: block;
}
body {
	line-height: 1;
}
ol, ul {
	list-style: none;
}
blockquote, q {
	quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
	content: '';
	content: none;
}
table {
	border-collapse: collapse;
	border-spacing: 0;
}

.page {
    width: 800px;
    margin-left: auto;
    margin-right: auto;
}

.header {
    border: 5px solid lightgray;
    margin: auto;
    padding: 10px;
    ;
}

.body {
    border: 5px solid red;
    margin: auto;
    padding: 10px;
}

.body p {
    color: blue;
    margin-bottom: 30px;
}

.body a {
    display: block;
    text-align: center;
    background: blue;
    color: white;
    width:100px;
    padding: 10px;
    margin-bottom: 50px;
}

.body a:hover {
    background: black;
}

.footer {
    background-color: lightpink;
    text-align: center;
    margin: auto;
    padding: 10px;
}
結果

星期日, 11月 23, 2025

[CSS] Flex 基礎練習

課程練習,練習完發現,其實也只有用上 justify-content: space-around 而已,其他就基礎排版囉 

index.html
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="styles.css">
    <title>CSS - Flex 基礎練習</title>
</head>

<body>
    <div class="wrap">
        <div class="content">
            <div class="item">
                <img src="https://82534363_4f518a102e_n.jpg" alt="高雄愛河">
                <h2>高雄愛河</h2>
                <p>高雄愛河是穿梭都市的生命之水,兩岸綠樹成蔭,微風徐徐。夜間尤其迷人,河畔燈光倒映水面,波光粼粼,如同一條璀璨的銀帶。遊客可漫步親水步道,或搭乘愛之船,感受迎面而來的浪漫氣息。週末更有市集和街頭藝人,為這片靜謐的河景增添熱鬧與活力,是高雄最具代表性的休憩與觀光勝地。</p>
            </div>
            <div class="item">
                <img src="https://1776884718_0ed0417709_n.jpg" alt="溫哥華市中心">
                <h2>溫哥華市中心</h2>
                <p>溫哥華市中心坐擁壯麗的海灣景色,現代化的玻璃帷幕高樓直插雲霄,與湛藍的英吉利灣和布拉德內灣形成鮮明對比。街道充滿活力,從時尚的羅布森街到歷史悠久的蓋士鎮,處處可見繁忙的購物人潮、多元的餐飲文化。市區緊鄰史丹利公園,讓這座都會在繁華之餘,仍保有親近大自然的綠色魅力。</p>
            </div>
            <div class="item">
                <img src="https://125408140_c9204a223e_n.jpg" alt="台北大直橋">
                <h2>台北大直橋</h2>
                <p>台北大直橋是橫跨基隆河的一座醒目地標,以其獨特的白色拱形鋼纜設計而聞名,流線型的橋身優雅地劃過水面,極具現代感。夜幕降臨後,橋體會亮起多變的燈光秀,與周圍的內湖科技園區和美麗華摩天輪夜景相互輝映,是攝影愛好者的熱點。它不僅是重要的交通樞紐,更是一座融合工程與藝術的城市景觀橋。</p>
            </div>
        </div>
    </div>
</body>
</html>
style.css
.wrap{
    width: 1024px;
    margin: 0 auto;
}

.content{
    display: flex;
    justify-content: space-around;;
}

.item{
    width: 300px;
    padding: 10px;
    border: lightgray 1px solid;
}

.item img{
    width: 100%;
}

.item h2{
    text-align: center;
    font-size: 25px;
}
結果

星期四, 11月 13, 2025

[GAS] HTMLService - setSandboxMode

請 AI 產生需求 Code 時發現沒有使用過的語法 setSandboxMode
function doGet() {
  return HtmlService.createHtmlOutputFromFile('index')
      .setTitle('網頁抬頭')
      .setSandboxMode(HtmlService.SandboxMode.IFRAME);
}
從官方文件發現
SandboxMode 雖然有 NATIVE、EMULATED、IFRAME 三種模式,但 NATIVE、EMULATED 已經在 20151013 淘汰,目前一律以 IFRAME 為主囉,根本就沒有需要透過 setSandboxMode 再次設定的必要

星期一, 11月 10, 2025

[GAS] LineBot 取得圖檔並儲存至 Goolge Drive

Line 有提供 content API 可以取得使用者傳送的圖檔 (Images)、影片 (videos)、音檔 (audio) 和檔案 (files),該筆記會以傳送 jpeg 圖檔 (Images) 為主

content API
https://api-data.line.me/v2/bot/message/{messageId}/content

Response

status code 返回 200 且 content 是 blob,該 blob 會自動被刪除,官方並不保證會留存多久


Error response

呼叫後的 status code 有 404 和 410
  • 404:不存在的 messageID
  • 410:使用者並沒有傳送訊息 (message)
// 404 Not Found json
{
  "message": "not found"
}

// 410 Gone json
{
  "message": "The content is gone"
}

Google Apps Script .gs Code
function doPost(e) {
  try {
    let contents = JSON.parse(e.postData.contents);
    let event = contents.events[0];

    if (event.type === 'message' && 
        event.message &&
        event.message.type === 'image') {

      let messageId = event.message.id;

      let imageBlob = downloadLineImage(messageId);

      if (imageBlob) {
        let folderName = 'LineBot_Images';
        saveBlobToDrive(imageBlob, folderName);
      }
    }

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

function downloadLineImage(messageId) {

  let lineChannelAccessToken = getProperties("LineChannelAccessToken");

  let url = `https://api-data.line.me/v2/bot/message/${messageId}/content`;

  let options = {
    'headers': {
      'Authorization': 'Bearer ' + lineChannelAccessToken,
    },
    'method': 'get',
    'muteHttpExceptions': true,
  };

  try {
    let response = UrlFetchApp.fetch(url, options);
    return response.getBlob();
  } catch (e) {
    WriteLog([new Date(), "錯誤訊息:", "下載 Line 圖片錯誤:" + e.toString()]);
    return null;
  }
}

function saveBlobToDrive(blob, folderName) {

  try {
    // 尋找或建立目標資料夾
    let folder = DriveApp.getFoldersByName(folderName);
    if (folder.hasNext()) {
      folder = folder.next();
    } else {
      folder = DriveApp.createFolder(folderName);
    }

    // 設定檔案名稱 (例如: LineImage_20251028_100000.jpg)
    let timestamp = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyyMMdd_HHmmss');

    // 範例:blob.getContentType() 回傳 image/jpeg
    let fileName = `LineImage_${timestamp}.${blob.getContentType().split('/').pop()}`;

    // 儲存檔案
    folder.createFile(blob.setName(fileName));
    WriteLog([new Date(), `檔案已儲存到 Drive:${fileName}`]);

  } catch (e) {
    WriteLog([new Date(), "儲存到 Google Drive 錯誤:" + e.toString()]);
  }
}

測試結果

星期四, 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 存在,省下不少麻煩