【GASアプリ】csvファイルから必要な列・行をフィルタし保存するスキャフォールド

に公開

概要

前回、PDFファイルを読み取りアップロードするスキャフォールドを作成しました。
https://zenn.dev/yuta_enginner/articles/014eb79d7b9905

今回はこれのcsvファイルバージョンです。

会社のシステムや、官公庁の生データを定期的に取得しスプレッドシートに保存するような作業では、
自分の業務では必要ない列や行があり、そのままではハンドリングしにくい問題があります。

保存前に必要な列・行だけを残してスプレッドシートに保存します。

アプリの操作

  1. スプレッドシートのメニューバーに、csvアップロードのダイアログを表示させます

  2. csvファイルをアップロードします。プレビューが表示されます。
    (このとき、まだcsvファイルは保存されていません)

  3. データ加工ボタンを押すと、サーバー側で決められたスクリプトに従って、行や列がフィルタリングされます

  4. 保存ボタンを押すと、データがスプレッドシートに保存されます。また、オリジナルのcsvファイルもGoogleドライブに保存され、スプレッドシートにはそのファイルURLが保存されます

コード

フロント側

csv_upload_form.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <base target="_top">
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>CSV読み込み</title>
  <?!= include('Stylesheet'); ?>
  <!--Grid.jsのテーマを適用する。https://gridjs.io/docs/theme/mermaid-->
  <link href="https://unpkg.com/gridjs/dist/theme/mermaid.min.css" rel="stylesheet" />
  <style>
    #rawPreview, #processSection, #resultSection {
      display: flex;
      flex-direction: column;
      min-width: 0;
      min-height: 0;
      overflow: hidden;
    }
    #rawPreview  { flex: 1 1 50%; }
    #processSection, #resultSection { flex: 1 1 50%; }
    #toast a { color: #1a56db; }
    .gridjs-td,
    .gridjs-th { padding: 1px 4px !important; font-size: 0.75rem !important; white-space: nowrap; line-height: 1.4 !important; }
    .gridjs-tr { height: auto !important; }
  </style>
