Notice: Function _load_textdomain_just_in_time was called incorrectly. Translation loading for the ultimate-addons-for-gutenberg domain was triggered too early. This is usually an indicator for some code in the plugin or theme running too early. Translations should be loaded at the init action or later. Please see Debugging in WordPress for more information. (This message was added in version 6.7.0.) in /opt/bitnami/wordpress/wp-includes/functions.php on line 6114
June, 2024 - 八寶周的研究小屋

Month: June 2024

0. 前言


在生活中我們時常透過線上表單報名活動,但很多只會在會前提醒而沒有立即告知用戶是否報名成功,造成不必要的焦慮。另外透過設計良好的信件樣式,也能讓參與者感受到不同的專業性。

那麼接下來我們就試試當用戶提交表單時寄送立即性的通知給對方吧。

1. 重點


多數 Trigger 會回傳事件物件方便資料處理
透過 GmailApp 寄送信件,切記有不少 options 可以進階調整
現今的 eMail 服務大多支援 HTML 樣式

2. 內容


2.1. 信件寄送

以下是最基礎版的,針對用戶清單寄送 plain-text 純文字的訊息。其中 subject 指的是信件標題,而 body 則是信件內容,收件者除單目標外也支援 List 讀取一次寄送給多位用戶。

JavaScript
// Sending plain text email
function textEmail(email) {
  const subject = "Thank you for your submission";
  const body = `Welcome to the course`;

  // Send the email
  GmailApp.sendEmail(email, subject, body);
}

但你是否好奇過為何訂閱的週報總那麼好看?這是因為那些信件是透過 HTML 格式排版過的。我們就要依靠 htmlBody 參數傳輸內容。而我們讀取的是專案中的 htmlBody.html 檔案內容進行傳輸,那範例程式在本篇的最底部。另外我們平時作系統方的確認,通常也會有 cc / bcc 給內部信箱作留存,其架構則固定要求為 List 格式。而配送附件的方式我們以下也有列出,可以看到其綁定時是依靠 File Class 的物件陣列進行存取。

另外當然還有許多設定,比如:回應哪封信件,客製化寄件人姓名 … 等,各位能在看參考資料 [1] 。

JavaScript
// Sending colourful HTML email
function htmlEmail(email) {

  const subject = "Thank you for your submission";
  const body = `Welcome to the course`;

  // CC
  const ccList = ['YOUR_EMAIL'];

  // Attachments
  const file_001 = DriveApp.getFileById('FILE_001_ID');
  const file_002 = DriveApp.getFileById('FILE_002_ID');

  const attachments = [file_001, file_002];

  // HTML
  const htmlBody = HtmlService.createHtmlOutputFromFile('htmlBody').getContent();

  // Send the email
  GmailApp.sendEmail(email, subject, body, { htmlBody: htmlBody, cc: ccList.join(','), attachments: attachments });
}

2.2. 表單回應

提到要在用戶遞交表單時觸發方法我們就要依靠 onSubmit 這個 Trigger 方式進行處理,而其實這些觸發器本身通常會傳入物件幫助進行處理,也就是寫程式常聽見的 event(常用 e 縮寫)。

在過程中,我們依靠 event.response 取得用戶的表單回應,再後續透過 getRespondentEmail() 讀取提交者的信箱。那理所當然我們的表單便要開啟紀錄信箱的功能,看選擇自動偵測或手動輸入都一樣格式。這邊不建議自己設定 QA 讓用戶填寫信箱,原因在於處理上困難許多。

JavaScript
// Send email to user on sumbitting the form
function sendEmail(event) {
  console.warn(`# RUN - sendEmail()`);

  try {

    console.warn(`@ Reading Form Response`);

    // Get the submitted form response
    const response = event.response;

    // Get the email address from the form response
    const email = response.getRespondentEmail();

    console.warn(`@ Sending Email to User`);

    // textEmail(email); 
    htmlEmail(email);
  } catch (error) { console.error(error); }

  console.warn(`# END - sendEmail()`);
}

那後續就是呼叫我們前面介紹的兩個方法,傳輸不同種類的信件啦!

2.3. 表單遞交

我們接著設定以下 Trigger,並簡易設計完表單並提交後應該會取得以下結果。

3. 後話


那這篇我們簡單提到突如取得提交者的信箱並寄送信件,而下期則會深入到用戶回答的部分,並透過 regex 與 split 進行日期的擷取,而後接續更動 Calendar 事件。

那我們下次再見 ~~

4. 參考


[1] GmailApp.sendEmail() — Official Doc
https://developers.google.com/apps-script/reference/gmail/gmail-app#sendemailrecipient,-subject,-body,-options

[2] Class FormResponse — Official Doc
https://developers.google.com/apps-script/reference/forms/form-response

5. 素材


[1] 圖片素材 by QuAn_
https://www.pixiv.net/users/6657532

[2] 範例 HTML 碼

HTML
<!-- https://unlayer.com/templates/online-course -->

<!DOCTYPE HTML
  PUBLIC "-//W3C//DTD XHTML 1.0 Transitional //EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
  xmlns:o="urn:schemas-microsoft-com:office:office">

<head>
  <!--[if gte mso 9]>
<xml>
  <o:OfficeDocumentSettings>
    <o:AllowPNG/>
    <o:PixelsPerInch>96</o:PixelsPerInch>
  </o:OfficeDocumentSettings>
</xml>
<![endif]-->
  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta name="x-apple-disable-message-reformatting">
  <!--[if !mso]><!-->
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <!--<![endif]-->
  <title></title>

  <style type="text/css">
    @media only screen and (min-width: 620px) {
      .u-row {
        width: 600px !important;
      }

      .u-row .u-col {
        vertical-align: top;
      }

      .u-row .u-col-50 {
        width: 300px !important;
      }

      .u-row .u-col-100 {
        width: 600px !important;
      }

    }

    @media (max-width: 620px) {
      .u-row-container {
        max-width: 100% !important;
        padding-left: 0px !important;
        padding-right: 0px !important;
      }

      .u-row .u-col {
        min-width: 320px !important;
        max-width: 100% !important;
        display: block !important;
      }

      .u-row {
        width: 100% !important;
      }

      .u-col {
        width: 100% !important;
      }

      .u-col>div {
        margin: 0 auto;
      }
    }

    body {
      margin: 0;
      padding: 0;
    }

    table,
    tr,
    td {
      vertical-align: top;
      border-collapse: collapse;
    }

    p {
      margin: 0;
    }

    .ie-container table,
    .mso-container table {
      table-layout: fixed;
    }

    * {
      line-height: inherit;
    }

    a[x-apple-data-detectors='true'] {
      color: inherit !important;
      text-decoration: none !important;
    }

    table,
    td {
      color: #000000;
    }

    #u_body a {
      color: #0000ee;
      text-decoration: underline;
    }
  </style>



</head>

<body class="clean-body u_body"
  style="margin: 0;padding: 0;-webkit-text-size-adjust: 100%;background-color: #ffffff;color: #000000">
  <!--[if IE]><div class="ie-container"><![endif]-->
  <!--[if mso]><div class="mso-container"><![endif]-->
  <table id="u_body"
    style="border-collapse: collapse;table-layout: fixed;border-spacing: 0;mso-table-lspace: 0pt;mso-table-rspace: 0pt;vertical-align: top;min-width: 320px;Margin: 0 auto;background-color: #ffffff;width:100%"
    cellpadding="0" cellspacing="0">
    <tbody>
      <tr style="vertical-align: top">
        <td style="word-break: break-word;border-collapse: collapse !important;vertical-align: top">
          <!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" style="background-color: #ffffff;"><![endif]-->



          <!--[if gte mso 9]>
      <table cellpadding="0" cellspacing="0" border="0" style="margin: 0 auto;min-width: 320px;max-width: 600px;">
        <tr>
          <td background="https://cdn.templates.unlayer.com/assets/1597211254070-1597165113823-Lessons_Hero_Desktop_2x.jpg" valign="top" width="100%">
      <v:rect xmlns:v="urn:schemas-microsoft-com:vml" fill="true" stroke="false" style="width: 600px;">
        <v:fill type="frame" src="https://cdn.templates.unlayer.com/assets/1597211254070-1597165113823-Lessons_Hero_Desktop_2x.jpg" /><v:textbox style="mso-fit-shape-to-text:true" inset="0,0,0,0">
      <![endif]-->

          <div class="u-row-container" style="padding: 0px;background-color: transparent">
            <div class="u-row"
              style="margin: 0 auto;min-width: 320px;max-width: 600px;overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;background-color: #bbe2ec;">
              <div
                style="border-collapse: collapse;display: table;width: 100%;height: 100%;background-image: url('images/image-6.jpeg');background-repeat: no-repeat;background-position: center top;background-color: transparent;">
                <!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding: 0px;background-color: transparent;" align="center"><table cellpadding="0" cellspacing="0" border="0" style="width:600px;"><tr style="background-image: url('images/image-6.jpeg');background-repeat: no-repeat;background-position: center top;background-color: #bbe2ec;"><![endif]-->

                <!--[if (mso)|(IE)]><td align="center" width="600" style="width: 600px;padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;" valign="top"><![endif]-->
                <div class="u-col u-col-100"
                  style="max-width: 320px;min-width: 600px;display: table-cell;vertical-align: top;">
                  <div style="height: 100%;width: 100% !important;">
                    <!--[if (!mso)&(!IE)]><!-->
                    <div
                      style="box-sizing: border-box; height: 100%; padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;">
                      <!--<![endif]-->

                      <table style="font-family:helvetica,sans-serif;" role="presentation" cellpadding="0"
                        cellspacing="0" width="100%" border="0">
                        <tbody>
                          <tr>
                            <td
                              style="overflow-wrap:break-word;word-break:break-word;padding:40px 10px 10px;font-family:helvetica,sans-serif;"
                              align="left">

                              <table width="100%" cellpadding="0" cellspacing="0" border="0">
                                <tr>
                                  <td style="padding-right: 0px;padding-left: 0px;" align="center">

                                  </td>
                                </tr>
                              </table>

                            </td>
                          </tr>
                        </tbody>
                      </table>

                      <table style="font-family:helvetica,sans-serif;" role="presentation" cellpadding="0"
                        cellspacing="0" width="100%" border="0">
                        <tbody>
                          <tr>
                            <td
                              style="overflow-wrap:break-word;word-break:break-word;padding:17px 10px 10px;font-family:helvetica,sans-serif;"
                              align="left">

                              <div
                                style="font-size: 14px; color: #2f3033; line-height: 140%; text-align: left; word-wrap: break-word;">
                                <p style="font-size: 14px; line-height: 140%; text-align: center;">
                                  <span style="font-size: 28px; line-height: 39.2px;"><strong>Learn smart from home.</strong></span>
                                </p>
                              </div>

                            </td>
                          </tr>
                        </tbody>
                      </table>

                      <table style="font-family:helvetica,sans-serif;" role="presentation" cellpadding="0"
                        cellspacing="0" width="100%" border="0">
                        <tbody>
                          <tr>
                            <td
                              style="overflow-wrap:break-word;word-break:break-word;padding:0px 9px 9px;font-family:helvetica,sans-serif;"
                              align="left">

                              <div style="font-size: 14px; line-height: 180%; text-align: left; word-wrap: break-word;">
                                <p style="font-size: 14px; line-height: 180%; text-align: center;">
                                  <span style="font-size: 16px; line-height: 28.8px;">Lorem ipsum dolor sit amet, consectetuer adipiscing </span>
                                </p>
                                <p style="font-size: 14px; line-height: 180%; text-align: center;">
                                  <span style="font-size: 16px; line-height: 28.8px;">elit, sed diam nonummy nibh euismod tincidunt</span>
                                </p>
                                <p style="font-size: 14px; line-height: 180%; text-align: center;">
                                  <span style="font-size: 16px; line-height: 28.8px;"> ut laoreet dolore magna aliq</span>
                                </p>
                              </div>

                            </td>
                          </tr>
                        </tbody>
                      </table>

                      <table style="font-family:helvetica,sans-serif;" role="presentation" cellpadding="0"
                        cellspacing="0" width="100%" border="0">
                        <tbody>
                          <tr>
                            <td
                              style="overflow-wrap:break-word;word-break:break-word;padding:23px 10px 50px;font-family:helvetica,sans-serif;"
                              align="left">

                              <!--[if mso]><style>.v-button {background: transparent !important;}</style><![endif]-->
                              <div align="center">
                                <!--[if mso]><table border="0" cellspacing="0" cellpadding="0"><tr><td align="center" bgcolor="#55ceed" style="padding:15px 40px;" valign="top"><![endif]-->
                                <a href="https://babaochou2420.com" target="_blank" class="v-button"
                                  style="box-sizing: border-box;display: inline-block;text-decoration: none;-webkit-text-size-adjust: none;text-align: center;color: #FFFFFF; background-color: #55ceed; border-radius: 4px;-webkit-border-radius: 4px; -moz-border-radius: 4px; width:auto; max-width:100%; overflow-wrap: break-word; word-break: break-word; word-wrap:break-word; mso-border-alt: none;font-size: 14px;">
                                  <span style="display:block;padding:15px 40px;line-height:120%;"><strong><span style="font-size: 14px; line-height: 16.8px;">Start Tutor Pro</span></strong></span>
                                </a>
                                <!--[if mso]></td></tr></table><![endif]-->
                              </div>

                            </td>
                          </tr>
                        </tbody>
                      </table>

                      <!--[if (!mso)&(!IE)]><!-->
                    </div>
                    <!--<![endif]-->
                  </div>
                </div>
                <!--[if (mso)|(IE)]></td><![endif]-->
                <!--[if (mso)|(IE)]></tr></table></td></tr></table><![endif]-->
              </div>
            </div>
          </div>

          <!--[if gte mso 9]>
      </v:textbox></v:rect>
    </td>
    </tr>
    </table>
    <![endif]-->





          <div class="u-row-container" style="padding: 0px;background-color: transparent">
            <div class="u-row"
              style="margin: 0 auto;min-width: 320px;max-width: 600px;overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;background-color: #000000;">
              <div
                style="border-collapse: collapse;display: table;width: 100%;height: 100%;background-color: transparent;">
                <!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding: 0px;background-color: transparent;" align="center"><table cellpadding="0" cellspacing="0" border="0" style="width:600px;"><tr style="background-color: #000000;"><![endif]-->

                <!--[if (mso)|(IE)]><td align="center" width="600" style="width: 600px;padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;" valign="top"><![endif]-->
                <div class="u-col u-col-100"
                  style="max-width: 320px;min-width: 600px;display: table-cell;vertical-align: top;">
                  <div style="height: 100%;width: 100% !important;">
                    <!--[if (!mso)&(!IE)]><!-->
                    <div
                      style="box-sizing: border-box; height: 100%; padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;">
                      <!--<![endif]-->

                      <table style="font-family:helvetica,sans-serif;" role="presentation" cellpadding="0"
                        cellspacing="0" width="100%" border="0">
                        <tbody>
                          <tr>
                            <td
                              style="overflow-wrap:break-word;word-break:break-word;padding:18px 20px 7px;font-family:helvetica,sans-serif;"
                              align="left">

                              <div
                                style="font-size: 14px; color: #ced4d9; line-height: 170%; text-align: left; word-wrap: break-word;">
                                <p style="font-size: 14px; line-height: 170%;">
                                  <span style="font-size: 18px; line-height: 30.6px;">Contact</span></p>
                              </div>

                            </td>
                          </tr>
                        </tbody>
                      </table>

                      <table style="font-family:helvetica,sans-serif;" role="presentation" cellpadding="0"
                        cellspacing="0" width="100%" border="0">
                        <tbody>
                          <tr>
                            <td
                              style="overflow-wrap:break-word;word-break:break-word;padding:0px 20px 9px;font-family:helvetica,sans-serif;"
                              align="left">

                              <div
                                style="font-size: 14px; color: #9c9ca0; line-height: 140%; text-align: left; word-wrap: break-word;">
                                <p style="font-size: 14px; line-height: 140%;">
                                  <span style="font-size: 14px; line-height: 19.6px;">Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunreet dolore magna aliquam erat volutpatamet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincna aliquam erat volutpaUt wisi enim ad </span>
                                </p>
                              </div>

                            </td>
                          </tr>
                        </tbody>
                      </table>

                      <table style="font-family:helvetica,sans-serif;" role="presentation" cellpadding="0"
                        cellspacing="0" width="100%" border="0">
                        <tbody>
                          <tr>
                            <td
                              style="overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:helvetica,sans-serif;"
                              align="left">

                              <table height="0px" align="center" border="0" cellpadding="0" cellspacing="0" width="96%"
                                style="border-collapse: collapse;table-layout: fixed;border-spacing: 0;mso-table-lspace: 0pt;mso-table-rspace: 0pt;vertical-align: top;border-top: 1px solid #6e7074;-ms-text-size-adjust: 100%;-webkit-text-size-adjust: 100%">
                                <tbody>
                                  <tr style="vertical-align: top">
                                    <td
                                      style="word-break: break-word;border-collapse: collapse !important;vertical-align: top;font-size: 0px;line-height: 0px;mso-line-height-rule: exactly;-ms-text-size-adjust: 100%;-webkit-text-size-adjust: 100%">
                                      <span> </span>
                                    </td>
                                  </tr>
                                </tbody>
                              </table>

                            </td>
                          </tr>
                        </tbody>
                      </table>

                      <!--[if (!mso)&(!IE)]><!-->
                    </div>
                    <!--<![endif]-->
                  </div>
                </div>
                <!--[if (mso)|(IE)]></td><![endif]-->
                <!--[if (mso)|(IE)]></tr></table></td></tr></table><![endif]-->
              </div>
            </div>
          </div>





          <div class="u-row-container" style="padding: 0px;background-color: transparent">
            <div class="u-row"
              style="margin: 0 auto;min-width: 320px;max-width: 600px;overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;background-color: #000000;">
              <div
                style="border-collapse: collapse;display: table;width: 100%;height: 100%;background-color: transparent;">
                <!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding: 0px;background-color: transparent;" align="center"><table cellpadding="0" cellspacing="0" border="0" style="width:600px;"><tr style="background-color: #000000;"><![endif]-->

                <!--[if (mso)|(IE)]><td align="center" width="300" style="width: 300px;padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;" valign="top"><![endif]-->
                <div class="u-col u-col-50"
                  style="max-width: 320px;min-width: 300px;display: table-cell;vertical-align: top;">
                  <div style="height: 100%;width: 100% !important;">
                    <!--[if (!mso)&(!IE)]><!-->
                    <div
                      style="box-sizing: border-box; height: 100%; padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;">
                      <!--<![endif]-->

                      <table style="font-family:helvetica,sans-serif;" role="presentation" cellpadding="0"
                        cellspacing="0" width="100%" border="0">
                        <tbody>
                          <tr>
                            <td
                              style="overflow-wrap:break-word;word-break:break-word;padding:17px 20px 9px;font-family:helvetica,sans-serif;"
                              align="left">

                              <div
                                style="font-size: 14px; color: #9c9ca0; line-height: 140%; text-align: center; word-wrap: break-word;">
                                <p style="font-size: 14px; line-height: 140%;">
                                  <span style="font-size: 12px; line-height: 16.8px;">HOME  /  CONTACT  /  COURSES  /  TERMS</span>
                                </p>
                              </div>

                            </td>
                          </tr>
                        </tbody>
                      </table>

                      <!--[if (!mso)&(!IE)]><!-->
                    </div>
                    <!--<![endif]-->
                  </div>
                </div>
                <!--[if (mso)|(IE)]></td><![endif]-->
                <!--[if (mso)|(IE)]><td align="center" width="300" style="width: 300px;padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;" valign="top"><![endif]-->
                <div class="u-col u-col-50"
                  style="max-width: 320px;min-width: 300px;display: table-cell;vertical-align: top;">
                  <div style="height: 100%;width: 100% !important;">
                    <!--[if (!mso)&(!IE)]><!-->
                    <div
                      style="box-sizing: border-box; height: 100%; padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;">
                      <!--<![endif]-->

                      <table style="font-family:helvetica,sans-serif;" role="presentation" cellpadding="0"
                        cellspacing="0" width="100%" border="0">
                        <tbody>
                          <tr>
                            <td
                              style="overflow-wrap:break-word;word-break:break-word;padding:10px 10px 25px 20px;font-family:helvetica,sans-serif;"
                              align="left">

                              <div align="center">
                                <div style="display: table; max-width:247px;">
                                  <!--[if (mso)|(IE)]><table width="247" cellpadding="0" cellspacing="0" border="0"><tr><td style="border-collapse:collapse;" align="center"><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse; mso-table-lspace: 0pt;mso-table-rspace: 0pt; width:247px;"><tr><![endif]-->


                                  <!--[if (mso)|(IE)]><td width="32" style="width:32px; padding-right: 30px;" valign="top"><![endif]-->
                                  <table align="center" border="0" cellspacing="0" cellpadding="0" width="32"
                                    height="32"
                                    style="width: 32px !important;height: 32px !important;display: inline-block;border-collapse: collapse;table-layout: fixed;border-spacing: 0;mso-table-lspace: 0pt;mso-table-rspace: 0pt;vertical-align: top;margin-right: 30px">
                                    <tbody>
                                      <tr style="vertical-align: top">
                                        <td align="center" valign="middle"
                                          style="word-break: break-word;border-collapse: collapse !important;vertical-align: top">

                                        </td>
                                      </tr>
                                    </tbody>
                                  </table>
                                  <!--[if (mso)|(IE)]></td><![endif]-->

                                  <!--[if (mso)|(IE)]><td width="32" style="width:32px; padding-right: 30px;" valign="top"><![endif]-->
                                  <table align="center" border="0" cellspacing="0" cellpadding="0" width="32"
                                    height="32"
                                    style="width: 32px !important;height: 32px !important;display: inline-block;border-collapse: collapse;table-layout: fixed;border-spacing: 0;mso-table-lspace: 0pt;mso-table-rspace: 0pt;vertical-align: top;margin-right: 30px">
                                    <tbody>
                                      <tr style="vertical-align: top">
                                        <td align="center" valign="middle"
                                          style="word-break: break-word;border-collapse: collapse !important;vertical-align: top">

                                        </td>
                                      </tr>
                                    </tbody>
                                  </table>
                                  <!--[if (mso)|(IE)]></td><![endif]-->

                                  <!--[if (mso)|(IE)]><td width="32" style="width:32px; padding-right: 30px;" valign="top"><![endif]-->
                                  <table align="center" border="0" cellspacing="0" cellpadding="0" width="32"
                                    height="32"
                                    style="width: 32px !important;height: 32px !important;display: inline-block;border-collapse: collapse;table-layout: fixed;border-spacing: 0;mso-table-lspace: 0pt;mso-table-rspace: 0pt;vertical-align: top;margin-right: 30px">
                                    <tbody>
                                      <tr style="vertical-align: top">
                                        <td align="center" valign="middle"
                                          style="word-break: break-word;border-collapse: collapse !important;vertical-align: top">

                                        </td>
                                      </tr>
                                    </tbody>
                                  </table>
                                  <!--[if (mso)|(IE)]></td><![endif]-->

                                  <!--[if (mso)|(IE)]><td width="32" style="width:32px; padding-right: 0px;" valign="top"><![endif]-->
                                  <table align="center" border="0" cellspacing="0" cellpadding="0" width="32"
                                    height="32"
                                    style="width: 32px !important;height: 32px !important;display: inline-block;border-collapse: collapse;table-layout: fixed;border-spacing: 0;mso-table-lspace: 0pt;mso-table-rspace: 0pt;vertical-align: top;margin-right: 0px">
                                    <tbody>
                                      <tr style="vertical-align: top">
                                        <td align="center" valign="middle"
                                          style="word-break: break-word;border-collapse: collapse !important;vertical-align: top">

                                        </td>
                                      </tr>
                                    </tbody>
                                  </table>
                                  <!--[if (mso)|(IE)]></td><![endif]-->


                                  <!--[if (mso)|(IE)]></tr></table></td></tr></table><![endif]-->
                                </div>
                              </div>

                            </td>
                          </tr>
                        </tbody>
                      </table>

                      <!--[if (!mso)&(!IE)]><!-->
                    </div>
                    <!--<![endif]-->
                  </div>
                </div>
                <!--[if (mso)|(IE)]></td><![endif]-->
                <!--[if (mso)|(IE)]></tr></table></td></tr></table><![endif]-->
              </div>
            </div>
          </div>



          <!--[if (mso)|(IE)]></td></tr></table><![endif]-->
        </td>
      </tr>
    </tbody>
  </table>
  <!--[if mso]></div><![endif]-->
  <!--[if IE]></div><![endif]-->
