🐙

【GAS】スプレッドシートで使いやすいClass紹介

に公開

長らく手続き型でしかGASを書けなかったのですが、Classを勉強したことで便利なことに気が付きました。

スプレッドシートにコピペするだけで、あらゆるツールが使えるようになるので、よろしければお使いください(もっと便利な機能があればコメントまでお待ちしています)。

/**
 * スプレッドシートの操作を簡略化する汎用クラス
 * - 基本的な読み書き
 * - オブジェクト形式での操作
 * - 検索・情報取得
 */
class SheetAccess {
  /**
   * @param {string} sheetName - 操作したいシート名
   * @param {string} [spreadsheetId] - (任意) 別スプレッドシートのID
   */
  constructor(sheetName = null, spreadsheetId = null) {
    this.ss = spreadsheetId
      ? SpreadsheetApp.openById(spreadsheetId)
      : SpreadsheetApp.getActiveSpreadsheet();

    this.sheet = sheetName
      ? this.ss.getSheetByName(sheetName)
      : this.ss.getActiveSheet();

    if (!this.sheet) {
      const targetName = spreadsheetId ? `ID:${spreadsheetId}内` : "アクティブSS内";
      throw new Error(`${targetName}にシート「${sheetName}」が見つかりません。`);
    }
  }

  // ==========================================
  //  基本操作 (Read / Write)
  // ==========================================

  /**
   * 単一セルの値を取得(A1形式 / RC形式 両対応)
   * @param {string|number} arg1 - アドレス(A1) or 行番号
   * @param {number} [arg2] - 列番号
   */
  getValue(arg1, arg2 = null) {
    if (typeof arg1 === 'number' && typeof arg2 === 'number') {
      return this.sheet.getRange(arg1, arg2).getValue();
    }
    return this.sheet.getRange(arg1).getValue();
  }

  /**
   * 単一セルに値をセット(A1形式 / RC形式 両対応)
   * @param {string|number} arg1 - アドレス(A1) or 行番号
   * @param {any} arg2 - 値 (A1形式の時) or 列番号 (RC形式の時)
   * @param {any} [arg3] - 値 (RC形式の時のみ)
   */
  setValue(arg1, arg2, arg3 = null) {
    if (typeof arg1 === 'number') {
      this.sheet.getRange(arg1, arg2).setValue(arg3);
    } else {
      this.sheet.getRange(arg1).setValue(arg2);
    }
  }

  /**
   * 範囲(Rangeオブジェクト)を取得
   * 1. 引数なし → データ全範囲
   * 2. 文字列 ("A1:C5")
   * 3. 数値 (row, col, [numRows], [numCols])
   */
  getRectRange(arg1, arg2, arg3, arg4) {
    if (arg1 === undefined) return this.sheet.getDataRange();
    if (typeof arg1 !== 'number') return this.sheet.getRange(arg1);

    const row = arg1;
    const col = arg2;
    // 指定がなければ最終行/列まで
    let numRows = arg3 || (Math.max(1, this.sheet.getLastRow() - row + 1));
    let numCols = arg4 || (Math.max(1, this.sheet.getLastColumn() - col + 1));

    return this.sheet.getRange(row, col, numRows, numCols);
  }

  /**
   * 範囲の値(2次元配列)を取得
   * @param {string|number} arg1 - アドレス or 行番号
   */
  getRectValues(arg1, arg2, arg3, arg4) {
    return this.getRectRange(arg1, arg2, arg3, arg4).getValues();
  }

  /**
   * 2次元配列を書き込む
   * @param {string|number} arg1 - アドレス(A1) or 行番号
   * @param {any} arg2 - 値([][]) or 列番号
   * @param {any[][]} [arg3] - 値([][])
   */
  setRectValues(arg1, arg2, arg3 = null) {
    let row, col, values;
    if (typeof arg1 === 'number') {
      row = arg1; col = arg2; values = arg3;
    } else {
      const range = this.sheet.getRange(arg1);
      row = range.getRow(); col = range.getColumn(); values = arg2;
    }

    if (!values || !values.length) return;
    this.sheet.getRange(row, col, values.length, values[0].length).setValues(values);
  }

  // ==========================================
  //  情報取得・検索 (Info / Search)
  // ==========================================