</head>
<body>
  <div id="container">

    <div id="toast" hidden>
      <span id="toastMessage"></span>
    </div>

    <form id="form">

      <!--CSVが未選択の時に表示される-->
      <div id="picker">
        <label for="csv">CSVファイル</label>
        <input id="csv" name="csv" type="file" accept=".csv,text/csv" onchange="handlePreview()" required>
      </div>

      <!--CSVを選択した後に表示される-->
      <div id="work" class="work-layout" hidden>
        <!--パネル左側:元データのプレビュー-->
        <section id="rawPreview">
          <div class="panel-header">
            <div>元データ</div>
            <span id="fileInfo"></span>
          </div>
          <div class="panel-body">
            <div id="rawTable"></div>
          </div>
        </section>

        <!--パネル右側:加工ボタンを押す前に表示される-->
        <section id="processSection" hidden>
          <button type="button" id="processButton" onclick="processCSV()">データ加工</button>
        </section>

        <!--パネル右側:加工ボタンを押した後に表示される-->
        <section id="resultSection" hidden>
          <div class="panel-header">
            <div>加工結果</div>
            <button type="button" id="saveButton" onclick="saveCSV()">保存</button>
          </div>
          <div class="panel-body">
            <div id="processedTable"></div>
          </div>
        </section>
      </div>

      <input type="hidden" id="dataJson" name="dataJson" value="">
    </form>
  </div>

  <!--表作成にGrid.jsを使用する。https://gridjs.io/docs/install-->
  <script type="module">
    import { Grid, html } from "https://unpkg.com/gridjs?module";
    window.Grid = Grid;
  </script>

  <script>
    const $ = (selector) => document.querySelector(selector);
    const $$ = (selector) => document.querySelectorAll(selector);

    function googleScriptRun(functionName, params) {
      return new Promise(function(resolve, reject) {
        google.script.run
          .withSuccessHandler(resolve)
          .withFailureHandler(reject)
          [functionName](params);
      });
    }

    const disableButtons = () => $$('button').forEach(button => button.disabled = true);
    const enableButtons  = () => $$('button').forEach(button => button.disabled = false);

    const useStep = {
      _step: 'select',
      get step() { return this._step; },
      set step(next) {
        if (this._step === next) return;
        this._step = next;

        if (this._step === 'select') {
          ['picker'].forEach(id => $('#'+id).hidden = false);
          ['work'].forEach(id => $('#'+id).hidden = true);
        } else if (this._step === 'preview') {
          ['work', 'processSection'].forEach(id => $('#'+id).hidden = false);
          ['picker', 'resultSection'].forEach(id => $('#'+id).hidden = true);
        } else if (this._step === 'result') {
          ['work', 'resultSection'].forEach(id => $('#'+id).hidden = false);
          ['picker', 'processSection'].forEach(id => $('#'+id).hidden = true);
        }
      }
    }

    const useToast = {
      set message(message) {
        $('#toast').hidden = !message;
        $('#toastMessage').textContent = message;
      }
    }

    const useData = {
      _data: {headers: [], rows: []},
      get headers() {
        return this._data.headers;
      },
      set headers(headers) {
        this._data.headers = headers;
      },
      get rows() {
        return this._data.rows;
      },
      set rows(rows) {
        this._data.rows = rows;
      },
      set items(items) {
        this._data.headers = Object.keys(items[0]);
        this._data.rows = items.map(item=> this._data.headers.map(header=> item[header]));
      }
    }

    const truncate = cell => {
      const s = String(cell ?? '');
      return s.length > 15 ? s.slice(0, 15) + '…' : s;
    };

    async function handlePreview() {
      useToast.message = 'ファイルを読み込み中...';
      disableButtons();

      try {
        const file = $('#csv').files[0];
        const text = await readText(file, 'Shift_JIS');
        const allRows = text.split('\n').filter(line => line.trim()).map(line => line.split(','));
        const headers = allRows[0] || [];
        const dataRows = allRows.slice(1);

        $('#rawTable').replaceChildren();
        const grid = new Grid({ columns: headers.map(h => ({ name: h, formatter: truncate })), data: dataRows, search: true });
        grid.render($('#rawTable'));

        $('#fileInfo').textContent = `${file.name}(${dataRows.length}行 / ${Math.round(file.size / 1024)} KB)`;

        useStep.step = 'preview';
        useToast.message = '';
      } catch (error) {
        useToast.message = 'ファイルの読み込みに失敗しました。';
        console.log(error);
        useStep.step = 'select';
      } finally {
        enableButtons();
      }
    }

    function readText(file, encoding) {
      return new Promise(function(resolve, reject) {
        const reader = new FileReader();
        reader.onload = function() { resolve(reader.result); };
        reader.onerror = reject;
        reader.readAsText(file, encoding);
      });
    }

    async function processCSV() {
      useToast.message = 'データを加工中...';
      disableButtons();

      try {
        const items = await googleScriptRun('processCSV', $('#form'));

        useData.items = items;

        $('#processedTable').replaceChildren();

        const grid = new Grid({
          // google.script.run由来の配列は別realm扱いで Grid.js の instanceof Array 判定を通らないため、ローカル配列に詰め替える。
          data: Array.from(items, item => ({ ...item })),
          search: true,
        });
        grid.render($('#processedTable'));

        useStep.step = 'result';
        useToast.message = 'データ加工が完了しました';
      } catch (error) {
        useToast.message = `加工失敗: ${error.message}`;
        useStep.step = 'preview';
      } finally {
        enableButtons();
      }
    }

    async function saveCSV() {
      useToast.message = '保存中...';
      disableButtons();

      try {
        $('#dataJson').value = JSON.stringify({headers: useData.headers, rows: useData.rows});
        await googleScriptRun('saveCSV', $('#form'));

        useToast.message = '保存完了';
      } catch (error) {
        useToast.message = `保存失敗: ${error.message}`;
        console.log(error);
      } finally {
        enableButtons();
      }
    }

  </script>
