スプレッドシートのデータ例
※ ヘッダーは手動登録想定
| id |
タイトル |
本文 |
| 8d27dd38-19b6-4983-9cb0-415e4e37c3ca |
hoge |
fuga |
Code .gs
const SHEET_ID = 'スプレッドシートID';
const SHEET_NAME = 'シート1';
const ID_COL = 'id'; // A列の見出し
function doGet() {
return HtmlService.createHtmlOutputFromFile('index')
.setTitle('CRUD')
.addMetaTag('viewport', 'width=device-width, initial-scale=1');
}
function list() {
const [header, ...rows] = sheet_().getDataRange().getDisplayValues();
const items = rows
.filter(r => r[0] !== '')
.map(r => Object.fromEntries(header.map((h, i) => [h, r[i]])));
return { header, items };
}
function create(item) {
return withLock_(() => {
const sh = sheet_();
const id = Utilities.getUuid();
sh.appendRow(header_(sh).map(h => (h === ID_COL ? id : item[h] ?? '')));
return id;
});
}
function update(item) {
withLock_(() => {
const sh = sheet_();
const header = header_(sh);
const row = findRow_(sh, item[ID_COL]);
sh.getRange(row, 1, 1, header.length).setValues([header.map(h => item[h] ?? '')]);
});
}
function remove(id) {
withLock_(() => {
const sh = sheet_();
sh.deleteRow(findRow_(sh, id));
});
}
// 既存データの空IDを埋める(エディタから一度だけ手動実行)
function fillMissingIds() {
const sh = sheet_();
const n = sh.getLastRow() - 1;
if (n < 1) return;
const range = sh.getRange(2, 1, n, 1);
range.setValues(range.getValues().map(([v]) => [v || Utilities.getUuid()]));
}
// ---- 末尾 _ の関数はフロントから呼べない ----
function sheet_() {
return SpreadsheetApp.openById(SHEET_ID).getSheetByName(SHEET_NAME);
}
function header_(sh) {
return sh.getRange(1, 1, 1, sh.getLastColumn()).getValues()[0];
}
function findRow_(sh, id) {
const cell = sh.getRange('A2:A').createTextFinder(String(id)).matchEntireCell(true).findNext();
if (!cell) throw new Error('対象が見つかりません: ' + id);
return cell.getRow();
}
function withLock_(fn) {
const lock = LockService.getScriptLock();
lock.waitLock(10000);
try { return fn(); } finally { lock.releaseLock(); }
}
index.html
<!DOCTYPE html>
<html lang="ja">
<head>
<style>
body { font-family: system-ui, sans-serif; max-width: 40rem; margin: auto; padding: 1rem; }
.item { display: grid; gap: .5rem; padding: 1rem 0; border-bottom: 1px solid #ddd; }
.item p { margin: 0; white-space: pre-wrap; word-break: break-word; }
textarea { width: 100%; box-sizing: border-box; }
</style>
</head>
<body>
<section id="listView">
<h1>一覧 <button onclick="show('createView')">+ 新規</button></h1>
<p id="status"></p>
<div id="list"></div>
</section>
<section id="createView" hidden>
<h1><button onclick="show('listView')">←</button> 新規作成</h1>
<form id="createForm" class="item"></form>
</section>
<script>
const ID = 'id';
let header = [], items = [];
const $ = id => document.getElementById(id);
const esc = s => String(s ?? '').replace(/[&<>"]/g, c => `&#${c.charCodeAt(0)};`);
const fields = () => header.filter(h => h !== ID);
// google.script.run を Promise 化
const api = new Proxy({}, { get: (_, fn) => (...args) => new Promise((ok, ng) =>
google.script.run.withSuccessHandler(ok).withFailureHandler(ng)[fn](...args)) });
function show(view) {
for (const v of ['listView', 'createView']) $(v).hidden = v !== view;
}
// 見出しから入力欄を生成
const inputs = (item = {}) => fields().map(h =>
`<label>${esc(h)}<textarea name="${esc(h)}">${esc(item[h])}</textarea></label>`).join('');
// 表示用
const viewHtml = it => `
<div class="item" data-id="${esc(it[ID])}">
${fields().map(h => `<p><b>${esc(h)}</b><br>${esc(it[h])}</p>`).join('')}
<div><button data-act="edit">編集</button> <button data-act="del">削除</button></div>
</div>`;
// 編集用
const editHtml = it => `
<form class="item" data-id="${esc(it[ID])}">${inputs(it)}
<div><button>保存</button> <button type="button" data-act="cancel">キャンセル</button></div>
</form>`;
async function load() {
({ header, items } = await api.list());
$('list').innerHTML = items.map(viewHtml).join('') || 'データがありません';
$('createForm').innerHTML = inputs() + '<button>追加</button>';
}
// 書き込み → 再読み込み(処理中は label を表示)
const run = (p, label) => {
$('status').textContent = label;
return p.then(load)
.then(() => { $('status').textContent = ''; })
.catch(e => { $('status').textContent = ''; alert(e.message); });
};
// 編集・キャンセル・削除
$('list').addEventListener('click', e => {
const act = e.target.dataset.act;
if (!act) return;
const el = e.target.closest('.item');
const it = items.find(x => x[ID] === el.dataset.id);
if (act === 'edit') el.outerHTML = editHtml(it);
if (act === 'cancel') el.outerHTML = viewHtml(it);
if (act === 'del' && confirm('削除しますか?')) run(api.remove(it[ID]), '削除中…');
});
// 保存(Update)
$('list').addEventListener('submit', e => {
e.preventDefault();
const data = Object.fromEntries(new FormData(e.target));
run(api.update({ ...data, [ID]: e.target.dataset.id }), '保存中…');
});
// 追加(Create)
$('createForm').addEventListener('submit', e => {
e.preventDefault();
show('listView');
run(api.create(Object.fromEntries(new FormData(e.target))), '追加中…');
});
run(Promise.resolve(), '読み込み中…');
</script>
</body>
</html>
権限の振り方A:自分専用
| 項目 |
設定 |
| 次のユーザーとして実行 |
自分 |
| アクセスできるユーザー |
自分のみ |
| シートの共有 |
変更不要 |
権限の振り方B:招待した人と共有
| 項目 |
設定 |
| 次のユーザーとして実行 |
ウェブアプリにアクセスしているユーザー |
| アクセスできるユーザー |
Googleアカウントを持つ全員 |
| シートの共有 |
招待する人を編集者として追加 |