연습 화면입니다. 실제 구글 화면이 아니며 아무 데도 보내지 않습니다.
할 일로 건너뛰기
← 연습 목록
1-2
구글 시트 다루기
책 1-2 · 32쪽
0/8
처음부터
주소창
연습용 시트
· 알림
연습용 코드 편집기
폰 미리보기
주소창(연습)
이동
브라우저에서 새 탭을 연 모습입니다. 연습실에서는 sheets.new 만 열리고, 실제 주소로는 나가지 않습니다.
실제 이름·연락처를 넣지 마세요(이 기기에만 저장됩니다).
{"id":"biz/1-2","book":"biz","chapter":"1-2","title":"구글 시트 다루기","minutes":10,"anchor":"1-2","webappSlug":"sheet","assets":{"data":{"practice_header":{"src":"examples_biz/sample_practice.tsv","part":"header"},"practice_body":{"src":"examples_biz/sample_practice.tsv","part":"body"}}},"start":[{"a":"panel","p":"address"}],"steps":[{"id":"sheet","do":"주소창(연습)에 sheets.new 를 입력하고 Enter를 누른 뒤, 파일 이름을 연습-회원-시트로 바꿉니다.","ok":"빈 시트가 열리고 왼쪽 위 이름이 연습-회원-시트이면 성공입니다.","anchor":"1-2-h2-2","bookStep":"① 1","check":[{"t":"address"},{"t":"fileName","eq":"연습-회원-시트"}],"msg":{"1":"파일 이름이 아직 “연습-회원-시트”가 아닙니다. 파일 이름 칸을 누르고 바꾼 뒤 Enter 키를 누르세요."},"hints":["주소창(연습)에 sheets.new 를 입력하고 Enter 키를 누르면 시트가 열립니다. 그다음 왼쪽 위 파일 이름을 바꿉니다.","테두리가 둘러진 주소창을 쓴 뒤, 파일 이름 칸에 연습-회원-시트를 입력하세요."],"target":"addr-input","preset":[{"a":"panel","p":"address"}],"show":[{"a":"address","text":"sheets.new"},{"a":"rename","name":"연습-회원-시트"}],"page":35},{"id":"header","do":"카드의 [머리줄 복사]를 누른 뒤, 시트의 A1 칸을 눌러 붙여 넣습니다.","ok":"맨 위 줄에 머리줄 이름 여섯 개가 들어가면 성공입니다.","anchor":"1-2-h2-2","bookStep":"① 2","check":[{"t":"range","tab":"시트1","at":"A1:F1","eq":[["회원번호","부르는 이름","상품명","남은 횟수","만료일","상태"]]}],"msg":{"0":"[시트1] 탭 A1:F1에 머리줄 여섯 개가 아직 들어 있지 않습니다. [머리줄 복사]를 누른 뒤 A1 칸에 붙여 넣으세요."},"hints":["머리줄은 회원번호, 부르는 이름, 상품명, 남은 횟수, 만료일, 상태 여섯 개입니다. A1 칸 하나에 붙여 넣으면 옆 칸으로 나뉘어 들어갑니다.","테두리가 둘러진 [머리줄 복사] 단추를 누르고, A1 칸을 눌러 붙여 넣으세요."],"target":"copy:practice_header","preset":[{"a":"panel","p":"address"},{"a":"address","text":"sheets.new"},{"a":"rename","name":"연습-회원-시트"}],"show":[{"a":"paste","tab":"시트1","at":"A1","data":"practice_header"}],"copy":["practice_header"],"page":35},{"id":"body","do":"카드의 [가짜 회원 20명 복사]를 누른 뒤, A2 칸을 눌러 붙여 넣습니다.","ok":"머리줄 아래에 회원01부터 회원20까지 스무 줄이 쌓이면 성공입니다. 마지막 줄 번호가 21이면 스무 명이 맞습니다.","anchor":"1-2-h2-2","bookStep":"① 3","check":[{"t":"lastRow","tab":"시트1","eq":21},{"t":"cell","tab":"시트1","at":"B2","eq":"회원01"}],"msg":{"0":"[시트1] 탭의 마지막 줄이 21행이 아닙니다. 머리줄(1행) 아래 A2에 스무 줄을 붙여 넣었는지 보세요.","1":"[시트1] 탭 B2에 회원01이 없습니다. A2 칸에 붙여 넣었는지 보세요."},"hints":["A1이 아니라 A2 칸에 붙여 넣어야 머리줄이 덮이지 않습니다.","테두리가 둘러진 [가짜 회원 20명 복사] 단추를 누르고, A2 칸을 눌러 붙여 넣으세요."],"target":"copy:practice_body","preset":[{"a":"panel","p":"address"},{"a":"address","text":"sheets.new"},{"a":"rename","name":"연습-회원-시트"},{"a":"paste","tab":"시트1","at":"A1","data":"practice_header"}],"show":[{"a":"paste","tab":"시트1","at":"A2","data":"practice_body"}],"copy":["practice_body"],"page":35},{"id":"freeze","do":"위쪽 메뉴 보기 → 고정에서 맨 위 1행을 고릅니다.","ok":"아래로 밀어도 머리줄이 남아 있으면 성공입니다.","anchor":"1-2-h2-2","bookStep":"① 4","check":[{"t":"frozen","tab":"시트1","eq":1}],"msg":{"0":"[시트1] 탭의 고정 행이 아직 1개가 아닙니다. 메뉴 보기 → 고정 → 1행을 누르세요."},"hints":["메뉴 줄의 보기를 열면 고정이 있고, 그 안에 1행이 있습니다.","테두리가 둘러진 메뉴 항목 보기 → 고정 → 1행을 누르세요."],"target":"menuitem:보기/고정/1행","preset":[{"a":"panel","p":"address"},{"a":"address","text":"sheets.new"},{"a":"rename","name":"연습-회원-시트"},{"a":"paste","tab":"시트1","at":"A1","data":"practice_header"},{"a":"paste","tab":"시트1","at":"A2","data":"practice_body"}],"show":[{"a":"freeze","tab":"시트1","rows":1}],"page":35},{"id":"dates","do":"붙여 넣은 날짜가 칸 오른쪽에 붙는지 봅니다. 설명 단계이니 [다음]을 누릅니다.","ok":"오른쪽에 붙으면 날짜이고, 왼쪽에 붙으면 글자입니다. 왼쪽이면 책의 막힐 때 표를 봅니다.","anchor":"1-2-h2-2","bookStep":"① 4 뒤 안내","check":[],"hints":["만료일 열의 값이 칸의 오른쪽 끝에 붙어 있으면 날짜로 읽힌 것입니다.","만료일 열을 보세요. 이 단계는 확인만 하므로 [다음]을 누르면 됩니다."],"preset":[{"a":"panel","p":"address"},{"a":"address","text":"sheets.new"},{"a":"rename","name":"연습-회원-시트"},{"a":"paste","tab":"시트1","at":"A1","data":"practice_header"},{"a":"paste","tab":"시트1","at":"A2","data":"practice_body"},{"a":"freeze","tab":"시트1","rows":1}],"show":[],"page":35},{"id":"tab","do":"아래쪽 탭 시트1을 두 번 눌러 이름을 회원으로 바꿉니다.","ok":"탭 이름이 회원으로 바뀌면 성공입니다.","anchor":"1-2-h2-3","bookStep":"② 1","check":[{"t":"tabRenamed","from":"시트1","to":"회원"}],"msg":{"0":"탭 이름이 아직 “시트1”에서 “회원”으로 바뀌지 않았습니다. 탭을 두 번 눌러 이름을 고치세요."},"hints":["시트 창 아래 탭 줄에서 시트1 글자를 두 번 누르면 이름을 고칠 수 있습니다.","테두리가 둘러진 탭 시트1을 두 번 눌러 회원을 입력하고 Enter 키를 누르세요."],"target":"tab:시트1","preset":[{"a":"panel","p":"address"},{"a":"address","text":"sheets.new"},{"a":"rename","name":"연습-회원-시트"},{"a":"paste","tab":"시트1","at":"A1","data":"practice_header"},{"a":"paste","tab":"시트1","at":"A2","data":"practice_body"},{"a":"freeze","tab":"시트1","rows":1}],"show":[{"a":"renameTab","from":"시트1","to":"회원"}],"page":39},{"id":"filter","do":"도구 줄에서 거르기 단추를 누르고, 만료일 칸의 거르기 단추(▾)에서 보고 싶은 날짜 두 개만 남기고 확인을 누릅니다.","ok":"고른 날짜의 줄만 보이고 나머지는 숨겨지면 성공입니다.","anchor":"1-2-h2-3","bookStep":"② 2·3","check":[{"t":"filtered","tab":"회원","col":"E"}],"msg":{"0":"[회원] 탭 E열(만료일)에 거르기가 아직 없습니다. 거르기 단추를 누른 뒤 만료일 열의 ▾에서 날짜를 고르세요."},"hints":["거르기는 줄을 숨기는 것이지 지우는 것이 아닙니다. 만료일 열의 ▾를 눌러 날짜 두 개(예: 2026-11-25, 2026-11-30)만 체크합니다.","테두리가 둘러진 거르기 단추를 누른 뒤, 만료일(E열) ▾에서 날짜 두 개만 남기고 확인을 누르세요."],"target":"filter-toggle","preset":[{"a":"panel","p":"address"},{"a":"address","text":"sheets.new"},{"a":"rename","name":"연습-회원-시트"},{"a":"paste","tab":"시트1","at":"A1","data":"practice_header"},{"a":"paste","tab":"시트1","at":"A2","data":"practice_body"},{"a":"freeze","tab":"시트1","rows":1},{"a":"renameTab","from":"시트1","to":"회원"}],"show":[],"page":39},{"id":"sort","do":"남은 횟수 칸의 거르기 단추(▾)에서 작은 수부터 보는 정렬 항목을 누릅니다.","ok":"남은 횟수가 적은 회원이 맨 위에 오면 성공입니다.","anchor":"1-2-h2-3","bookStep":"② 4","check":[{"t":"sorted","tab":"회원","col":"D","dir":"asc"}],"msg":{"0":"[회원] 탭 D열(남은 횟수)이 아직 작은 수부터 정렬되어 있지 않습니다."},"hints":["정렬은 줄의 순서를 실제로 바꿉니다. 머리줄을 고정해 두었으므로 머리줄은 섞이지 않습니다.","테두리가 둘러진 남은 횟수(D열) ▾에서 작은 수부터(오름차순) 정렬을 누르세요."],"target":"filter:D","preset":[{"a":"panel","p":"address"},{"a":"address","text":"sheets.new"},{"a":"rename","name":"연습-회원-시트"},{"a":"paste","tab":"시트1","at":"A1","data":"practice_header"},{"a":"paste","tab":"시트1","at":"A2","data":"practice_body"},{"a":"freeze","tab":"시트1","rows":1},{"a":"renameTab","from":"시트1","to":"회원"}],"show":[],"page":39}],"end":{"did":["가짜 회원 20명(회원01~회원20)을 머리줄과 함께 시트에 붙여 넣고 머리줄을 고정했습니다.","탭 이름을 회원으로 바꿨습니다.","만료일로 거르고 남은 횟수로 정렬해, 만료가 가깝고 횟수가 적은 회원을 먼저 보는 법을 익혔습니다."],"diff":["실제 화면은 모양과 글이 다릅니다. 같은 뜻의 단추를 찾으세요.","거르기는 줄을 숨길 뿐 지우지 않고, 정렬은 줄 순서를 실제로 바꿉니다(책 1-2 ② 설명).","책의 ③ 사본과 공유는 이 연습에 없습니다. 실제 시트에서 책대로 해 보세요."],"next":"biz/2-1","nextHref":"../../biz/2-1/","nextTitle":"2-1 회원·수강권·출석 관리하기(앞부분)"},"page":32,"promptPages":{}}
// 연습실 모의 환경 핵심(gas-core). Apps Script 서비스를 메모리에서 흉내 냅니다. // 옮긴 원본: examples/tests/mock.mjs (vm 과 Node 전용 API 를 없애고, 브라우저 Worker 와 Node 에서 같은 글로 돌게 했습니다) // mock.mjs sha256: 3b58ef069d99af94d58446e35f8fd0bd4477657d516b0ec3af0c827bd9921bf7 // (core.test.mjs 가 지금 mock.mjs 의 해시와 견줍니다. mock 이 바뀌면 시험이 실패하니 다시 옮기세요.) // 한계: 이 모의는 실제 Apps Script 의 동작을 보장하지 않습니다. 실제 계정 실행은 [확인필요: 실제 계정 실행 미확인]. // 사용: 브라우저 Worker 는 전역 PRCore, Node 는 module.exports. 사용자 코드를 돌리는 일은 바깥 담당입니다 // (Node 는 tests/mock_compat.mjs 가 vm 으로, 브라우저는 runtime/harness.js 가 Worker 전역으로). (function (root, factory) { if (typeof module === 'object' && module && module.exports) module.exports = factory(); else root.PRCore = factory(); })(typeof globalThis !== 'undefined' ? globalThis : this, function () { 'use strict'; // ---------- 한도와 문구 ---------- const LIMITS = Object.freeze({ rows: 2000, cols: 52, logs: 500, alerts: 20, mails: 50, triggers: 20, forms: 10, docs: 20 }); const MSG = Object.freeze({ BLOCKED: '연습실에서는 외부 호출을 하지 않습니다', UNSUPPORTED: '연습실에서는 아직 흉내 내지 않는 기능입니다', SHEET_LIMIT: '연습실 시트는 ' + LIMITS.rows + '행·' + LIMITS.cols + '열까지입니다', LOG_TRUNCATED: '로그가 많아 뒤는 줄였습니다', ALERT_LIMIT: '연습실에서는 알림을 한 번에 ' + LIMITS.alerts + '개까지 띄웁니다', MAIL_LIMIT: '연습실에서는 메일을 ' + LIMITS.mails + '통까지만 기록합니다', TRIGGER_LIMIT: '연습실에서는 트리거를 ' + LIMITS.triggers + '개까지 만들 수 있습니다', FORM_LIMIT: '연습실에서는 폼을 ' + LIMITS.forms + '개까지 만들 수 있습니다', DOC_LIMIT: '연습실에서는 문서를 ' + LIMITS.docs + '개까지 만들 수 있습니다', }); const BLOCKED_SERVICES = ['UrlFetchApp', 'GmailApp', 'CalendarApp', 'ContactsApp', 'LanguageApp', 'Maps', 'Jdbc']; const UNSUPPORTED_SERVICES = ['ContentService', 'SlidesApp', 'CardService', 'Charts', 'XmlService', 'Browser']; const DEFAULT_URLS = { forms: 'https://practice.invalid/forms', script: 'https://practice.invalid/macros/s', docs: 'https://practice.invalid/document' }; const RESPONSE_TAB = '폼 응답(연습)'; // ---------- 시간대 계산(실제 시간대 규칙 사용) ---------- function zonedParts(tz, t) { const f = new Intl.DateTimeFormat('en-CA', { timeZone: tz, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }); const o = {}; for (const p of f.formatToParts(new Date(t))) o[p.type] = p.value; return { yyyy: o.year, MM: o.month, dd: o.day, HH: o.hour === '24' ? '00' : o.hour, mm: o.minute, ss: o.second }; } function tzOffset(tz, t) { const p = zonedParts(tz, t); return Date.UTC(+p.yyyy, +p.MM - 1, +p.dd, +p.HH, +p.mm, +p.ss) - Math.floor(t / 1000) * 1000; } // tz 의 y-m-d hh:mm 을 순간(ms)으로 function zonedTime(tz, y, m, d, hh = 0, mi = 0) { const guess = Date.UTC(y, m - 1, d, hh, mi); let t = guess - tzOffset(tz, guess); t = guess - tzOffset(tz, t); return t; } function formatDate(d, tz, fmt) { const p = zonedParts(tz, d.getTime()); const map = { yyyy: p.yyyy, MM: p.MM, dd: p.dd, HH: p.HH, mm: p.mm, ss: p.ss, M: String(+p.MM), d: String(+p.dd) }; return fmt.replace(/yyyy|MM|dd|HH|mm|ss|M|d/g, (t) => map[t]); } // ---------- 칸 주소 ---------- const colNum = (s) => [...s].reduce((n, ch) => n * 26 + ch.charCodeAt(0) - 64, 0); function colName(n) { let s = ''; for (; n > 0; n = Math.floor((n - 1) / 26)) s = String.fromCharCode(65 + ((n - 1) % 26)) + s; return s; } function a1(ref) { const m = /^([A-Z]+)(\d+)(?::([A-Z]+)(\d+))?$/.exec(ref); if (!m) throw new Error('bad A1 ' + ref); const c1 = colNum(m[1]), r1 = Number(m[2]); return [r1, c1, m[4] ? Number(m[4]) - r1 + 1 : 1, m[3] ? colNum(m[3]) - c1 + 1 : 1]; } // ---------- 칸 값 변환(snapshot 의 Cell 모양: 글자 | 숫자 | 참거짓 | {d: epoch ms}) ---------- const isDate = (x) => Object.prototype.toString.call(x) === '[object Date]'; function cellOut(x) { if (x === null || x === undefined) return ''; if (isDate(x)) return Number.isNaN(x.getTime()) ? '' : { d: x.getTime() }; if (typeof x === 'string' || typeof x === 'boolean') return x; if (typeof x === 'number') return Number.isFinite(x) ? x : String(x); return String(x); } const cellIn = (c, DateCtor) => (c !== null && typeof c === 'object' && 'd' in c ? new DateCtor(c.d) : c); // 화면에 보이는 글자. 날짜는 시트 시간대로 0시면 yyyy-MM-dd, 아니면 yyyy-MM-dd HH:mm. 수식은 원래 글자(계산하지 않음). // 실제 시트의 표시 모양은 서식·로캘에 따라 다릅니다 [확인필요: 실제 시트 표시 모양]. function displayValue(cell, tz) { if (cell === null || cell === undefined || cell === '') return ''; if (isDate(cell)) cell = { d: cell.getTime() }; if (typeof cell === 'object' && 'd' in cell) { const d = new Date(cell.d); if (Number.isNaN(d.getTime())) return ''; const p = zonedParts(tz, cell.d); return formatDate(d, tz, p.HH === '00' && p.mm === '00' && p.ss === '00' ? 'yyyy-MM-dd' : 'yyyy-MM-dd HH:mm'); } if (typeof cell === 'boolean') return cell ? 'TRUE' : 'FALSE'; if (typeof cell === 'string') return cell.startsWith('#FORMULA(') && cell.endsWith(')') ? cell.slice(9, -1) : cell; return String(cell); } // 손 입력·붙여 넣기 글을 칸 값으로 읽습니다(코드가 setValues 로 넣는 값은 아래 Sheet.put 의 mock 규칙을 따릅니다). function coerceInput(text, { tz = 'Asia/Seoul', format } = {}) { const x = String(text === null || text === undefined ? '' : text); if (x === '') return ''; if (x.startsWith("'")) return x.slice(1); // 앞의 ' 는 글자 그대로 if (format === '@') return x; // 글자 서식 칸 const t = x.trim(); if (/^[+-]?((\d{1,3}(,\d{3})+|\d+)(\.\d+)?|\.\d+)$/.test(t)) return Number(t.replace(/,/g, '')); if (/^[=+\-@]/.test(x)) return '#FORMULA(' + x + ')'; // mock 규칙: 수식은 계산하지 않고 글자로 둔다 const m = /^(\d{4})[-/](\d{1,2})[-/](\d{1,2})(?:[ T](\d{1,2}):(\d{2}))?$/.exec(t) || /^(\d{4})\. ?(\d{1,2})\. ?(\d{1,2})\.?(?: (\d{1,2}):(\d{2}))?$/.exec(t); if (m) { const [y, mo, d, hh, mi] = [+m[1], +m[2], +m[3], +(m[4] || 0), +(m[5] || 0)]; if (mo >= 1 && mo <= 12 && d >= 1 && d <= 31 && hh < 24 && mi < 60) { const ms = zonedTime(tz, y, mo, d, hh, mi); const back = formatDate(new Date(ms), tz, 'yyyy-MM-dd HH:mm'); const want = String(y).padStart(4, '0') + '-' + String(mo).padStart(2, '0') + '-' + String(d).padStart(2, '0') + ' ' + String(hh).padStart(2, '0') + ':' + String(mi).padStart(2, '0'); if (back === want) return { d: ms }; // 없는 날짜(2월 30일)는 글자로 남는다 } return x; } if (/^(true|false)$/i.test(t)) return t.toLowerCase() === 'true'; return x; } // ---------- 씨앗 난수(재실행이 결정적이도록) ---------- function mulberry32(seed) { let a = seed >>> 0; return function () { a = (a + 0x6D2B79F5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function seededUuid(rnd) { const b = Array.from({ length: 16 }, () => Math.floor(rnd() * 256)); b[6] = (b[6] & 0x0f) | 0x40; b[8] = (b[8] & 0x3f) | 0x80; const h = b.map((v) => v.toString(16).padStart(2, '0')).join(''); return h.slice(0, 8) + '-' + h.slice(8, 12) + '-' + h.slice(12, 16) + '-' + h.slice(16, 20) + '-' + h.slice(20); } // ---------- base64 (btoa·atob·TextEncoder; 글자는 UTF-8 바이트로) ---------- function b64Encode(b) { const bytes = typeof b === 'string' ? new TextEncoder().encode(b) : Uint8Array.from(Array.from(b), (v) => v & 255); let s = ''; for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000)); return btoa(s); } function b64Decode(str) { let s = String(str).replace(/[\s=]+/g, '').replace(/-/g, '+').replace(/_/g, '/'); while (s.length % 4) s += '='; const bin = atob(s); return Array.from(bin, (ch) => ch.charCodeAt(0)); } // ---------- 사용자 입력이 모자랄 때(prompt·confirm 재실행 방식) ---------- class NeedInput extends Error { constructor(info) { super('입력이 필요합니다: ' + info.text); this.name = 'NeedInput'; this.needInput = info; } } // ---------- 시트 ---------- class Range { constructor(sh, r, c, nr, nc) { Object.assign(this, { sh, r, c, nr, nc }); } getValues() { return Array.from({ length: this.nr }, (_, i) => Array.from({ length: this.nc }, (_, j) => this.sh.cell(this.r + i, this.c + j))); } getValue() { return this.sh.cell(this.r, this.c); } setValues(v) { if (v.length !== this.nr || v.some((row) => row.length !== this.nc)) throw new Error('데이터의 크기가 범위와 다릅니다. range ' + this.nr + 'x' + this.nc + ', data ' + v.length + 'x' + (v[0] || []).length); v.forEach((row, i) => row.forEach((x, j) => this.sh.put(this.r + i, this.c + j, x))); return this; } setValue(x) { this.sh.put(this.r, this.c, x); return this; } setFontWeight() { return this; } setBackground() { return this; } setNumberFormat(f) { for (let i = 0; i < this.nr; i++) for (let j = 0; j < this.nc; j++) this.sh.formats.set(`${this.r + i},${this.c + j}`, f); return this; } clearContent() { for (let i = 0; i < this.nr; i++) for (let j = 0; j < this.nc; j++) this.sh.put(this.r + i, this.c + j, '', true); return this; } // 연습실 덧붙임 getA1Notation() { const s = colName(this.c) + this.r; return this.nr === 1 && this.nc === 1 ? s : s + ':' + colName(this.c + this.nc - 1) + (this.r + this.nr - 1); } getDisplayValues() { const tz = this.sh.ss.tz; return this.getValues().map((row) => row.map((x) => displayValue(cellOut(x), tz))); } } class Sheet { constructor(ss, name, id) { this.ss = ss; this.name = name; this.id = id; this.rows = []; this.formats = new Map(); this.frozenRows = 0; this.colWidths = {}; this.kind = 'normal'; this.filter = null; this.sort = undefined; } getName() { return this.name; } getSheetId() { return this.id; } setName(nn) { if (this.ss.sheets.has(nn) && this.ss.sheets.get(nn) !== this) throw new Error('A sheet with the name "' + nn + '" already exists.'); // 탭 자리를 지킨다(실제와 같게). mock 은 끝으로 보냈지만 이름으로 찾는 코드에는 차이가 없다. const entries = [...this.ss.sheets.values()].map((s) => [s === this ? nn : s.name, s]); if (this.ss.activeName === this.name) this.ss.activeName = nn; this.name = nn; this.ss.sheets.clear(); for (const [k, s] of entries) this.ss.sheets.set(k, s); return this; } cell(r, c) { const v = (this.rows[r - 1] || [])[c - 1]; return v === undefined ? '' : v; } put(r, c, x, raw) { if (this.ss.P && !(raw && x === '') && (r > LIMITS.rows || c > LIMITS.cols)) throw new Error(MSG.SHEET_LIMIT); while (this.rows.length < r) this.rows.push([]); const row = this.rows[r - 1]; while (row.length < c) row.push(''); if (!raw) { // 앞에 ' 가 있으면 그 뒤는 글자 그대로. = + - @ 로 시작하는 다른 글자는 수식(결과가 읽힘) if (typeof x === 'string' && x.startsWith("'")) x = x.slice(1); else if (typeof x === 'string' && /^[=+\-@]/.test(x) && !/^[+-]?\d+(\.\d+)?$/.test(x)) x = '#FORMULA(' + x + ')'; else if (typeof x === 'string' && this.formats.get(`${r},${c}`) !== '@') { // 글자 서식이 아닌 칸에 넣은 2026-03-02 는 날짜 칸이 된다(시트 시간대 기준 0시), 2026-03-02 09:30 은 그 시각 const m = /^(\d{4})-(\d{2})-(\d{2})(?: (\d{2}):(\d{2}))?$/.exec(x); if (m) x = new this.ss.DateCtor(zonedTime(this.ss.tz, +m[1], +m[2], +m[3], +(m[4] || 0), +(m[5] || 0))); } } row[c - 1] = x; } getRange(r, c, nr = 1, nc = 1) { if (typeof r === 'string') [r, c, nr, nc] = a1(r); return new Range(this, r, c, nr, nc); } getLastRow() { for (let i = this.rows.length; i > 0; i--) if (this.rows[i - 1].some((x) => x !== '')) return i; return 0; } getLastColumn() { return this.rows.reduce((m, r) => Math.max(m, r.length), 0); } getMaxRows() { return Math.max(1000, this.rows.length); } getMaxColumns() { return Math.max(26, this.getLastColumn()); } getDataRange() { return this.getRange(1, 1, Math.max(1, this.getLastRow()), Math.max(1, this.getLastColumn())); } appendRow(arr) { const r = this.getLastRow() + 1; arr.forEach((x, j) => this.put(r, j + 1, x)); } deleteRow(r) { if (r < 1 || r > this.rows.length) throw new Error('행 번호가 범위를 벗어났습니다: ' + r); this.rows.splice(r - 1, 1); } clearContents() { this.rows = []; return this; } clear() { this.rows = []; this.formats.clear(); return this; } setColumnWidth(col, px) { if (this.ss.P) this.colWidths[String(col)] = px; return this; } setFrozenRows(n) { if (this.ss.P) this.frozenRows = n; return this; } } class Spreadsheet { constructor(id, tz, cfg) { this.id = id; this.tz = tz; this.sheets = new Map(); this.nextSheetId = 100; this.name = '내 스프레드시트'; this.P = cfg.P; this.DateCtor = cfg.DateCtor; this.urls = cfg.urls; this.activeName = cfg.activeSheet || null; } getId() { return this.id; } getName() { return this.name; } getSpreadsheetTimeZone() { return this.tz; } setSpreadsheetTimeZone(tz) { this.tz = tz; } getSheetByName(n) { return this.sheets.get(n) || null; } getSheets() { return [...this.sheets.values()]; } insertSheet(n) { if (this.sheets.has(n)) throw new Error('A sheet with the name "' + n + '" already exists.'); const s = new Sheet(this, n, this.nextSheetId++); this.sheets.set(n, s); return s; } // 연습실 덧붙임 getActiveSheet() { return this.sheets.get(this.activeName) || this.sheets.values().next().value || null; } setActiveSheet(sh) { this.activeName = sh.getName(); return sh; } getUrl() { return this.urls.sheets + '/d/' + this.id + '/edit'; } } // ---------- 폼 ---------- // saved 가 있으면 snapshot 의 폼 하나를 되살립니다(번호표 seq 를 올리지 않습니다). function makeForm(env, title, saved) { const P = env.practice; if (!saved && P && env.forms.size >= LIMITS.forms) throw new Error(MSG.FORM_LIMIT); const form = { id: saved ? saved.id : 'FORM' + ++env.seq, title, items: [], dest: null, quiz: false, desc: '', accepting: true, collectEmail: false, responses: [] }; const item = (type) => { const it = { type, title: '', required: false, choices: [], points: null, bounds: null, labels: null, help: '', fbOk: null, fbNo: null }; Object.assign(it, { setTitle: (t) => { it.title = t; return it; }, getTitle: () => it.title, setRequired: (b) => { it.required = !!b; return it; }, setHelpText: (t) => { it.help = t; return it; }, setBounds: (a, b) => { it.bounds = [a, b]; return it; }, setLabels: (a, b) => { it.labels = [a, b]; return it; }, setChoiceValues: (v) => { it.choices = v.map((x) => ({ text: x, correct: false })); return it; }, createChoice: (text, correct) => ({ text, correct: !!correct }), setChoices: (v) => { it.choices = v; return it; }, setPoints: (n) => { it.points = n; return it; }, asListItem: () => it, setFeedbackForCorrect: (f) => { it.fbOk = f; return it; }, setFeedbackForIncorrect: (f) => { it.fbNo = f; return it; }, }); form.items.push(it); return it; }; Object.assign(form, { setTitle: (t) => { form.title = t; return form; }, setDescription: (d) => { form.desc = d; return form; }, setIsQuiz: (b) => { form.quiz = !!b; return form; }, setDestination: (type, id) => { form.dest = { type, id }; if (P) env.syncResponseTab(form); return form; }, setCollectEmail: (b) => { if (P) form.collectEmail = !!b; return form; }, setConfirmationMessage: () => form, setAllowResponseEdits: () => form, addTextItem: () => item('text'), addParagraphTextItem: () => item('paragraph'), addScaleItem: () => item('scale'), addCheckboxItem: () => item('checkbox'), addMultipleChoiceItem: () => item('choice'), addListItem: () => item('list'), getItems: () => form.items.slice(), deleteResponse: (id) => { form.deleted = (form.deleted || []).concat(id); return form; }, setAcceptingResponses: (b) => { form.accepting = !!b; return form; }, isAcceptingResponses: () => form.accepting !== false, getId: () => form.id, getPublishedUrl: () => env.urls.forms + '/d/e/' + form.id + '/viewform', getEditUrl: () => env.urls.forms + '/d/' + form.id + '/edit', }); if (saved) { Object.assign(form, { desc: saved.desc, quiz: saved.quiz, accepting: saved.accepting, collectEmail: saved.collectEmail, dest: saved.dest ? { ...saved.dest } : null }); if (saved.deleted && saved.deleted.length) form.deleted = saved.deleted.slice(); for (const s of saved.items) { const it = item(s.type); Object.assign(it, { title: s.title, required: s.required, choices: s.choices.map((c) => ({ text: c.text, correct: !!c.correct })), bounds: s.bounds ? s.bounds.slice() : null, labels: s.labels ? s.labels.slice() : null, help: s.help, points: s.points }); } form.responses = saved.responses.map((r) => ({ id: r.id, at: r.at, answers: JSON.parse(JSON.stringify(r.answers)) })); } env.forms.set(form.id, form); return form; } // ---------- 환경 ---------- // opts: sheetTz, now(ISO 글자 또는 epoch ms), seed, ownerEmail, urls{forms,script,docs[,sheets]}, webUrl, practice, activeSheet, responseTab // urls 는 주소 앞부분입니다. 폼 보기 주소는 urls.forms + '/d/e/<id>/viewform', 배포 주소 기본은 urls.script + '/EXAMPLE/exec'. function createEnv(opts = {}) { const sheetTz = opts.sheetTz === undefined ? 'America/Los_Angeles' : opts.sheetTz; const now = opts.now === undefined ? '2026-03-02T09:00:00+09:00' : opts.now; const P = !!opts.practice; const ownerEmail = opts.ownerEmail === undefined ? 'owner@example.com' : opts.ownerEmail; const urls = { ...DEFAULT_URLS, ...(opts.urls || {}) }; if (!urls.sheets) urls.sheets = urls.docs.replace(/\/document$/, '/spreadsheets'); const rnd = mulberry32(opts.seed === undefined ? 20260302 : opts.seed); const log = { logged: [], alerts: [], mails: [], triggers: [], menus: [], prompts: [], confirms: [], timeline: [] }; const clock = { t: typeof now === 'number' ? now : Date.parse(now) }; class FakeDate extends Date { constructor(...a) { if (a.length === 0) super(clock.t); else super(...a); } static now() { return clock.t; } } const env = { seq: 0, trigSeq: 0, forms: new Map(), folders: new Map(), files: new Map(), docs: new Map(), htmlFiles: {}, spreadsheets: new Map(), log, ui: { enabled: true, yes: true }, practice: P, urls, FakeDate, ownerEmail, responseTab: opts.responseTab || RESPONSE_TAB, menusBase: [], needInput: null, }; if (opts.webUrl !== undefined) env.webUrl = opts.webUrl; else if (P) env.webUrl = ''; const active = new Spreadsheet('SS1', sheetTz, { P, DateCtor: FakeDate, urls, activeSheet: opts.activeSheet }); env.spreadsheets.set('SS1', active); // 실행 기록(timeline)과 로그. 연습실이면 로그를 500줄까지만 남깁니다. const step = (s) => { log.timeline.push(s); }; let logCount = 0, logNoted = false; const logLine = (level, text) => { if (P && logCount >= LIMITS.logs) { if (!logNoted) { logNoted = true; step({ k: 'log', level: 'warn', text: MSG.LOG_TRUNCATED }); } return false; } logCount++; step({ k: 'log', level, text }); return true; }; const fmtArg = (v) => { if (typeof v === 'string') return v; if (v instanceof Error) return String(v); if (v !== null && typeof v === 'object') { try { return JSON.stringify(v); } catch (e) { return String(v); } } return String(v); }; const consoleTo = (level) => (...a) => { logLine(level, a.map(fmtArg).join(' ')); }; const needInput = (info) => { env.needInput = info; return new NeedInput(info); }; // 구글 문서 흉내: 문단 목록만 가집니다(표·글꼴은 흉내 내지 못합니다 [확인필요: 실제 계정에서 서식 사본이 표·글꼴을 지키는지]) const makeDoc = (name, paras = [], saved) => { if (!saved && P && env.docs.size >= LIMITS.docs) throw new Error(MSG.DOC_LIMIT); const id = saved ? saved.id : 'DOC' + ++env.seq, d = { id, name, paras: paras.map((p) => ({ ...p })), trashed: saved ? !!saved.trashed : false }; const body = { appendParagraph: (t) => { const p = { text: String(t), heading: null }; d.paras.push(p); const h = { setHeading(x) { p.heading = x; return h; }, getText: () => p.text }; return h; }, getText: () => d.paras.map((p) => p.text).join('\n'), replaceText: (pattern, repl) => { const re = new RegExp(pattern, 'g'); d.paras.forEach((p) => { p.text = p.text.replace(re, () => String(repl)); }); return body; }, }; d.api = { getId: () => id, getName: () => d.name, setName(n) { d.name = n; return d.api; }, getBody: () => body, saveAndClose() {}, getUrl: () => urls.docs + '/d/' + id + '/edit' }; d.file = { getId: () => id, getName: () => d.name, setTrashed(b) { d.trashed = !!b; return d.file; }, makeCopy: (n) => makeDoc(n || '사본 - ' + d.name, d.paras).file }; env.docs.set(id, d); return d; }; env.makeDoc = makeDoc; const makeFolder = (name, id = 'FOLDER' + ++env.seq) => { const f = { id, name, files: [], getId: () => id, getName: () => name, createFile: (blob) => addFile(f, blob), getFiles: () => { const live = f.files.filter((x) => !x.trashed); let i = 0; return { hasNext: () => i < live.length, next: () => live[i++] }; } }; env.folders.set(id, f); return f; }; const addFile = (f, blob, fid = 'FILE' + ++env.seq, trashed = false) => { const file = { id: fid, name: blob.name, blob, trashed, getId: () => fid, getName: () => file.name, setTrashed(b) { file.trashed = !!b; return file; } }; f.files.push(file); env.files.set(fid, file); return file; }; env.addSpreadsheet = (id, tz = sheetTz) => { const s = new Spreadsheet(id, tz, { P, DateCtor: FakeDate, urls }); env.spreadsheets.set(id, s); return s; }; const htmlOut = (html) => { const o = { html, title: '', xframe: 'DEFAULT', setTitle(t) { o.title = t; return o; }, addMetaTag() { return o; }, setXFrameOptionsMode(m) { o.xframe = m; return o; }, getContent: () => html }; return o; }; const props = new Map(); const cache = new Map(); const blocked = (service) => new Proxy({}, { get: (_, method) => { if (typeof method === 'symbol') return undefined; step({ k: 'blocked', service, method: String(method) }); throw new Error(MSG.BLOCKED); }, }); const unsupported = () => new Proxy({}, { get: (_, method) => { if (typeof method === 'symbol') return undefined; throw new Error(MSG.UNSUPPORTED); } }); const noSupport = () => { throw new Error(MSG.UNSUPPORTED); }; const queue = () => (Array.isArray(env.ui.queue) ? env.ui.queue : null); // 연습실: 대화 답을 차례로 꺼냅니다. 모자라면 NeedInput const ui = { ButtonSet: { OK: 'OK', OK_CANCEL: 'OK_CANCEL', YES_NO: 'YES_NO' }, Button: { OK: 'OK', YES: 'YES', NO: 'NO', CANCEL: 'CANCEL' }, // alert(글) / alert(글, 버튼) / alert(제목, 글, 버튼) 세 모양을 받습니다 alert: (...a) => { const buttons = a.length >= 2 ? a[a.length - 1] : null, text = a.length === 3 ? a[1] : a[0]; if (buttons === 'YES_NO') { const q = queue(); let ans; if (q) { if (!q.length) throw needInput({ kind: 'confirm', title: a.length === 3 ? String(a[0]) : '', text: String(text), buttons: 'YES_NO' }); ans = q.shift().button; } else ans = env.ui.yes ? 'YES' : 'NO'; log.confirms.push(String(text)); step({ k: 'confirm', text: String(text), answer: ans }); return ans; } if (P && log.alerts.length >= LIMITS.alerts) throw new Error(MSG.ALERT_LIMIT); log.alerts.push(String(text)); step({ k: 'alert', text: String(text) }); return 'OK'; }, prompt: (title, msg, buttons) => { const q = queue(); let button, text; if (q) { if (!q.length) throw needInput({ kind: 'prompt', title: String(title), text: String(msg === undefined ? '' : msg), buttons: typeof buttons === 'string' ? buttons : 'OK_CANCEL' }); const a = q.shift(); button = a.button; text = a.text === undefined ? '' : String(a.text); } else { button = env.ui.answer == null ? 'CANCEL' : 'OK'; text = String(env.ui.answer === undefined || env.ui.answer === null ? '' : env.ui.answer); } log.prompts.push([title, msg]); step({ k: 'prompt', title: String(title), text: String(msg === undefined ? '' : msg), answer: text }); return { getSelectedButton: () => button, getResponseText: () => text }; }, createMenu: (name) => { const m = { name, items: [], addItem(l, fn) { m.items.push([l, fn]); return m; }, addSeparator() { return m; }, addToUi() { log.menus.push(m); step({ k: 'menu', name }); return m; } }; return m; }, }; if (P) { ui.showModalDialog = noSupport; ui.showSidebar = noSupport; } // 트리거: newTrigger(fn).forSpreadsheet(ss).onFormSubmit().create() 처럼 이어 부르면 spec 에 쌓고, create 에서 목록에 넣는다. const makeTrigger = (fn, spec = [], id) => { const t = { fn, spec, id, getHandlerFunction: () => fn, getUniqueId: () => t.id }; const chain = new Proxy(t, { get: (o, k) => (k in o ? o[k] : k === 'create' ? () => { if (P && log.triggers.length >= LIMITS.triggers) throw new Error(MSG.TRIGGER_LIMIT); if (!t.id) t.id = 'TRG' + ++env.trigSeq; log.triggers.push(t); return t; } : (...a) => { t.spec.push([k, ...a]); return chain; }), }); return { t, chain }; }; const ctx = { Logger: { log: (m) => { if (logLine('info', String(m))) log.logged.push(String(m)); } }, console: { log: consoleTo('info'), info: consoleTo('info'), debug: consoleTo('info'), warn: consoleTo('warn'), error: consoleTo('error') }, Date: FakeDate, SpreadsheetApp: { getActive: () => active, getActiveSpreadsheet: () => active, openById: (id) => { const s = env.spreadsheets.get(id); if (!s) throw new Error('no such spreadsheet'); return s; }, getUi: () => { if (!env.ui.enabled) throw new Error('Cannot call SpreadsheetApp.getUi() from this context.'); return ui; }, flush: () => {}, }, PropertiesService: { getScriptProperties: () => ({ getProperty: (k) => (props.has(k) ? props.get(k) : null), setProperty: (k, v) => { props.set(k, String(v)); }, deleteProperty: (k) => { props.delete(k); }, getKeys: () => [...props.keys()] }) }, CacheService: { getScriptCache: () => ({ get: (k) => { const e = cache.get(k); if (!e) return null; if (e.until && e.until <= clock.t) { cache.delete(k); return null; } return e.v; }, put: (k, v, sec) => { cache.set(k, { v: String(v), until: sec ? clock.t + sec * 1000 : 0 }); }, remove: (k) => { cache.delete(k); }, }), }, LockService: { getScriptLock: () => ({ waitLock() { log.locks = (log.locks || 0) + 1; }, tryLock: () => true, releaseLock() { log.unlocks = (log.unlocks || 0) + 1; } }) }, Utilities: { sleep: () => {}, getUuid: () => seededUuid(rnd), formatDate: (d, tz, fmt) => formatDate(d, tz, fmt), base64Decode: (s) => b64Decode(s), base64Encode: (b) => b64Encode(b), newBlob: (bytes, mime, name) => ({ bytes, mime, name, getBytes: () => bytes, getContentType: () => mime, getName: () => name, setName(n) { this.name = n; return this; } }), }, MailApp: { sendEmail: (to, subject, body, o) => { if (P && log.mails.length >= LIMITS.mails) throw new Error(MSG.MAIL_LIMIT); const m = { to, subject, body, html: o && o.htmlBody }; if (P) m.at = clock.t; log.mails.push(m); step({ k: 'mail', to: String(to), subject: String(subject) }); }, getRemainingDailyQuota: () => env.quota ?? 100, }, Session: { getEffectiveUser: () => ({ getEmail: () => ownerEmail }), getActiveUser: () => ({ getEmail: () => ownerEmail }), getScriptTimeZone: () => env.scriptTz || 'Asia/Seoul' }, HtmlService: { XFrameOptionsMode: { ALLOWALL: 'ALLOWALL', DEFAULT: 'DEFAULT' }, createHtmlOutput: (html) => htmlOut(html), createHtmlOutputFromFile: (name) => { if (typeof env.htmlFiles[name] !== 'string') throw new Error('Exception: No HTML file named ' + name + ' was found.'); return htmlOut(env.htmlFiles[name]); }, }, ScriptApp: { getService: () => ({ getUrl: () => (env.webUrl === undefined ? urls.script + '/EXAMPLE/exec' : env.webUrl) }), getProjectTriggers: () => log.triggers, deleteTrigger: (t) => { const k = log.triggers.indexOf(t); if (k >= 0) log.triggers.splice(k, 1); }, newTrigger: (fn) => makeTrigger(fn).chain, EventType: { CLOCK: 'CLOCK', ON_FORM_SUBMIT: 'ON_FORM_SUBMIT' }, }, DriveApp: { createFolder: (name) => makeFolder(name), getFolderById: (id) => { const f = env.folders.get(id); if (!f) throw new Error('폴더를 찾을 수 없습니다: ' + id); return f; }, getFileById: (id) => { if (env.docs.has(id)) return env.docs.get(id).file; const f = env.files.get(id); if (!f) throw new Error('파일을 찾을 수 없습니다: ' + id); return f; }, }, DocumentApp: { ParagraphHeading: { NORMAL: 'NORMAL', TITLE: 'TITLE', HEADING1: 'HEADING1', HEADING2: 'HEADING2' }, create: (name) => makeDoc(name).api, openById: (id) => { const d = env.docs.get(id); if (!d) throw new Error('문서를 찾을 수 없습니다: ' + id); return d.api; }, }, FormApp: { DestinationType: { SPREADSHEET: 'SPREADSHEET' }, create: (title) => makeForm(env, title), openById: (id) => { const f = env.forms.get(id); if (!f) throw new Error('폼을 찾을 수 없습니다: ' + id); return f; }, createFeedback: () => { const b = { text: '' }; b.setText = (t) => { b.text = t; return b; }; b.build = () => ({ text: b.text }); return b; }, }, }; if (P) { for (const n of BLOCKED_SERVICES) ctx[n] = blocked(n); for (const n of UNSUPPORTED_SERVICES) ctx[n] = unsupported(); ctx.HtmlService.createTemplateFromFile = noSupport; } // 연습실: 폼 응답 탭(kind response)을 만들고 머리 줄을 문항 제목에 맞춥니다. env.syncResponseTab = (form) => { const ss = form.dest && env.spreadsheets.get(form.dest.id); if (!ss) return null; let tab = ss.sheets.get(env.responseTab); if (!tab) { tab = ss.insertSheet(env.responseTab); tab.kind = 'response'; } ['타임스탬프'].concat(form.items.map((it) => it.title)).forEach((h, j) => tab.put(1, j + 1, h, true)); return tab; }; // 연습실: 폼에 응답 하나를 더합니다(미리보기에서 제출). 응답 탭이 있으면 [타임스탬프, 답…] 한 줄을 넣습니다. env.addResponse = (form, answers, { at } = {}) => { const when = at === undefined ? clock.t : (typeof at === 'string' ? Date.parse(at) : at); const ans = {}; for (const [k, v] of Object.entries(answers)) ans[k] = [].concat(v).map(String); const resp = { id: 'RESP' + ++env.seq, at: when, answers: ans }; form.responses.push(resp); const tab = form.dest && env.syncResponseTab(form); if (tab) { const cells = form.items.map((it) => { const v = ans[it.title] || []; return it.type === 'scale' && v.length === 1 && v[0] !== '' && Number.isFinite(Number(v[0])) ? Number(v[0]) : v.join(', '); }); const r = tab.getLastRow() + 1; // 응답 글은 수식·날짜로 읽지 않고 그대로 둔다 [new FakeDate(when)].concat(cells).forEach((v, j) => tab.put(r, j + 1, v, true)); } return resp; }; env.makeTrigger = makeTrigger; env.makeFolder = makeFolder; env.addFile = addFile; Object.assign(env, { globals: ctx, active, props, cache, clock, setNow(iso) { clock.t = Date.parse(iso); }, advance(ms) { clock.t += ms; }, // 폼 제출 이벤트 두 모양: 시트에 건 트리거(namedValues), 폼에 건 트리거(response) events(form, answers, { at } = {}) { const named = {}; for (const [k, v] of Object.entries(answers)) named[k] = [].concat(v).map(String); const resp = { getId: () => 'RESP' + ++env.seq, getTimestamp: () => new FakeDate(at === undefined ? clock.t : (typeof at === 'string' ? Date.parse(at) : at)), getItemResponses: () => form.items.filter((it) => it.title in answers).map((it) => ({ getItem: () => ({ getTitle: () => it.title }), getResponse: () => (Array.isArray(answers[it.title]) ? answers[it.title] : answers[it.title]) })) }; return { sheet: { namedValues: named }, form: { response: resp } }; }, triggerFns() { return log.triggers.map((t) => t.fn); }, }); return env; } // ---------- snapshot 왕복 ---------- const jsonCopy = (x) => JSON.parse(JSON.stringify(x)); function specArgOut(env, a) { if (a instanceof Spreadsheet) return { ss: a.id }; if (a && typeof a === 'object' && env.forms.get(a.id) === a) return { form: a.id }; if (a === undefined || typeof a === 'function') return null; if (isDate(a)) return a.getTime(); try { return jsonCopy(a); } catch (e) { return null; } } function specArgIn(env, a) { if (a && typeof a === 'object' && !Array.isArray(a)) { const ks = Object.keys(a); if (ks.length === 1 && ks[0] === 'ss' && env.spreadsheets.has(a.ss)) return env.spreadsheets.get(a.ss); if (ks.length === 1 && ks[0] === 'form' && env.forms.has(a.form)) return env.forms.get(a.form); } return a; } const triggerKind = (spec) => { const k = spec.map((s) => s[0]); return k.includes('forForm') ? 'form' : k.includes('forSpreadsheet') ? 'sheet' : k.includes('timeBased') ? 'clock' : 'other'; }; function toSnapshot(env) { const ss = env.active, now = env.clock.t; const sheets = [...ss.sheets.values()].map((s) => { const o = { id: s.id, name: s.name, rows: s.rows.map((row) => row.map(cellOut)), formats: Object.fromEntries(s.formats), frozenRows: s.frozenRows, colWidths: { ...s.colWidths }, kind: s.kind, filter: s.filter ? jsonCopy(s.filter) : null, }; if (s.sort) o.sort = { ...s.sort }; return o; }); const triggers = env.log.triggers.map((t) => { const spec = t.spec.map((s) => s.map((a, i) => (i === 0 ? a : specArgOut(env, a)))); const o = { id: t.id || '', fn: t.fn, kind: triggerKind(t.spec), spec }; const f = t.spec.find((s) => s[0] === 'forForm'); if (f && f[1] && f[1].id) o.formId = f[1].id; return o; }); // 이번 실행에서 addToUi 로 만든 메뉴는 같은 이름의 옛 메뉴 자리를 대신하고, 새 이름은 뒤에 붙는다. const fresh = new Map(); for (const m of env.log.menus) fresh.set(m.name, { name: m.name, items: m.items.map((i) => [String(i[0]), String(i[1])]) }); const menus = env.menusBase.map((b) => { const f = fresh.get(b.name); fresh.delete(b.name); return f || { name: b.name, items: b.items.map((i) => [i[0], i[1]]) }; }); for (const f of fresh.values()) menus.push(f); return jsonCopy({ v: 1, workbook: { id: 'SS1', name: ss.name, tz: ss.tz, nextSheetId: ss.nextSheetId, active: (ss.getActiveSheet() || { name: '' }).name, sheets }, props: Object.fromEntries(env.props), cache: Object.fromEntries([...env.cache].map(([k, e]) => [k, { v: e.v, until: e.until }])), triggers, menus, forms: [...env.forms.values()].map((f) => ({ id: f.id, title: f.title, desc: f.desc, quiz: !!f.quiz, accepting: f.accepting !== false, collectEmail: !!f.collectEmail, dest: f.dest ? { ...f.dest } : null, deleted: (f.deleted || []).slice(), items: f.items.map((it) => ({ type: it.type, title: it.title, required: !!it.required, choices: it.choices.map((c) => ({ text: c.text, correct: !!c.correct })), bounds: it.bounds ? it.bounds.slice() : null, labels: it.labels ? it.labels.slice() : null, help: it.help, points: it.points })), responses: f.responses.map((r) => ({ id: r.id, at: r.at, answers: jsonCopy(r.answers) })), })), docs: [...env.docs.values()].map((d) => ({ id: d.id, name: d.name, paras: d.paras.map((p) => ({ text: p.text, heading: p.heading })), trashed: !!d.trashed })), folders: [...env.folders.values()].map((f) => ({ id: f.id, name: f.name, files: f.files.map((x) => ({ id: x.id, name: x.name, mime: (x.blob && x.blob.mime) || '', trashed: !!x.trashed })) })), mails: env.log.mails.map((m) => { const o = { to: String(m.to), subject: String(m.subject), body: String(m.body), at: m.at === undefined ? now : m.at }; if (m.html !== undefined) o.html = String(m.html); return o; }), seq: env.seq, }); } function fromSnapshot(snap, opts = {}) { const w = snap.workbook; const env = createEnv({ ...opts, sheetTz: w.tz, activeSheet: opts.activeSheet || w.active }); const ss = env.active, D = env.FakeDate; ss.name = w.name; ss.nextSheetId = w.nextSheetId; for (const s of w.sheets) { const sh = new Sheet(ss, s.name, s.id); sh.rows = s.rows.map((row) => row.map((c) => cellIn(c, D))); for (const [k, f] of Object.entries(s.formats || {})) sh.formats.set(k, f); sh.frozenRows = s.frozenRows || 0; sh.colWidths = { ...(s.colWidths || {}) }; sh.kind = s.kind || 'normal'; sh.filter = s.filter ? jsonCopy(s.filter) : null; sh.sort = s.sort ? { ...s.sort } : undefined; ss.sheets.set(s.name, sh); } for (const [k, v] of Object.entries(snap.props || {})) env.props.set(k, String(v)); for (const [k, e] of Object.entries(snap.cache || {})) env.cache.set(k, { v: e.v, until: e.until }); for (const f of snap.forms || []) makeForm(env, f.title, f); for (const d of snap.docs || []) env.makeDoc(d.name, d.paras, d); for (const f of snap.folders || []) { const folder = env.makeFolder(f.name, f.id); for (const x of f.files) env.addFile(folder, { bytes: [], mime: x.mime, name: x.name }, x.id, !!x.trashed); } for (const t of snap.triggers || []) { const spec = t.spec.map((s) => s.map((a, i) => (i === 0 ? a : specArgIn(env, a)))); env.log.triggers.push(env.makeTrigger(t.fn, spec, t.id).t); const n = /^TRG(\d+)$/.exec(t.id || ''); if (n) env.trigSeq = Math.max(env.trigSeq, +n[1]); } env.menusBase = (snap.menus || []).map((m) => ({ name: m.name, items: m.items.map((i) => [i[0], i[1]]) })); for (const m of snap.mails || []) env.log.mails.push({ to: m.to, subject: m.subject, body: m.body, html: m.html, at: m.at }); env.seq = snap.seq || 0; return env; } return { createEnv, fromSnapshot, toSnapshot, formatDate, zonedTime, coerceInput, displayValue, a1, colName, colNum, LIMITS, MSG, NeedInput }; }); /* PR:HARNESS:BEGIN — 연습실 실행 틀(L2). Worker 안에서 gas-core 글과 `const __PR_JOB = …;` 줄 바로 뒤에 붙습니다. 이 뒤에 사용자 .gs 파일들이 이어 붙고, 마지막 줄 `__PR.finish();` 가 작업(kind)을 실행해 결과를 보냅니다. 하는 일(설계 §7.2): 외부로 나가는 길 막기(NET_OFF), 씨앗 난수, 고정 시계(FakeDate), 타이머·crypto 없애기, 모의 환경(PRCore) 전역 설치, 작업 실행, 결과 보내기. 예약어는 __PR, __PR_JOB 두 개입니다. 한계: 모의 환경이라 실제 Apps Script 동작을 보장하지 않습니다. [확인필요: 실제 계정 실행 미확인] */ const __PR = (function (G, job) { 'use strict'; const Core = G.PRCore; const MSG = Core.MSG; const MAX_SNAPSHOT = 5 * 1024 * 1024; const HINT_NOFN = '연습실 모의 환경에 없는 기능일 수 있습니다. 실제 시트에서 확인하세요.'; const HINT_BLOCKED = '실제 Apps Script 는 외부 주소를 부를 수 있지만, 연습실은 아무 데도 요청을 보내지 않도록 막아 두었습니다.'; const HINT_UNSUPPORTED = '연습실이 흉내 내는 기능 목록은 알림·면책 쪽에 있습니다.'; const HINT_EVAL = '연습실은 보안 때문에 글자를 코드로 바꿔 실행하는 기능을 쓰지 않습니다.'; // 사용자 코드가 __PR_JOB 을 바꿔도 이번 작업이 흔들리지 않도록 필요한 값만 먼저 꺼내 둡니다. const K = Object.freeze({ kind: String(job.kind), fn: job.fn === undefined ? undefined : String(job.fn), args: Array.isArray(job.args) ? job.args : [], event: job.event, triggerId: job.triggerId, formId: job.formId, answers: job.answers || {}, at: job.at, }); const gsFiles = (job.files || []).filter((f) => f && f.type !== 'html'); // 결과를 보내는 길은 이 틀만 가집니다(사용자 코드에서는 지웁니다). const send = G.postMessage.bind(G); const fnToString = Function.prototype.toString; const hasOwn = Object.prototype.hasOwnProperty; const listen = typeof G.addEventListener === 'function' ? G.addEventListener.bind(G) : null; const encoder = typeof G.TextEncoder === 'function' ? new G.TextEncoder() : null; let env = null; let sent = false; // 전역과 그 prototype 사슬에서 이름을 지웁니다. 지워지지 않으면 값을 덮습니다. function strip(name, value) { for (let o = G; o && o !== Object.prototype; o = Object.getPrototypeOf(o)) { const d = Object.getOwnPropertyDescriptor(o, name); if (d && d.configurable) { try { delete o[name]; } catch (e) { /* 아래에서 덮습니다 */ } } } if (value === undefined && !(name in G)) return true; try { Object.defineProperty(G, name, { value, writable: false, configurable: false, enumerable: false }); } catch (e) { try { G[name] = value; } catch (e2) { /* 확인에서 걸립니다 */ } } return G[name] === value; } function blockedError(service, method) { if (env) env.log.timeline.push({ k: 'blocked', service, method }); const e = new Error(MSG.BLOCKED); e.prBlocked = true; return e; } // 부르기·new·속성 읽기·쓰기 모두 차단 문구를 냅니다(함수로 부르든, new 로 만들든, 속성을 꺼내든). function blockedStub(name) { return new Proxy(function () {}, { apply() { throw blockedError(name, ''); }, construct() { throw blockedError(name, ''); }, get(t, k) { if (typeof k === 'symbol' || k === 'prototype') return undefined; throw blockedError(name, String(k)); }, set() { throw blockedError(name, ''); }, }); } const leaks = []; function off(name) { if (!strip(name, blockedStub(name))) leaks.push(name); } // ---------- 1. 외부로 나가는 길 막기 ---------- strip('postMessage', undefined); // PR:NET_OFF:BEGIN off('fetch'); off('XMLHttpRequest'); off('WebSocket'); off('WebSocketStream'); off('EventSource'); off('importScripts'); off('WebTransport'); off('BroadcastChannel'); off('indexedDB'); off('caches'); off('navigator'); off('Worker'); off('SharedWorker'); // PR:NET_OFF:END // ---------- 2. 씨앗 난수(같은 작업이면 같은 값: 재실행이 결정적) ---------- let seedState = ((job.seed >>> 0) ^ 0x9e3779b9) >>> 0; Math.random = function random() { seedState = (seedState + 0x6d2b79f5) >>> 0; let t = seedState; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; // ---------- 3. 타이머·crypto 없애기(Apps Script 서버에도 없습니다) ---------- for (const n of ['setTimeout', 'setInterval', 'clearTimeout', 'clearInterval', 'crypto']) strip(n, undefined); function post(msg) { if (sent) return; sent = true; send(msg); } function fail(name, message) { post({ t: 'done', result: { ok: false, kind: K.kind, fn: K.fn, ms: 0, error: { name, message }, snapshot: null, timeline: [] } }); } if (leaks.length) { fail('SecurityError', '이 브라우저에서는 외부 요청을 안전하게 막지 못해 실행하지 않았습니다: ' + leaks.join(', ')); throw new Error('PR_NET_OFF'); } // ---------- 4. 모의 환경 만들고 전역에 설치 ---------- try { const opts = { now: job.now, seed: job.seed, ownerEmail: job.ownerEmail || 'practice@example.com', webUrl: job.webUrl || '', practice: true }; if (job.activeSheet) opts.activeSheet = job.activeSheet; env = job.snapshot ? Core.fromSnapshot(job.snapshot, opts) : Core.createEnv({ ...opts, sheetTz: 'Asia/Seoul' }); env.ui.enabled = !!job.uiEnabled; env.ui.queue = (job.uiAnswers || []).map((a) => ({ button: String(a.button), text: a.text === undefined ? undefined : String(a.text) })); for (const f of job.files || []) if (f && f.type === 'html') env.htmlFiles[f.name] = String(f.text); } catch (e) { fail('Error', '연습 상태를 읽지 못했습니다: ' + (e && e.message)); throw new Error('PR_ENV'); } strip('PRCore', undefined); const globals = env.globals; for (const k of Object.keys(globals)) { try { G[k] = globals[k]; } catch (e) { Object.defineProperty(G, k, { value: globals[k], writable: true, configurable: true }); } } // ---------- 사용자 함수 찾기 ---------- const isNative = (f) => { try { return /\{\s*\[native code\]\s*\}\s*$/.test(fnToString.call(f)); } catch (e) { return true; } }; function userFn(name) { if (typeof name !== 'string' || !name || name === '__PR' || name === '__PR_JOB' || hasOwn.call(globals, name)) return null; const f = G[name]; return typeof f === 'function' && !isNative(f) ? f : null; } // 최상위 function 선언(이름이 _ 로 끝나지 않는 것)을 파일 순서·줄 순서대로. 안쪽 함수는 전역에 없어서 빠집니다. function listFunctions() { const out = []; for (const f of gsFiles) { const re = /\bfunction\s*\*?\s*([A-Za-z_$][\w$]*)\s*\(/g; let m; while ((m = re.exec(String(f.text)))) { const n = m[1]; if (!/_$/.test(n) && out.indexOf(n) < 0 && userFn(n)) out.push(n); } } return out; } function call(name, args) { const f = userFn(name); if (!f) throw new Error('실행할 함수를 찾을 수 없습니다: ' + name + ' (저장했는지, 이름이 맞는지 보세요)'); return f.apply(G, args || []); } const jsonCopy = (v) => { if (v === undefined) return undefined; try { return JSON.parse(JSON.stringify(v)); } catch (e) { return null; } }; // ---------- 오류 정보 ---------- function stackLines(e, lineno) { const out = []; const loc = G.location; const url = loc && typeof loc.href === 'string' ? loc.href : ''; const stack = e && typeof e.stack === 'string' ? e.stack : ''; for (const ln of stack.split('\n')) { const at = url ? ln.lastIndexOf(url) : -1; const tail = at >= 0 ? ln.slice(at + url.length) : (url ? '' : ln); const m = /:(\d+)(?::\d+)?\)?\s*$/.exec(tail); if (m) out.push(Number(m[1])); } if (lineno) out.push(Number(lineno)); return out; } function errInfo(e, lineno) { const name = e && typeof e === 'object' && e.name ? String(e.name) : 'Error'; const message = e && typeof e === 'object' && 'message' in e ? String(e.message) : String(e); const o = { name, message, lines: stackLines(e, lineno) }; if (e && e.prBlocked || message === MSG.BLOCKED) o.hint = HINT_BLOCKED; else if (message === MSG.UNSUPPORTED) o.hint = HINT_UNSUPPORTED; else if (/is not a function/.test(message)) o.hint = HINT_NOFN; else if (name === 'EvalError' || /unsafe-eval/.test(message)) o.hint = HINT_EVAL; return o; } // ---------- 결과 보내기 ---------- function snapshotOrNull() { if (K.kind === 'parse') return { snap: null }; const snap = Core.toSnapshot(env); const json = JSON.stringify(snap); const bytes = encoder ? encoder.encode(json).length : json.length * 3; if (bytes > MAX_SNAPSHOT) return { snap: null, big: true }; return { snap }; } function done(ok, error, extra) { if (sent) return; if (env.needInput) { const n = env.needInput; post({ t: 'needInput', kind: n.kind, title: n.title, text: n.text, buttons: n.buttons, timeline: env.log.timeline.slice() }); return; } const result = { ok, kind: K.kind, fn: K.fn, ms: 0, snapshot: null, timeline: env.log.timeline.slice() }; Object.assign(result, extra || {}); if (error) result.error = error; try { const s = snapshotOrNull(); result.snapshot = s.snap; if (s.big) { result.ok = false; result.error = { name: 'LimitError', message: '실행 결과가 너무 큽니다(5MB). 이번 실행의 변경은 버렸습니다.' }; } } catch (e) { result.ok = false; result.error = errInfo(e); } post({ t: 'done', result }); } // 최상위 코드의 오류(예: 첫 줄의 SpreadsheetApp.openById 실패)는 여기로 옵니다. 문법 오류는 바깥(host)이 받습니다. if (listen) { listen('error', function (ev) { if (sent) return; if (ev && typeof ev.preventDefault === 'function') ev.preventDefault(); const e = ev && ev.error !== undefined ? ev.error : { name: 'Error', message: String(ev && ev.message) }; if (K.kind === 'parse' && !env.needInput) { done(true, null, { functions: listFunctions() }); return; } done(false, errInfo(e, ev && ev.lineno)); }); } function runKind() { switch (K.kind) { case 'parse': return { functions: listFunctions() }; case 'run': case 'menu': call(K.fn, []); return {}; case 'open': env.menusBase = []; if (userFn('onOpen')) call('onOpen', [{ source: env.active }]); return {}; case 'trigger': { const t = env.log.triggers.find((x) => x.id && x.id === K.triggerId); const fn = t ? t.fn : K.fn; if (!fn) throw new Error('트리거를 찾을 수 없습니다: ' + K.triggerId); call(fn, [K.event === undefined ? {} : K.event]); return { fn }; } case 'formSubmit': return formSubmit(); case 'doGet': { const ev = K.event || { parameter: {}, parameters: {}, queryString: '', contextPath: '', contentLength: -1 }; const out = call('doGet', [ev]); if (!out || typeof out.getContent !== 'function') throw new Error('doGet 이 화면(HtmlOutput)을 돌려주지 않았습니다. return HtmlService.createHtmlOutput(…) 처럼 돌려주세요.'); return { html: { content: String(out.getContent()), title: String(out.title || ''), xframe: String(out.xframe || 'DEFAULT') } }; } case 'rpc': if (/_$/.test(String(K.fn))) throw new Error('이름이 _ 로 끝나는 함수는 웹 화면에서 부를 수 없습니다: ' + K.fn); return { value: jsonCopy(call(K.fn, K.args)) }; default: throw new Error('알 수 없는 작업입니다: ' + K.kind); } } // 폼 제출: 응답을 더하고, 그 폼에 건 form 트리거와 폼 대상 시트에 건 sheet 트리거를 차례로 돌립니다(이벤트 모양은 mock events()). function formSubmit() { const form = env.forms.get(K.formId); if (!form) throw new Error('폼을 찾을 수 없습니다: ' + K.formId); if (form.accepting === false) throw new Error('이 폼은 지금 응답을 받지 않습니다.'); const ev = env.events(form, K.answers, { at: K.at }); const resp = env.addResponse(form, K.answers, { at: K.at }); const extra = { value: { responseId: resp.id } }; for (const t of env.log.triggers.slice()) { const keys = t.spec.map((s) => s[0]); if (keys.indexOf('onFormSubmit') < 0) continue; const arg = (k) => { const s = t.spec.find((x) => x[0] === k); return s ? s[1] : null; }; let e = null; if (keys.indexOf('forForm') >= 0) { const f = arg('forForm'); if (f === form || (f && f.id === form.id)) e = ev.form; } else if (keys.indexOf('forSpreadsheet') >= 0) { const s = arg('forSpreadsheet'), id = s && typeof s.getId === 'function' ? s.getId() : s && s.id; if (form.dest && id === form.dest.id) e = ev.sheet; } if (!e) continue; try { call(t.fn, [e]); } catch (err) { if (env.needInput) throw err; if (!extra.fail) extra.fail = err; env.log.timeline.push({ k: 'log', level: 'error', text: t.fn + ': ' + (err && err.message !== undefined ? err.message : String(err)) }); } } return extra; } function finish() { if (sent) return; let extra; try { extra = runKind() || {}; } catch (e) { if (K.kind === 'parse' && !env.needInput) { done(true, null, { functions: listFunctions() }); return; } done(false, errInfo(e)); return; } const failErr = extra.fail; delete extra.fail; done(!failErr, failErr ? errInfo(failErr) : null, extra); } return Object.freeze({ finish }); })(this, __PR_JOB);
1 회원01 그룹 수업 8 2026-12-20 활성 2 회원02 그룹 수업 3 2026-11-30 활성 3 회원03 개인 수업 1 2026-11-30 활성 4 회원04 그룹 수업 12 2027-01-15 활성 5 회원05 그룹 수업 0 2026-11-25 활성 6 회원06 개인 수업 6 2026-12-20 활성 7 회원07 그룹 수업 10 2027-01-15 활성 8 회원08 개인 수업 4 2026-12-20 활성 9 회원09 그룹 수업 5 2026-11-25 활성 10 회원10 그룹 수업 9 2027-01-15 활성 11 회원11 개인 수업 7 2026-12-20 활성 12 회원12 그룹 수업 11 2027-01-15 활성 13 회원13 그룹 수업 2 2026-12-20 활성 14 회원14 개인 수업 12 2027-01-15 활성 15 회원15 그룹 수업 6 2026-12-20 활성 16 회원16 그룹 수업 4 2026-12-20 휴면 17 회원17 그룹 수업 8 2027-01-15 활성 18 회원18 개인 수업 3 2026-12-20 활성 19 회원19 그룹 수업 10 2027-01-15 활성 20 회원20 그룹 수업 5 2026-12-20 활성
회원번호 부르는 이름 상품명 남은 횟수 만료일 상태