  /**
   * 最終行を取得
   * @param {string} [column='A'] - 判定基準にする列
   */
  getLastRow(column = 'A') {
    const colIndex = isNaN(column) ? this.sheet.getRange(`${column}1`).getColumn() : column;
    const maxRows = this.sheet.getMaxRows();
    if (maxRows === 0) return 1;
    return this.sheet.getRange(maxRows, colIndex).getNextDataCell(SpreadsheetApp.Direction.UP).getRow();
  }

  /**
   * 最終列を取得
   * @param {number} [row=1] - 判定基準にする行
   */
  getLastCol(row = 1) {
    const maxCols = this.sheet.getMaxColumns();
    if (maxCols === 0) return 1;
    return this.sheet.getRange(row, maxCols).getNextDataCell(SpreadsheetApp.Direction.PREVIOUS).getColumn();
  }

  /**
   * 指定列からキーワードを検索し行番号を返す
   * ※列が固定の場合に使用
   * @param {string} keyword
   * @param {string} [column='A'] - 列文字 (例: 'A', 'B')
   */
  getRowIndex(keyword, column = 'A') {
    const finder = this.sheet.getRange(`${column}:${column}`).createTextFinder(keyword).matchEntireCell(true).findNext();
    return finder ? finder.getRow() : null;
  }

  /**
   * ヘッダー名から列を特定し、その列内でキーワードを検索して行番号を返す
   * ※列位置が変わる可能性がある場合に使用
   * @param {string} keyword - 検索したい値
   * @param {string} headerName - ヘッダー名
   * @param {number} [headerRow=1] - ヘッダーがある行番号
   */
  findRowByHeader(keyword, headerName, headerRow = 1) {
    // 1. まずヘッダーがある列番号を探す(既存メソッドを活用)
    const colIndex = this.getColumnIndex(headerName, headerRow);

    // ヘッダー自体が見つからない場合は null
    if (!colIndex) return null;

    // 2. 特定した列(colIndex)全体を対象に検索
    // 行数が多い場合を考慮し、列全体(MaxRows)を指定
    const finder = this.sheet.getRange(1, colIndex, this.sheet.getMaxRows(), 1)
      .createTextFinder(keyword)
      .matchEntireCell(true)
      .findNext();

    return finder ? finder.getRow() : null;
  }

  /**
   * 指定行からキーワードを検索し列番号を返す
   * @param {string} keyword
   * @param {number} [row=1]
   */
  getColumnIndex(keyword, row = 1) {
    const finder = this.sheet.getRange(`${row}:${row}`).createTextFinder(keyword).matchEntireCell(true).findNext();
    return finder ? finder.getColumn() : null;
  }

  // ==========================================
  //  オブジェクト操作 (Object Mapper)
  // ==========================================

  /**
   * シートデータをオブジェクト配列に変換
   * @param {number} [headerRow=1]
   * @param {number} [dataStartRow=null]
   */
  getDataAsObjects(headerRow = 1, dataStartRow = null) {
    const values = this.sheet.getDataRange().getValues();
    const headers = values[headerRow - 1];
    const startIdx = dataStartRow ? dataStartRow - 1 : headerRow;

    if (!headers) return [];

    return values.slice(startIdx).map((rowValues, index) => {
      const item = headers.reduce((obj, header, i) => {
        obj[header] = rowValues[i];
        return obj;
      }, {});
      item.row = startIdx + index + 1;
      return item;
    });
  }

  /**
   * オブジェクト配列を指定キー順で書き込み
   * @param {Object[]} items
   * @param {string[]} [keys=null] - 省略時は row 以外の全キー
   * @param {number} [startRow=1]
   * @param {number} [startCol=1]
   */
  setRectObjects(items, keys = null, startRow = 1, startCol = 1) {
    if (!items || !items.length) return;

    let targetKeys = keys;
    if (!targetKeys || targetKeys.length === 0) {
      targetKeys = Object.keys(items[0]).filter(k => k !== 'row');
    }

    const values = [targetKeys];
    items.forEach(item => {
      values.push(targetKeys.map(key => (item.hasOwnProperty(key) ? item[key] : '')));
    });

    this.setRectValues(startRow, startCol, values);
  }

