const DB_PROPERTY = 'ANONCHAT_SPREADSHEET_ID'; const PEPPER_PROPERTY = 'ANONCHAT_AUTH_PEPPER'; const SESSION_DAYS = 30; const ONLINE_MS = 45000; const SERVER_DAYS = 30; const PASS_ROUNDS = 8000; const HEADERS = { Users: [ 'userId', 'salt', 'passwordHash', 'createdAt', 'displayName', 'lastDisplayNameChangeAt' ], Sessions: [ 'tokenHash', 'userKey', 'publicId', 'mode', 'expiresAt', 'createdAt' ], Messages: [ 'id', 'timestamp', 'scope', 'kind', 'fromKey', 'fromId', 'displayName', 'content' ], Presence: [ 'userKey', 'publicId', 'displayName', 'roomKey', 'viewType', 'mode', 'idle', 'lastSeen', 'typingUntil' ], Servers: [ 'room', 'name', 'ownerId', 'updated', 'type', 'passwordHash' ], Friends: [ 'fromId', 'toId', 'status', 'createdAt', 'updatedAt' ] }; function doGet() { return HtmlService.createHtmlOutputFromFile('Index') .setTitle('匿名チャット') .addMetaTag('viewport', 'width=device-width, initial-scale=1'); } function setup_() { const props = PropertiesService.getScriptProperties(); let id = props.getProperty(DB_PROPERTY); let ss; if (id) { ss = SpreadsheetApp.openById(id); } else { ss = SpreadsheetApp.getActiveSpreadsheet(); if (!ss) { throw new Error('保存先スプレッドシートの拡張機能 > Apps Script から実行してください。'); } props.setProperty(DB_PROPERTY, ss.getId()); } if (!props.getProperty(PEPPER_PROPERTY)) { props.setProperty(PEPPER_PROPERTY, Utilities.getUuid() + Utilities.getUuid()); } Object.keys(HEADERS).forEach(name => { ensureSheet_(ss, name, HEADERS[name]); }); return ss.getUrl(); } function createAccount(password, displayName) { const pass = String(password || ''); const name = String(displayName || '').trim(); if (!name) throw new Error('表示名を入力してください。'); if (name.length > 32) throw new Error('表示名は32文字以内にしてください。'); if (pass.length < 8 || pass.length > 128) { throw new Error('パスワードは8〜128文字で設定してください。'); } return withLock_(() => { const sheet = getSheet_('Users'); let userId = ''; for (let attempt = 0; attempt < 20; attempt++) { const suffix = Utilities.getUuid().replace(/-/g, '').slice(0, 4).toLowerCase(); const candidate = 'u-' + suffix; if (!findUser_(candidate)) { userId = candidate; break; } } if (!userId) { throw new Error('IDを発行できませんでした。もう一度お試しください。'); } const salt = Utilities.getUuid(); const now = Date.now(); sheet.appendRow([ userId, salt, passwordHash_(salt, pass), now, name, now ]); return makeSession_(userId, 'account'); }); } function login(userId, password) { const id = cleanAccountId_(userId); const pass = String(password || ''); if (pass.length > 128) { throw new Error('IDまたはパスワードが違います。'); } return withLock_(() => { const user = findUser_(id); if (!user || passwordHash_(String(user.values[1]), pass) !== String(user.values[2])) { throw new Error('IDまたはパスワードが違います。'); } return makeSession_(id, 'account'); }); } function startAnonymous() { return withLock_(() => { const key = 'anon-' + Utilities.getUuid().replace(/-/g, '').toLowerCase(); return makeSession_(key, 'anonymous'); }); } function identity_(userKey, mode) { if (mode === 'account') { const user = findUser_(userKey); if (!user) { throw new Error('アカウントが見つかりません。もう一度ログインしてください。'); } return { userKey: userKey, publicId: userKey, displayName: String(user.values[4] || '').trim() || userKey, mode: 'account' }; } if (mode !== 'anonymous') { throw new Error('ログイン状態が無効です。'); } return { userKey: userKey, publicId: 'tokumei', displayName: 'tokumei', mode: 'anonymous' }; } function publicMe_(session) { return { userId: session.publicId, userKey: session.userKey, displayName: session.displayName, mode: session.mode }; } function getMe(token) { return withLock_(() => publicMe_(auth_(token))); } function makeSession_(userKey, mode) { const session = identity_(userKey, mode); const token = Utilities.getUuid() + Utilities.getUuid(); const now = Date.now(); getSheet_('Sessions').appendRow([ tokenHash_(token), session.userKey, session.publicId, session.mode, now + SESSION_DAYS * 86400000, now ]); return Object.assign({ token: token }, publicMe_(session)); } function auth_(token) { const value = String(token || ''); if (value.length < 32 || value.length > 128) { throw new Error('ログイン状態が無効です。もう一度ログインしてください。'); } const hash = tokenHash_(value); const sheet = getSheet_('Sessions'); const rows = sheet.getDataRange().getValues(); const now = Date.now(); for (let i = 1; i < rows.length; i++) { if (String(rows[i][0]) !== hash) continue; if (Number(rows[i][4]) < now) { sheet.deleteRow(i + 1); throw new Error('ログイン期限が切れました。もう一度ログインしてください。'); } const session = identity_(String(rows[i][1]), String(rows[i][3])); if (String(rows[i][2]) !== session.publicId) { sheet.getRange(i + 1, 3).setValue(session.publicId); } sheet.getRange(i + 1, 5).setValue(now + SESSION_DAYS * 86400000); session.tokenHash = hash; return session; } throw new Error('ログイン状態が無効です。もう一度ログインしてください。'); } function logout(token) { return withLock_(() => { const hash = tokenHash_(token); const sheet = getSheet_('Sessions'); const rows = sheet.getDataRange().getValues(); let userKey = ''; for (let i = rows.length - 1; i >= 1; i--) { if (String(rows[i][0]) === hash) { userKey = String(rows[i][1]); sheet.deleteRow(i + 1); break; } } if (userKey) removePresence_(userKey); return true; }); } function roomAccessKey_(session, room) { return 'ANONCHAT_ROOM_ACCESS_' + Utilities.base64EncodeWebSafe( Utilities.computeDigest( Utilities.DigestAlgorithm.SHA_256, session.tokenHash + '|' + room, Utilities.Charset.UTF_8 ) ).replace(/=+$/g, ''); } function roomPasswordHash_(password) { return Utilities.base64Encode( Utilities.computeDigest( Utilities.DigestAlgorithm.SHA_256, String(password || ''), Utilities.Charset.UTF_8 ) ); } function findServer_(room) { const rows = getSheet_('Servers').getDataRange().getValues(); for (let i = 1; i < rows.length; i++) { if (String(rows[i][0]) === room) { return { row: i + 1, room: room, name: String(rows[i][1] || room), ownerId: String(rows[i][2] || ''), updated: Number(rows[i][3]) || 0, type: String(rows[i][4] || 'public'), passwordHash: String(rows[i][5] || '') }; } } return null; } function checkRoomAccess_(session, room) { const server = findServer_(room); if (!server || !server.passwordHash) return; const allowedHash = CacheService.getScriptCache().get(roomAccessKey_(session, room)); if (allowedHash !== server.passwordHash) { throw new Error('入室確認が必要です。ホームからサーバーに入り直してください。'); } CacheService.getScriptCache().put( roomAccessKey_(session, room), server.passwordHash, 21600 ); } function joinRoom(token, room, nickname, idle, providedPassword = '') { const code = cleanRoom_(room); if (!code) throw new Error('サーバーコードを入力してください。'); return withLock_(() => { const session = auth_(token); const server = findServer_(code); if (server && server.passwordHash) { if (!providedPassword) { throw new Error('パスワードが必要です。'); } if (roomPasswordHash_(providedPassword) !== server.passwordHash) { throw new Error('パスワードが正しくありません。'); } CacheService.getScriptCache().put( roomAccessKey_(session, code), server.passwordHash, 21600 ); } const context = normalizeView_(session, { kind: 'room', room: code, idle: Boolean(idle), typing: false }); const now = Date.now(); upsertPresence_(session, context, now); return Object.assign( history_(context.scope, now - 60 * 60 * 1000), { me: publicMe_(session) }, snapshot_(session, context, now) ); }); } function openDM(token, peerId) { const peer = cleanAccountId_(peerId); return withLock_(() => { const session = auth_(token); requireAccount_(session); if (!areFriends_(session.publicId, peer)) { throw new Error('DMを始めるには先にフレンドになる必要があります。'); } const context = normalizeView_(session, { kind: 'dm', peerId: peer, idle: false, typing: false }); const now = Date.now(); upsertPresence_(session, context, now); const user = findUser_(peer); return Object.assign( history_(context.scope, 0), { me: publicMe_(session), peerId: peer, peerDisplayName: user ? String(user.values[4] || peer) : peer }, snapshot_(session, context, now) ); }); } function history_(scope, since) { const rows = getSheet_('Messages').getDataRange().getValues(); const messages = []; let cursor = 0; for (let i = 1; i < rows.length; i++) { const row = rows[i]; const id = Number(row[0]) || 0; cursor = Math.max(cursor, id); if (String(row[2]) === scope && Number(row[1]) >= since) { messages.push(messageFromRow_(row)); } } messages.sort((a, b) => a.id - b.id); return { history: messages.slice(-200), cursor: cursor }; } function syncState(token, view, cursor) { const after = Math.max(0, Number(cursor) || 0); return withLock_(() => { const session = auth_(token); const context = normalizeView_(session, view || { kind: 'home' }); const now = Date.now(); upsertPresence_(session, context, now); const rows = getSheet_('Messages').getDataRange().getValues(); const events = []; let maxId = after; for (let i = 1; i < rows.length; i++) { const row = rows[i]; const id = Number(row[0]) || 0; maxId = Math.max(maxId, id); if (context.scope && id > after && String(row[2]) === context.scope) { events.push(messageFromRow_(row)); } } events.sort((a, b) => a.id - b.id); return Object.assign( { events: events, cursor: maxId, me: publicMe_(session) }, snapshot_(session, context, now) ); }); } function sendChatMessage(token, view, content) { const text = String(content || '').trim(); if (!text) throw new Error('メッセージが空です。'); if (text.length > 1000) { throw new Error('メッセージは1000文字以内にしてください。'); } return withLock_(() => { const session = auth_(token); const context = normalizeView_(session, view || {}); if (!context.scope) { throw new Error('チャットを開いてください。'); } const now = Date.now(); const id = nextMessageId_(); const name = context.kind === 'room' ? context.nickname : session.displayName; getSheet_('Messages').appendRow([ id, now, context.scope, context.kind, session.userKey, session.publicId, name, text ]); upsertPresence_(session, context, now); return { id: id }; }); } function publishServer(token, name, room, type = 'public', password = '') { const serverName = cleanName_(name); const code = cleanRoom_(room); const visibility = String(type || 'public'); const pass = String(password || ''); if (!serverName || !code) { throw new Error('サーバー名とコードを入力してください。'); } if (visibility !== 'public' && visibility !== 'private') { throw new Error('公開設定が正しくありません。'); } if (pass.length > 128) { throw new Error('サーバーパスワードは128文字以内にしてください。'); } return withLock_(() => { const session = auth_(token); const sheet = getSheet_('Servers'); const existing = findServer_(code); const now = Date.now(); const passHash = pass ? roomPasswordHash_(pass) : ''; const owner = session.userKey; if (existing) { if ( existing.ownerId !== session.userKey && !(session.mode === 'account' && existing.ownerId === session.publicId) ) { throw new Error('このサーバーコードは使用済みです。別のコードにしてください。'); } sheet.getRange(existing.row, 2, 1, 5).setValues([[ serverName, owner, now, visibility, passHash ]]); } else { sheet.appendRow([ code, serverName, owner, now, visibility, passHash ]); } return { room: code, name: serverName, updated: now, type: visibility }; }); } function sendFriendRequest(token, targetId) { const target = cleanAccountId_(targetId); return withLock_(() => { const session = auth_(token); requireAccount_(session); if (session.publicId === target) { throw new Error('自分自身には申請できません。'); } if (!findUser_(target)) { throw new Error('そのIDのアカウントは見つかりません。'); } const sheet = getSheet_('Friends'); const relation = latestRelation_( sheet.getDataRange().getValues(), session.publicId, target ); if (relation) { if (relation.status === 'accepted') { throw new Error('すでにフレンドです。'); } if (relation.status === 'pending') { if (relation.fromId === session.publicId) { throw new Error('申請済みです。'); } throw new Error('相手からの申請が届いています。申請一覧から承認してください。'); } } const now = Date.now(); const values = [session.publicId, target, 'pending', now, now]; if (relation) { sheet.getRange(relation.row, 1, 1, 5).setValues([values]); } else { sheet.appendRow(values); } return true; }); } function respondFriendRequest(token, fromId, accept) { const requester = cleanAccountId_(fromId); return withLock_(() => { const session = auth_(token); requireAccount_(session); const sheet = getSheet_('Friends'); const relation = latestRelation_( sheet.getDataRange().getValues(), requester, session.publicId ); if ( !relation || relation.status !== 'pending' || relation.fromId !== requester || relation.toId !== session.publicId ) { throw new Error('申請が見つかりません。'); } sheet.getRange(relation.row, 3, 1, 3).setValues([[ accept ? 'accepted' : 'rejected', relation.createdAt, Date.now() ]]); return true; }); } function removeFriend(token, peerId) { const peer = cleanAccountId_(peerId); return withLock_(() => { const session = auth_(token); requireAccount_(session); const sheet = getSheet_('Friends'); const relation = latestRelation_( sheet.getDataRange().getValues(), session.publicId, peer ); if (!relation || relation.status !== 'accepted') { throw new Error('フレンドが見つかりません。'); } sheet.getRange(relation.row, 3, 1, 3).setValues([[ 'removed', relation.createdAt, Date.now() ]]); return true; }); } function updateDisplayName(token, newDisplayName) { const nextName = cleanName_(newDisplayName); if (!nextName) throw new Error('表示名を入力してください。'); if (nextName.length > 32) { throw new Error('表示名は32文字以内にしてください。'); } return withLock_(() => { const session = auth_(token); requireAccount_(session); const user = findUser_(session.userKey); if (!user) { throw new Error('アカウントが見つかりません。'); } const now = Date.now(); const createdAt = Number(user.values[3]) || 0; const lastChanged = Number(user.values[5]) || createdAt || 0; const limitMs = 24 * 60 * 60 * 1000; const remain = limitMs - (now - lastChanged); if (remain > 0) { const hours = Math.floor(remain / 3600000); const minutes = Math.ceil((remain % 3600000) / 60000); throw new Error( '表示名の変更は1日1回までです。あと ' + hours + '時間' + minutes + '分 後に変更できます。' ); } const oldName = String(user.values[4] || '').trim(); if (oldName === nextName) { return publicMe_(identity_(session.userKey, session.mode)); } getSheet_('Users').getRange(user.row, 5, 1, 2).setValues([[ nextName, now ]]); const presenceSheet = getSheet_('Presence'); const presenceRows = presenceSheet.getDataRange().getValues(); for (let i = 1; i < presenceRows.length; i++) { if (String(presenceRows[i][0]) === session.userKey) { presenceSheet.getRange(i + 1, 3).setValue(nextName); } } return publicMe_(identity_(session.userKey, session.mode)); }); } function normalizeView_(session, view) { const kind = String(view.kind || 'home'); if (kind === 'room') { const room = cleanRoom_(view.room); if (!room) { throw new Error('サーバーコードを確認してください。'); } checkRoomAccess_(session, room); return { kind: 'room', room: room, nickname: session.displayName, roomKey: room, viewType: 'room', scope: 'room:' + room, idle: Boolean(view.idle), typing: Boolean(view.typing) }; } if (kind === 'dm') { requireAccount_(session); const peer = cleanAccountId_(view.peerId); if (!areFriends_(session.publicId, peer)) { throw new Error('フレンドのみDMできます。'); } const scope = dmScope_(session.publicId, peer); return { kind: 'dm', peerId: peer, nickname: session.displayName, roomKey: scope, viewType: 'dm', scope: scope, idle: Boolean(view.idle), typing: Boolean(view.typing) }; } return { kind: 'home', room: '', nickname: session.displayName, roomKey: '', viewType: 'home', scope: '', idle: Boolean(view.idle), typing: false }; } function upsertPresence_(session, context, now) { const sheet = getSheet_('Presence'); const rows = sheet.getDataRange().getValues(); const values = [ session.userKey, session.publicId, context.nickname || session.displayName, context.roomKey, context.viewType, session.mode, context.idle, now, context.typing ? now + 5000 : 0 ]; for (let i = 1; i < rows.length; i++) { if (String(rows[i][0]) === session.userKey) { sheet.getRange(i + 1, 1, 1, HEADERS.Presence.length).setValues([values]); return; } } sheet.appendRow(values); } function snapshot_(session, context, now) { const rows = getSheet_('Presence').getDataRange().getValues(); const active = []; const userKeys = new Set(); const publicIds = new Set(); for (let i = 1; i < rows.length; i++) { const row = rows[i]; const lastSeen = Number(row[7]) || 0; if (!row[0] || now - lastSeen > ONLINE_MS) continue; const person = { userKey: String(row[0]), publicId: String(row[1]), displayName: String(row[2] || ''), roomKey: String(row[3] || ''), viewType: String(row[4] || 'home'), mode: String(row[5] || ''), idle: row[6] === true || String(row[6]) === 'true', lastSeen: lastSeen, typing: (Number(row[8]) || 0) > now }; active.push(person); userKeys.add(person.userKey); publicIds.add(person.publicId); } let members = []; let typing = []; if (context.kind === 'room') { members = uniquePresence_( active.filter(person => person.viewType === 'room' && person.roomKey === context.room ) ); typing = members.filter(person => person.typing && person.userKey !== session.userKey ); } else if (context.kind === 'dm') { typing = active.filter(person => person.viewType === 'dm' && person.roomKey === context.scope && person.userKey !== session.userKey && person.typing ); } const friendData = session.mode === 'account' ? friendState_(session.publicId, publicIds) : { friends: [], requests: [], outgoing: [] }; const serverRows = getSheet_('Servers').getDataRange().getValues(); const servers = []; for (let i = 1; i < serverRows.length; i++) { const row = serverRows[i]; if (!row[0] || now - (Number(row[3]) || 0) > SERVER_DAYS * 86400000) { continue; } if (String(row[4]) === 'private') continue; servers.push({ room: String(row[0]), name: String(row[1] || row[0]), updated: Number(row[3]) || 0, hasPassword: Boolean(row[5]) }); } servers.sort((a, b) => b.updated - a.updated); return { siteCount: userKeys.size, roomCount: members.length, members: members.map(person => ({ userId: person.publicId, userKey: person.userKey, displayName: person.displayName, idle: person.idle })), typing: typing.map(person => ({ userId: person.publicId, displayName: person.displayName })), friends: friendData.friends, requests: friendData.requests, outgoing: friendData.outgoing, servers: servers }; } function friendState_(userId, activePublicIds) { const rows = getSheet_('Friends').getDataRange().getValues(); const latest = new Map(); const names = new Map(); const users = getSheet_('Users').getDataRange().getValues(); for (let i = 1; i < users.length; i++) { const id = String(users[i][0] || ''); if (id) names.set(id, String(users[i][4] || id)); } for (let i = 1; i < rows.length; i++) { const a = String(rows[i][0] || ''); const b = String(rows[i][1] || ''); if (!a || !b || (a !== userId && b !== userId)) continue; const key = [a, b].sort().join('|'); const updated = Number(rows[i][4]) || 0; const old = latest.get(key); if (!old || updated >= old.updatedAt) { latest.set(key, { fromId: a, toId: b, status: String(rows[i][2]), updatedAt: updated }); } } const friends = []; const requests = []; const outgoing = []; const item = id => ({ userId: id, displayName: names.get(id) || id }); for (const relation of latest.values()) { if (relation.status === 'accepted') { const peer = relation.fromId === userId ? relation.toId : relation.fromId; friends.push(Object.assign(item(peer), { online: activePublicIds.has(peer) })); } else if (relation.status === 'pending') { if (relation.toId === userId) { requests.push(item(relation.fromId)); } else { outgoing.push(item(relation.toId)); } } } const sort = (a, b) => a.userId.localeCompare(b.userId); friends.sort(sort); requests.sort(sort); outgoing.sort(sort); return { friends: friends, requests: requests, outgoing: outgoing }; } function latestRelation_(rows, a, b) { let found = null; for (let i = 1; i < rows.length; i++) { const from = String(rows[i][0] || ''); const to = String(rows[i][1] || ''); if (!((from === a && to === b) || (from === b && to === a))) { continue; } const updated = Number(rows[i][4]) || 0; if (!found || updated >= found.updatedAt) { found = { row: i + 1, fromId: from, toId: to, status: String(rows[i][2]), createdAt: Number(rows[i][3]) || 0, updatedAt: updated }; } } return found; } function areFriends_(a, b) { if (!a || !b || a === b) return false; const relation = latestRelation_( getSheet_('Friends').getDataRange().getValues(), a, b ); return Boolean(relation && relation.status === 'accepted'); } function uniquePresence_(items) { const map = new Map(); items.forEach(person => { const old = map.get(person.userKey); if (!old || person.lastSeen > old.lastSeen) { map.set(person.userKey, person); } }); return Array.from(map.values()); } function messageFromRow_(row) { return { id: Number(row[0]) || 0, timestamp: Number(row[1]) || 0, scope: String(row[2] || ''), kind: String(row[3] || ''), fromKey: String(row[4] || ''), userId: String(row[5] || ''), displayName: String(row[6] || ''), content: String(row[7] || '') }; } function passwordHash_(salt, password) { const pepper = PropertiesService.getScriptProperties().getProperty(PEPPER_PROPERTY); if (!pepper) { throw new Error('先にエディタから setup_() を実行してください。'); } let value = salt + '|' + String(password) + '|' + pepper; for (let i = 0; i < PASS_ROUNDS; i++) { value = Utilities.base64Encode( Utilities.computeDigest( Utilities.DigestAlgorithm.SHA_256, value, Utilities.Charset.UTF_8 ) ); } return value; } function tokenHash_(token) { const pepper = PropertiesService.getScriptProperties().getProperty(PEPPER_PROPERTY) || ''; return Utilities.base64Encode( Utilities.computeDigest( Utilities.DigestAlgorithm.SHA_256, pepper + '|' + String(token || ''), Utilities.Charset.UTF_8 ) ); } function requireAccount_(session) { if (session.mode !== 'account') { throw new Error('この機能はアカウント利用者のみ使えます。'); } } function dmScope_(a, b) { return 'dm:' + [a, b].sort().join('|'); } function cleanAccountId_(id) { const value = String(id || '').trim().toLowerCase(); if (!/^u-[a-z0-9]{4}$/.test(value)) { throw new Error('IDは u-xxxx の形式で入力してください。'); } return value; } function cleanRoom_(room) { return String(room || '') .trim() .toLowerCase() .replace(/[^a-z0-9-]/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, '') .slice(0, 48); } function cleanName_(name) { return String(name || '').trim().slice(0, 32); } function nextMessageId_() { const props = PropertiesService.getScriptProperties(); const id = (Number(props.getProperty('ANONCHAT_NEXT_MESSAGE_ID')) || 0) + 1; props.setProperty('ANONCHAT_NEXT_MESSAGE_ID', String(id)); return id; } function findUser_(userId) { const rows = getSheet_('Users').getDataRange().getValues(); for (let i = 1; i < rows.length; i++) { if (String(rows[i][0]) === userId) { return { row: i + 1, values: rows[i] }; } } return null; } function removePresence_(userKey) { const sheet = getSheet_('Presence'); const rows = sheet.getDataRange().getValues(); for (let i = rows.length - 1; i >= 1; i--) { if (String(rows[i][0]) === userKey) { sheet.deleteRow(i + 1); } } } function getSpreadsheet_() { const props = PropertiesService.getScriptProperties(); const id = props.getProperty(DB_PROPERTY); if (id) return SpreadsheetApp.openById(id); const ss = SpreadsheetApp.getActiveSpreadsheet(); if (!ss) { throw new Error('保存先が未設定です。スプレッドシートから setup_() を実行してください。'); } props.setProperty(DB_PROPERTY, ss.getId()); if (!props.getProperty(PEPPER_PROPERTY)) { props.setProperty(PEPPER_PROPERTY, Utilities.getUuid() + Utilities.getUuid()); } Object.keys(HEADERS).forEach(name => { ensureSheet_(ss, name, HEADERS[name]); }); return ss; } function getSheet_(name) { const sheet = getSpreadsheet_().getSheetByName(name); if (!sheet) { throw new Error('保存用シートがありません。setup_() を実行してください。'); } return sheet; } function ensureSheet_(ss, name, headers) { let sheet = ss.getSheetByName(name); if (!sheet) sheet = ss.insertSheet(name); if (sheet.getLastRow() === 0) { sheet.getRange(1, 1, 1, headers.length).setValues([headers]); return; } for (let i = 0; i < headers.length; i++) { const cell = sheet.getRange(1, i + 1); if (!cell.getValue()) cell.setValue(headers[i]); } } function withLock_(fn) { const lock = LockService.getScriptLock(); lock.waitLock(10000); try { return fn(); } finally { lock.releaseLock(); } }