연습 화면입니다. 실제 구글 화면이 아니며 아무 데도 보내지 않습니다.
할 일로 건너뛰기
← 연습 목록
1-1
첫 설문 링크 받기(앞부분)
책 1-1 · 21쪽
0/4
처음부터
주소창
연습용 시트
· 알림
연습용 코드 편집기
폰 미리보기
주소창(연습)
이동
브라우저에서 새 탭을 연 모습입니다. 연습실에서는 sheets.new 만 열리고, 실제 주소로는 나가지 않습니다.
실제 이름·연락처를 넣지 마세요(이 기기에만 저장됩니다).
{"id":"edu/1-1","book":"edu","chapter":"1-1","title":"첫 설문 링크 받기(앞부분)","minutes":5,"anchor":"1-1","webappSlug":"start","assets":{"code":{"survey.gs":"examples/survey.gs"}},"start":[{"a":"panel","p":"address"}],"steps":[{"id":"address","do":"주소창(연습)에 sheets.new 를 입력하고 Enter를 누릅니다.","ok":"빈 스프레드시트가 열리면 성공입니다.","anchor":"1-1-h2-2","bookStep":"① 2","check":[{"t":"address"},{"t":"panel","eq":"sheet"}],"msg":{"1":"아직 시트 창이 열리지 않았습니다. 주소창에 sheets.new 를 입력하고 Enter 키를 눌러야 합니다."},"hints":["주소창(연습)은 화면 위쪽의 긴 입력 칸입니다. sheets.new 를 그대로 입력하고 Enter 키를 누르세요.","테두리가 둘러진 주소창을 누르고 sheets.new 를 입력한 뒤 Enter 키를 누릅니다."],"target":"addr-input","preset":[{"a":"panel","p":"address"}],"show":[{"a":"address","text":"sheets.new"}],"page":23},{"id":"rename","do":"왼쪽 위 파일 이름을 내-수업-설문으로 바꿉니다.","ok":"파일 이름이 바뀌면 성공입니다.","anchor":"1-1-h2-2","bookStep":"① 3","check":[{"t":"fileName","eq":"내-수업-설문"}],"msg":{"0":"파일 이름이 아직 “내-수업-설문”이 아닙니다. 이름 칸을 누르고 바꾼 뒤 Enter 키를 누르세요."},"hints":["시트 왼쪽 위의 파일 이름 칸을 눌러 지우고 내-수업-설문을 입력한 뒤 Enter 키를 누릅니다.","테두리가 둘러진 파일 이름 칸에 내-수업-설문을 입력합니다. 하이픈(-)이 들어갑니다."],"target":"file-name","preset":[{"a":"panel","p":"address"},{"a":"address","text":"sheets.new"}],"show":[{"a":"rename","name":"내-수업-설문"}],"page":23},{"id":"copy","do":"카드의 [코드 복사] 단추로 survey.gs 코드를 복사합니다(책에서는 예제 쪽의 코드 복사 단추).","ok":"복사되었다는 알림이 뜨면 성공입니다.","anchor":"1-1-h2-2","bookStep":"① 4","check":[{"t":"copied","id":"survey.gs"}],"hints":["이 카드 아래의 [코드 복사] 단추를 누릅니다. 눌렀을 때 복사했다는 알림이 나옵니다.","테두리가 둘러진 [코드 복사] 단추를 누르세요."],"target":"copy:survey.gs","preset":[{"a":"panel","p":"address"},{"a":"address","text":"sheets.new"},{"a":"rename","name":"내-수업-설문"}],"show":[{"a":"copy","id":"survey.gs"}],"copy":["survey.gs"],"page":23},{"id":"editor","do":"시트 위쪽 메뉴에서 확장 프로그램 → Apps Script를 누릅니다.","ok":"새 탭에 코드 편집기가 열리면 성공입니다(연습에서는 ‘연습용 코드 편집기’ 창 탭이 생깁니다).","anchor":"1-1-h2-3","bookStep":"② 1","check":[{"t":"panel","eq":"editor"}],"msg":{"0":"코드 편집기 창이 아직 열리지 않았습니다. 메뉴 확장 프로그램 → Apps Script를 누르세요."},"hints":["시트 위쪽 메뉴 줄의 확장 프로그램을 열면 Apps Script 항목이 있습니다.","테두리가 둘러진 메뉴 항목 Apps Script를 누릅니다."],"target":"menuitem:확장 프로그램/Apps Script","preset":[{"a":"panel","p":"address"},{"a":"address","text":"sheets.new"},{"a":"rename","name":"내-수업-설문"}],"show":[{"a":"openEditor"}],"page":25}],"end":{"did":["sheets.new 로 새 시트를 열었습니다(연습 화면).","파일 이름을 내-수업-설문으로 바꿨습니다.","survey.gs 코드를 복사하고 확장 프로그램 → Apps Script 로 편집기를 열었습니다."],"diff":["실제 화면은 이 연습 화면과 모양과 글이 다릅니다. 같은 뜻의 단추를 찾으세요.","실제로는 구글 계정에 로그인해 있어야 sheets.new 가 열립니다(책 1-1 ① 1).","연습 화면의 파일은 이 기기에만 저장되고, 실제 계정에는 아무것도 만들어지지 않습니다."],"next":"edu/2-1","note":"붙여 넣고 실행하는 것은 2-1 연습에서 그대로 이어집니다.","nextHref":"../../edu/2-1/","nextTitle":"2-1 수업 만족도 설문 만들기"},"page":21,"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);
/* 《AI로 일하는 교수》 예제 | 2-1장 수업 만족도 조사와 강의장 결과 화면 | survey.gs 라이선스: MIT (저자 변경 가능) 폼 만들기, 폼 제출 받기, [설정]·[분석] 탭, 요약 메일(숫자만), 강의장 결과 화면. AI를 부르지 않는 책판입니다. [확인필요: 실제 구글 계정 실행 미확인] 이 코드는 Node 모의 시험으로만 확인했습니다. 모의 시험은 실제 구글 동작을 보장하지 않습니다. 쓰는 법: 구글 시트 → 확장 프로그램 → Apps Script → 편집기의 글을 모두 지우고 이 파일 전체를 붙여 넣은 뒤 저장 → 함수 start 실행. 화면 글자(메뉴, 알림)는 서비스 업데이트로 달라질 수 있으니 같은 뜻을 찾으세요. AI에 넣지 않습니다: 이름·연락처 같은 개인정보, 건강정보, 비밀번호, API 키, 성적. 비밀값(PIN, 토큰)은 코드나 시트 칸이 아니라 스크립트 속성에만 둡니다. */ /* ===== 공통 도우미 ① 기본: 알림, 날짜(한국 시간), 시트 찾기, 잠금 ===== say_ : 편집기 실행 로그와 시트 알림창에 같이 알립니다(자동 실행 중에는 로그만 남습니다). ymd_ dayNo_ today_ : 날짜는 모두 한국 시간(Asia/Seoul)으로 자릅니다. UTC로 자르면 하루씩 어긋납니다. num_ : 빈칸은 null, 0은 0. "입력 안 함"과 "0"을 구분하는 읽기 도구입니다. withLock_ : 한꺼번에 들어오는 쓰기를 한 줄씩 차례로 처리합니다. */ var TZ = 'Asia/Seoul'; // 편집기의 ▶ 실행이 맨 처음 함수를 고르므로 start 를 가장 위에 둡니다: 시트 탭, 메뉴, 자동 실행을 한 번에 준비합니다. function start() { if (typeof setup !== 'function') { say_('이 코드에는 start 단계가 없습니다. 만들어진 함수를 직접 실행하세요.'); return; } setup(); if (typeof onOpen === 'function') onOpen(); } // 편집기 실행 로그와 시트 알림창에 같이 알립니다(자동 실행 중에는 알림창이 없어서 로그만 남습니다). function say_(msg) { Logger.log(msg); try { SpreadsheetApp.getUi().alert(msg); } catch (e) {} } function isDate_(v) { return Object.prototype.toString.call(v) === '[object Date]' && !isNaN(v.getTime()); } function ymd_(d) { return Utilities.formatDate(d, TZ, 'yyyy-MM-dd'); } function stamp_(d) { return Utilities.formatDate(d, TZ, 'yyyy-MM-dd HH:mm'); } function monthKey_(d) { return Utilities.formatDate(d, TZ, 'yyyy-MM'); } // 날짜 칸의 값(Date 또는 2026-03-02 같은 글자)을 '한국 날짜의 일 번호'로 바꿉니다. 비었거나 날짜가 아니면 null. function dayNo_(v) { if (v === '' || v == null) return null; var s = isDate_(v) ? ymd_(v) : String(v).trim(); var m = /^(\d{4})[-./]\s*(\d{1,2})[-./]\s*(\d{1,2})/.exec(s); if (!m) return null; var y = Number(m[1]), mo = Number(m[2]), d = Number(m[3]), t = Date.UTC(y, mo - 1, d), c = new Date(t); if (c.getUTCFullYear() !== y || c.getUTCMonth() !== mo - 1 || c.getUTCDate() !== d) return null; return t / 86400000; } function dayStr_(n) { var c = new Date(n * 86400000), p = function (x) { return (x < 10 ? '0' : '') + x; }; return c.getUTCFullYear() + '-' + p(c.getUTCMonth() + 1) + '-' + p(c.getUTCDate()); } function today_() { return dayNo_(new Date()); } // 칸의 값을 숫자로 읽습니다. 빈칸이나 숫자가 아닌 값은 null(입력 안 함), 0은 0입니다. function num_(v) { if (v === '' || v == null || typeof v === 'boolean' || isDate_(v)) return null; if (typeof v === 'number') return isFinite(v) ? v : null; var s = String(v).trim().replace(/,/g, ''); if (s === '') return null; var n = Number(s); return isFinite(n) ? n : null; } function ss_() { var p = PropertiesService.getScriptProperties(), id = p.getProperty('SS_ID'); if (id) return SpreadsheetApp.openById(id); var a = SpreadsheetApp.getActive(); if (!a) throw new Error('시트 메뉴에서 "처음 준비"를 먼저 실행하세요.'); p.setProperty('SS_ID', a.getId()); return a; } function sheet_(name, header) { var ss = ss_(), sh = ss.getSheetByName(name); if (!sh) { sh = ss.insertSheet(name); if (header) { sh.getRange(1, 1, 1, header.length).setValues([header]).setFontWeight('bold'); sh.setFrozenRows(1); } } return sh; } // 사람이 쓴 글을 시트에 넣을 때: = + - @ 로 시작하면 시트가 수식으로 계산하므로, 앞에 ' 를 붙여 글자 그대로 둡니다. function cell_(v) { return typeof v === 'string' && /^[=+\-@]/.test(v) ? "'" + v : v; } function row_(sh, arr) { sh.appendRow(arr.map(cell_)); } // 첫 열(col)의 값이 key와 같은 첫 데이터 줄 번호(2 이상)를 돌려줍니다. 없으면 0. function rowOf_(sh, col, key) { var n = sh.getLastRow() - 1; if (n < 1) return 0; var v = sh.getRange(2, col, n, 1).getValues(), k = String(key); for (var i = 0; i < n; i++) if (String(v[i][0]) === k) return i + 2; return 0; } function withLock_(ms, fn) { var lock = LockService.getScriptLock(); lock.waitLock(ms); try { return fn(); } finally { lock.releaseLock(); } } // 폼 제출 이벤트에서 답을 꺼냅니다. 폼에 건 트리거에는 e.response 만 있고, 시트에 건 트리거에만 e.namedValues 가 있습니다. 둘 다 받습니다. // 돌려주는 값: { 문항 제목: [답, ...] } (체크박스는 답이 여러 개일 수 있어 항상 배열) function answers_(e) { var out = {}; if (e && e.namedValues) { Object.keys(e.namedValues).forEach(function (k) { out[k] = [].concat(e.namedValues[k]).map(String); }); return out; } if (e && e.response) e.response.getItemResponses().forEach(function (r) { out[r.getItem().getTitle()] = [].concat(r.getResponse()).map(String); }); return out; } function first_(arr) { return arr && arr.length ? String(arr[0]).trim() : ''; } /* ===== 공통 도우미 ② 메일 ===== mail_ : 메일 한 통을 보냅니다. 받는 사람은 항상 코드가 정한 한 사람(내 메일)입니다. 이 책의 예제는 학생이나 신청자 모두에게 코드가 메일을 마음대로 보내지 않습니다(접수 확인처럼 본인이 직접 신청한 일에 대한 안내 한 통만 예외입니다). */ function mail_(to, subject, text) { var esc = function (x) { return String(x).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }; var clean = function (x) { return String(x).replace(/[\r\n]+/g, ' ').trim(); }; if (MailApp.getRemainingDailyQuota() < 1) throw new Error('오늘 보낼 수 있는 메일을 다 썼습니다. 내일 다시 하세요.'); var body = String(text).split('\n').map(function (l) { if (!l.trim()) return '<div style="height:10px"></div>'; return '<div>' + esc(l).replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" style="color:#1a1a1a">$1</a>') + '</div>'; }).join(''); var html = '<div style="max-width:560px;padding:8px 2px 28px;font:15px/1.75 \'Malgun Gothic\',\'Apple SD Gothic Neo\',sans-serif;color:#1a1a1a">' + '<div style="border-top:3px solid #1a1a1a;padding-top:14px;margin-bottom:16px;font-size:20px;line-height:1.35;font-weight:700">' + esc(clean(subject)) + '</div>' + body + '</div>'; MailApp.sendEmail(to, clean(subject), text, { htmlBody: html }); } /* ===== 공통 도우미 ④ 웹 화면 ===== page_ : 웹 화면 하나를 만듭니다. body에는 <script>까지 넣습니다. 외부 글꼴·외부 주소를 부르지 않습니다. esc_ : 화면에 글자를 끼워 넣을 때 < > & 를 막습니다. js_ : 화면 스크립트 안에 값을 넣을 때 씁니다(글자 속 < 가 스크립트를 끊지 못하게 합니다). STAGE_JS : 강의장 큰 화면의 전체화면 버튼(F 키). */ function esc_(x) { return String(x).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); } function js_(v) { return JSON.stringify(v).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026'); } var BASE_CSS = [ ':root{--bg:#f2f2f2;--fg:#1a1a1a;--mut:#6b6b6b;--line:rgba(26,26,26,.18);--red:#e54536;--slip:#fff}', '*{box-sizing:border-box}', 'body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.55 -apple-system,"Apple SD Gothic Neo","Malgun Gothic",sans-serif;word-break:keep-all}', 'main{max-width:560px;margin:0 auto;padding:20px 16px 48px}', 'h1{margin:.2em 0 .6em;font-size:24px;line-height:1.3}h2{margin:1.2em 0 .4em;font-size:17px}', 'p{margin:.4em 0}.mut{color:var(--mut);font-size:14px}', 'button{font:inherit;font-weight:600;line-height:1;padding:14px 18px;border:1px solid var(--fg);border-radius:2px;background:var(--fg);color:var(--bg);cursor:pointer}', 'button.ghost{background:transparent;color:var(--fg)}button:disabled{opacity:.4}', 'input,textarea,select{width:100%;font:inherit;line-height:1.4;padding:12px;border:1px solid var(--line);border-radius:2px;background:var(--slip);color:var(--fg)}', 'label{display:block;margin:12px 0 4px;font-weight:600}', '.card{background:var(--slip);border:1px solid var(--line);padding:14px 16px;margin:10px 0}', '.msg{margin:12px 0;padding:10px 12px;border-left:4px solid var(--red);background:var(--slip)}', '.ok{border-left-color:var(--fg)}table{border-collapse:collapse;width:100%}td,th{padding:8px;border-bottom:1px solid var(--line);text-align:left;font-size:15px}', '[hidden]{display:none!important}' ].join(''); function page_(title, body, opts) { opts = opts || {}; var css = opts.raw ? (opts.css || '') : BASE_CSS + (opts.css || ''); var inner = opts.raw ? body : '<main>' + body + '</main>'; var out = HtmlService.createHtmlOutput('<!doctype html><html lang="ko"><head><meta charset="utf-8"><title>' + esc_(title) + '</title><style>' + css + '</style></head><body>' + inner + '</body></html>') .setTitle(title).addMetaTag('viewport', 'width=device-width, initial-scale=1'); if (opts.embed) out.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL); return out; } function stageKit_() { if (typeof document === 'undefined' || !document.body || !document.createElement) return; var b = document.createElement('button'); b.type = 'button'; b.textContent = '전체화면'; b.className = 'fsbtn'; b.onclick = function () { var d = document.documentElement, req = d.requestFullscreen || d.webkitRequestFullscreen; if (document.fullscreenElement) { document.exitFullscreen(); return; } if (!req || document.fullscreenEnabled === false) { b.textContent = '키보드로: 윈도우 F11, 맥 Control + Command + F'; return; } try { var p = req.call(d); if (p && p.catch) p.catch(function () { b.textContent = '키보드로: 윈도우 F11, 맥 Control + Command + F'; }); } catch (e) {} }; document.body.appendChild(b); } var STAGE_JS = '<script>(' + stageKit_.toString() + ')();</script>'; var STAGE_CSS = '.fsbtn{position:fixed;right:12px;bottom:12px;z-index:30;padding:9px 12px;font-size:13px;opacity:.55;background:#161616;color:#f2f2f2;border:1px solid #f2f2f2}.fsbtn:hover{opacity:1}'; /* ===== 수업 만족도 조사: 폼 + [분석] 탭 + 요약 메일 + 강의장 결과 화면 ===== 설문은 익명으로 받으세요. 이름이나 학번을 묻지 않습니다. 이 책판에는 AI 호출이 없습니다: 저장, 평균, 1~5 분포, 좋았던 점 인용, 숫자 요약 메일만 돕니다. AI로 분석하고 싶으면 [분석] 탭의 글 두 열을 직접 복사해 AI 채팅에 붙입니다(개인정보가 없는지 먼저 눈으로 확인). */ function onOpen() { SpreadsheetApp.getUi().createMenu('설문 분석') .addItem('처음 준비', 'setup').addItem('지금 요약 메일', 'sendDigest').addItem('주소 보기', 'showUrls').addToUi(); } var Q_SAT = '오늘 수업 만족도', Q_GOOD = '좋았던 점', Q_WANT = '아쉬웠던 점이나 바라는 점'; var H_ANALYSIS = ['시간', '만족도(1~5)', '좋았던 점', '아쉬웠던 점/바라는 점', '숨김(X)']; function setup() { var ss = SpreadsheetApp.getActive(), p = PropertiesService.getScriptProperties(); p.setProperty('SS_ID', ss.getId()); ss.setSpreadsheetTimeZone(TZ); var s = ss.getSheetByName('설정'); if (!s) { s = ss.insertSheet('설정'); s.getRange('A1:B2').setValues([['요약 메일을 보낼 응답 수', 10], ['요약을 받을 메일 주소', Session.getEffectiveUser().getEmail()]]); s.setColumnWidth(1, 240); s.setColumnWidth(2, 320); } sheet_('분석', H_ANALYSIS); if (!p.getProperty('FORM_ID')) { var form = FormApp.create('수업 후 한 줄 설문'); form.setDescription('익명으로 받습니다. 이름이나 학번은 쓰지 마세요.'); form.setCollectEmail(false); // 익명 설문: 응답자 메일을 모으지 않습니다 form.addScaleItem().setTitle(Q_SAT).setBounds(1, 5).setLabels('아쉬웠다', '매우 좋았다').setRequired(true); form.addParagraphTextItem().setTitle(Q_GOOD); form.addParagraphTextItem().setTitle(Q_WANT); form.setDestination(FormApp.DestinationType.SPREADSHEET, ss.getId()); p.setProperty('FORM_ID', form.getId()); ScriptApp.newTrigger('onSurveySubmit').forForm(form).onFormSubmit().create(); } say_('준비 끝! 학생용 설문 주소: ' + FormApp.openById(p.getProperty('FORM_ID')).getPublishedUrl()); } function showUrls() { var id = PropertiesService.getScriptProperties().getProperty('FORM_ID'); var web = ScriptApp.getService().getUrl(); say_(id ? '학생용 설문 주소: ' + FormApp.openById(id).getPublishedUrl() + (web ? '\n강의장 결과 화면: ' + web : '\n강의장 결과 화면은 웹 앱으로 배포하면 생깁니다(배포 → 새 배포 → 웹 앱).') : '먼저 처음 준비를 실행하세요.'); } // 폼에 건 제출 트리거의 이벤트에는 e.response 만 있고, 시트에 건 트리거에만 e.namedValues 가 있습니다. answers_ 가 둘 다 받습니다. function onSurveySubmit(e) { var v = answers_(e), raw = num_(first_(v[Q_SAT])); var sat = raw != null && raw >= 1 && raw <= 5 && Math.floor(raw) === raw ? raw : ''; // 점수가 없으면 빈칸(0이 아닙니다) var good = first_(v[Q_GOOD]), want = first_(v[Q_WANT]); if (sat === '' && !good && !want) { Logger.log('빈 응답이라 기록하지 않았습니다.'); return; } withLock_(20000, function () { row_(sheet_('분석', H_ANALYSIS), [new Date(), sat, good, want, '']); }); var n = num_(ss_().getSheetByName('설정').getRange('B1').getValue()), every = n == null ? 10 : Math.max(3, n); var p = PropertiesService.getScriptProperties(); if (sheet_('분석', H_ANALYSIS).getLastRow() - 1 - Number(p.getProperty('SENT') || 0) >= every) sendDigest(true); } function mailTo_() { var m = String(ss_().getSheetByName('설정').getRange('B2').getValue()).trim(); return m || Session.getEffectiveUser().getEmail(); } // 받은 응답 가운데 아직 요약 메일에 담지 않은 것을 숫자로 요약해 내 메일로 보냅니다. 학생에게는 보내지 않습니다. function sendDigest(quiet) { var lock = LockService.getScriptLock(); if (!lock.tryLock(20000)) return; try { var p = PropertiesService.getScriptProperties(), sh = sheet_('분석', H_ANALYSIS), done = Number(p.getProperty('SENT') || 0), total = sh.getLastRow() - 1; if (total < done) { done = total; p.setProperty('SENT', String(total)); } // 줄을 지웠다면 센 값을 맞춥니다 if (total <= done) { if (quiet !== true) say_('새로 쌓인 응답이 없습니다.'); return; } var to = mailTo_(); if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(to)) { say_('요약을 받을 메일 주소를 확인하세요: [설정] 탭 B2 (' + to + ')'); return; } var rows = sh.getRange(2 + done, 1, total - done, 5).getValues(); var sats = rows.map(function (r) { return num_(r[1]); }).filter(function (x) { return x != null && x >= 1 && x <= 5; }); var avg = sats.length ? Math.round(sats.reduce(function (a, b) { return a + b; }, 0) / sats.length * 10) / 10 : '-'; var dist = [1, 2, 3, 4, 5].map(function (k) { return k + '점 ' + sats.filter(function (x) { return x === k; }).length; }).join(', '); var body = '새 응답 ' + rows.length + '개 (누적 ' + total + '개)\n평균 만족도: ' + avg + ' / 5\n만족도 분포: ' + dist + '\n점수 없이 글만 있는 응답: ' + (rows.length - sats.length) + '개\n\n글로 쓴 의견은 [분석] 탭에서 읽습니다. 이 메일에는 숫자만 담았습니다.'; mail_(to, '[수업 설문] 새 응답 ' + rows.length + '개 요약', body); p.setProperty('SENT', String(total)); } finally { lock.releaseLock(); } } // 강의장 결과 화면: 응답이 들어올 때마다 평균 링, 1~5 분포, 좋았던 점 인용이 움직입니다(6초마다 새 값). // 아쉬웠던 점은 화면에 내보내지 않습니다. 화면에 내고 싶지 않은 응답은 [분석] 탭 E열에 X를 적습니다. function doGet() { return page_('수업 반응', REPORT_HTML, { raw: true, css: REPORT_CSS + STAGE_CSS, embed: true }); } function getReport() { var cache = CacheService.getScriptCache(), hit = cache.get('report'); if (hit) return JSON.parse(hit); var sh = sheet_('분석', H_ANALYSIS), n = sh.getLastRow() - 1, rows = n > 0 ? sh.getRange(2, 1, n, 5).getValues() : []; var sat = [0, 0, 0, 0, 0], sum = 0, k = 0, quotes = []; rows.forEach(function (r) { var x = num_(r[1]); if (x != null && x >= 1 && x <= 5 && Math.floor(x) === x) { sat[x - 1]++; sum += x; k++; } var g = String(r[2]).replace(/\s+/g, ' ').trim(); if (g && String(r[4]).trim().toUpperCase() !== 'X') quotes.push(g.slice(0, 90)); }); var out = { n: rows.length, scored: k, avg: k ? Math.round(sum / k * 10) / 10 : null, sat: sat, quotes: quotes.slice(-8) }; cache.put('report', JSON.stringify(out), 5); return out; } var REPORT_CSS = [ ':root{--bg:#161616;--fg:#f2f2f2;--mut:#8e8e8e;--line:rgba(242,242,242,.16);--red:#e54536}', '*{box-sizing:border-box}html,body{height:100%}', 'body{margin:0;overflow:hidden;background:var(--bg);color:var(--fg);font:16px/1.5 -apple-system,"Apple SD Gothic Neo","Malgun Gothic",sans-serif;word-break:keep-all}', '.stage{height:100vh;display:grid;grid-template-rows:auto minmax(0,1fr) auto;gap:clamp(14px,3vh,34px);padding:clamp(14px,2.8vw,48px)}', '.live{display:flex;align-items:center;gap:.9em;font:700 clamp(10px,1vw,14px)/1 Arial,sans-serif;letter-spacing:.3em;color:var(--mut)}', '.live:before{content:"";width:.8em;height:.8em;background:var(--red)}', 'h1{margin:.6em 0 0;font-size:clamp(22px,3.6vw,56px);font-weight:800;letter-spacing:-.03em;line-height:1.15}', '.main{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.2fr);gap:clamp(14px,2.4vw,40px);min-height:0}', '.main>section{display:flex;flex-direction:column;min-height:0;border-top:1px solid var(--fg);padding-top:clamp(8px,1.2vw,16px)}', '.main h2{margin:0 0 clamp(8px,1.6vh,20px);font:700 clamp(10px,.9vw,13px)/1 Arial,sans-serif;letter-spacing:.24em;color:var(--mut)}', '.ring{position:relative;flex:1;min-height:0;display:flex;align-items:center;justify-content:center}', '.ring svg{height:100%;max-height:100%;aspect-ratio:1;transform:rotate(-90deg)}', '.ring circle{fill:none;stroke-width:12}.ring .trk{stroke:var(--line)}', '.ring .arc{stroke:var(--red);stroke-dasharray:527.8;stroke-dashoffset:527.8;transition:stroke-dashoffset 1.6s cubic-bezier(.2,.8,.2,1)}', '.ring .val{position:absolute;text-align:center;font:800 clamp(44px,8vw,128px)/.9 Arial,sans-serif;letter-spacing:-.05em}', '.ring .val small{display:block;margin-top:.5em;font:600 clamp(10px,1vw,15px)/1 sans-serif;letter-spacing:.14em;color:var(--mut)}', '.hist{flex:1;min-height:0;display:flex;align-items:flex-end;gap:clamp(4px,.8vw,12px)}', '.hist div{flex:1;height:100%;display:flex;flex-direction:column;justify-content:flex-end;align-items:center;gap:6px;font:700 clamp(10px,1vw,14px)/1 Arial,sans-serif;color:var(--mut)}', '.hist i{display:block;width:100%;height:0;background:#f2f2f2;transition:height 1.2s cubic-bezier(.2,.8,.2,1)}.hist i.top{background:var(--red)}', '.quote{min-height:3.4em;padding-left:clamp(12px,1.4vw,22px);border-left:4px solid var(--red);font-size:clamp(16px,2.4vw,38px);font-weight:700;letter-spacing:-.02em;line-height:1.3}', '.quote small{display:block;margin-bottom:.6em;font:700 clamp(10px,.9vw,13px)/1 Arial,sans-serif;letter-spacing:.24em;color:var(--mut)}', '.wait{color:var(--mut);font-size:clamp(16px,2.4vw,38px);font-weight:700}', '[hidden]{display:none!important}' ].join(''); function reportClient_() { var $ = function (s) { return document.querySelector(s); }; var qs = [], qi = 0, started = false; function mk(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; } function showQuote() { if (!qs.length) { $('#qt').textContent = ''; started = false; return; } $('#qt').textContent = '“' + qs[qi % qs.length] + '”'; qi++; setTimeout(showQuote, 6000); } function draw(d) { if (!d.n) { $('#wait').hidden = false; $('#main').hidden = true; return; } $('#wait').hidden = true; $('#main').hidden = false; $('#avg').textContent = d.avg == null ? '-' : d.avg.toFixed(1); $('#cnt').textContent = '응답 ' + d.n + '개'; $('#arc').style.strokeDashoffset = String(527.8 * (1 - (d.avg == null ? 0 : d.avg / 5))); var h = $('#hist'); h.textContent = ''; var mx = Math.max.apply(null, d.sat.concat([1])), top = d.sat.indexOf(Math.max.apply(null, d.sat)); d.sat.forEach(function (n, i) { var c = mk('div'), bar = mk('i', i === top && n ? 'top' : ''); bar.style.height = Math.round(n / mx * 86) + '%'; c.appendChild(mk('span', '', String(n))); c.appendChild(bar); c.appendChild(mk('span', '', String(i + 1))); h.appendChild(c); }); qs = d.quotes; if (!started && qs.length) { started = true; showQuote(); } } function tick() { google.script.run.withSuccessHandler(function (d) { draw(d); setTimeout(tick, 6000); }) .withFailureHandler(function () { setTimeout(tick, 10000); }).getReport(); } tick(); } var REPORT_HTML = '<div class="stage"><div><div class="live">LIVE · <span>수업 반응</span></div><h1>오늘 수업, 학생들은 이렇게 느꼈습니다</h1></div>' + '<div class="wait" id="wait">첫 번째 응답을 기다리고 있습니다. 설문 QR을 보여 주세요.</div>' + '<div class="main" id="main" hidden><section><h2>SATISFACTION</h2><div class="ring"><svg viewBox="0 0 200 200"><circle class="trk" cx="100" cy="100" r="84"/><circle class="arc" id="arc" cx="100" cy="100" r="84"/></svg><div class="val"><span id="avg">0.0</span><small id="cnt">응답 0개</small></div></div></section>' + '<section><h2>DISTRIBUTION</h2><div class="hist" id="hist"></div></section></div>' + '<div class="quote"><small>VOICE</small><span id="qt"></span></div></div>' + '<script>(' + reportClient_.toString() + ')();</script>' + STAGE_JS;