</body>
</html>

Stylesheet.html(内容は前回記事と同じ)
<style>
  body { font-family: system-ui, sans-serif; margin: 1.5rem; }

  /* Toast */
  #toast {
    position: sticky;
    top: 0;
    z-index: 10;
    display: flex;
    align-items: center;
    gap: 0.75rem;
    padding: 0.5rem 0.75rem;
    margin-bottom: 0.75rem;
    background: #f0f4ff;
    border: 1px solid #c7d7fa;
    border-radius: 0.375rem;
  }
  #toastMessage { flex: 1; font-size: 0.875rem; }

  /* File picker */
  label { display: block; margin-bottom: 0.35rem; font-size: 0.875rem; color: #444; }
  input[type="file"] { display: block; margin-bottom: 0.75rem; padding: 0; }

  /* Two-panel layout */
  .work-layout {
    display: flex;
    gap: 1.5rem;
    align-items: stretch;
    height: calc(100vh - 7rem);
    min-height: 200px;
  }
  #preview, #read, #edit {
    display: flex;
    flex-direction: column;
    min-width: 0;
    min-height: 0;
    overflow: hidden;
  }
  #preview { flex: 0 0 50%; }
  #read, #edit { flex: 1 1 auto; }

  /* Panel header */
  .panel-header {
    flex-shrink: 0;
    display: flex;
    align-items: baseline;
    gap: 0.5rem;
    margin-bottom: 0.75rem;
  }
  #fileInfo {
    display: block;
    font-size: 0.8125rem;
    color: #555;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    margin-bottom: 0.5rem;
  }

  /* Scrollable panel body */
  .panel-body {
    flex: 1;
    min-height: 0;
    overflow-y: auto;
    overscroll-behavior: contain;
  }

  /* PDF preview canvases */
  #previewPages canvas {
    display: block;
    width: 100%;
    height: auto;
    margin-bottom: 0.5rem;
    border: 1px solid #ccc;
  }

  /* Extracted fields list */
  #fields {
    list-style: none;
    margin: 0;
    padding: 0;
  }
  #fields li {
    display: flex;
    flex-direction: column;
    gap: 0.25rem;
    margin-bottom: 0.75rem;
  }
  #fields li span { font-size: 0.875rem; color: #444; }
  #fields li input {
    box-sizing: border-box;
    width: 100%;
    padding: 0.375rem 0.5rem;
  }

  /* Buttons */
  button { cursor: pointer; }
  button:disabled { opacity: 0.6; cursor: not-allowed; }

  [hidden] { display: none !important; }
</style>

サーバー側

csv_upload.js
const KEEP_COLUMNS = new Map([
  // もとの列名:加工後の列名
  ['観測所番号','観測所番号'],
  ['都道府県','都道府県'],
  ['現在値(mm)','現在値'],
])


/**
 * アップロードされたCSVを加工し、プレビュー用データを返す(Driveには保存しない)。
 * @param {Object} form file input name="csv"
 * @returns {{ headers: string[], rows: string[][], originalRowCount: number }}
 */
function processCSV(form) {
  const blob    = form.csv;
  const allRows = Utilities.parseCsv(blob.getDataAsString('Shift_JIS'));

  // ---- ここから用途に合わせて編集 ----  

  const columns = allRows.shift();

  // 列の加工:KEEP_COLUMNSに指定された列だけを残し、不要な列を削除する。
  let items = allRows.map(row => {
    return columns.reduce((items, colName, idx)=>{
      if (KEEP_COLUMNS.has(colName)) {
        const key = KEEP_COLUMNS.get(colName);
        return Object.assign(items, { [key]: row[idx] });
      }else{
        return items;
      }
    }, {})
  })

  // 行の加工:条件を満たす行にフィルタリング
  items = items.filter(item => item['都道府県'] == '大阪府');

  // ---- ここまで ----

  return items;
}