  /**
   * 既存ヘッダーに合わせてオブジェクト配列を追記
   * @param {Object[]} dataObjects
   * @param {number} [headerRow=1]
   */
  insertObjects(dataObjects, headerRow = 1) {
    if (!dataObjects.length) return;

    const lastCol = this.sheet.getLastColumn();
    const headers = this.sheet.getRange(headerRow, 1, 1, lastCol).getValues()[0];
    const newRows = dataObjects.map(obj => {
      return headers.map(header => (obj.hasOwnProperty(header) ? obj[header] : ''));
    });

    const lastRow = this.sheet.getLastRow();
    this.sheet.getRange(lastRow + 1, 1, newRows.length, newRows[0].length).setValues(newRows);
  }

  // ==========================================
  //  静的メソッド (Factory / Static)
  // ==========================================

  /**
   * シートをリセット(クリアまたは新規作成)してインスタンスを返す
   * @param {string} sheetName
   * @param {string} [spreadsheetId]
   */
  static resetSheet(sheetName, spreadsheetId = null) {
    const ss = spreadsheetId ? SpreadsheetApp.openById(spreadsheetId) : SpreadsheetApp.getActiveSpreadsheet();
    let sheet = ss.getSheetByName(sheetName);

    if (sheet) {
      sheet.clear();
    } else {
      sheet = ss.insertSheet(sheetName);
    }
    sheet.activate();
    return new SheetAccess(sheetName, spreadsheetId);
  }
}
/**
 * 日付操作ユーティリティ
 * DateUtils.format(...) のように直接使用可能
 */
class DateUtils {

  // 定数として曜日配列を定義
  static get DAYS_JP() {
    return ['日', '月', '火', '水', '木', '金', '土'];
  }

  /**
   * 日付を指定フォーマットに変換
   * @param {Date|string} date - 日付オブジェクトまたは日付文字列
   * @param {string} [type='date'] - フォーマット種別(key) または 直接パターン(yyyy/MM/ddなど)
   * @return {string} フォーマットされた文字列
   */
  static format(date, type = 'date') {
    if (!date) return "";

    // 文字列で来てもDate型に変換
    const d = new Date(date);
    // 無効な日付なら空文字を返す
    if (isNaN(d.getTime())) return '';

    // 基本パーツの生成 (Utilities.formatDateはGAS専用メソッド)
    // タイムゾーンはJST固定
    const ymd = Utilities.formatDate(d, 'JST', 'yyyy/MM/dd');
    const ymdJP = Utilities.formatDate(d, 'JST', 'yyyy年MM月dd日');
    const hm = Utilities.formatDate(d, 'JST', 'HH:mm');

    // 曜日の取得 (this.DAYS_JP でこのクラスのstatic getterにアクセス)
    const dayName = this.DAYS_JP[d.getDay()];

    // 定義済みパターンのマップ
    const formats = {
      'date': ymd, // 2023/10/01
      'withDay': `${ymd}(${dayName})`, // 2023/10/01(日)
      'jp': ymdJP, // 2023年10月01日
      'jpFull': `${ymdJP}(${dayName})`, // 2023年10月01日(日)
      'time': hm, // 12:00
      'full': `${ymd}(${dayName}) ${hm}`, // 2023/10/01(日) 12:00
      'dayOnly': `(${dayName})` // (日)
    };

    // マップにあればそれを返し、なければユーザー指定のパターン(yyyyMMddなど)として処理
    return formats[type] || Utilities.formatDate(d, 'JST', type);
  }
}
/**
 * Google Drive / ファイル操作用ユーティリティ
 */
class DriveAccess {

  /**
   * 1. 空のスプレッドシートを新規作成し、指定フォルダに配置
   * @param {string} fileName - 新しいファイル名
   * @param {string} folderId - 格納先のフォルダID
   * @return {string} 作成されたSSのID
   */
  static createSpreadsheet(fileName, folderId) {
    // 1. フォルダ確認
    const folder = this._getFolderOrThrow(folderId);

    // 2. 作成
    const ss = SpreadsheetApp.create(fileName);
    const file = DriveApp.getFileById(ss.getId());

    // 3. 指定のフォルダに移動
    file.moveTo(folder);

    return ss.getId();
  }

  /**
   * 2. 既存のスプレッドシートをコピーし、指定フォルダに配置
   * @param {string} templateId - 元になるSSのID
   * @param {string} newFileName - コピー後のファイル名
   * @param {string} folderId - 格納先のフォルダID
   * @return {string} 新しいSSのID
   */
  static copySpreadsheet(templateId, newFileName, folderId) {
    // 1. フォルダと元ファイルの確認
    const folder = this._getFolderOrThrow(folderId);
    const templateFile = DriveApp.getFileById(templateId); 

    // 2. 指定フォルダの中にコピーを作成
    const newFile = templateFile.makeCopy(newFileName, folder);

    return newFile.getId();
  }