</body>

</html>
  • G-Drive 智慧工坊 – 磁碟檔案 002 – 檔案屬性與日期比對

    G-Drive 智慧工坊 – 磁碟檔案 002 – 檔案屬性與日期比對


    0. 前言


    在上回我們說到,若是我們很常分享設定期效的檔案,逐個設定也是相當累人。

    不只是檔案分享,假如我們今天有份重要檔案每日有個備份,我們通常只會留下 7 日的復原點,那此次我們會依靠檔案的建立日期進行比較,雖然降低點精確度,但可以自動將到期的檔案進行刪除。

    1. 重點


    普遍 Apps Script 回傳的 Date 格式為 UTC,但普遍 API 主要採用 ISO 8601 格式 。
    Trigger 的 Day Timer 為時段觸發而非整點觸發

    2. 內容


    2.1. 時間格式

    在程式碼中的時間記錄,各位或許都聽過 Date 型態,然若沒有深入探索而不清楚其多種的樣式的,相對在處理與解析上就會有不同的麻煩點。

    2.1.1. 常用格式

    那在下方我們首先依靠 new Date() 取得現在時間,可以看到我們分別列出四種常用的紀錄方式。

    JavaScript
    function getDateFormat() {
      let now = new Date();
    
      // ISO 8601
      console.log(now.toISOString()); // 2024-06-26T09:16:29.183Z
      // Local Time
      console.log(now.toString()); // Wed Jun 26 2024 17:16:29 GMT+0800 (GMT+08:00)
      // RFC 7231
      console.log(now.toUTCString()); // Wed, 26 Jun 2024 09:16:29 GMT
      // Milliseconds
      console.log(now.getTime()); // 1719393389183
    }

    ISO 8601 是國際標準日期和時間格式,其中的 T 區隔日期與時間,尾端的 Z 代表時區偏移。而 Local Time 則是生活中最常使用,區分時區之後的時間,可以看到尾端還會標示加減多少。RFC 7231 則主要用於 HTTP 其 Header 中攜帶的 HTTP-date 參數。

    最後多介紹個毫秒數的表示法,它有致命的缺點是其開始自 UNIX 時間(1970),並且在長遠的未來數字會用完,但由於其計時單位極精細的因素,常用來判斷程式執行時長。

    2.1.2. 時間細節

    那在同樣都是 Date 格式下,可以倚靠以下方法快速取得指定數值。

    JavaScript
    function getDateDetail()
    {
      // Year
      console.log("Year:", now.getFullYear());
      // Month (0-11, where 0 = January and 11 = December)
      console.log("Month:", now.getMonth() + 1); // +1 because months are zero-indexed
      // Day of the Month
      console.log("Day of the Month:", now.getDate());
      // Day of the Week (0-6, where 0 = Sunday and 6 = Saturday)
      console.log("Day of the Week:", now.getDay());
      // Hours (0-23)
      console.log("Hours:", now.getHours());
      // Minutes (0-59)
      console.log("Minutes:", now.getMinutes());
      // Seconds (0-59)
      console.log("Seconds:", now.getSeconds());
      // Milliseconds (0-999)
      console.log("Milliseconds:", now.getMilliseconds());
      // Timezone Offset in Minutes
      console.log("Timezone Offset (in minutes):", now.getTimezoneOffset());
    }

    要注意是 “月份” 與 “星期幾” 是從 0 開始記錄,而時差也有些反常理,+8 時區回傳值是 -480 分鐘。

    2.2. 檔案屬性

    在 Drive 中點擊檔案後能在右側看到許多指標,而我們接著就要提取檔案的屬性。

    JavaScript
    // Get all attributes of file
    function getFileAttribute() {
      const FILE_ID = 'FILE_ID';
      const file = DriveApp.getFileById(FILE_ID);
    
      // Basic Attribute
      const fileName = file.getName();
      const fileSize = file.getSize();
      const fileId = file.getId(); 
      const fileUrl = file.getUrl();
      const fileDescription = file.getDescription();
      const fileType = file.getMimeType();
      const fileCreatedDate = file.getDateCreated();
      const fileLastUpdated = file.getLastUpdated();
      const fileOwner = file.getOwner().getEmail();
      const fileEditors = file.getEditors().map(function(editor) {
        return editor.getEmail();
      }).join(', ');
      const fileViewers = file.getViewers().map(function(viewer) {
        return viewer.getEmail();
      }).join(', ');
    
      console.log('File Name: ' + fileName);
      console.log('File Size: ' + fileSize + ' bytes');
      console.log(`File ID: ${fileId}`); 
      console.log('File URL: ' + fileUrl);
      console.log('File Description: ' + fileDescription);
      console.log('File Type: ' + fileType);
      console.log('File Created Date: ' + fileCreatedDate);
      console.log('File Last Updated: ' + fileLastUpdated);
      console.log('File Owner: ' + fileOwner);
      console.log('File Editors: ' + fileEditors);
      console.log('File Viewers: ' + fileViewers);
    
      // File download URL
      const fileDownloadUrl = file.getDownloadUrl(); 
      console.log(`File Download URL: ${fileDownloadUrl}`); 
    
      // An data intercahnge object
      const fileBlob = file.getBlob(); 
      console.log(`File BLOB: ${fileBlob.getBytes()}`); 
    }

    其中的 getUrl 是獲取檔案在 Drive 的位置,而 getDownloadUrl 則是下載連結,但同理用戶需要至少 READ 權限才能夠下載檔案。

    而 Blob 比較常用於資料傳輸的過程,主要是給機器閱覽而非人類能快速理解的資訊,通常比較少用到。

    2.3. 日期比對

    我們進入到今日的議題,假設我們準備一個資料夾安置檔案的每日備份,以 7 日座基底進行測試 。

    那由於時間的特性,為方便計算我設為 00:00.000 的情況,另外需要判定時間長過 days * 24 hr * 60 min * 60 sec * 1000 milli-sec 。後續可以看到一系列操作並透過 sorting 檔案時間,避免迴圈做無謂的檢查。

    JavaScript
    // - Scan childs in the target folder
    // -- Sort all childs by date created
    // ---- Compare date created with date today if is |lifeTime| days ago
    // ------ T
    // -------- Delete the file
    // ------ F
    // -------- BREAK the loop for there's no more longer than max life-time
    function scanLifeTime() {
      let folderId = 'FOLDER_ID';
    
      // Max lifetime in days
      let lifeTime = 7 * (24 * 3600 * 1000);
    
      console.warn('# RUN - scanLifeTime()');
    
      try {
        // Parent 
        let folder = DriveApp.getFolderById(folderId);
        // Childs
        let files = folder.getFiles();
    
        // List to store file info
        let fileList = [];
    
        // Timestamp to check lifetime
        let today = new Date().setHours(0, 0, 0, 0);
    
        // Gather all files into an array
        while (files.hasNext()) {
          let file = files.next();
          fileList.push({
            file: file,
            createdDate: file.getDateCreated()
          });
        }
    
        console.warn(`@ Sorting Files by Time Created`);
    
        // Sort files by their creation date
        fileList.sort((a, b) => a.createdDate - b.createdDate);
    
        console.warn(`@ Deleting Files over Lifetime`);
    
        // Iterate over the sorted array and delete files over lifetime
        for (let i = 0; i < fileList.length; i++) {
          let fileInfo = fileList[i];
          let createdDate = fileInfo.createdDate.setHours(0, 0, 0, 0);
    
          console.log(today - createdDate)
    
          if ((today - createdDate) >= lifeTime) {
            console.log(`(ID: ${fileInfo.file.getId()}) File ${fileInfo.file.getName()} removed for over lifetime`);
            // Move to trash can
            fileInfo.file.setTrashed(true);
          }
          // BREAK loop for no more longer than lifetime 
          else { console.log(`No files older than lifetime`); break; }
        }
      } catch (error) { console.error(error); }
    
      console.warn('# END - scanLifeTime()');
    }
    
    

    那前面提到我們需要針對每日進行檢查,就要另外設置 Trigger 的 Day Timer,可以選定個人偏好的時段。

    3. 後話


    到這邊我們已基本了解磁碟操作方式,並了解到常用的兩種定時 Trigger。 但各位應該覺得目前活用範圍偏少,這便要依靠 Google 編輯器進行擴增與資料保存。

    在過後呢我們將進入到 Google Form 並結合其他服務,那我們下期再見囉 ~~

    4. 參考


    [1] Date — MDN Web Docs
    https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date

    [2] Class File — Official Doc
    https://developers.google.com/apps-script/reference/drive/file

    [3] Class Blob — Official Doc
    https://developers.google.com/apps-script/reference/base/blob.html

    5. 素材


    [1] 圖片素材 by QuAn_
    https://www.pixiv.net/users/6657532

  • G-Drive 智慧工坊 – 磁碟檔案 001 – 磁碟架構與到點觸發

    G-Drive 智慧工坊 – 磁碟檔案 001 – 磁碟架構與到點觸發


    0. 前言


    在生活中我們不乏有需要分享的資料,但針對那些需要在期限內收回的我們則往往會忘記。

    就像是圖書館今日借出了文件資料,現實中通常只能依靠催促,但在科技上我們則會透過 ePub 設定過期時效,當時間到點之後電子檔就會自我毀損不再提供功能。

    而此時我們就可以依靠定時器,在指定的時間點處理事項,不必擔心事後遺忘。

    1. 重點


    磁碟的檔案都具帶獨特 ID,這也是為何搬移資料位置並不會影響連線
    可以透過 Trigger 指定單次觸發時間

    2. 內容


    2.1. 資料遷移

    在我們進入正題之前,我希望先介紹如何在磁碟中進行檔案與資料夾的增刪改查,讓各位先清楚其架構。

    現在各位看到上圖我準備了空資料夾 1 與 2,而紅框的圖片則是我們待會進行示範的素材,此時若我們透過 右鍵 >> Share >> Copy Link,將網址複製出來將類似以下格式,其中的 file 代表其屬性,而在 d 之後的 33 位字元便是普遍檔案的 File ID 。

    JavaScript
    https://drive.google.com/file/d/1nraXFA4oTV4KaBPvxIZfSsECpM_M1MHH/view?usp=drive_link

    而如果是像 Google Docs Editors,當我們進入編輯頁面後則會看到是 44 位字元,可以看到並不歸屬於普通的 file 分類,而會各自呈現 document, spreadsheet, presentation 與 forms 等等標籤。

    JavaScript
    https://docs.google.com/document/d/1T8UI3P4S9PkBALRtI9mck3ORGDmVk0XG-fKd0k2vQlo/edit

    此時我們在往上看到雲端磁碟的網址尾端,除了 My Drive 之外都能直接夠看到這個資料夾的 Folder ID 。而這也不代表 My Drive 沒有 ID,在結構下它也屬資料夾,只是必須透過 Apps Script 的回應取得。

    JavaScript
    https://drive.google.com/drive/folders/1Dtsmmoh_57RDAi50Nl3lpaP5H7wsQ-DG

    而正是有這些 ID,我們的檔案就算搬遷了,擁有權限的人還是可以正常連線到。而此時就是為何私人檔案或小組作業千萬別偷懶開放 Everyone,透過猜網址的辦法不安好心的用戶也能夠抓取到檔案。

    那我們接著就先進入到 Apps Script 的簡單示範,我們將設定好參數中所有的 ID。那可以看到針對檔案的讀取我們使用 DriveApp.get####ById,另外其實有 ByName 方法用名稱搜尋但個人並不推薦。

    JavaScript
    var file = DriveApp.getFileById('FILE_ID');
    
    var folder_001 = DriveApp.getFolderById('FOLDER_1_ID');
    var folder_002 = DriveApp.getFolderById('FOLDER_2_ID');

    2.2.1. 檔案副本

    接著先執行看看 copy 方法,完成後應該會在資料夾 1 出現同名的檔案。那各位應該有注意到三種不同的makeCopy 格式,第一種就是我們在同資料夾內 ctrl c / v 會產出帶有 Copy 字樣的檔案,此時在同路徑下想要客製化檔名就要依靠第二種,而最後的第三種則又包含要儲存到哪個資料夾。

    JavaScript
    // Make a copy of file
    function copy() {
      try {
        // // Make a copy under same root
        // file.makeCopy(); 
    
        // file.makeCopy(`${NAME}`); 
    
        file.makeCopy(file.getName(), folder_001);
      } catch (error) { console.error(error); }
    }

    2.2.2. 檔案命名

    我們接著針對該副本進行處理,複製其 ID 並替換程式碼的設定。

    再來我們測試 rename 方法的效果,在上一部我們用過 getName 取得檔名,而這次我們則是用 setName 將其重新命名,在範例我們檢查是否變成 TESTING 字樣。

    JavaScript
    // Rename file
    function rename() {
      try { file.setName(`TESTING`); } catch (error) { console.error(error); }
    }

    2.2.3. 檔案遷移

    那再來我們將嘗試將檔案變換位置,接著執行看看我們的 move 方法,應該會將檔案轉到不同資料夾。

    JavaScript
    // Move file
    function move() {
      try {
        file.moveTo(folder_002);
    
        console.log('Moved file from folder 1 to 2');
      }
      catch (error) { console.error(error); }
    }

    2.2.4. 軟性刪除(Soft Delete)

    我們所謂的軟性刪除是指給予檔案標籤,而尚未實際刪除檔案,常見於垃圾筒的功能。我們接著執行 trash 方法,並到垃圾桶檢查是否有刪除該檔案。

    JavaScript
    // Soft Delete
    function trash() {
      try {
        file.setTrashed(true);
    
        console.log('Moved file to trash can');
      } catch (error) { console.error(error); }
    }

    2.2.5. 硬性刪除(Hard Delete)

    反之硬性刪除則是直接從磁碟完全刪除,而此時我們就必須先仰賴額外的 Service >> 選擇 Drive API 。

    我們選擇 Drive API 最新版的 V3,並維持預設的呼叫名稱: Drive 。

    完成後再來試試 remove 方法,過後檔案應該從垃圾桶直接被刪除,阿當然也可直接套用於磁碟檔案。

    JavaScript
    // Hard Delete
    function remove() {
      try {
        Drive.Files.remove(file.getId());
    
        console.log('Removed file permanently');
      } catch (error) { console.error(error); }
    }

    2.2. 權限定時

    總算來到我們的實作,那我們的目的就是在特定時間重設檔案的權限,或者套用剛剛的所學。若你剛剛有按照上面的步驟嘗試,請記得將最開始定義檔案 ID 與 資料夾 ID 的部份註解,否則會報錯找不到檔案。

    JavaScript
    // Update sharing permission of folder
    // 
    // - Get all users already registered on list
    // -- Remove Viewers
    // -- Remove Editors
    // - Reset folder permission
    function updateFolderPermissions() {
      let folderId = 'YOUR_FOLDER_ID'; 
    
      console.warn('# RUN - updateFolderPermissions()');
    
      try {
        let folder = DriveApp.getFolderById(folderId);
        
        // Remove existing permissions
        // let editors = folder.getEditors();
        // let viewers = folder.getViewers();
        
        // editors.forEach(function(editor) {
        //   folder.removeEditor(editor);
        // });
        
        // viewers.forEach(function(viewer) {
        //   folder.removeViewer(viewer);
        // });
        
        // @ 適用範圍 Access Type
        // @ 分配權限 Permission Type
        folder.setSharing(DriveApp.Access.PRIVATE, DriveApp.Permission.NONE);
    
      } catch (error) {
        console.error(error);
      }
    
      console.warn('# END - updateFolderPermissions()');
    }
    

    那我們接著就要學到簡易的 Trigger,並利用簡易的時間格式設定時間,選擇左側選單的 Trigger >> 右下角新增,並依照以下設定。那關於右側當程式出錯寄信的頻率,我一般都選即時以立即進行排錯。

    那現在我將資料夾先設為 Everyone is Viewer,並設定時間於當日的 22:30 分來測試看看結果。此時重整頁面後會看到 Who has access 多了一顆綠色的地球圖樣。

    3. 後話


    那在這篇之中,我們簡單帶過磁碟檔案的基礎變動方式,以及單次定時的方式。

    但目前定時的方式稍加麻煩必須每次手動調整,而在下回我們則會更進一步使用物件屬性,透過時間的比對自動刪除超過時限的特定檔案。

    4. 參考


    [1] Class DriveApp — Official Doc
    https://developers.google.com/apps-script/reference/drive/drive-app

    5. 素材


    [1] 圖片素材 by QuAn_
    https://www.pixiv.net/users/6657532

  • G-Drive 智慧工坊 – 檔案偵測 004 – 部屬與測試

    0. 前言


    在過往的三篇中我們完成了所有物件的變動偵測功能,將著就要進行基礎的壓力測試與共用資料夾測試,最終確認後進行部屬。

    1. 重點


    GAS 本身沒有偵測磁碟變動的 Trigger,我們改用每日定時的方式

    2. 內容


    2.1. 共享資源測試

    在上回的測試中只展現了個人的垃圾桶,正巧該專案本身就是要在共用環境使用因此順道測試 run 方法。

    此時我換隻帳號,將上回殘存的檔案刪除與修改各一筆,另外上傳一份檔案。當然若你只是個人使用,可以跳過這邊的調整。

    接著切回帳號執行 GAS 的三種方法,雖然檢測新增與修改都能正常運作,但這時會發現很嚴重的事實。因為 searchFilesDeleted() 是翻找用戶的垃圾桶,因此我們無法抓到其他用戶刪除的檔案。所以我們必須將刪除的部分獨立出來讓每個用戶都註冊 Trigger 。

    雖然我們教學沒提及,但 Shared Drive 的話則是本身共用一個垃圾桶,因此不必擔心這個議題。

    2.2. 時間測試

    那緊接著我就要為各位展示基礎的壓力測試,或許各位只是要記錄如 Doc 或 Spreadsheet 等資料,每日增加的數目並不大多,但我目前一位作者的作品抓下來並人工整理後有時高達 500 – 600 張甚至超過,那我接著就利用執行時間來進行推估。

    我們一般用戶的單次執行時間上限是 6 分鐘,並現在似乎沒了每分鐘最大修改 Spreadsheet 操作數量。

    那我先將表單建立副本到其他地方進行測試,那我們依序觸發以下功能分開計時 。

    — 先清空紀錄總表

    — 先將 100 張照片上傳在一個資料夾,

    2.2.1. 執行 GAS 紀錄新增用時。

    在 43:29 執行於 47:13 完成 –> 3 分 44 秒,但我們必須進行 sorting 才能存到正確的時間戳,我們試試看如何加速。那首先我將 searchFilesCreated() 內的排序方法改為 function,並減少 DriveApp 的呼叫。

    JavaScript
    // Sorting by createdDate
    fileList = sortFilesByCreatedDate(fileList); 

    JavaScript
    // Sort files by File Time Created
    function sortFilesByCreatedDate(fileList) {
      // Fetch metadata for all files once
      const filesWithDates = fileList.map(file => {
        const fileMeta = DriveApp.getFileById(file.id);
        return {
          id: file.id,
          name: file.name,
          createdDate: fileMeta.getDateCreated()
        };
      });
    
      // Sort the array by created date
      filesWithDates.sort((a, b) => a.createdDate - b.createdDate);
    
      // If needed, you can now map this back to the original fileList format
      const sortedFileList = filesWithDates.map(file => ({
        id: file.id,
        name: file.name
      }));
    
      return sortedFileList;
    }

    從 07:35 執行到 09:31 –> 1 分 56 秒

    2.2.2. 執行 GAS 紀錄更新用時

    我利用 Windows 的 .bat 檔案將資料夾中所有檔案改名,我們接著執行看看更新的部分。

    於 20:03 開始至 21:34 –> 1 分 31 秒

    2.2.3. 刪除資料並記錄 GAS 用時

    那接著我們將資料夾刪除再度進行測試,卻發現是資料是空的,因為在垃圾桶中系統攤不開裡面的檔案,我們退回一步,將資料夾中的圖片全數刪除。

    於 27:49 開始至 28:30 –> 41 秒,但這是在垃圾桶乾淨的環境下測的,因此迴圈數量有減少。

    經過這些實驗,可以知道其實我們這個程式由於迴圈居多,若綁一起非常容易超時,為此我們需要切割。

    2.3. 執行方法

    首先我們將書寫新的方法專門給後續的 Trigger 觸發,那在步驟上我們會依靠偵測刪除先減少表上的資料量,接著更新有變動的資料,最終才新增新的紀錄。

    如果你是個人用戶可以使用以下方法,且看過上述測試不怕超時的可用以下方法。

    JavaScript
    // Run all functions in sequences
    function run() {
      console.info(`【刪除階段】`); 
      try { searchFilesDeleted(); } catch (error) { console.error(`【刪除報錯】`); }
      console.info(`【修改階段】`); 
      try { searchFilesModified(); } catch (error) { console.error(`【修改報錯】`); }
      console.info(`【新增階段】`); 
      try { searchFilesCreated(); } catch (error) { console.error(`【新增報錯】`); }
    
      console.info(`【工作完成】`); 
    }

    但如果跟我一樣套用在共享資料夾,則必須拆開刪除與兩個方法。那由於刪除會影響資料表的排序,但其本身也沒有對於時限的需求,只能偵測垃圾桶中全部 30 日內的檔案,因此我們不用特別修改設定。而 Trigger 的定時器是以整點為基準,因此我們調快在 1 小時前觸發即可。

    那我範例的 run 方法則只有修改與新增的功能。

    JavaScript
    // Run all functions in sequences
    // -- yet run searchFilesDeleted() 1 hr ere for problems on detecting others' trash can
    function run() {
      console.info(`【修改階段】`); 
      try { searchFilesModified(); } catch (error) { console.error(`【修改報錯】`); }
      console.info(`【新增階段】`); 
      try { searchFilesCreated(); } catch (error) { console.error(`【新增報錯】`); }
    
      console.info(`【工作完成】`); 
    }

    完成之後,我們將 Properties 中的時間再往前撥動之後,換執行 run() 看看是否正常。

    但如果我們有超時的風險,則我建議用原始的三個程式,將檢查修改與新增放同個時段,他們之間不會互相影響。

    2.4. 定時機制

    那我假設各位作息正常,並且不怕超時,我們就在 04:00 的時候觸發 run 方法。

    我們點選左側選單中的鬧鐘圖樣(Trigger),並點選又下的藍色按鈕新增觸發器。接著如以下設定,並選擇執行的時間從 4am – 5am 。右側的是當有報錯時的通知設定,預設是每日,但我調整成立即。

    那接著與我相同是共享資料夾的用戶,我們依照 24 小時制我們最多可以提供 23 個使用者進行刪除檢測,分開時段是為了避免相衝導致行數被錯刪。對於小團體來說已是非常足夠,也適用於區分管理員的群組。

    我們只要注意時間不要與 run 方法同時段,以及用戶之間不相衝就可以了。

    那像我一樣資料過大的,則將三個 search 方法建立 Trigger,並讓刪除避開偵測修改與新增即可。

    3. 後話


    到這邊我們的檔案偵測系統基本就完成,但我們可以看到他還是非常粗淺的版本,十分的脆弱又緩慢。

    但我們就將就使用,但基本檔案若沒很多還是個實用的小工具。但若有成功我會再推出更新後的方案。

    4. 參考


    [1] Quotas for Google Services — Official Doc
    https://developers.google.com/apps-script/guides/services/quotas

    5. 成品


    JavaScript
    // Find parent folder by where the spreadsheet is stored
    function getParentFolder() {
        // Get the active spreadsheet
        var spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
        // Get the ID of the spreadsheet
        var spreadsheetId = spreadsheet.getId();
    
        // Get the parent folders of the spreadsheet
        var parentFolders = DriveApp.getFileById(spreadsheetId).getParents();
    
        // Check if there are any parent folders
        if (parentFolders.hasNext()) {
            // Assuming we only have a parent folder
            var parentFolder = parentFolders.next();
    
            // For testing purpose
            console.log(`Parent Folder: ${parentFolder.getUrl()}`);
    
            // Return the folder object
            return parentFolder;
        }
        // If there are no parent folders, return null 
        else {
            console.error("[ERROR] Critical Error on Getting Parent URL");
    
            return null;
        }
    }
    
    // Init setting in project propeties
    function init() {
        // Sheet Name
        const sheetFilesDetection_sheetName = "總表";
    
        try {
            PropertiesService.getScriptProperties().setProperties({
                'sheetFilesDetection_sheetName': sheetFilesDetection_sheetName,
                // Init lastSearchFilesCreatedTime
                'lastSearchFilesCreatedTime': new Date().toISOString()
            });
            console.info("[ O K ] Successfully initalize the app.");
        } catch (error) { console.error(`[ERROR] Failed to initialize the app: ${error}`); }
    }
    
    // Run all functions in sequences
    // -- yet run searchFilesDeleted() 1 hr ere for problems on detecting others' trash can
    function run() {
        console.info(`【修改階段】`);
        try { searchFilesModified(); } catch (error) { console.error(`【修改報錯】`); }
        console.info(`【新增階段】`);
        try { searchFilesCreated(); } catch (error) { console.error(`【新增報錯】`); }
    
        console.info(`【工作完成】`);
    }
    
    // Search Files Newly Deleted
    // 
    // - Loop through stack to see if any folder need to be scan
    // -- Get all soft-deleted items under folder
    // ---- Loop through every file
    // ------ Using file id to search which row data at by URL
    // -------- Delete the row
    // -- Look into the subfolder
    // ---- Push subfolder to stack to continue while-loop
    function searchFilesDeleted() {
        // Getting the spreadsheet for data writing
        const sheetFilesDetection = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(PropertiesService.getScriptProperties().getProperty('sheetFilesDetection_sheetName'));
    
        // Init first folder path
        let folderStack = [DriveApp.getFileById(SpreadsheetApp.getActiveSpreadsheet().getId()).getParents().next().getId()];
    
        // List for search result
        let fileList = [];
    
        while (folderStack.length > 0) {
            // // Debugging
            // console.log(folderStack);
    
            // Choosing folder to run process
            let folderId = folderStack.pop();
            // Query
            let query = `trashed=true and mimeType != "application/vnd.google-apps.folder" and "${folderId}" in parents`;
    
            // Checkpoint
            let pageToken = "";
    
            do {
                try {
                    const url = encodeURI(
                        `https://www.googleapis.com/drive/v3/files?q=${query}&pageSize=1000&pageToken=${pageToken}`
                    ); // Include driveId and includeItemsFromAllDrives parameters
                    const res = UrlFetchApp.fetch(url, {
                        headers: { authorization: "Bearer " + ScriptApp.getOAuthToken() },
                    });
                    const obj = JSON.parse(res.getContentText());
                    if (obj.files && obj.files.length > 0) {
                        fileList = [...fileList, ...obj.files];
                    }
                    pageToken = obj.nextPageToken;
                } catch (error) {
                    console.error(`[ERROR] Error fetching files: ${error}`);
                    break;
                }
            } while (pageToken);
    
            // Find all subfolders
            const folders = DriveApp.getFolderById(folderId).getFolders();
            // Add subfolders to stack
            while (folders.hasNext()) {
                folderStack.push(folders.next().getId());
            }
        }
    
        // Debugging
        console.log(fileList);
    
        // Delete data from spreadsheet one by one
        fileList.forEach(file => {
            // Delete from spreadsheet
            rowDelete(sheetFilesDetection, file);
        });
    }
    
    // Search Files Newly Modified
    // 
    // - Loop through stack to see if any folder need to be scan
    // -- Comparing Files Modified Time and Latestest File Created Time on last execution to see if is new
    // -- Look into the subfolder
    // ---- Push subfolder to stack to continue while-loop
    // - Update attributes to spreadsheet
    function searchFilesModified() {
        // Getting the spreadsheet for data writing
        const sheetFilesDetection = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(PropertiesService.getScriptProperties().getProperty('sheetFilesDetection_sheetName'));
    
        // Latest file created time on last execution
        const lastSearchFilesCreatedTime = PropertiesService.getScriptProperties().getProperty('lastSearchFilesCreatedTime');
    
        // Init first folder path
        let folderStack = [DriveApp.getFileById(SpreadsheetApp.getActiveSpreadsheet().getId()).getParents().next().getId()];
    
        // List for search result
        let fileList = [];
    
        while (folderStack.length > 0) {
            // // Debugging
            // console.log(folderStack);
    
            // Choosing folder to run process
            let folderId = folderStack.pop();
            // Query
            let query = `modifiedTime  > "${lastSearchFilesCreatedTime}" and trashed=false and mimeType != "application/vnd.google-apps.folder" and "${folderId}" in parents and not name = "${SpreadsheetApp.getActiveSpreadsheet().getName()}"`;
    
            // Checkpoint
            let pageToken = "";
    
            do {
                try {
                    const url = encodeURI(
                        `https://www.googleapis.com/drive/v3/files?q=${query}&pageSize=1000&pageToken=${pageToken}`
                    ); // Include driveId and includeItemsFromAllDrives parameters
                    const res = UrlFetchApp.fetch(url, {
                        headers: { authorization: "Bearer " + ScriptApp.getOAuthToken() },
                    });
                    const obj = JSON.parse(res.getContentText());
                    if (obj.files && obj.files.length > 0) {
                        fileList = [...fileList, ...obj.files];
                    }
                    pageToken = obj.nextPageToken;
                } catch (error) {
                    console.error(`[ERROR] Error fetching files: ${error}`);
                    break;
                }
            } while (pageToken);
    
            // Find all subfolders
            const folders = DriveApp.getFolderById(folderId).getFolders();
            // Add subfolders to stack
            while (folders.hasNext()) {
                folderStack.push(folders.next().getId());
            }
        }
    
        // Debugging
        console.log(fileList);
    
        // Write attributes to spreadsheet one by one
        fileList.forEach(json => {
            // Use ID to get Object of File Class
            file = DriveApp.getFileById(json.id);
            // Write to spreadsheet
            rowUpdate(sheetFilesDetection, file);
        });
    }
    
    // Search Files Newly Created
    // 
    // - Loop through stack to see if any folder need to be scan
    // -- Comparing Files CreatedDate and Latestest File Created Time on last execution to see if is new
    // -- Look into the subfolder
    // ---- Push subfolder to stack to continue while-loop
    // - Resort full fileList by file created date
    // - Write attributes to spreadsheet
    // - Reset property "lastSearchFilesCreatedTime" to latest
    function searchFilesCreated() {
        // Getting the spreadsheet for data writing
        const sheetFilesDetection = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(PropertiesService.getScriptProperties().getProperty('sheetFilesDetection_sheetName'));
    
        // Latest file created time on last execution
        const lastSearchFilesCreatedTime = PropertiesService.getScriptProperties().getProperty('lastSearchFilesCreatedTime');
    
        // Init first folder path
        let folderStack = [DriveApp.getFileById(SpreadsheetApp.getActiveSpreadsheet().getId()).getParents().next().getId()];
    
        // List for search result
        let fileList = [];
    
        while (folderStack.length > 0) {
            // // Debugging
            // console.log(folderStack);
    
            // Choosing folder to run process
            let folderId = folderStack.pop();
            // Query
            let query = `createdTime > "${lastSearchFilesCreatedTime}" and trashed=false and mimeType != "application/vnd.google-apps.folder" and "${folderId}" in parents`;
    
            // Checkpoint
            let pageToken = "";
    
            do {
                try {
                    const url = encodeURI(
                        `https://www.googleapis.com/drive/v3/files?q=${query}&pageSize=1000&pageToken=${pageToken}`
                    ); // Include driveId and includeItemsFromAllDrives parameters
                    const res = UrlFetchApp.fetch(url, {
                        headers: { authorization: "Bearer " + ScriptApp.getOAuthToken() },
                    });
                    const obj = JSON.parse(res.getContentText());
                    if (obj.files && obj.files.length > 0) {
                        fileList = [...fileList, ...obj.files];
                    }
                    pageToken = obj.nextPageToken;
                } catch (error) {
                    console.error(`[ERROR] Error fetching files: ${error}`);
                    break;
                }
            } while (pageToken);
    
            // Find all subfolders
            const folders = DriveApp.getFolderById(folderId).getFolders();
            // Add subfolders to stack
            while (folders.hasNext()) {
                folderStack.push(folders.next().getId());
            }
        }
    
        // Sorting by createdDate
        fileList = sortFilesByCreatedDate(fileList); 
    
        // Debugging
        console.log(fileList);
    
        // Write attributes to spreadsheet on sequence
        fileList.forEach(json => {
            // Use ID to get Object of File Class
            file = DriveApp.getFileById(json.id);
            // Write to spreadsheet
            rowAppend(sheetFilesDetection, file);
        });
    
        // There's new file
        if (fileList.length > 0) {
            // Store time of lastest file created time
            PropertiesService.getScriptProperties().setProperty('lastSearchFilesCreatedTime', DriveApp.getFileById((fileList[fileList.length - 1].id)).getDateCreated().toISOString());
        }
    }
    
    // Delete all related row datas from spreadsheet
    function rowDelete(sheet, file) {
        // index
        const fileId = file.id;
        // Search result
        var occurrences;
        // TMP storage for row index to delete from end to front
        var rows = [];
    
        try {
            // Find all URL contains the id
            occurrences = sheet.createTextFinder(fileId).findAll();
    
            // If found
            if (occurrences.length > 0) {
                // Push all into list
                occurrences.forEach(range => { rows.push(range.getRow()); });
    
                // Change to by from end to front
                rows.sort((a, b) => b - a);
    
                // Delete all rows related
                rows.forEach(row => { sheet.deleteRow(row); })
    
                console.log(`【刪除成功】 -- 從偵測表刪除 ${fileId} 資料`);
            }
        } catch (error) {
            console.error(`【刪除失敗】 -- 從偵測表刪除 ${fileId} 資料`);
        }
    }
    
    // Update attributes in spreadsheet
    // 
    // @ Author Name
    // @ File Name
    // @ File URL
    // @ Folder URL
    // @ File Modified Date 
    function rowUpdate(sheet, file) {
        // index
        const fileId = file.getId();
        // Search result
        var occurrences;
        // TMP storage for row index to delete from end to front
        var rows = [];
    
        try {
            // Find all URL contains the id
            occurrences = sheet.createTextFinder(fileId).findAll();
    
            // If found
            if (occurrences.length > 0) {
                // Push all into list
                occurrences.forEach(range => { rows.push(range.getRow()); });
    
                // Get the header row
                const columnHeaders = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
    
                // Index
                const indexWorkCategory = columnHeaders.indexOf('繪畫風格');
                const indexWorkAuthor = columnHeaders.indexOf('作家名稱');
                const indexDateCreated = columnHeaders.indexOf('建檔時間');
                const indexDateModified = columnHeaders.indexOf('更新時間');
                const indexFolderURL = columnHeaders.indexOf('母資料夾');
                const indexWorkName = columnHeaders.indexOf('作品名稱');
                const indexWorkURL = columnHeaders.indexOf('檔案位置');
    
                // Read new values
                // -- File name
                const parts = file.getName().split(' - ');
                // ---- Author Name
                const authorName = parts[0];
                // ---- Work Name
                const workName = parts[1];
                // -- File URL
                const workURL = file.getUrl();
                // -- Folder URL
                const folderURL = file.getParents().next().getUrl();
                // -- File Modified Date
                const formattedDateModified = Utilities.formatDate(file.getLastUpdated(), Session.getScriptTimeZone(), "yyyy-MM-dd HH:mm:ss");
    
                // Update all rows related
                rows.forEach(row => {
                    let range = sheet.getRange(row, 1, 1, sheet.getLastColumn());
    
                    // Read all original values as array
                    let data = range.getValues()[0];
    
                    // Replace values
                    data[indexWorkAuthor] = authorName || "Unknown";
                    data[indexDateModified] = formattedDateModified;
                    data[indexFolderURL] = folderURL;
                    data[indexWorkName] = workName || "Undefined";
                    data[indexWorkURL] = workURL;
    
                    // Replace row
                    range.setValues([data]);
                })
    
                console.log(`【更新成功】 -- 從偵測表修改 ${fileId} 資料`);
            }
        } catch (error) {
            console.error(`【更新失敗】 -- 從偵測表修改 ${fileId} 資料`);
        }
    }
    
    // Write attributes back to spreadsheet
    // 
    // @ Artstyle
    // @ Author Name
    // @ File Name
    // @ File URL
    // @ Folder URL
    // @ File Created Date
    // @ File Modified Date 
    function rowAppend(sheet, file) {
        // Get attributes
        // -- File Name
        const parts = file.getName().split(' - ');
        // ---- Author Name
        const authorName = parts[0];
        // ---- Work Name
        const workName = parts[1];
        // -- File URL
        const workURL = file.getUrl();
        // -- Folder URL
        const folderURL = file.getParents().next().getUrl();
        // -- File Created Date
        const formattedDateCreated = Utilities.formatDate(file.getDateCreated(), Session.getScriptTimeZone(), "yyyy-MM-dd HH:mm:ss");
        // -- File Modified Date
        const formattedDateModified = Utilities.formatDate(file.getLastUpdated(), Session.getScriptTimeZone(), "yyyy-MM-dd HH:mm:ss");
    
        // Get the header row
        const columnHeaders = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
    
        // Index
        const indexWorkCategory = columnHeaders.indexOf('繪畫風格');
        const indexWorkAuthor = columnHeaders.indexOf('作家名稱');
        const indexDateCreated = columnHeaders.indexOf('建檔時間');
        const indexDateModified = columnHeaders.indexOf('更新時間');
        const indexFolderURL = columnHeaders.indexOf('母資料夾');
        const indexWorkName = columnHeaders.indexOf('作品名稱');
        const indexWorkURL = columnHeaders.indexOf('檔案位置');
    
        // Prepare the data array for the new row
        let row = new Array(columnHeaders.length).fill('');
    
        row[indexWorkCategory] = getArtStyle(file.getParents().next());
        row[indexWorkAuthor] = authorName || "Unknown";
        row[indexDateCreated] = formattedDateCreated;
        row[indexDateModified] = formattedDateModified;
        row[indexFolderURL] = folderURL;
        row[indexWorkName] = workName || "Undefined";
        row[indexWorkURL] = workURL;
    
        // Write to last row in sheet
        sheet.appendRow(row);
    }
    
    // Get artstyle based on folder name
    // 
    // - Look up parent folder until no more @ in folder name
    function getArtStyle(folder) {
        // With @ -> Artist portfolio
        if (folder.getName().indexOf('@') !== -1) {
            // Find it's parent 
            do {
                folder = folder.getParents().next();
            } while (folder.getName().indexOf('@') !== -1);
        }
        // Art style
        return folder.getName();
    }
    
    // Sort files by File Time Created
    function sortFilesByCreatedDate(fileList) {
      // Fetch metadata for all files once
      const filesWithDates = fileList.map(file => {
        const fileMeta = DriveApp.getFileById(file.id);
        return {
          id: file.id,
          name: file.name,
          createdDate: fileMeta.getDateCreated()
        };
      });
    
      // Sort the array by created date
      filesWithDates.sort((a, b) => a.createdDate - b.createdDate);
    
      // If needed, you can now map this back to the original fileList format
      const sortedFileList = filesWithDates.map(file => ({
        id: file.id,
        name: file.name
      }));
    
      return sortedFileList;
    }
    
  • G-Drive 智慧工坊 – 檔案偵測 003 – 偵測刪除與修改

    0. 前言


    在前面的實驗中,我們成功取得新增的檔案資訊並依照格式寫入總表中,但我們並沒有建立更新的判斷,也代表刪除的檔案仍殘存於資料表上。

    那這回我們就來看看如何偵測並對記錄進行改動吧!

    1. 重點


    Drive API 只能偵測到在垃圾桶中的檔案,若永久清除則無法觸發以下效果
    透過 Spreadsheet 的 createTextFinder 方法避開迴圈搜尋

    2. 內容


    2.1. 偵測刪除

    那對於紀錄影響最大的莫過於刪除,同時減少資料量也能讓我們下一步的偵測修改略為快速。

    2.1.1. 資料測試

    我們複製 searchFilesCreated 並利用其架構進行改動,並先將其命名為 searchFilesDeleted 方法。那在目前我們無法限制搜尋何時刪除,只能取得整個垃圾桶 30 日的資料。另外我們將排序時間的部分刪除以減少處理時間,畢竟我們只是找資料清除,就不用美化總表了。

    JavaScript
    // Search Files Newly Deleted
    // 
    // - Loop through stack to see if any folder need to be scan
    // -- Get all soft-deleted items under folder
    // ---- Loop through every file
    // ------ Using file id to search which row data at by URL
    // -------- Delete the row
    // -- Look into the subfolder
    // ---- Push subfolder to stack to continue while-loop
    function searchFilesDeleted() {
      // Getting the spreadsheet for data writing
      const sheetFilesDetection = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(PropertiesService.getScriptProperties().getProperty('sheetFilesDetection_sheetName'));
    
      // Init first folder path
      let folderStack = [DriveApp.getFileById(SpreadsheetApp.getActiveSpreadsheet().getId()).getParents().next().getId()];
    
      // List for search result
      let fileList = [];
    
      while (folderStack.length > 0) {
        // // Debugging
        // console.log(folderStack);
    
        // Choosing folder to run process
        let folderId = folderStack.pop();
        // Query
        let query = `trashed=true and mimeType != "application/vnd.google-apps.folder" and "${folderId}" in parents`;
    
        // Checkpoint
        let pageToken = "";
    
        do {
          try {
            const url = encodeURI(
              `https://www.googleapis.com/drive/v3/files?q=${query}&pageSize=1000&pageToken=${pageToken}`
            ); // Include driveId and includeItemsFromAllDrives parameters
            const res = UrlFetchApp.fetch(url, {
              headers: { authorization: "Bearer " + ScriptApp.getOAuthToken() },
            });
            const obj = JSON.parse(res.getContentText());
            if (obj.files && obj.files.length > 0) {
              fileList = [...fileList, ...obj.files];
            }
            pageToken = obj.nextPageToken;
          } catch (error) {
            console.error(`[ERROR] Error fetching files: ${error}`);
            break;
          }
        } while (pageToken);
    
        // Find all subfolders
        const folders = DriveApp.getFolderById(folderId).getFolders();
        // Add subfolders to stack
        while (folders.hasNext()) {
          folderStack.push(folders.next().getId());
        }
      }
    
      // Debugging
      console.log(fileList);
    
      // // Write attributes to spreadsheet on sequence
      // fileList.forEach(json => {
      //   // Use ID to get Object of File Class
      //   file = DriveApp.getFileById(json.id);
      //   // Write to spreadsheet
      //   writeToSpreadsheet(sheetFilesDetection, file);
      // });
    }

    那接著我將兩張圖片丟到垃圾桶,並執行 searchFilesDeleted 方法。那經由測試結果,也能夠看到我們只找母資料夾路徑下的結果,因此不會增加無謂的資料量。

    2.1.2. 總表更新

    那我們拿到了已刪除的清單,接著就是對照是否在總表內,並清除上方的資料。那首先我們將該方法最底下註解的地方改為以下樣貌,並接者書寫刪除資料的方法。

    JavaScript
    // Delete data from spreadsheet one by one
      fileList.forEach(file => {
        // Delete from spreadsheet
        rowDelete(sheetFilesDetection, file);
      });

    JavaScript
    // Delete all related row datas from spreadsheet
    function rowDelete(sheet, file) {
      // index
      const fileId = file.id;
      // Search result
      var occurrences;
      // TMP storage for row index to delete from end to front
      var rows = [];
    
      try {
        // Find all URL contains the id
        occurrences = sheet.createTextFinder(fileId).findAll();
    
        // If found
        if (occurrences.length > 0) {
          // Push all into list
          occurrences.forEach(range => {rows.push(range.getRow());});
    
          // Change to by from end to front
          rows.sort((a, b) => b - a);
    
          // Delete all rows related
          rows.forEach(row => { sheet.deleteRow(row); })
    
          console.log(`【刪除成功】 -- 從偵測表刪除 ${fileId} 資料`);
        }
      } catch (error) 
      {
        console.error(`【刪除失敗】 -- 從偵測表刪除 ${fileId} 資料`); 
      }
    }

    那接著我們就依靠上次的紀錄結果進行測試,直接完整執行一次方法。可以看到原先有四行,我們故意將已刪除的一筆複製測試對於資料重複的效果,而在過程中依序搜尋順序刪除對應結果。

    那可以看到,就算資料有重複,因為我們有先排序過 row index,因此會從下往上刪不會影響到位置。

    2.2. 偵測修改

    那在某些情況,我們可能需要搬移或者編輯資料,此時我們就要跟著進行資料的異動確保正確性。

    2.2.1. 資料測試

    再度複製 searchFilesCreated 並利用其架構進行改動,並先將其命名為 searchFilesModified 方法。那我們這次限制搜尋時間在上次檢查的檔案之後,依此更動 query 參數。

    JavaScript
    // Search Files Newly Modified
    // 
    // - Loop through stack to see if any folder need to be scan
    // -- Comparing Files Modified Time and Latestest File Created Time on last execution to see if is new
    // -- Look into the subfolder
    // ---- Push subfolder to stack to continue while-loop
    // - Update attributes to spreadsheet
    function searchFilesModified() {
      // Getting the spreadsheet for data writing
      const sheetFilesDetection = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(PropertiesService.getScriptProperties().getProperty('sheetFilesDetection_sheetName'));
    
      // Latest file created time on last execution
      const lastSearchFilesCreatedTime = PropertiesService.getScriptProperties().getProperty('lastSearchFilesCreatedTime');
    
      // Init first folder path
      let folderStack = [DriveApp.getFileById(SpreadsheetApp.getActiveSpreadsheet().getId()).getParents().next().getId()];
    
      // List for search result
      let fileList = [];
    
      while (folderStack.length > 0) {
        // // Debugging
        // console.log(folderStack);
    
        // Choosing folder to run process
        let folderId = folderStack.pop();
        // Query
        let query = `modifiedTime  > "${lastSearchFilesCreatedTime}" and trashed=false and mimeType != "application/vnd.google-apps.folder" and "${folderId}" in parents`;
    
        // Checkpoint
        let pageToken = "";
    
        do {
          try {
            const url = encodeURI(
              `https://www.googleapis.com/drive/v3/files?q=${query}&pageSize=1000&pageToken=${pageToken}`
            ); // Include driveId and includeItemsFromAllDrives parameters
            const res = UrlFetchApp.fetch(url, {
              headers: { authorization: "Bearer " + ScriptApp.getOAuthToken() },
            });
            const obj = JSON.parse(res.getContentText());
            if (obj.files && obj.files.length > 0) {
              fileList = [...fileList, ...obj.files];
            }
            pageToken = obj.nextPageToken;
          } catch (error) {
            console.error(`[ERROR] Error fetching files: ${error}`);
            break;
          }
        } while (pageToken);
    
        // Find all subfolders
        const folders = DriveApp.getFolderById(folderId).getFolders();
        // Add subfolders to stack
        while (folders.hasNext()) {
          folderStack.push(folders.next().getId());
        }
      }
    
      // Debugging
      console.log(fileList);
    
      // // Write attributes to spreadsheet on sequence
      // fileList.forEach(json => {
      //   // Use ID to get Object of File Class
      //   file = DriveApp.getFileById(json.id);
      //   // Write to spreadsheet
      //   rowUpdate(sheetFilesDetection, file);
      // });
    }

    我們簡單將兩個檔案修改名稱,接著執行看看 searchFilesModified 方法檢查結果。

    可以看到結果有點出入,因為其包括剛刪除資料行的總表,那我們在修改 query 讓他避開特定檔案。

    那為了之後表單能夠簡單遷移,我們避免手動設置條件,但由於沒有限制檔案 id 的參數,我們改用 name 限制檔案名稱的方式來替代 。接著再執行一次試試。

    JavaScript
    let query = `modifiedTime  > "${lastSearchFilesCreatedTime}" and trashed=false and mimeType != "application/vnd.google-apps.folder" and "${folderId}" in parents and not name = "${SpreadsheetApp.getActiveSpreadsheet().getName()}"`;

    2.2.2. 總表更新

    那同理,我們需要更新表格上的資訊,我們能夠複製 writeToSpreadsheet 進行修改,並配合剛剛搜尋欄位的辦法進行。

    JavaScript
    // Write attributes to spreadsheet one by one
      fileList.forEach(json => {
        // Use ID to get Object of File Class
        file = DriveApp.getFileById(json.id);
        // Write to spreadsheet
        rowUpdate(sheetFilesDetection, file);
      });

    JavaScript
    // Update attributes in spreadsheet
    // 
    // @ Author Name
    // @ File Name
    // @ File URL
    // @ Folder URL
    // @ File Modified Date 
    function rowUpdate(sheet, file) {
      // index
      const fileId = file.getId();
      // Search result
      var occurrences;
      // TMP storage for row index to delete from end to front
      var rows = [];
    
      try {
        // Find all URL contains the id
        occurrences = sheet.createTextFinder(fileId).findAll();
    
        // If found
        if (occurrences.length > 0) {
          // Push all into list
          occurrences.forEach(range => { rows.push(range.getRow()); });
    
          // Get the header row
          const columnHeaders = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
    
          // Index
          const indexWorkCategory = columnHeaders.indexOf('繪畫風格');
          const indexWorkAuthor = columnHeaders.indexOf('作家名稱');
          const indexDateCreated = columnHeaders.indexOf('建檔時間');
          const indexDateModified = columnHeaders.indexOf('更新時間');
          const indexFolderURL = columnHeaders.indexOf('母資料夾');
          const indexWorkName = columnHeaders.indexOf('作品名稱');
          const indexWorkURL = columnHeaders.indexOf('檔案位置');
    
          // Read new values
          // -- File name
          const parts = file.getName().split(' - ');
          // ---- Author Name
          const authorName = parts[0];
          // ---- Work Name
          const workName = parts[1];
          // -- File URL
          const workURL = file.getUrl();
          // -- Folder URL
          const folderURL = file.getParents().next().getUrl();
          // -- File Modified Date
          const formattedDateModified = Utilities.formatDate(file.getLastUpdated(), Session.getScriptTimeZone(), "yyyy-MM-dd HH:mm:ss");
    
          // Update all rows related
          rows.forEach(row => {
            let range = sheet.getRange(row, 1, 1, sheet.getLastColumn());
    
            // Read all original values as array
            let data = range.getValues()[0];
    
            // Replace values
            data[indexWorkAuthor] = authorName || "Unknown";
            data[indexDateModified] = formattedDateModified;
            data[indexFolderURL] = folderURL;
            data[indexWorkName] = workName || "Undefined";
            data[indexWorkURL] = workURL;
    
            // Replace row
            range.setValues([data]);
          })
    
          console.log(`【更新成功】 -- 從偵測表修改 ${fileId} 資料`);
        }
      } catch (error) {
        console.error(`【更新失敗】 -- 從偵測表修改 ${fileId} 資料`);
      }
    }

    完成更改後,我們首先將檔案剪下並移動,並修改上方我們有偵測修改的欄位。並依樣先多複製一行的資料,測試若有重複資料的處理是否正常。

    接著執行 searchFilesModified 看看效果如何吧。

    3. 後話


    總合上述功能,我們的掃描方法基本已經完成,那接著我們就只剩下定時執行與接續呼叫三個掃描方法。

    4. 參考


    [1] Drive API — Official Doc
    https://developers.google.com/drive/api/reference/rest/v3/files/list

    [2] createTextFinder — Official Doc
    https://developers.google.com/apps-script/reference/spreadsheet/sheet#createtextfinderfindtext

    5. 素材


    [1] QuAn_ — pixiv
    https://www.pixiv.net/users/6657532

    [2] P.art — pixiv
    https://www.pixiv.net/en/users/35929537

    6. 成品


    為了增加識別度,我這邊將 writeToSpreadsheet() 改為 rowAppend() 。

    JavaScript
    // Find parent folder by where the spreadsheet is stored
    function getParentFolder() {
      // Get the active spreadsheet
      var spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
      // Get the ID of the spreadsheet
      var spreadsheetId = spreadsheet.getId();
    
      // Get the parent folders of the spreadsheet
      var parentFolders = DriveApp.getFileById(spreadsheetId).getParents();
    
      // Check if there are any parent folders
      if (parentFolders.hasNext()) {
        // Assuming we only have a parent folder
        var parentFolder = parentFolders.next();
    
        // For testing purpose
        console.log(`Parent Folder: ${parentFolder.getUrl()}`);
    
        // Return the folder object
        return parentFolder;
      }
      // If there are no parent folders, return null 
      else {
        console.error("[ERROR] Critical Error on Getting Parent URL");
    
        return null;
      }
    }
    
    // Init setting in project propeties
    function init() {
      // Sheet Name
      const sheetFilesDetection_sheetName = "總表";
    
      try {
        PropertiesService.getScriptProperties().setProperties({
          'sheetFilesDetection_sheetName': sheetFilesDetection_sheetName,
          // Init lastSearchFilesCreatedTime
          'lastSearchFilesCreatedTime': new Date().toISOString()
        });
        console.info("[ O K ] Successfully initalize the app.");
      } catch (error) { console.error(`[ERROR] Failed to initialize the app: ${error}`); }
    }
    
    // Search Files Newly Deleted
    // 
    // - Loop through stack to see if any folder need to be scan
    // -- Get all soft-deleted items under folder
    // ---- Loop through every file
    // ------ Using file id to search which row data at by URL
    // -------- Delete the row
    // -- Look into the subfolder
    // ---- Push subfolder to stack to continue while-loop
    function searchFilesDeleted() {
      // Getting the spreadsheet for data writing
      const sheetFilesDetection = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(PropertiesService.getScriptProperties().getProperty('sheetFilesDetection_sheetName'));
    
      // Init first folder path
      let folderStack = [DriveApp.getFileById(SpreadsheetApp.getActiveSpreadsheet().getId()).getParents().next().getId()];
    
      // List for search result
      let fileList = [];
    
      while (folderStack.length > 0) {
        // // Debugging
        // console.log(folderStack);
    
        // Choosing folder to run process
        let folderId = folderStack.pop();
        // Query
        let query = `trashed=true and mimeType != "application/vnd.google-apps.folder" and "${folderId}" in parents`;
    
        // Checkpoint
        let pageToken = "";
    
        do {
          try {
            const url = encodeURI(
              `https://www.googleapis.com/drive/v3/files?q=${query}&pageSize=1000&pageToken=${pageToken}`
            ); // Include driveId and includeItemsFromAllDrives parameters
            const res = UrlFetchApp.fetch(url, {
              headers: { authorization: "Bearer " + ScriptApp.getOAuthToken() },
            });
            const obj = JSON.parse(res.getContentText());
            if (obj.files && obj.files.length > 0) {
              fileList = [...fileList, ...obj.files];
            }
            pageToken = obj.nextPageToken;
          } catch (error) {
            console.error(`[ERROR] Error fetching files: ${error}`);
            break;
          }
        } while (pageToken);
    
        // Find all subfolders
        const folders = DriveApp.getFolderById(folderId).getFolders();
        // Add subfolders to stack
        while (folders.hasNext()) {
          folderStack.push(folders.next().getId());
        }
      }
    
      // Debugging
      console.log(fileList);
    
      // Delete data from spreadsheet one by one
      fileList.forEach(file => {
        // Delete from spreadsheet
        rowDelete(sheetFilesDetection, file);
      });
    }
    
    // Search Files Newly Modified
    // 
    // - Loop through stack to see if any folder need to be scan
    // -- Comparing Files Modified Time and Latestest File Created Time on last execution to see if is new
    // -- Look into the subfolder
    // ---- Push subfolder to stack to continue while-loop
    // - Update attributes to spreadsheet
    function searchFilesModified() {
      // Getting the spreadsheet for data writing
      const sheetFilesDetection = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(PropertiesService.getScriptProperties().getProperty('sheetFilesDetection_sheetName'));
    
      // Latest file created time on last execution
      const lastSearchFilesCreatedTime = PropertiesService.getScriptProperties().getProperty('lastSearchFilesCreatedTime');
    
      // Init first folder path
      let folderStack = [DriveApp.getFileById(SpreadsheetApp.getActiveSpreadsheet().getId()).getParents().next().getId()];
    
      // List for search result
      let fileList = [];
    
      while (folderStack.length > 0) {
        // // Debugging
        // console.log(folderStack);
    
        // Choosing folder to run process
        let folderId = folderStack.pop();
        // Query
        let query = `modifiedTime  > "${lastSearchFilesCreatedTime}" and trashed=false and mimeType != "application/vnd.google-apps.folder" and "${folderId}" in parents and not name = "${SpreadsheetApp.getActiveSpreadsheet().getName()}"`;
    
        // Checkpoint
        let pageToken = "";
    
        do {
          try {
            const url = encodeURI(
              `https://www.googleapis.com/drive/v3/files?q=${query}&pageSize=1000&pageToken=${pageToken}`
            ); // Include driveId and includeItemsFromAllDrives parameters
            const res = UrlFetchApp.fetch(url, {
              headers: { authorization: "Bearer " + ScriptApp.getOAuthToken() },
            });
            const obj = JSON.parse(res.getContentText());
            if (obj.files && obj.files.length > 0) {
              fileList = [...fileList, ...obj.files];
            }
            pageToken = obj.nextPageToken;
          } catch (error) {
            console.error(`[ERROR] Error fetching files: ${error}`);
            break;
          }
        } while (pageToken);
    
        // Find all subfolders
        const folders = DriveApp.getFolderById(folderId).getFolders();
        // Add subfolders to stack
        while (folders.hasNext()) {
          folderStack.push(folders.next().getId());
        }
      }
    
      // Debugging
      console.log(fileList);
    
      // Write attributes to spreadsheet one by one
      fileList.forEach(json => {
        // Use ID to get Object of File Class
        file = DriveApp.getFileById(json.id);
        // Write to spreadsheet
        rowUpdate(sheetFilesDetection, file);
      });
    }
    
    // Search Files Newly Created
    // 
    // - Loop through stack to see if any folder need to be scan
    // -- Comparing Files CreatedDate and Latestest File Created Time on last execution to see if is new
    // -- Look into the subfolder
    // ---- Push subfolder to stack to continue while-loop
    // - Resort full fileList by file created date
    // - Write attributes to spreadsheet
    // - Reset property "lastSearchFilesCreatedTime" to latest
    function searchFilesCreated() {
      // Getting the spreadsheet for data writing
      const sheetFilesDetection = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(PropertiesService.getScriptProperties().getProperty('sheetFilesDetection_sheetName'));
    
      // Latest file created time on last execution
      const lastSearchFilesCreatedTime = PropertiesService.getScriptProperties().getProperty('lastSearchFilesCreatedTime');
    
      // Init first folder path
      let folderStack = [DriveApp.getFileById(SpreadsheetApp.getActiveSpreadsheet().getId()).getParents().next().getId()];
    
      // List for search result
      let fileList = [];
    
      while (folderStack.length > 0) {
        // // Debugging
        // console.log(folderStack);
    
        // Choosing folder to run process
        let folderId = folderStack.pop();
        // Query
        let query = `createdTime > "${lastSearchFilesCreatedTime}" and trashed=false and mimeType != "application/vnd.google-apps.folder" and "${folderId}" in parents`;
    
        // Checkpoint
        let pageToken = "";
    
        do {
          try {
            const url = encodeURI(
              `https://www.googleapis.com/drive/v3/files?q=${query}&pageSize=1000&pageToken=${pageToken}`
            ); // Include driveId and includeItemsFromAllDrives parameters
            const res = UrlFetchApp.fetch(url, {
              headers: { authorization: "Bearer " + ScriptApp.getOAuthToken() },
            });
            const obj = JSON.parse(res.getContentText());
            if (obj.files && obj.files.length > 0) {
              fileList = [...fileList, ...obj.files];
            }
            pageToken = obj.nextPageToken;
          } catch (error) {
            console.error(`[ERROR] Error fetching files: ${error}`);
            break;
          }
        } while (pageToken);
    
        // Find all subfolders
        const folders = DriveApp.getFolderById(folderId).getFolders();
        // Add subfolders to stack
        while (folders.hasNext()) {
          folderStack.push(folders.next().getId());
        }
      }
    
      // Sorting by createdDate
      fileList.sort((a, b) => new Date(DriveApp.getFileById(a.id).getDateCreated()) - new Date(DriveApp.getFileById(b.id).getDateCreated()));
    
      // Debugging
      console.log(fileList);
    
      // Write attributes to spreadsheet on sequence
      fileList.forEach(json => {
        // Use ID to get Object of File Class
        file = DriveApp.getFileById(json.id);
        // Write to spreadsheet
        rowAppend(sheetFilesDetection, file);
      });
    
      // There's new file
      if (fileList.length > 0) {
        // Store time of lastest file created time
        PropertiesService.getScriptProperties().setProperty('lastSearchFilesCreatedTime', DriveApp.getFileById((fileList[fileList.length - 1].id)).getDateCreated().toISOString());
      }
    }
    
    // Delete all related row datas from spreadsheet
    function rowDelete(sheet, file) {
      // index
      const fileId = file.id;
      // Search result
      var occurrences;
      // TMP storage for row index to delete from end to front
      var rows = [];
    
      try {
        // Find all URL contains the id
        occurrences = sheet.createTextFinder(fileId).findAll();
    
        // If found
        if (occurrences.length > 0) {
          // Push all into list
          occurrences.forEach(range => { rows.push(range.getRow()); });
    
          // Change to by from end to front
          rows.sort((a, b) => b - a);
    
          // Delete all rows related
          rows.forEach(row => { sheet.deleteRow(row); })
    
          console.log(`【刪除成功】 -- 從偵測表刪除 ${fileId} 資料`);
        }
      } catch (error) {
        console.error(`【刪除失敗】 -- 從偵測表刪除 ${fileId} 資料`);
      }
    }
    
    // Update attributes in spreadsheet
    // 
    // @ Author Name
    // @ File Name
    // @ File URL
    // @ Folder URL
    // @ File Modified Date 
    function rowUpdate(sheet, file) {
      // index
      const fileId = file.getId();
      // Search result
      var occurrences;
      // TMP storage for row index to delete from end to front
      var rows = [];
    
      try {
        // Find all URL contains the id
        occurrences = sheet.createTextFinder(fileId).findAll();
    
        // If found
        if (occurrences.length > 0) {
          // Push all into list
          occurrences.forEach(range => { rows.push(range.getRow()); });
    
          // Get the header row
          const columnHeaders = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
    
          // Index
          const indexWorkCategory = columnHeaders.indexOf('繪畫風格');
          const indexWorkAuthor = columnHeaders.indexOf('作家名稱');
          const indexDateCreated = columnHeaders.indexOf('建檔時間');
          const indexDateModified = columnHeaders.indexOf('更新時間');
          const indexFolderURL = columnHeaders.indexOf('母資料夾');
          const indexWorkName = columnHeaders.indexOf('作品名稱');
          const indexWorkURL = columnHeaders.indexOf('檔案位置');
    
          // Read new values
          // -- File name
          const parts = file.getName().split(' - ');
          // ---- Author Name
          const authorName = parts[0];
          // ---- Work Name
          const workName = parts[1];
          // -- File URL
          const workURL = file.getUrl();
          // -- Folder URL
          const folderURL = file.getParents().next().getUrl();
          // -- File Modified Date
          const formattedDateModified = Utilities.formatDate(file.getLastUpdated(), Session.getScriptTimeZone(), "yyyy-MM-dd HH:mm:ss");
    
          // Update all rows related
          rows.forEach(row => {
            let range = sheet.getRange(row, 1, 1, sheet.getLastColumn());
    
            // Read all original values as array
            let data = range.getValues()[0];
    
            // Replace values
            data[indexWorkAuthor] = authorName || "Unknown";
            data[indexDateModified] = formattedDateModified;
            data[indexFolderURL] = folderURL;
            data[indexWorkName] = workName || "Undefined";
            data[indexWorkURL] = workURL;
    
            // Replace row
            range.setValues([data]);
          })
    
          console.log(`【更新成功】 -- 從偵測表修改 ${fileId} 資料`);
        }
      } catch (error) {
        console.error(`【更新失敗】 -- 從偵測表修改 ${fileId} 資料`);
      }
    }
    
    // Write attributes back to spreadsheet
    // 
    // @ Artstyle
    // @ Author Name
    // @ File Name
    // @ File URL
    // @ Folder URL
    // @ File Created Date
    // @ File Modified Date 
    function rowAppend(sheet, file) {
      // Get attributes
      // -- File Name
      const parts = file.getName().split(' - ');
      // ---- Author Name
      const authorName = parts[0];
      // ---- Work Name
      const workName = parts[1];
      // -- File URL
      const workURL = file.getUrl();
      // -- Folder URL
      const folderURL = file.getParents().next().getUrl();
      // -- File Created Date
      const formattedDateCreated = Utilities.formatDate(file.getDateCreated(), Session.getScriptTimeZone(), "yyyy-MM-dd HH:mm:ss");
      // -- File Modified Date
      const formattedDateModified = Utilities.formatDate(file.getLastUpdated(), Session.getScriptTimeZone(), "yyyy-MM-dd HH:mm:ss");
    
      // Get the header row
      const columnHeaders = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
    
      // Index
      const indexWorkCategory = columnHeaders.indexOf('繪畫風格');
      const indexWorkAuthor = columnHeaders.indexOf('作家名稱');
      const indexDateCreated = columnHeaders.indexOf('建檔時間');
      const indexDateModified = columnHeaders.indexOf('更新時間');
      const indexFolderURL = columnHeaders.indexOf('母資料夾');
      const indexWorkName = columnHeaders.indexOf('作品名稱');
      const indexWorkURL = columnHeaders.indexOf('檔案位置');
    
      // Prepare the data array for the new row
      let row = new Array(columnHeaders.length).fill('');
    
      row[indexWorkCategory] = getArtStyle(file.getParents().next());
      row[indexWorkAuthor] = authorName || "Unknown";
      row[indexDateCreated] = formattedDateCreated;
      row[indexDateModified] = formattedDateModified;
      row[indexFolderURL] = folderURL;
      row[indexWorkName] = workName || "Undefined";
      row[indexWorkURL] = workURL;
    
      // Write to last row in sheet
      sheet.appendRow(row);
    }
    
    // Get artstyle based on folder name
    // 
    // - Look up parent folder until no more @ in folder name
    function getArtStyle(folder) {
      // With @ -> Artist portfolio
      if (folder.getName().indexOf('@') !== -1) {
        // Find it's parent 
        do {
          folder = folder.getParents().next();
        } while (folder.getName().indexOf('@') !== -1);
      }
      // Art style
      return folder.getName();
    }
    
  • JavaScript 探險方針 006 – 陣列沿伸

    0. 前言


    但如果今天我們有多筆數值要進行處理,無論是篩選或合併透過迴圈都會增加閱讀的複雜度,而此時我們就能使用 JS 提供的高階方法,簡單的處理這些事項了。

    1. 重點


    concat 可以新增陣列 / 數字,而利用 spread operator 代表逐一推入數字
    List item
    List item

    2. 內容


    2.1. 陣列迴圈

    在之前 004 我們提過的迴圈相當多種,基本都能套用,雖然能夠限制運行的條件,但容易造成架構冗長。這邊我們就複習下 forEach 以及學習 Spread Operator 方法吧。

    2.1.1. forEach
    JavaScript
    {
        let array = [3, 5, 7];
        array.macro = "polo";
    
        console.log(array);
    
        array.forEach((element) => { console.log(element); })
    }
    
    // 3
    // 5
    // 7

    2.1.2. Spread Operator

    展開運算符(…)本質上不算迴圈結構,只是逐一將陣列中的元素讀取,但這種方式可以讓我們透過簡短一句搭配 filter 或 map 等陣列方法進行處理,省去不少的迴圈架構。

    而 map() 的主要用途是建立個新的陣列,避免覆蓋到原始的資料。

    JavaScript
    {
        let numbers = [1, 2, 3, 4, 5];
    
        let doubledNumbers = [...numbers.map(number => number * 2)];
    
        console.log(doubledNumbers); // [2, 4, 6, 8, 10]
    }

    2.2. 陣列合併

    但如果我們今天有兩個陣列要合併,迴圈數值再各自 push 是很低效的方法,加長太多繁瑣的架構,為此 JS 有推出下面兩種方式,快速的將陣列進行合併。

    那我個人比較偏好 Spread Operator,因為其架構類似乎 n-D 陣列的建立且使用方法廣泛。

    2.5.1. concat

    透過 concat 我們能直接在母陣列的後方加入的元素。

    JavaScript
    {
        let array1 = [1, 2];
        let array2 = [3, 4];
        let array3 = [5, 6];
    
        let newArray = array1.concat(array2, array3, 7, 8, [9, 10]);
    
        console.log(newArray); // [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
    }

    2.5.2. Spread Operator

    展開運算符允許我們展開陣列中的元素,逐一丟入新的陣列之中。

    JavaScript
    {
        let array1 = [1, 2];
        let array2 = [3, 4];
        let array3 = [5, 6];
    
        let newArray = [...array1, ...array2, ...array3, 7, 8, ...[9, 10]];
    
        console.log(newArray); // [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
    }

    2.3. 陣列篩選

    在前篇我們主要講到單筆資料的塞選結果,而此時我們則要透過 filter 方法進行處理整個陣列。

    JavaScript
    {
        let array = [1, 2, 3, 4, 5, 6, 7, 8, 9];
    
        console.log(array.filter(element => element > 5));
    }

    3. 後話


    透過上述這接方法都能簡化我們處理陣列時的複雜度,雖然有些方法反而會增加處理時間,但對於初學來說,可讀性會更為重要。

    而我們的基礎教學大概也只到這邊就結束了,希望各位都能吸收到並妥善使用 ~~

  • Minecraft 1.20.5 物品參數更新

    Minecraft 1.20.5 物品參數更新

    0. 前言


    在許久沒碰觸電玩,近期回歸時發現從 1.20.1 已然到 1.20.6,而在 snapshot-24w09a (1.20.5) 時 MOJANG 針對物品格式做了全新調整,徹底改變了既有的操作。

    而又過短短的三周間 1.21 出來了,各大指令生成器也大概支援 1.20.5 的功能了,但還是讓我們透過 /give 指令大概了解新的格式究竟如何吧。

    1. 重點


    客製化道具敘述與樣貌會是製作大型地圖的基本功
    可以限制最大耐久度 max_damage 令武具一敲就斷,但建議附加 damage 讓道具顯示耐久條
    屬性變更對於地圖製作是密不可分,尤其關於自訂義道具
    搭配 effect 之效果格式製作客製化藥水還能自行設定時效
    善用冒險模式可防止玩家惡意破壞地圖破關
    盾牌的像素較差,要注意旗幟圖像變樣
    scale 放大或縮小並不會影響生物的移動速度

    2. 內容


    2.1. 附魔效果

    物品的直接附魔都是透過 enchantments 參數,其結構為:

    JavaScript
    {
    
      // 附魔項目與強度調整
    
      levels: {附魔_1”:強度,附魔_2”:強度}, 
    
      // 是否顯示附魔效果於資訊
    
      show_in_tooltip: true/false
    
    }

    而對於喜歡整人的朋友,擊退棒棒肯定是不可少的好夥伴

    JavaScript
    /give @p minecraft:stick[minecraft:enchantments={levels:{"minecraft:knockback":255}}

    我們再升級透過 enchantment_glint_override 不顯示附魔光輝,在外觀上就是單純的木棒

    JavaScript
    /give @p minecraft:stick[minecraft:enchantments={levels:{"minecraft:knockback":255}}, minecraft:enchantment_glint_override=false

    而對於地圖製作者,則可能聽過 StoredEnchantments,製作道具加強的素材,其附魔的結構也是相同的

    兩個的差異在於能否在鐵砧上使用,而預設情況前者為青色後者為黃色,這邊範例以 “保護 II”。而不同於原版的附魔書,StoredEnchantments 能夠融入任何素材。因此在地圖製作中由於無法限制玩家,其實很難用到該功能。

    而過於 Overpowered 的武器其修復成本往往會顯示 “Too Expensive”,我們則能透過 repair_cost=經驗等級 給予或限制用戶修復道具的可能

    2.2. 物品介紹

    在地圖製作中,製作特殊道具的情況更加普遍,此時重要的便是物品的介紹文字。

    物品名稱則以 custom_name 進行設定,以下示範幾種命名方式

    — 拒絕特殊字元

    JavaScript
    /give @p minecraft:iron_sword[minecraft:custom_name=Claymore]

    — 支援特殊字元

    JavaScript
    /give @p minecraft:iron_sword[minecraft:custom_name="\"Shiny Claymore\""]

    — 附帶特效文字

    要讓文字帶有特效與色彩就要依靠 text component,然而其格式編譯很長很麻煩,可以使用 https://minecraft.tools/en/tellraw.php 這份工具幫忙生成

    JavaScript
    /give @p minecraft:iron_sword[minecraft:custom_name='{"text":"Shiny Claymore","bold":true,"italic":true,"strikethrough":true,"underlined":true,"color":"yellow"}']

    而敘述 lore 的格式是相同的,只是要多包在 [ ] 之中。

    而在預設中 Minecraft 會顯示許多礙眼的資訊,圖中範例是 [5. 藥水效果] 的範例二,透過 hide_additional_tooltip={} 將效果進行隱藏。

    2.3. 耐久設定

    在劇情地圖中極具特殊性的道具要麼不會受損或壞得很快。

    若要讓物品不消耗耐久,我們則能添加 unbreakable,奇怪的格式是當你輸入這串時 MOJANG 就預設你要不壞,只需要設定是否顯示在道具資訊即可

    JavaScript
    /give @p minecraft:iron_sword[minecraft:unbreakable={show_in_tooltip:true/false}]

    而要生成一敲就爆的道具,我們只要將 damage 設為 9999,這種的道具是可以正常維修的

    JavaScript
    /give @p minecraft:iron_sword[minecraft:damage=9999]

    但今天我們設計了把秒殺劍,用戶肯定會不斷修復該道具,此時我們就能利用 max_damage 條件設置最大耐久為 1 強迫只能使用一次,或者你能創造比獄髓更耐用的工具

    JavaScript
    /give @p minecraft:iron_sword[minecraft:damage=9999, minecraft:max_damage=1]

    範例本身耐久已經見底,因此是無法維修的

    而直接使用 max_damage=1 的缺點則是缺少耐久條提示使用者,資訊尚未更新就被敲爆了

    JavaScript
    /give @p minecraft:iron_sword[minecraft:max_damage=1]

    2.4. 客製模型

    對於地圖材質包與客製物品 CustomModelData 一直以來是不可或缺的角色。在材質包能透過 override 改變許多樣態,而物品偵測相對 display:Name(且在現版本已無法使用)簡易許多。

    關於材質包中的簡單應用可參考 【此篇文章】,自製模型可參考 【英文影片】

    接著我們寫個敏捷護符,當用戶放在副手觸發,首先我們拿根羽毛作為代表,你可以自由修改 CustomModelData

    JavaScript
    /give @p minecraft:feather[minecraft:custom_name="\"Wind Charm\"", minecraft:lore=["\"You felt there's strong wind behind you.\""], minecraft:custom_model_data=2420001]

    這邊我們使用指令方塊 Repeat | Always Active

    JavaScript
    /execute as @a if items entity @s weapon.offhand *[minecraft:custom_model_data=2420001] run effect give @s minecraft:speed 1 5 false

    接著讓我們來測試效果

    2.5. 屬性變更

    一直以來物品的屬性變更是極具重要的方法,其不只能設定武器的傷害,或者提供 % 加成,甚至限制物品要裝備特定位置才會觸發設定的效果。

    這邊搬出有提供生成 /give 指令的網站 Gamer Geeks,目前也更新到 1.20.5 了,我們進入到 Attributes 分頁,必須說新的格式實在是長的有些噁心。

    關於 operation 分為三種, additive 直接加上指定數值,add_multiplied_base 是將增加幾倍給物品數值,而 add_multiplied_total 則是增加幾倍玩家總和數值。

    而在這網站目前缺少的便是前陣子炒熱的 scale,用來調整生物的大小。不過變成巨人走路的距離也沒有改變實在有點智障 … 

    JavaScript
    /give @a iron_chestplate[attribute_modifiers={modifiers:[{type:"generic.scale",amount:10,slot:chest,operation:add_multiplied_base,name:"generic.armor",uuid:[I;-124514,39357,205617,-78714]}]}]

    2.6. 藥水效果

    藥水作為冒險地圖最簡單的效果賦予方式,同時能強迫用戶考量使用時間,然而不靠指令沒有其他方法取得多狀態的藥水,因此也算常用的部分。

    我們不採用只能產出原版藥水的 potion ,統一使用 custom_contents 結合 effect 製作與定時,另外可以透過 custom_color 搭配 RGB 碼進行配色。

    範例 1: 兼具立即恢復 II 與 10s 回復效果 I
    JavaScript
    /give @p minecraft:potion[potion_contents={custom_effects:[{id:"minecraft:instant_health",amplifier:1,duration:1}, {id:"minecraft:regeneration", amplifier:0, duration:200}],custom_color:12257822}]

    範例 2: 瞬間回滿使用者血量但會造成噁心的副作用
    JavaScript
    /give @p minecraft:potion[potion_contents={custom_effects:[{id:"minecraft:instant_health",amplifier:255,duration:1}, {id:"minecraft:nausea", amplifier:0, duration:200}],custom_color:12257822}]

    而該參數能套用在 3 種藥水型態與藥效箭矢上,而前面在 [2. 物品介紹] 也提到如何隱藏藥水資訊

    2.7. 物品堆疊

    前面我們示範的藥水其預設不可堆疊的特性總是佔滿玩家的背包,我們便能透過 max_stack_size 進行設定

    JavaScript
    /give @p minecraft:potion[potion_contents={custom_effects:[{id:"minecraft:instant_health",amplifier:1,duration:1}]}, minecraft:max_stack_size=64]

    那要注意的是 max_stack_size 之間是不可混用的,即使比較小的 size 32 也是無法放入 size 64

    2.8. 冒險模式

    在多數情況我們並不希望玩家透過破壞地圖的手段跳過辛苦設計的關卡,尤其是解謎地圖更需要透過冒險模式與設定好的道具進行破關。

    那首先我們設計一把限制用來破牆的破舊石鎬

    JavaScript
    /give @p minecraft:stone_pickaxe[minecraft:can_break={blocks:'minecraft:cracked_stone_bricks'}]

    但地圖並不只有破壞,還常尋找門的鑰匙,而此時限制擺放位置能避免用戶無法回收導致卡關。

    JavaScript
    /give @p minecraft:light_weighted_pressure_plate[minecraft:can_place_on={blocks:'minecraft:iron_block'}]

    有更進階的需求,可以依靠以下方式,限制有向方塊的判斷

    JavaScript
    {predicates:{blocks:'minecraft:furnace',state:{facing:'north'}}

    2.9. 地圖範本

    在生存模式中你是否有過經驗,意外弄丟地圖導致無法複製?如果你記得編號就能夠利用以下介紹的指令將其拿回。但通常在生存中我們頂多再跑一趟建個新的地圖,從來沒人關心地圖編號。

    然而地圖製作者則可能需要給與特定地區的冒險地圖,因此需要紀錄特定的編號,作為任務道具或依此讓商人進行販售。此時就會用到 map_id 的參數。

    這邊順便介紹一個古早的工具 MC Map Item Tool ,你可以透過該工具將圖片轉成地圖檔,你只要接著將檔案更新到 .minecraft >> saves >> |地圖名稱| >> data 之中。那要注意如果是覆蓋舊的地圖且玩家在遊戲之中,拿起舊的地圖會讓暫存檔覆蓋掉我們剛上傳的新檔,請離開存檔後重新進入。

    那此時我要拿出生成好的地圖,切記要使用 filled_map 而不是空白的 map 喔。

    JavaScript
    /give @p minecraft:filled_map[minecraft:map_id=0]

    (圖片來源: 明日方舟頭像 3 by QuAn_ — pixiv

    而在通常我們所拿到的地圖會是黑色的字樣,此時能夠透過 map_color 與 RGB 碼進行調整。

    JavaScript
    /give @p minecraft:filled_map[minecraft:map_color=16711680]

    而實際上還有 map_decorations 用來在世界的指定座標加上標示(ex: 旗幟),但太麻煩了還不如編輯圖片並轉檔,還能夠自訂義任何想要的標籤。

    2.10. 旗幟製作

    在原版之中製作旗幟用織布機是非常簡單,但如果今天我們想要設計一個旗手(Standard Bearer)的職業,增強範圍內的友軍力量,此時就會牽涉到許多只能靠指令生成的部分,而旗幟就必須在過程中同時生成。

    各位可以找到如 Minecraft Banner Gallery 該網站尋找設計,但目前生成的指令大多還是舊版的縮寫模式,但目前需要使用設計的全名,各位需要依照該 Minecraft Wiki 中的 Resource Name 對照圖案進行設計。

    想想段考前我還必須撬開 .jar 檔 … 廢話不多說我們開始吧,指令順序與製作的工序是一樣的,此次範例我挑選該較簡單的旗幟

    我們複製指令下來先找個好用的編輯器修改圖樣與顏色的參數,順道將 Pattern 與 Color 改成小寫,刪除 Patterens: 與其 { } ,最後將 BlockEntityTag 改為更簡單的 banner_patterns 參數。

    JavaScript
    /give @p minecraft:white_banner[minecraft:banner_patterns=[{color:"black",pattern:"straight_cross"},{color:"white",pattern:"straight_cross"},{color:"black",pattern:"flower"},{color:"white",pattern:"flower"}]]

    那接著我們若想弄出一個蝦趴的盾牌,我們同樣要依靠 banner_patterns 設定圖樣,但此時我們知道盾牌是無法染色的,因此我們需要依靠 base_color 設定底色。

    但由於盾牌會縮小旗幟圖樣,直接用同樣辦法製作會拿到個白色盾牌,為了展示我刪除剛才的圖樣設定的最後道工序,將指令改成以下樣貌。

    JavaScript
    /give @p minecraft:shield[minecraft:base_color="white", minecraft:banner_patterns=[{color:"black",pattern:"straight_cross"},{color:"white",pattern:"straight_cross"},{color:"black",pattern:"flower"}]]

    2.11. 玩家頭顱

    依靠玩家頭顱設計裝飾或 NPC 也是常見的手法,而我們再也不用依賴長到天邊的 UUID,可以直接依靠玩家的名稱來取得頭顱了。

    JavaScript
    /give @p minecraft:player_head[profile={name:'CavalryHill'}]

    這邊再先推薦個網站 Minecraft Heads,上面有多樣的選擇可以使用還附帶搜尋功能。逛過各位能發現上面並沒有公開用戶名稱,但這個網站更新很快已經有提供 1.20.6 的程式碼了。以下取站上的一顆蘋果為案例。

    可以看到其多加了 properties: [{name:”textures”, value: “|TEXTURE VALUE|”}] 之架構,如果 datapack 需要自行客製化成就的時候還大概了解如何找起。

    JavaScript
    /give @p minecraft:player_head[minecraft:custom_name='{"text":"Apple","color":"gold","underlined":true,"bold":true,"italic":false}',minecraft:lore=['{"text":"Custom Head ID: 60982","color":"gray","italic":false}','{"text":"www.minecraft-heads.com","color":"blue","italic":false}'],profile={id:[I;661117833,194662243,-1247430837,-551990915],properties:[{name:"textures",value:"eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvYjU0YTA5YzhlMzZjYzA5M2UzNzUzMjg3YTVjYWM0YjM3ZTU2NTRjYTUxOGM2NTgyYWI5OWNhYTk1MTM0NTk1ZSJ9fX0="}]}] 1

    2.12. 煙火設計

    說起來有些雞肋,再鞘翅出現的年代沒多少人注目煙火這過往奢侈的玩意兒。

    設計的方式是類似的,但 firework_explosion 是針對煙火球,而 fireworks 是針對做好的煙火。

    除了客製化結合許多設計之外,我要講的是 fireworks 的 flight_duration 參數,目前測試最大值是 159,其代表煙火飛升到爆炸的秒數,同時代表能給予鞘翅的驅動時長。在最大值之下飛過 1000 的高度都還綽綽有餘,但也代表不太適合作為跑圖的工具,因為 power 太強只能仰賴 crash landing 進行迫降 … 

    關於煙火的效果與 shape 參數可參考此篇 Minecraft Wiki 視覺化理解。

    JavaScript
    /give @p minecraft:firework_rocket[minecraft:fireworks={explosions:[{shape:'large_ball',colors:[16711680],has_trail:true}],flight_duration:2}]

    3. 後話


    在過程中我決定跳過許多功能,比如上膛的弩箭與不可撿取的功能,界伏盒與皮帶的內容物,磁石指針,要嘛是有點雞肋,要麼是結構簡單卻長。

    還是希望我所篩選下來的功能都能滿足多數,若覺得有缺少的也歡迎另做詢問。

    4. 參考


    [1] Snapshot Report
    https://www.minecraft.net/en-us/article/minecraft-snapshot-24w09a

  • G-Drive 智慧工坊 – 檔案偵測 002 – 向下探索與基礎分類

    0. 前言


    我們上次完成母資料夾內的掃瞄,然而為了區分畫風與標註作家,我們將預期有許多的子資料夾。我們也不可能手動新增 Google Spreadsheet 在各個子料夾,且若如此我們也難統一管理。

    因此這次的目標就是透過迴圈與自我呼叫,掃描所有的子資料夾,並透過資料夾名稱或檔名進行分類。

    1. 重點


    透過迴圈搜尋檔案與子資料夾,再度呼叫方法在子資料夾向下迴圈掃描
    善用 regex 或 split 進行文字處理

    2. 內容


    2.1. 確立分類依據

    由於我目前的計畫,是針對與朋友共享的繪畫素材庫,那我們範例中的依據就是畫風。以下我先建立好幾種常見的畫風分類作為判斷依據,而圖的部分當然還是得之後人工上傳。

    那由於我所下載的繪畫素材,有經過一定的命名處理為 {作者} – {作品} – {相簿順序} – {作品編號},由於是利用 – 分開屬性,並有兩個空格包住而非單個特殊符號,因此我夠避開例外並透過切割標註所有的作者名稱。

    另外我們前面說過 G-Drive 的 Lazy-Loading 會讓找圖片不太方便,正巧多數畫家發布的作品都有自己的獨特風格,因此又另外建立如 @ {作家名稱} 的資料夾。由於我們給予 @ 標記,我們之後再分類作品風格時能依靠 regex 向上翻找直到沒有 @ 符號為止。

    2.2. 建立迴圈掃瞄所有子資料夾

    確立好索引後我們就可以開工了。那向下掃描的方法,其實也沒有各位想得困難,我們在母子料夾中除了掃描檔案外,再加上掃描所有子資料夾的步驟,並在過程中呼叫方法自身持續下去掃描子資料夾即可。

    那我們取代舊有的方法,並稍微解釋下。

    在最開始從 Google Spreadsheet 所在的母資料夾開始迴圈掃描,先檢查資料夾是否有新的檔案,若無則檢查是否有子資料夾。當有子資料夾,他們的 folderId 會被推到 stack,也就是會優先比他母資料夾同輩先進行掃描,直到整個分支掃完後才換到其他風個的資料夾。

    JavaScript
    // Search Files Newly Created
    // 
    // - Loop through stack to see if any folder need to be scan
    // -- Comparing Files CreatedDate and Latestest File Created Time on last execution to see if is new
    // -- Look into the subfolder
    // ---- Push subfolder to stack to continue while-loop
    // - Resort full fileList by file created date
    // - Write attributes to spreadsheet
    // - Reset property "lastSearchFilesCreatedTime" to latest
    function searchFilesCreated() {
      // Getting the spreadsheet for data writing
      const sheetFilesDetection = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(PropertiesService.getScriptProperties().getProperty('sheetFilesDetection_sheetName'));
    
      // Latest file created time on last execution
      const lastSearchFilesCreatedTime = PropertiesService.getScriptProperties().getProperty('lastSearchFilesCreatedTime');
    
      // Init first folder path
      let folderStack = [DriveApp.getFileById(SpreadsheetApp.getActiveSpreadsheet().getId()).getParents().next().getId()];
    
      // List for search result
      let fileList = [];
    
      while (folderStack.length > 0) {
        // // Debugging
        // console.log(folderStack);
    
        // Choosing folder to run process
        let folderId = folderStack.pop();
        // Query
        let query = `createdTime > "${lastSearchFilesCreatedTime}" and trashed=false and mimeType != "application/vnd.google-apps.folder" and "${folderId}" in parents`;
    
        // Checkpoint
        let pageToken = "";
    
        do {
          try {
            const url = encodeURI(
              `https://www.googleapis.com/drive/v3/files?q=${query}&pageSize=1000&pageToken=${pageToken}`
            ); // Include driveId and includeItemsFromAllDrives parameters
            const res = UrlFetchApp.fetch(url, {
              headers: { authorization: "Bearer " + ScriptApp.getOAuthToken() },
            });
            const obj = JSON.parse(res.getContentText());
            if (obj.files && obj.files.length > 0) {
              fileList = [...fileList, ...obj.files];
            }
            pageToken = obj.nextPageToken;
          } catch (error) {
            console.error(`[ERROR] Error fetching files: ${error}`);
            break;
          }
        } while (pageToken);
    
        // Find all subfolders
        const folders = DriveApp.getFolderById(folderId).getFolders();
        // Add subfolders to stack
        while (folders.hasNext()) {
          folderStack.push(folders.next().getId());
        }
      }
    
      // Sorting by createdDate
      fileList.sort((a, b) => new Date(DriveApp.getFileById(a.id).getDateCreated()) - new Date(DriveApp.getFileById(b.id).getDateCreated()));
    
      // Debugging
      console.log(fileList);
    
      // Write attributes to spreadsheet on sequence
      fileList.forEach(json => {
        // Use ID to get Object of File Class
        file = DriveApp.getFileById(json.id);
        // Write to spreadsheet
        writeToSpreadsheet(sheetFilesDetection, file);
      });
    
      // There's new file
      if (fileList.length > 0) {
        // Store time of lastest file created time
        PropertiesService.getScriptProperties().setProperty('lastSearchFilesCreatedTime', DriveApp.getFileById((fileList[fileList.length - 1].id)).getDateCreated().toISOString());
      }
    }

    那接著,我將幾張新下載的圖片素材放入兩個子資料夾之中。再來就透過手動測試上述的 searchFilesCreated 方法,應該會最終只會看到的整個陣列結果。

    2.3. 修改資料回傳模板

    在上一步的操作中,我們還是維持上次的資料型態,那接著我們就需要新增畫風與作家的分類了。這邊就需要各位依據需求自行改動了。

    那首先我要透過資料夾的名稱作為繪畫風格的依據,可以看到我多寫一個 getArtStyle() 用來向上搜尋,直到母資料夾名字中不包含 @ 標籤。並利用處理過的檔名分割出作品名稱與作家。我也有稍微修改以前的變數名稱增加對我自己的識別度。

    JavaScript
    // Write attributes back to spreadsheet
    // 
    // @ Artstyle
    // @ Author Name
    // @ File Name
    // @ File URL
    // @ Folder URL
    // @ File Created Date
    // @ File Modified Date 
    function writeToSpreadsheet(sheet, file) {
      // Get attributes
      // -- File Name
      const parts = file.getName().split(' - ');
      // ---- Author Name
      const authorName = parts[0];
      // ---- Work Name
      const workName = parts[1];
      // -- File URL
      const workURL = file.getUrl();
      // -- Folder URL
      const folderURL = file.getParents().next().getUrl();
      // -- File Created Date
      const formattedDateCreated = Utilities.formatDate(file.getDateCreated(), Session.getScriptTimeZone(), "yyyy-MM-dd HH:mm:ss");
      // -- File Modified Date
      const formattedDateModified = Utilities.formatDate(file.getLastUpdated(), Session.getScriptTimeZone(), "yyyy-MM-dd HH:mm:ss");
    
      // Get the header row
      const columnHeaders = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
    
      // Index
      const indexWorkCategory = columnHeaders.indexOf('繪畫風格');
      const indexWorkAuthor = columnHeaders.indexOf('作家名稱');
      const indexDateCreated = columnHeaders.indexOf('建檔時間');
      const indexDateModified = columnHeaders.indexOf('更新時間');
      const indexFolderURL = columnHeaders.indexOf('母資料夾');
      const indexWorkName = columnHeaders.indexOf('作品名稱');
      const indexWorkURL = columnHeaders.indexOf('檔案位置');
    
      // Prepare the data array for the new row
      let row = new Array(columnHeaders.length).fill('');
    
      row[indexWorkCategory] = getArtStyle(file.getParents().next()); 
      row[indexWorkAuthor] = authorName || "Unknown";
      row[indexDateCreated] = formattedDateCreated;
      row[indexDateModified] = formattedDateModified;
      row[indexFolderURL] = folderURL;
      row[indexWorkName] = workName || "Undefined";
      row[indexWorkURL] = workURL;
    
      // Write to last row in sheet
      sheet.appendRow(row);
    }
    
    // Get artstyle based on folder name
    // 
    // - Look up parent folder until no more @ in folder name
    function getArtStyle(folder) {
        // With @ -> Artist portfolio
        if (folder.getName().indexOf('@') !== -1) {
            // Find it's parent 
            do {
                folder = folder.getParents().next();
            } while (folder.getName().indexOf('@') !== -1);
        }
        // Art style
        return folder.getName();
    }

    我們刪除步驟二與之前寫入的試算表資料,並撥前 Project Properties 的時間紀錄,再度重新測試。接著檢查試算表,應該會依照建立時間排序,並能看到確實有依照新的模板與分類進行。

    3. 後話


    到這邊我們就成功完成迴圈掃描所有新增的底層資料並進行分配了!但我們可以想像可能會有搬遷予刪除的情況,我們也該因應而修改或刪除紀錄。

    那下一回我們就要依靠類似的架構,新增查詢修改與刪除的方法。

    4. 參考


    [1] Sorting Date format
    https://stackoverflow.com/questions/10123953/how-to-sort-an-object-array-by-date-property

    [2] Drive API — Official Doc
    https://developers.google.com/drive/api/reference/rest/v3/files/list

    5. 素材


    [1] QuAn_ — pixiv
    https://www.pixiv.net/users/6657532

    [2] P.art — pixiv
    https://www.pixiv.net/en/users/35929537

    6. 成品


    JavaScript
    // Find parent folder by where the spreadsheet is stored
    function getParentFolder() {
      // Get the active spreadsheet
      var spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
      // Get the ID of the spreadsheet
      var spreadsheetId = spreadsheet.getId();
    
      // Get the parent folders of the spreadsheet
      var parentFolders = DriveApp.getFileById(spreadsheetId).getParents();
    
      // Check if there are any parent folders
      if (parentFolders.hasNext()) {
        // Assuming we only have a parent folder
        var parentFolder = parentFolders.next();
    
        // For testing purpose
        console.log(`Parent Folder: ${parentFolder.getUrl()}`);
    
        // Return the folder object
        return parentFolder;
      }
      // If there are no parent folders, return null 
      else {
        console.error("[ERROR] Critical Error on Getting Parent URL");
    
        return null;
      }
    }
    
    // Init setting in project propeties
    function init() {
      // Sheet Name
      const sheetFilesDetection_sheetName = "總表";
    
      try {
        PropertiesService.getScriptProperties().setProperties({
          'sheetFilesDetection_sheetName': sheetFilesDetection_sheetName,
          // Init lastSearchFilesCreatedTime
          'lastSearchFilesCreatedTime': new Date().toISOString()
        });
        console.info("[ O K ] Successfully initalize the app.");
      } catch (error) { console.error(`[ERROR] Failed to initialize the app: ${error}`); }
    }
    
    // Search Files Newly Created
    // 
    // - Loop through stack to see if any folder need to be scan
    // -- Comparing Files CreatedDate and Latestest File Created Time on last execution to see if is new
    // -- Look into the subfolder
    // ---- Push subfolder to stack to continue while-loop
    // - Resort full fileList by file created date
    // - Write attributes to spreadsheet
    // - Reset property "lastSearchFilesCreatedTime" to latest
    function searchFilesCreated() {
      // Getting the spreadsheet for data writing
      const sheetFilesDetection = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(PropertiesService.getScriptProperties().getProperty('sheetFilesDetection_sheetName'));
    
      // Latest file created time on last execution
      const lastSearchFilesCreatedTime = PropertiesService.getScriptProperties().getProperty('lastSearchFilesCreatedTime');
    
      // Init first folder path
      let folderStack = [DriveApp.getFileById(SpreadsheetApp.getActiveSpreadsheet().getId()).getParents().next().getId()];
    
      // List for search result
      let fileList = [];
    
      while (folderStack.length > 0) {
        // // Debugging
        // console.log(folderStack);
    
        // Choosing folder to run process
        let folderId = folderStack.pop();
        // Query
        let query = `createdTime > "${lastSearchFilesCreatedTime}" and trashed=false and mimeType != "application/vnd.google-apps.folder" and "${folderId}" in parents`;
    
        // Checkpoint
        let pageToken = "";
    
        do {
          try {
            const url = encodeURI(
              `https://www.googleapis.com/drive/v3/files?q=${query}&pageSize=1000&pageToken=${pageToken}`
            ); // Include driveId and includeItemsFromAllDrives parameters
            const res = UrlFetchApp.fetch(url, {
              headers: { authorization: "Bearer " + ScriptApp.getOAuthToken() },
            });
            const obj = JSON.parse(res.getContentText());
            if (obj.files && obj.files.length > 0) {
              fileList = [...fileList, ...obj.files];
            }
            pageToken = obj.nextPageToken;
          } catch (error) {
            console.error(`[ERROR] Error fetching files: ${error}`);
            break;
          }
        } while (pageToken);
    
        // Find all subfolders
        const folders = DriveApp.getFolderById(folderId).getFolders();
        // Add subfolders to stack
        while (folders.hasNext()) {
          folderStack.push(folders.next().getId());
        }
      }
    
      // Sorting by createdDate
      fileList.sort((a, b) => new Date(DriveApp.getFileById(a.id).getDateCreated()) - new Date(DriveApp.getFileById(b.id).getDateCreated()));
    
      // Debugging
      console.log(fileList);
    
      // Write attributes to spreadsheet on sequence
      fileList.forEach(json => {
        // Use ID to get Object of File Class
        file = DriveApp.getFileById(json.id);
        // Write to spreadsheet
        writeToSpreadsheet(sheetFilesDetection, file);
      });
    
      // There's new file
      if (fileList.length > 0) {
        // Store time of lastest file created time
        PropertiesService.getScriptProperties().setProperty('lastSearchFilesCreatedTime', DriveApp.getFileById((fileList[fileList.length - 1].id)).getDateCreated().toISOString());
      }
    }
    
    // Write attributes back to spreadsheet
    // 
    // @ Artstyle
    // @ Author Name
    // @ File Name
    // @ File URL
    // @ Folder URL
    // @ File Created Date
    // @ File Modified Date 
    function writeToSpreadsheet(sheet, file) {
      // Get attributes
      // -- File Name
      const parts = file.getName().split(' - ');
      // ---- Author Name
      const authorName = parts[0];
      // ---- Work Name
      const workName = parts[1];
      // -- File URL
      const workURL = file.getUrl();
      // -- Folder URL
      const folderURL = file.getParents().next().getUrl();
      // -- File Created Date
      const formattedDateCreated = Utilities.formatDate(file.getDateCreated(), Session.getScriptTimeZone(), "yyyy-MM-dd HH:mm:ss");
      // -- File Modified Date
      const formattedDateModified = Utilities.formatDate(file.getLastUpdated(), Session.getScriptTimeZone(), "yyyy-MM-dd HH:mm:ss");
    
      // Get the header row
      const columnHeaders = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
    
      // Index
      const indexWorkCategory = columnHeaders.indexOf('繪畫風格');
      const indexWorkAuthor = columnHeaders.indexOf('作家名稱');
      const indexDateCreated = columnHeaders.indexOf('建檔時間');
      const indexDateModified = columnHeaders.indexOf('更新時間');
      const indexFolderURL = columnHeaders.indexOf('母資料夾');
      const indexWorkName = columnHeaders.indexOf('作品名稱');
      const indexWorkURL = columnHeaders.indexOf('檔案位置');
    
      // Prepare the data array for the new row
      let row = new Array(columnHeaders.length).fill('');
    
      row[indexWorkCategory] = getArtStyle(file.getParents().next());
      row[indexWorkAuthor] = authorName || "Unknown";
      row[indexDateCreated] = formattedDateCreated;
      row[indexDateModified] = formattedDateModified;
      row[indexFolderURL] = folderURL;
      row[indexWorkName] = workName || "Undefined";
      row[indexWorkURL] = workURL;
    
      // Write to last row in sheet
      sheet.appendRow(row);
    }
    
    // Get artstyle based on folder name
    // 
    // - Look up parent folder until no more @ in folder name
    function getArtStyle(folder) {
      // With @ -> Artist portfolio
      if (folder.getName().indexOf('@') !== -1) {
        // Find it's parent 
        do {
          folder = folder.getParents().next();
        } while (folder.getName().indexOf('@') !== -1);
      }
      // Art style
      return folder.getName();
    }
    
  • G-Drive 智慧工坊 – 檔案偵測 001 – 單路徑掃描

    0. 前言


    由於我個人在雲端資料夾中,與朋友共享繪畫用的參考圖庫,積年累月下來,圖片多了我們也不容易分類。突發奇想下,是否能夠利用 Google Spreadsheet 紀錄新的檔案並區分出風格與作者名稱,過後利用篩選器更快找到想要的資訊呢?

    那我們就先從最基本的單資料夾進行偵測,紀錄到 Google Spreadsheet 開始搭建吧。

    1. 重點


    利用 Google Spreadsheet 判斷母資料夾之路徑
    至少有資料夾的閱覽權限與 Spreadsheet 寫入權限即可運行,共用資料夾也適用
    利用 properties 儲存上次檢查到的最新檔案時間,判斷是否為新的檔案
    內建的 DriveApp 使用 v2 Query 無法偵測到 createdDate

    2. 內容


    2.1. 判斷資料夾在雲端硬碟的位置

    如果用資料夾名稱搜尋,我們必須經過多筆迴圈,還有可能遭遇同名的議題。因此,我決定用 Google Spreadsheet 的位置作為判斷依據,偵測試算表所在的資料夾內部活動。

    由於後續會利用 Folder ID 限制搜尋範圍,因此共用的資料夾理論上也是能執行的。我們接著建立個 Spreadsheet 用來儲存資料與執行程式吧。

    當我們手動執行該方法後,應該會打印出母資料夾的網址,我們嘗試連線進入確認是否正確。那由於 My Drive 本身在 Google 的架構中也屬於 Folder,因此也會回傳 URL,只是作為頂層其 Folder ID 也等同於 Drive ID,主要用於 Shared Drive 的跨磁碟分享。

    JavaScript
    // Find parent folder by where the spreadsheet is stored
    function getParentFolder()
    {
      // Get the active spreadsheet
      var spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
      // Get the ID of the spreadsheet
      var spreadsheetId = spreadsheet.getId();
      
      // Get the parent folders of the spreadsheet
      var parentFolders = DriveApp.getFileById(spreadsheetId).getParents();
      
      // Check if there are any parent folders
      if (parentFolders.hasNext()) {
        // Assuming we only have a parent folder
        var parentFolder = parentFolders.next();
    
        // For testing purpose
        console.log(`Parent Folder: ${parentFolder.getUrl()}`); 
    
        // Return the folder object
        return parentFolder;
      }
      // If there are no parent folders, return null 
      else {
        console.error("[ERROR] Critical Error on Getting Parent URL"); 
    
        return null;
      }
    }

    我們還是聚焦在個人帳戶的基礎上,不討論要 Workspace 帳戶才能建立的 Shared Drive。總之進入連結後應該能看到我們剛所建立的檔案。

    2.2. 建立檢查資料夾內檔案的方法

    取得了資料夾位置後,我們便要搜尋其內部的變動。那為了先確認可行性,這邊我只先掃描該層資料夾,而沒有迴圈內部的子資料夾。

    此時我們先新增一個 function 專門用來初始化設定。貼上以後,請修改 sheetname 的常數為你的分頁名稱,之後再執行該方法。

    JavaScript
    // Init setting in project propeties
    function init() {
      // Sheet Name
      const sheetFilesDetection_sheetName = "總表";
    
      try {
        PropertiesService.getScriptProperties().setProperties({
          'sheetFilesDetection_sheetName': sheetFilesDetection_sheetName,
          // Init lastSearchFilesCreatedTime
          'lastSearchFilesCreatedTime': new Date().toISOString()
        });
        console.info("[ O K ] Successfully initalize the app.");
      } catch (error) { console.error(`[ERROR] Failed to initialize the app: ${error}`); }
    }

    當我們執行之後,進到 Project Settings >> Properties 區塊,除了紀錄分頁名稱之外,還有最重要用來判斷時間的基準,之後會以上次偵測所紀錄的最近期建檔檔案時間做判斷,在他之後的都算新檔案。後面我們統一會用 ISO 8601 的日期格式。

    接著我們就要開始測試執行檔案了,避免用迴圈浪費大把時間,就要使用官方提供的搜尋方法。但由於 DriveApp 本身的搜尋方法無法檢測到 createdDate 參數,我們就透過 HTTP Request 呼叫 Drive API v3 進行查詢。請注意變數 folderId 目前只是暫時測試用,之後會進行更動以適用其他子資料夾。

    JavaScript
    // Search Files Newly Created
    // 
    // - Comparing Files CreatedDate and Latestest File Created Time on last execution to see if is new
    // -- T
    // ---- Write important details into spreadsheet
    // -- F
    // ---- 
    function searchFilesCreated() {
      // Getting the spreadsheet for data writing
      const sheetFilesDetection = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(PropertiesService.getScriptProperties().getProperty('sheetFilesDetection_sheetName'));
    
      // Latest file created time on last execution
      const lastSearchFilesCreatedTime = PropertiesService.getScriptProperties().getProperty('lastSearchFilesCreatedTime');
      
      // TMP
      let folderId = DriveApp.getFileById(SpreadsheetApp.getActiveSpreadsheet().getId()).getParents().next().getId(); 
    
      // Setting up search condition
      const query = `createdTime > "${lastSearchFilesCreatedTime}" and trashed=false and mimeType != "application/vnd.google-apps.folder" and "${folderId}" in parents`;
    
      // List for search result
      let fileList = [];
    
      // Checkpoint
      let pageToken = "";
    
      do {
        try {
          const url = encodeURI(
            `https://www.googleapis.com/drive/v3/files?q=${query}&pageSize=1000&pageToken=${pageToken}`
          ); // Include driveId and includeItemsFromAllDrives parameters
          const res = UrlFetchApp.fetch(url, {
            headers: { authorization: "Bearer " + ScriptApp.getOAuthToken() },
          });
          const obj = JSON.parse(res.getContentText());
          if (obj.files && obj.files.length > 0) {
            fileList = [...fileList, ...obj.files];
          }
          pageToken = obj.nextPageToken;
        } catch (error) {
          console.error(`[ERROR] Error fetching files: ${error}`);
          break;
        }
      } while (pageToken);
    
      // Debugging
      console.log(fileList);
    
      // fileList.forEach(json => {
      //   // Use ID to get Object of File Class
      //   file = DriveApp.getFileById(json.id);
      //   // Write to spreadsheet
      //   writeToSpreadsheet(sheetFilesDetection, file);
      // });
    
      // There's new file
      if (fileList.length > 0) {
        // Store time of lastest file created time
        PropertiesService.getScriptProperties().setProperty('lastSearchFilesCreatedTime', DriveApp.getFileById((fileList[fileList.length - 1].id)).getDateCreated().toISOString());
      }
    }
    

    我們首先隨便丟入幾張圖檔。

    接著執行 searchFilesCreated 方法,應該會找到這幾個檔案。

    檢查 Properties,可以看到雖然我在 19 分執行,但實際時間紀錄卻是在 2 分,這是避免迴圈過程太久導致的紀錄時差,當這空隙越大代表越多檔案可能被遺漏,尤其在同時執行的過程中。

    而我們偏不信邪,再次執行方法一次,此時應該就不會掃到任何檔案,並不改動時間標記,避免執行間的微秒差距造成缺漏的問題。

    2.2.1. Drive API Query 解說

    我們回頭講解下 query 中的參數,首先是檢查建檔時間晚於上次執行所紀錄的檔案時間,要求不在垃圾桶內(軟刪除),然後不屬於資料夾型態。最後依靠 Folder ID 限制偵測範圍,不然會偵測整個雲端磁碟。

    而由於我們之後會有多個子資料夾的結果要進行合併處理,我們就先不用 orderBy 參數要求排序檔案。

    而在呼叫的 URL 後方,pageSize 代表每次回傳的筆數,可設於 1 – 1000 之間。而 pageToken 的用途是接續點,假設今天檔案有 1500 筆,第一輪搜尋完會回傳 nextToken 的參數作為紀錄點,而第二輪就會從這邊開始。若沒有設定的話,就會因為有 nextToken 而迴圈卡死在搜尋前 pageSize 個檔案,非常重要。

    範例 1: pageSize 為 1 但無 pageToken 參數

    由於我們的程式碼會累加進 fileList,但各位應該有發現到陣列變成了但資料都同一筆,此時的 nextToken 一直指向下個符合的檔案,從而陷入無限迴圈。

    範例 2: pageSize 為 1 而有 pageToken 參數

    此時我補回 pageToken 參數,有了紀錄點,程式就能夠依靠上次的紀錄點持續執行。可以看到這次讀取到剛加入的幾分檔案,而由於沒其他符合搜尋條件的結果,nextToken 變為空值,同時脫出迴圈。

    2.3. 將檔案資訊寫回 Google Spreadsheet

    在前面我們成功的找到新的檔案了,但我們還沒有將他們寫入總表。我先超前設計好其它欄位,不過目前測試時所存的只有後面五項。請再依照個人需求設計欄位,並自行修改以下方法。

    JavaScript
    // Write attributes back to spreadsheet
    // 
    // @ File name
    // @ File URL
    // @ Folder URL
    // @ File Created Date
    // @ File Modified Date 
    function writeToSpreadsheet(sheet, file) {
      // Get attributes
      // -- Filename
      const fileName = file.getName();
      // -- File URL
      const fileUrl = file.getUrl();
      // -- Folder URL
      const folderURL = file.getParents().next().getUrl();
      // -- File Created Date
      const formattedDateCreated = Utilities.formatDate(file.getDateCreated(), Session.getScriptTimeZone(), "yyyy-MM-dd HH:mm:ss");
      // -- File Modified Date
      const formattedDateModified = Utilities.formatDate(file.getLastUpdated(), Session.getScriptTimeZone(), "yyyy-MM-dd HH:mm:ss");
    
      // Get the header row
      const columnHeaders = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
    
      // Index
      const indexDateCreated = columnHeaders.indexOf('建檔時間');
      const indexDateModified = columnHeaders.indexOf('更新時間'); 
      const indexFolderURL = columnHeaders.indexOf('母資料夾');
      const indexFileName = columnHeaders.indexOf('作品名稱');
      const indexFileURL = columnHeaders.indexOf('檔案位置');
    
      // Prepare the data array for the new row
      let row = new Array(columnHeaders.length).fill('');
    
      row[indexDateCreated] = formattedDateCreated;
      row[indexDateModified] = formattedDateModified;
      row[indexFolderURL] = folderURL;
      row[indexFileName] = fileName;
      row[indexFileURL] = fileUrl;
    
      // Write to last row in sheet
      sheet.appendRow(row);
    }

    完成之後,我們要再 searchFilesCreated() 啟用,請幫剛剛最底下被註解的地方啟用。

    JavaScript
    fileList.forEach(json => {
        // Use ID to get Object of File Class
        file = DriveApp.getFileById(json.id);
        // Write to spreadsheet
        writeToSpreadsheet(sheetFilesDetection, file);
      });

    完成之後,我們再放些新的檔案到資料夾,再度執行 searchFilesCreated 後,應該能看到成功寫回 Google Spreadsheet 之中,並能快速的連到資料夾與預覽圖片。這樣就完成基本款了。

    3. 後話


    但為了區分畫風與標註作家,我們將預期有許多的子資料夾,那下次的目標就是對底下的所有物件進行掃描,並嘗試透過資料夾名稱進行分類。

    4. 參考


    [1] Property Service
    https://developers.google.com/apps-script/guides/properties

    [2] Issue and Workaround on v2 Query of createdDate
    https://stackoverflow.com/a/74118817

    [3] Drive API — Official Doc
    https://developers.google.com/drive/api/reference/rest/v3/files/list

    5. 素材


    [1] QuAn_ — pixiv
    https://www.pixiv.net/users/6657532

    [2] HzW3 — pixiv
    https://www.pixiv.net/users/13804723

    6. 成品


    JavaScript
    // Init setting in project propeties
    function init() {
      // Sheet Name
      const sheetFilesDetection_sheetName = "總表";
    
      try {
        PropertiesService.getScriptProperties().setProperties({
          'sheetFilesDetection_sheetName': sheetFilesDetection_sheetName,
          // Init lastSearchFilesCreatedTime
          'lastSearchFilesCreatedTime': new Date().toISOString()
        });
        console.info("[ O K ] Successfully initalize the app.");
      } catch (error) { console.error(`[ERROR] Failed to initialize the app: ${error}`); }
    }
    
    // Search Files Newly Created
    // 
    // - Comparing Files CreatedDate and Latestest File Created Time on last execution to see if is new
    // -- T
    // ---- Write important details into spreadsheet
    // -- F
    // ---- 
    function searchFilesCreated() {
      // Getting the spreadsheet for data writing
      const sheetFilesDetection = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(PropertiesService.getScriptProperties().getProperty('sheetFilesDetection_sheetName'));
    
      // Latest file created time on last execution
      const lastSearchFilesCreatedTime = PropertiesService.getScriptProperties().getProperty('lastSearchFilesCreatedTime');
    
      // TMP
      let folderId = DriveApp.getFileById(SpreadsheetApp.getActiveSpreadsheet().getId()).getParents().next().getId();
    
      // Setting up search condition
      const query = `createdTime > "${lastSearchFilesCreatedTime}" and trashed=false and mimeType != "application/vnd.google-apps.folder" and "${folderId}" in parents`;
    
      // List for search result
      let fileList = [];
    
      // Checkpoint
      let pageToken = "";
    
      do {
        try {
          const url = encodeURI(
            `https://www.googleapis.com/drive/v3/files?q=${query}&pageSize=1000&pageToken=${pageToken}`
          ); // Include driveId and includeItemsFromAllDrives parameters
          const res = UrlFetchApp.fetch(url, {
            headers: { authorization: "Bearer " + ScriptApp.getOAuthToken() },
          });
          const obj = JSON.parse(res.getContentText());
          if (obj.files && obj.files.length > 0) {
            fileList = [...fileList, ...obj.files];
          }
          pageToken = obj.nextPageToken;
        } catch (error) {
          console.error(`[ERROR] Error fetching files: ${error}`);
          break;
        }
      } while (pageToken);
    
      // Debugging
      console.log(fileList);
    
      fileList.forEach(json => {
        // Use ID to get Object of File Class
        file = DriveApp.getFileById(json.id);
        // Write to spreadsheet
        writeToSpreadsheet(sheetFilesDetection, file);
      });
    
      // There's new file
      if (fileList.length > 0) {
        // Store time of lastest file created time
        PropertiesService.getScriptProperties().setProperty('lastSearchFilesCreatedTime', DriveApp.getFileById((fileList[fileList.length - 1].id)).getDateCreated().toISOString());
      }
    }
    
    // Find parent folder by where the spreadsheet is stored
    function getParentFolder() {
      // Get the active spreadsheet
      var spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
      // Get the ID of the spreadsheet
      var spreadsheetId = spreadsheet.getId();
    
      // Get the parent folders of the spreadsheet
      var parentFolders = DriveApp.getFileById(spreadsheetId).getParents();
    
      // Check if there are any parent folders
      if (parentFolders.hasNext()) {
        // Assuming we only have a parent folder
        var parentFolder = parentFolders.next();
    
        // For testing purpose
        console.log(`Parent Folder: ${parentFolder.getUrl()}`);
    
        // Return the folder object
        return parentFolder;
      }
      // If there are no parent folders, return null 
      else {
        console.error("[ERROR] Critical Error on Getting Parent URL");
    
        return null;
      }
    }
    
    // Write attributes back to spreadsheet
    // 
    // @ File name
    // @ File URL
    // @ Folder URL
    // @ File Created Date
    // @ File Modified Date 
    function writeToSpreadsheet(sheet, file) {
      // Get attributes
      // -- Filename
      const fileName = file.getName();
      // -- File URL
      const fileUrl = file.getUrl();
      // -- Folder URL
      const folderURL = file.getParents().next().getUrl();
      // -- File Created Date
      const formattedDateCreated = Utilities.formatDate(file.getDateCreated(), Session.getScriptTimeZone(), "yyyy-MM-dd HH:mm:ss");
      // -- File Modified Date
      const formattedDateModified = Utilities.formatDate(file.getLastUpdated(), Session.getScriptTimeZone(), "yyyy-MM-dd HH:mm:ss");
    
      // Get the header row
      const columnHeaders = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
    
      // Index
      const indexDateCreated = columnHeaders.indexOf('建檔時間');
      const indexDateModified = columnHeaders.indexOf('更新時間'); 
      const indexFolderURL = columnHeaders.indexOf('母資料夾');
      const indexFileName = columnHeaders.indexOf('作品名稱');
      const indexFileURL = columnHeaders.indexOf('檔案位置');
    
      // Prepare the data array for the new row
      let row = new Array(columnHeaders.length).fill('');
    
      row[indexDateCreated] = formattedDateCreated;
      row[indexDateModified] = formattedDateModified;
      row[indexFolderURL] = folderURL;
      row[indexFileName] = fileName;
      row[indexFileURL] = fileUrl;
    
      // Write to last row in sheet
      sheet.appendRow(row);
    }
  • JavaScript 探險方針 005 – 陣列編輯與搜尋

    JavaScript 探險方針 005 – 陣列編輯與搜尋

    0. 前言


    陣列是一種有序的集合,能夠存放任意數量的元素,並提供許多方便的操作方法。

    本文將介紹陣列的基本概念、操作方法以及常見的應用場景。

    1. 重點


    陣列的新增與刪除具有 queue 與 stack 的特性。
    陣列的排序預設為字元排列,若要數字排列則須依靠比較函式。
    陣列中的搜尋方法只回傳一筆資料,並區分回傳數值或回傳欄位。

    2. 內容


    2.1. 定義新陣列

    要初始化並定義陣列你可以直接透過 [ ] 符號包住物件,或者利用 Array 函數進行建立。

    JavaScript
    {
        let array_001 = [1, 2, 3];
    
        console.log(array_001);
    
        let array_002 = new Array(3, 2, 1);
    
        console.log(array_002);
    }

    2.2. 新增與刪除

    在 JS 中極為特殊的是,其早就設定好從開頭或尾巴處理,相當於 queue 與 stake 的功能。不用再自己平移陣列中的所有物件。

    JavaScript
    {
        let array_001 = [1, 2, 3];
    
        // Apply to end
        array_001.push(4);
    
        console.log(array_001); // 1, 2, 3, 4
    
        // Remove the last
        array_001.pop();
    
        console.log(array_001); // 1, 2, 3
    
        let array_002 = [3, 2, 1];
    
        // Insert to beginning
        array_002.unshift(4);
    
        console.log(array_002); // 4, 3, 2, 1
    
        // Remove the beginning
        array_002.shift();
    
        console.log(array_002); // 3, 2, 1
    }

    2.3. 訪問與修改

    在前面的步驟我們都只有將資料寫入,那我們又該如何取出並編輯呢?

    2.3.1. 順序欄位

    最簡單暴力的方法只要依照陣列的欄位指標進行操作即可。

    JavaScript
    {
        let array_001 = [1, 2, 3];
    
        console.log(array_001[0]); // 1
    
        array_001[0] = 9;
    
        console.log(array_001); // 9, 2, 3
    }

    2.3.2. 搜尋欄位

    但在資料格式不確定的情況下,利用寫死的欄位順序事後往往要大修改且不得調整。此時我們就可以依照內建的搜尋辦法進行,就算表格欄位替換位置也不會影響架構。

    範例 1: 回傳欄位 — indexOf 系列

    indexOf 系列會搜尋陣列中看到的單筆目標資料,並回傳其欄位資訊,若沒有結果則會回傳 -1 。

    JavaScript
    {
        let array_001 = [1, 2, 2];
    
        console.log(array_001.indexOf(2)); // 1
        console.log(array_001.indexOf(9)); // -1
    
        console.log(array_001.lastIndexOf(2)); // 2
        console.log(array_001.lastIndexOf(9)); // -1
    }

    範例 2: 條件搜尋 — find 與 findIndex 系列

    然而利用前述方法,我們可能需要進行二次處理透過 filter () 塞選出單筆結果,不如一開始就用條件搜尋。要注意的是兩個系列同樣只回傳第一筆數值,除 find 無結果時回傳 undefined ,findIndex 都是回傳 -1 。

    JavaScript
    {
        let array_001 = [1, 2, 2];
    
        console.log(array_001.find((element) => element > 1)); // 2
        console.log(array_001.find((element) => element > 9)); // undefined
    
        console.log(array_001.findIndex((element) => element > 1)); // 1
        console.log(array_001.findIndex((element) => element > 9)); // -1
    
        console.log(array_001.findLast((element) => element > 1)); // 2
        console.log(array_001.findLast((element) => element > 9)); // undefined
    
        console.log(array_001.findLastIndex((element) => element > 1)); // 2
        console.log(array_001.findLastIndex((element) => element > 9)); // -1
    }

    範例 3: 回傳布林 — includes()

    我們除了能依靠 indexOf() === -1 判斷陣列中是否有目標資料外,也能透過 includes() 方法回傳布林值。

    JavaScript
    {
        let array_001 = [1, 2, 2];
    
        console.log(array_001.includes(2)); // true
        console.log(array_001.includes(9)); // false
    }

    2.4. 重新排列

    在多數時候為了方便我們需要將資料重新按順序排好,此時主要常用以下功能。

    範例 1: 同陣列排列

    以下示範最基本的排序,會依照字母排序。

    JavaScript
    {
        let fruits = ["B", "O", "A", "M"];
    
        fruits.sort();
    
        console.log(fruits); // [A, B, M, O]
    
        fruits.reverse();
    
        console.log(fruits); // [O, M, B, A]
    }

    但這種排列方式有需多問題,首先是數字也是依照首個位數進行排序,如範例 11, 15 比 2 還早出現。

    JavaScript
    {
        let numbers = [9, 11, 15, 2];
    
        numbers.sort();
    
        console.log(numbers); // [11, 15, 2, 9]
    }

    再來是由於變數是指向相同的緩存位置,所以資料會是連動的。

    JavaScript
    {
        let array_001 = ["B", "O", "A", "M"];
    
        let array_002 = array_001;
    
        array_002.sort();
    
        console.log(array_001); // [A, B, M, O]
    }

    範例 2: 新陣列排列

    那為了解決相同儲存位置的問題,後來又引入了 toSorted() 與 toReversed() 兩種方法。但由於這是在 ES2023 新增的全新功能,Node JS 需要 20+ 以上的版本才能使用,而普遍的瀏覽器已支援。

    JavaScript
    {
        let array_001 = ["B", "O", "A", "M"];
    
        let array_002 = array_001.toSorted();
    
        console.log(array_001); // [B, O, A, M]
        console.log(array_002); // [A, B, M, O]
    }

    範例 3: 數字排列

    前面我們提到,其所排列的方式是字母順序,針對數字的排列我們則需要依靠比較函式。

    JavaScript
    {
        let numbers = [9, 11, 15, 2];
    
        numbers.sort((m, n) => m - n);
    
        console.log(numbers); // [2, 9, 11, 15]
    }

    3. 後話


    在這篇我們先簡單介紹如何的編輯與搜尋陣列的內容,那在下篇我們則會以陣列的迴圈陳述與合併方式做詳細介紹。

    4. 參考


    [1] JS Array Search — W3 Schools
    https://www.w3schools.com/js/js_array_search.asp

    [2] JS Array Sort — W3 Schools
    https://www.w3schools.com/js/js_array_sort.asp