/**
 * 加工済みデータをスプレッドシートに保存し、元CSVをDriveに保存する。
 * @param {Object} form csv, dataJson(文字列)
 * @returns {{ name: string, url: string, rowCount: number }}
 */
function saveCSV(form) {
  const blob     = form.csv;
  let {headers, rows} = JSON.parse(form.dataJson);

  const folderId = PropertiesService.getScriptProperties().getProperty('DRIVE_FOLDER_ID');
  const driveFile = folderId
    ? DriveApp.getFolderById(folderId).createFile(blob)
    : DriveApp.createFile(blob);

  const ファイルURL = driveFile.getUrl();
  const 更新日時 = new Date().toLocaleString('ja-JP', { timeZone: 'Asia/Tokyo' });

  rows = rows.map(row => [...row, ファイルURL, 更新日時]);

  // スプレッドシートにデータを追加
  const sheet = SpreadsheetApp.getActiveSheet();
  const rangeForData = sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, rows[0].length);
  rangeForData.setValues(rows);  

  return rows;
}

webpage.js(内容は前回記事と同じ)
/**
 * Web アプリのエントリ。index.html を表示する。
 * @returns {GoogleAppsScript.HTML.HtmlOutput}
 */

function onOpen(){
  const ui = SpreadsheetApp.getUi()

  ui.createMenu('オリジナルメニュー')
    .addItem('PDFアップロード','showPDFUploaderModal')
    .addItem('csvアップロード','showcsvUploaderModal')
    .addToUi()
}

function showPDFUploaderModal(){
  const template = HtmlService.createTemplateFromFile('pdf_upload_form')
  const html = template.evaluate().setWidth(1000).setHeight(800)

  const ui = SpreadsheetApp.getUi()
  ui.showModalDialog(html, 'PDFアップロード')

  // アクティブシートを「シート1」にする
  const targetSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('シート1');
  SpreadsheetApp.getActiveSpreadsheet().setActiveSheet(targetSheet);
}

function showcsvUploaderModal(){
  const template = HtmlService.createTemplateFromFile('csv_upload_form')
  const html = template.evaluate().setWidth(1000).setHeight(800)

  const ui = SpreadsheetApp.getUi()
  ui.showModalDialog(html, 'csvアップロード')
}

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

解説

表作成はgridjsを使う

https://gridjs.io/

私が気に入っている点として、

  • ソートやデータ検索が簡単にできる
  • データ形式がオブジェクト配列でも、そのまま使える(テーブル形式に変換しなくて良い)
  • (今回は使っていませんが)行をクリックすると、そのアイテムの詳細を表示させるなどの機能を実装できる

列名の変更

  • もとのデータの列名が列名としてふさわしくない場合は、列名を適宜修正します
const KEEP_COLUMNS = new Map([
  // もとの列名:加工後の列名
  ['観測所番号','観測所番号'],
  ['都道府県','都道府県'],
  ['現在値(mm)','現在値'],
])

必要な行、列を残す

  • GASエディタではフロント側のデバッグをやりにくいので、データ加工は基本的にサーバー側でやったほうがいいです
  const columns = allRows.shift();

  // 列の加工:KEEP_COLUMNSに指定された列だけを残し、不要な列を削除する。
  let items = allRows.map(row => {
    return columns.reduce((items, colName, idx)=>{
      if (KEEP_COLUMNS.has(colName)) {
        const key = KEEP_COLUMNS.get(colName);
        return Object.assign(items, { [key]: row[idx] });
      }else{
        return items;
      }
    }, {})
  })

  // 行の加工:条件を満たす行にフィルタリング
  items = items.filter(item => item['都道府県'] == '大阪府');

Discussion