  /**
   * 3. 新しいSSを作成し、別SSの特定シートだけをコピーして持ってくる
   * ・不要な初期シート「シート1」は削除されます。
   * @param {string} sourceSpreadsheetId - コピー元SSのID
   * @param {string} sourceSheetName - コピーしたいシート名
   * @param {string} newFileName - 新しいファイル名
   * @param {string} folderId - 格納先のフォルダID
   * @return {string} 新しいSSのID
   */
  static createFromSheet(sourceSpreadsheetId, sourceSheetName, newFileName, folderId) {
    // --- 準備フェーズ ---
    const folder = this._getFolderOrThrow(folderId);

    // コピー元の確認
    const srcSS = SpreadsheetApp.openById(sourceSpreadsheetId);
    const srcSheet = srcSS.getSheetByName(sourceSheetName);
    if (!srcSheet) {
      throw new Error(`コピー元のシート「${sourceSheetName}」が見つかりません。`);
    }

    // --- 作成フェーズ ---
    // 1. 新規SS作成と移動
    const newSS = SpreadsheetApp.create(newFileName);
    const newFile = DriveApp.getFileById(newSS.getId());
    newFile.moveTo(folder);

    // 2. シートのコピー実行
    const copiedSheet = srcSheet.copyTo(newSS);
    copiedSheet.setName(sourceSheetName); // コピー~ とつくのを防ぐ

    // 3. 初期シート(シート1)の削除
    const defaultSheet = newSS.getSheets()[0];
    // 万が一、コピーしたシートと同じ名前だった場合などの安全策
    if (newSS.getSheets().length > 1) {
      newSS.deleteSheet(defaultSheet);
    }

    return newSS.getId();
  }

  /**
   * 内部用ヘルパー:フォルダIDからFolderオブジェクトを取得
   * 取得できない場合は明確なエラーを投げる
   * @private
   */
  static _getFolderOrThrow(folderId) {
    try {
      if (!folderId) throw new Error();
      return DriveApp.getFolderById(folderId);
    } catch (e) {
      throw new Error(`フォルダが見つかりません。ID: ${folderId} を確認してください。`);
    }
  }
}
/**
 * Google Chatへの通知用クラス
 * ※Webhook URLが必要です
 */
class ChatUtils {
  /**
   * テキストメッセージを送信
   * @param {string} url - Webhook URL
   * @param {string} text - 送信メッセージ(メンション等は <users/all> 等の形式で記述)
   */
  static post(url, text) {
    if (!url || !text) return;

    const payload = { text: text };
    const options = {
      'method': 'post',
      'contentType': 'application/json',
      'payload': JSON.stringify(payload),
      'muteHttpExceptions': true
    };

    try {
      UrlFetchApp.fetch(url, options);
    } catch (e) {
      console.error('Chat通知に失敗しました: ' + e.message);
    }
  }
}
/**
 * メニュー作成用ユーティリティ
 */
class MenuUtils {
  /**
   * カスタムメニューを追加
   * @param {string} menuName - メニュー名
   * @param {Object} items - { '表示ラベル': '実行関数名', ... }
   * ※区切り線を入れたい場合はキーに 'SEPARATOR_1' などを指定
   */
  static create(menuName, items) {
    const ui = SpreadsheetApp.getUi();
    const menu = ui.createMenu(menuName);

    Object.keys(items).forEach(label => {
      if (label.includes('SEPARATOR')) {
        menu.addSeparator();
      } else {
        menu.addItem(label, items[label]);
      }
    });

    menu.addToUi();
  }
}
/**
 * スクリプトプロパティ操作用ショートカット
 */
class PropAccess {
  /**
   * プロパティの値を取得
   * @param {string} key
   * @return {string|null}
   */
  static get(key) {
    return PropertiesService.getScriptProperties().getProperty(key);
  }

  /**
   * プロパティに値をセット(基本はGUIでやると思うので、あまり使わないかもですが念のため)
   * @param {string} key
   * @param {string} value
   */
  static set(key, value) {
    PropertiesService.getScriptProperties().setProperty(key, value);
  }
}

Discussion