✨
【GASアプリ】csvファイルから必要な列・行をフィルタし保存するスキャフォールド
概要
前回、PDFファイルを読み取りアップロードするスキャフォールドを作成しました。
今回はこれのcsvファイルバージョンです。
会社のシステムや、官公庁の生データを定期的に取得しスプレッドシートに保存するような作業では、
自分の業務では必要ない列や行があり、そのままではハンドリングしにくい問題があります。
保存前に必要な列・行だけを残してスプレッドシートに保存します。
アプリの操作
-
スプレッドシートのメニューバーに、csvアップロードのダイアログを表示させます


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

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

-
保存ボタンを押すと、データがスプレッドシートに保存されます。また、オリジナルの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を使う
私が気に入っている点として、
- ソートやデータ検索が簡単にできる
- データ形式がオブジェクト配列でも、そのまま使える(テーブル形式に変換しなくて良い)
- (今回は使っていませんが)行をクリックすると、そのアイテムの詳細を表示させるなどの機能を実装できる
列名の変更
- もとのデータの列名が列名としてふさわしくない場合は、列名を適宜修正します
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