연습 화면입니다. 실제 구글 화면이 아니며 아무 데도 보내지 않습니다.
할 일로 건너뛰기
← 연습 목록
2-1
회원·수강권·출석 관리하기(앞부분)
책 2-1 · 93쪽
0/10
처음부터
주소창
연습용 시트
· 알림
연습용 코드 편집기
폰 미리보기
주소창(연습)
이동
브라우저에서 새 탭을 연 모습입니다. 연습실에서는 sheets.new 만 열리고, 실제 주소로는 나가지 않습니다.
실제 이름·연락처를 넣지 마세요(이 기기에만 저장됩니다).
{"id":"biz/2-1","book":"biz","chapter":"2-1","title":"회원·수강권·출석 관리하기(앞부분)","minutes":15,"anchor":"2-1","webappSlug":"member","assets":{"code":{"member.gs":"examples_biz/member.gs"},"data":{"sample_passes":{"src":"examples_biz/sample_passes.tsv","part":"body"},"sample_members":{"src":"examples_biz/sample_members.csv","part":"body"}}},"start":[{"a":"panel","p":"address"}],"steps":[{"id":"sheet","do":"주소창(연습)에 sheets.new 를 입력하고 Enter를 누른 뒤, 파일 이름을 내-센터-회원으로 바꿉니다.","ok":"빈 새 시트가 열리고 왼쪽 위 파일 이름이 내-센터-회원이면 성공입니다(실제 구글에서는 브라우저 탭 제목도 같이 바뀝니다).","anchor":"2-1-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":97},{"id":"copy","do":"카드의 [코드 복사] 단추로 member.gs 코드를 복사합니다(책에서는 예제 쪽의 코드 복사 단추).","ok":"복사됐다는 알림이 뜨면 성공입니다.","anchor":"2-1-h2-2","bookStep":"① 2","check":[{"t":"copied","id":"member.gs"}],"hints":["이 카드 아래의 [코드 복사] 단추를 누릅니다.","테두리가 둘러진 [코드 복사] 단추를 누르세요."],"target":"copy:member.gs","preset":[{"a":"panel","p":"address"},{"a":"address","text":"sheets.new"},{"a":"rename","name":"내-센터-회원"}],"show":[{"a":"copy","id":"member.gs"}],"copy":["member.gs"],"page":97},{"id":"editor","do":"시트 위쪽 메뉴에서 확장 프로그램 → Apps Script를 누릅니다.","ok":"새 탭에 코드 편집기가 열리면 성공입니다.","anchor":"2-1-h2-2","bookStep":"① 3","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":97},{"id":"paste","do":"편집기의 글을 모두 지우고(Ctrl+A, Delete) 복사한 코드를 붙여 넣은(Ctrl+V) 뒤 저장(Ctrl+S)합니다. 맥은 Ctrl 대신 ⌘를 씁니다.","ok":"편집기에 방금 붙인 코드만 남고 저장되어 있으면 성공입니다.","anchor":"2-1-h2-2","bookStep":"① 4","check":[{"t":"code","file":"Code.gs","sameAs":"member.gs"},{"t":"saved"}],"msg":{"0":"코드 칸의 글이 아직 member.gs와 같지 않습니다. 전체를 지운 뒤(Ctrl+A, Delete) 다시 붙여 넣으세요. 먼저 [코드 복사]를 눌렀는지도 보세요.","1":"아직 저장하지 않았습니다. 저장 단추를 누르세요."},"hints":["편집기 안을 누르고 Ctrl+A, Delete 순서로 기존 글을 모두 지운 뒤 Ctrl+V로 붙이고 Ctrl+S로 저장합니다.","테두리가 둘러진 코드 칸 안을 눌러 지우고 붙인 뒤, 저장 단추를 누르세요."],"target":"code","preset":[{"a":"panel","p":"address"},{"a":"address","text":"sheets.new"},{"a":"rename","name":"내-센터-회원"},{"a":"openEditor"}],"show":[{"a":"code","file":"Code.gs","src":"member.gs"},{"a":"save"}],"page":97},{"id":"run","do":"함수 칸에서 start를 골라 ▶ 실행을 누르고, 권한 검토 창에서 계정 선택 → 고급 → (안전하지 않음)으로 이동 → 허용 순서로 누릅니다.(창 제목에 1/4~4/4가 보입니다).","ok":"아래 실행 로그에 준비 끝이 보이면 성공입니다.","anchor":"2-1-h2-2","bookStep":"① 4·5","check":[{"t":"authorized"},{"t":"ran","fn":"start"},{"t":"log","includes":"준비 끝"}],"msg":{"0":"권한 검토 창에서 허용을 아직 누르지 않았습니다. 계정 선택 → 고급 → (안전하지 않음)으로 이동 → 허용 순서입니다.","2":"실행 로그에 “준비 끝”이 아직 없습니다. 시트 창에 알림이 떠 있으면 확인을 눌러야 실행이 이어집니다."},"hints":["함수 칸이 start인지 보고 ▶ 실행을 누르면 권한 검토 창이 뜹니다. 내가 붙여 넣은 코드이니 고급에서 이어 갑니다.","테두리가 둘러진 실행 단추를 누르세요. 권한 검토 창이 뜨면 안내대로 네 단계를 누릅니다."],"target":"run","preset":[{"a":"panel","p":"address"},{"a":"address","text":"sheets.new"},{"a":"rename","name":"내-센터-회원"},{"a":"openEditor"},{"a":"code","file":"Code.gs","src":"member.gs"},{"a":"save"}],"show":[{"a":"authorize"},{"a":"run","fn":"start"}],"page":97},{"id":"tabs","do":"시트로 돌아와 알림창의 확인을 누르고 아래 탭을 봅니다.","ok":"탭 일곱 개와 위쪽 메뉴 회원 관리가 보이면 성공입니다. 메뉴가 안 보이면 파일 → 다시 열기를 합니다.","anchor":"2-1-h2-2","bookStep":"① 6","check":[{"t":"menu","name":"회원 관리"},{"t":"tab","name":"설정"},{"t":"tab","name":"회원"},{"t":"tab","name":"수강권"},{"t":"tab","name":"출석"},{"t":"tab","name":"오늘 목록"},{"t":"tab","name":"월 요약"},{"t":"tab","name":"삭제 기록"}],"msg":{"0":"메뉴 “회원 관리”가 아직 보이지 않습니다. 시트를 연 채 코드를 붙였다면 파일 → 다시 열기를 누르거나 start를 다시 실행하세요."},"hints":["시트 창의 맨 위 메뉴 줄 끝에 회원 관리가 생기고, 아래 탭 줄에 설정·회원·수강권·출석·오늘 목록·월 요약·삭제 기록이 생깁니다.","테두리가 둘러진 ‘시트’ 창 탭으로 가서 알림창의 확인을 누르세요. 메뉴가 안 보이면 파일 → 다시 열기를 누릅니다."],"target":"wintab:sheet","preset":[{"a":"panel","p":"address"},{"a":"address","text":"sheets.new"},{"a":"rename","name":"내-센터-회원"},{"a":"openEditor"},{"a":"code","file":"Code.gs","src":"member.gs"},{"a":"save"},{"a":"authorize"},{"a":"run","fn":"start"}],"show":[{"a":"panel","p":"sheet"}],"page":97},{"id":"center","do":"[설정] 탭에서 센터 이름 줄의 값 칸(B2)을 ○○ 스튜디오로 바꿉니다.","ok":"값 칸에 ○○ 스튜디오가 보이면 성공입니다. 이 이름이 회원용 폼 제목과 출석 화면 맨 위에 보입니다.","anchor":"2-1-h2-2","bookStep":"① 6 뒤(설정)","check":[{"t":"cfg","tab":"설정","label":"센터 이름","eq":"○○ 스튜디오"}],"msg":{"0":"[설정] 탭의 센터 이름 값이 아직 “○○ 스튜디오”가 아닙니다. 처음 값(내 수업)을 지우고 ○○ 스튜디오를 입력하세요."},"hints":["아래 탭에서 설정을 열면 A열에 항목, B열에 값이 있습니다. 센터 이름 줄의 B칸을 눌러 ○○ 스튜디오를 입력합니다.","테두리가 둘러진 [설정] 탭을 열고 센터 이름 줄의 값 칸에 ○○ 스튜디오를 입력한 뒤 Enter 키를 누르세요."],"target":"tab:설정","preset":[{"a":"panel","p":"address"},{"a":"address","text":"sheets.new"},{"a":"rename","name":"내-센터-회원"},{"a":"openEditor"},{"a":"code","file":"Code.gs","src":"member.gs"},{"a":"save"},{"a":"authorize"},{"a":"run","fn":"start"},{"a":"panel","p":"sheet"}],"show":[{"a":"set","tab":"설정","at":"B2","value":"○○ 스튜디오"}],"page":97},{"id":"passes","do":"카드의 [복사]로 수강권 표(가상 3줄)를 복사해 [수강권] 탭 A2 칸에 붙여 넣습니다.","ok":"세 줄이 들어가고 종류 칸에 횟수와 기간이 보이면 성공입니다.","anchor":"2-1-h2-3","bookStep":"② 1","check":[{"t":"lastRow","tab":"수강권","eq":4},{"t":"rowsInclude","tab":"수강권","rows":[{"D":"횟수"},{"D":"기간"}]}],"msg":{"0":"[수강권] 탭의 마지막 줄이 4행이 아닙니다. 머리줄(1행) 아래 A2에 세 줄을 붙여 넣었는지 보세요.","1":"[수강권] 탭 D열(종류)에 횟수와 기간이 아직 보이지 않습니다. A2 칸에 붙여 넣었는지 보세요."},"hints":["첫 줄은 15회권, 둘째 줄은 기간만 있는 수강권, 셋째 줄은 총 횟수를 비운 줄입니다. [수강권] 탭의 A2 칸에 붙여 넣습니다.","테두리가 둘러진 [복사] 단추를 누르고, [수강권] 탭 A2 칸을 눌러 붙여 넣으세요."],"target":"copy:sample_passes","preset":[{"a":"panel","p":"address"},{"a":"address","text":"sheets.new"},{"a":"rename","name":"내-센터-회원"},{"a":"openEditor"},{"a":"code","file":"Code.gs","src":"member.gs"},{"a":"save"},{"a":"authorize"},{"a":"run","fn":"start"},{"a":"panel","p":"sheet"},{"a":"set","tab":"설정","at":"B2","value":"○○ 스튜디오"}],"show":[{"a":"paste","tab":"수강권","at":"A2","data":"sample_passes"}],"copy":["sample_passes"],"page":103},{"id":"members","do":"카드의 [복사]로 가짜 회원 20명 표를 복사해 [회원] 탭 A2 칸에 붙여 넣습니다.","ok":"회원01~회원20 스무 줄이 들어가고 출석 토큰 칸이 비어 있으면 성공입니다.","anchor":"2-1-h2-3","bookStep":"② 2","check":[{"t":"lastRow","tab":"회원","eq":21},{"t":"colEmpty","tab":"회원","col":"K","from":2,"to":21}],"msg":{"0":"[회원] 탭의 마지막 줄이 21행이 아닙니다. 머리줄(1행) 아래 A2에 스무 줄을 붙여 넣었는지 보세요.","1":"[회원] 탭 K열(출석 토큰) 2~21행이 비어 있지 않습니다. 이 단계에서는 토큰 칸이 비어 있어야 합니다."},"hints":["표는 머리줄이 없고 12열입니다. [회원] 탭의 A2 칸에 붙여 넣습니다. 회원16은 휴면, 회원19·회원20은 동의 칸이 비어 있도록 만든 줄입니다.","테두리가 둘러진 [복사] 단추를 누르고, [회원] 탭 A2 칸을 눌러 붙여 넣으세요."],"target":"copy:sample_members","preset":[{"a":"panel","p":"address"},{"a":"address","text":"sheets.new"},{"a":"rename","name":"내-센터-회원"},{"a":"openEditor"},{"a":"code","file":"Code.gs","src":"member.gs"},{"a":"save"},{"a":"authorize"},{"a":"run","fn":"start"},{"a":"panel","p":"sheet"},{"a":"set","tab":"설정","at":"B2","value":"○○ 스튜디오"},{"a":"paste","tab":"수강권","at":"A2","data":"sample_passes"}],"show":[{"a":"paste","tab":"회원","at":"A2","data":"sample_members"}],"copy":["sample_members"],"page":103},{"id":"tokens","do":"메뉴 회원 관리 → 출석 링크 만들기를 누릅니다.","ok":"“출석 링크를 20개 새로 만들었습니다.”가 보이고 [회원] 탭의 출석 토큰 칸이 채워지면 성공입니다.","anchor":"2-1-h2-3","bookStep":"② 3","check":[{"t":"alert","startsWith":"출석 링크를 20개 새로 만들었습니다"},{"t":"colFilled","tab":"회원","col":"K","from":2,"to":21}],"msg":{"0":"“출석 링크를 20개 새로 만들었습니다”로 시작하는 알림이 아직 없습니다. 메뉴 회원 관리 → 출석 링크 만들기를 누르세요.","1":"[회원] 탭 K열(출석 토큰) 2~21행에 빈 칸이 있습니다. 가짜 회원 20명이 A2부터 들어 있는지 보세요."},"hints":["시트 위쪽 메뉴 줄의 회원 관리를 열면 출석 링크 만들기가 있습니다. 권한 창이 또 뜨면 허용 순서대로 누릅니다.","테두리가 둘러진 메뉴 항목 출석 링크 만들기를 누르세요."],"target":"menuitem:회원 관리/출석 링크 만들기","preset":[{"a":"panel","p":"address"},{"a":"address","text":"sheets.new"},{"a":"rename","name":"내-센터-회원"},{"a":"openEditor"},{"a":"code","file":"Code.gs","src":"member.gs"},{"a":"save"},{"a":"authorize"},{"a":"run","fn":"start"},{"a":"panel","p":"sheet"},{"a":"set","tab":"설정","at":"B2","value":"○○ 스튜디오"},{"a":"paste","tab":"수강권","at":"A2","data":"sample_passes"},{"a":"paste","tab":"회원","at":"A2","data":"sample_members"}],"show":[{"a":"menu","fn":"makeTokens"}],"page":103}],"end":{"did":["member.gs를 붙여 넣고 start를 실행해 일곱 탭과 메뉴 회원 관리를 만들었습니다.","[설정] 센터 이름, [수강권] 가상 3줄, [회원] 가짜 회원 20명을 넣었습니다.","출석 링크 만들기로 회원마다 임의 글자(토큰)를 만들었습니다."],"diff":["실제 화면은 모양과 글이 다릅니다. 같은 뜻의 단추를 찾으세요.","출석 링크 주소는 웹 앱으로 배포한 뒤에 다시 실행해야 [회원] 탭에 채워집니다(책 2-1 ② 3, ④).","실제 회원 명단은 꼭 필요한 항목만 받습니다(책 2-1 ‘만들기 전에 정하는 것’). 연습 화면에는 가상 자료만 넣으세요."],"note":"나머지 단계(등록 폼·웹 앱 배포·출석 링크 카드·데스크 화면)는 책을 보며 실제 시트에서 이어 갑니다."},"page":93,"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로 일하는 원장》(가칭) 예제 | 회원·수강권·출석 관리 | member.gs 라이선스: MIT (저자 변경 가능) 회원·수강권·출석을 시트 하나로 관리하는 센터용 기본 파일입니다. 마케팅 수신 동의를 한 회원만 재등록 안내 대상 목록에 오르며, 메일·문자는 자동으로 보내지 않습니다. pass.gs(휴회), slot.gs(수업 예약), dash.gs(월 매출·출석), today.gs(오늘 운영 화면)는 같은 프로젝트에 파일로 더하면 이 파일의 고리(메뉴·화면·만료일 계산)로 이어지고, 더하지 않으면 이 파일만으로 이전과 똑같이 돕니다. [확인필요: 실제 구글 계정 실행 미확인] 이 코드는 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() : ''; } /* ===== 공통 도우미 ③ PIN과 연타 막기 ===== pin_ : 4자리 숫자 PIN을 스크립트 속성에 둡니다. 시트 칸이나 코드에는 적지 않습니다. 없으면 만듭니다. auth_ : PIN 확인. 맞으면 6시간짜리 화면용 표를 내줍니다. 10분에 10번 틀리면 잠깁니다. throttle_ : key 하나당 sec초 안에 max번까지만 true. menuOnly_ : 시트 메뉴(편집기)에서만 돌아야 하는 함수 맨 앞에 둡니다. */ function pin_(name) { var p = PropertiesService.getScriptProperties(), k = 'PIN_' + name, v = String(p.getProperty(k) || ''); if (!/^\d{4}$/.test(v)) { v = String(1000 + Math.floor(Math.random() * 9000)); p.setProperty(k, v); } return v; } // cred는 PIN 4자리, 또는 PIN이 맞았을 때 내준 화면용 표입니다. 세는 일은 잠금 안에서 합니다. function auth_(cred, name) { cred = String(cred || '').trim(); var cache = CacheService.getScriptCache(); if (/^t[0-9a-f]{24,}$/.test(cred)) return cache.get('tok:' + name + ':' + cred) ? { ok: true, token: cred } : { ok: false, expired: true, msg: '화면을 연 지 오래되었습니다. PIN을 다시 넣어 주세요.' }; return withLock_(10000, function () { var key = 'pinfail:' + name, fails = Number(cache.get(key) || 0); if (fails >= 10) return { ok: false, locked: true, msg: 'PIN을 여러 번 틀렸습니다. 10분 뒤에 다시 하세요.' }; if (/^\d{4}$/.test(cred) && cred === pin_(name)) { var t = 't' + Utilities.getUuid().replace(/-/g, ''); cache.put('tok:' + name + ':' + t, '1', 21600); return { ok: true, token: t }; } cache.put(key, String(fails + 1), 600); return { ok: false, msg: 'PIN이 맞지 않습니다.' }; }); } function throttle_(key, max, sec) { var cache = CacheService.getScriptCache(), n = Number(cache.get('t:' + key) || 0); if (n >= max) return false; cache.put('t:' + key, String(n + 1), sec); return true; } function menuOnly_() { try { SpreadsheetApp.getUi(); } catch (e) { throw new Error('시트 메뉴에서만 실행할 수 있습니다.'); } } /* ===== 공통 도우미 ④ 웹 화면 ===== 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}'; /* ===== 회원 관리: 회원·수강권·출석을 시트 하나로 ===== 시트 탭 7개: 설정 / 회원 / 수강권 / 출석 / 오늘 목록 / 월 요약 / 삭제 기록 받지 않는 것: 건강·통증·병력, 주민번호·생년월일, 사진, 카드 정보. 연락 수단은 비워 두어도 됩니다. 하지 않는 것: 회원에게 메일이나 문자를 코드가 보내는 일은 없습니다. 목록만 만들고, 보내는 일은 사람이 합니다. 기준 숫자(임박 기준, 장기 미출석 일수, 보유기간)는 코드에 없고 [설정] 탭 값만 씁니다. 센터마다 다르므로 직접 정하세요. [확인필요: 센터 운영 기준 확인] */ var T = { cfg: '설정', member: '회원', rule: '수강권', att: '출석', today: '오늘 목록', month: '월 요약', erased: '삭제 기록' }; var H_CFG = ['항목', '값', '설명']; var H_MEMBER = ['회원번호', '부르는 이름', '연락 수단(선택)', '수업 목표 한 줄', '등록일', '상태', '개인정보 동의 시각', '동의 문구 판', '마케팅 수신 동의 시각', '보유 기한', '출석 토큰', '출석 링크']; var H_RULE = ['발급일', '회원번호', '상품명', '종류', '총 횟수', '시작일', '만료일', '금액(선택)', '메모']; var H_ATT = ['시각', '회원번호', '방법', '수업명(선택)', '차감', '처리']; var H_TODAY = ['갱신 시각', '회원번호', '부르는 이름', '남은 횟수', '만료일', '마지막 출석', '미출석 일수', '사유', '재등록 안내 가능']; var H_MONTH = ['월', '출석 수', '출석 회원 수', '신규 등록', '재등록', '금액 합계']; var H_ERASED = ['삭제일', '회원번호', '사유', '처리']; var K = { name: '센터 이름', soon: '만료 임박 일수', low: '잔여 적음 기준(남은 횟수)', gone: '장기 미출석 일수', dup: '출석 중복 막기(분)', deduct: '출석 시 차감', ver: '동의 문구 판', keep: '보유기간(개월)' }; // [설정] 첫 칸에 넣는 값입니다. 기준 숫자 셋은 센터마다 달라서 비워 둡니다: 직접 적기 전까지 그 규칙은 쓰지 않습니다. 계산은 시트에 적힌 값만 읽습니다. var SEED = [ [K.name, '내 수업', '출석 화면 맨 위에 보이는 이름입니다.'], [K.soon, '', '센터 규정에 맞게 직접 적습니다. 만료일까지 남은 날이 이 값 이하이면 목록에 올립니다. 비워 두면 이 규칙은 쓰지 않습니다. [확인필요: 센터 운영 기준 확인]'], [K.low, '', '센터 규정에 맞게 직접 적습니다. 남은 횟수가 이 값 이하이면 목록에 올립니다. 비워 두면 이 규칙은 쓰지 않습니다. [확인필요: 센터 운영 기준 확인]'], [K.gone, '', '센터 규정에 맞게 직접 적습니다. 마지막 출석 뒤 이 일수 이상 오지 않으면 목록에 올립니다. 비워 두면 이 규칙은 쓰지 않습니다. [확인필요: 센터 운영 기준 확인]'], [K.dup, 60, '같은 회원이 이 분 안에 다시 누르면 출석 줄을 새로 만들지 않습니다. 비우면 막지 않습니다.'], [K.deduct, '예', '예이면 출석 한 번에 남은 횟수가 1 줄어듭니다.'], [K.ver, 'v1', '개인정보 동의 문구를 바꾸면 판 번호도 바꿉니다.'], [K.keep, '', '기관이 정한 보유기간(개월)을 적습니다. 비어 있으면 보유 기한을 계산하지 않습니다. [확인필요: 센터·기관 기준 확인]'] ]; var Q = { name: '부르는 이름', contact: '연락 수단(선택)', goal: '수업 목표 한 줄', consent: '개인정보 수집·이용에 동의합니다(필수)', notice: '마케팅 수신에 동의합니다(선택)' }; var AGREE = '동의합니다', DISAGREE = '동의하지 않습니다'; var S_ACTIVE = '활성', S_SLEEP = '휴면', S_DEL = '삭제 요청'; var KIND_COUNT = '횟수', KIND_PERIOD = '기간'; // 목록 사유 문구는 이 셋으로 고정입니다. 동의 미제출 = 개인정보 동의 시각이 비어 있는 회원(등록 서류를 아직 내지 않음). 이 회원은 출석도 기록되지 않습니다. var R_LIMIT = '만료 임박', R_GONE = '장기 미출석', R_NOFORM = '동의 미제출'; var WHY_KEEP = '보유기간', WHY_ASK = '본인 요청'; var ATT_FMT = 'yyyy-MM-dd HH:mm'; function onOpen() { var menu = SpreadsheetApp.getUi().createMenu('회원 관리') .addItem('처음 준비', 'setup').addItem('등록 폼 만들기', 'makeRegisterForm').addItem('출석 링크 만들기', 'makeTokens') .addItem('출석 링크 바꾸기', 'menuResetToken').addItem('오늘 목록 다시 만들기', 'rebuildToday').addItem('재등록 안내 대상 보기', 'showNoticeTargets') .addItem('월 요약', 'rebuildMonthly').addItem('매일 자동 켜기', 'enableDaily').addItem('매일 자동 끄기', 'disableDaily') .addItem('보유기간 지난 회원 보기', 'showExpired').addItem('보유기간 지난 회원 삭제', 'purgeExpired').addItem('삭제 요청 처리', 'processDeletion') .addItem('주소와 데스크 PIN 보기', 'showUrls'); if (typeof dashMenu_ === 'function') dashMenu_(menu); if (typeof passMenu_ === 'function') passMenu_(menu); if (typeof slotMenu_ === 'function') slotMenu_(menu); if (typeof todayMenu_ === 'function') todayMenu_(menu); menu.addToUi(); } function setup() { var ss = SpreadsheetApp.getActive(), p = PropertiesService.getScriptProperties(); p.setProperty('SS_ID', ss.getId()); ss.setSpreadsheetTimeZone(TZ); // 날짜 칸이 한국 날짜로 읽히게 시트 시간대를 맞춥니다 if (!ss.getSheetByName(T.cfg)) { var s = sheet_(T.cfg, H_CFG); s.getRange(2, 1, SEED.length, 3).setValues(SEED); s.setColumnWidth(1, 240); s.setColumnWidth(2, 120); s.setColumnWidth(3, 520); } sheet_(T.member, H_MEMBER); sheet_(T.rule, H_RULE); sheet_(T.att, H_ATT); sheet_(T.today, H_TODAY); sheet_(T.month, H_MONTH); sheet_(T.erased, H_ERASED); fmt_(T.member, [7, 9], ATT_FMT); fmt_(T.att, [1], ATT_FMT); fmt_(T.today, [1], ATT_FMT); fmt_(T.erased, [1], '@'); pin_('desk'); say_('준비 끝! 탭 7개를 만들었습니다. 다음은 메뉴 회원 관리 → 등록 폼 만들기입니다.'); } // 시각 칸에 보기 좋은 서식을 입힙니다(값은 그대로 두고 보이는 모양만 바꿉니다). function fmt_(name, cols, f) { var sh = ss_().getSheetByName(name); if (sh) cols.forEach(function (c) { sh.getRange(2, c, sh.getMaxRows() - 1, 1).setNumberFormat(f); }); } // ---------- 설정 읽기: 시트에 적힌 값만 씁니다 ---------- function cfg_() { var sh = sheet_(T.cfg, H_CFG), n = sh.getLastRow() - 1, o = {}; if (n < 1) return o; sh.getRange(2, 1, n, 2).getValues().forEach(function (r) { o[String(r[0]).trim()] = r[1]; }); return o; } // ---------- 시트 읽기 ---------- function readMembers_() { var sh = sheet_(T.member, H_MEMBER), n = sh.getLastRow() - 1; if (n < 1) return []; return sh.getRange(2, 1, n, H_MEMBER.length).getValues().map(function (r, i) { return { row: i + 2, no: String(r[0]).trim(), name: String(r[1]), contact: r[2], goal: r[3], reg: dayNo_(r[4]), status: String(r[5]).trim(), consent: r[6], ver: r[7], notice: r[8], expire: dayNo_(r[9]), token: String(r[10]).trim(), link: String(r[11]) }; }).filter(function (m) { return m.no !== ''; }); } function readRules_() { var sh = sheet_(T.rule, H_RULE), n = sh.getLastRow() - 1; if (n < 1) return []; return sh.getRange(2, 1, n, H_RULE.length).getValues().map(function (r, i) { return { row: i + 2, issued: dayNo_(r[0]), no: String(r[1]).trim(), cls: r[2], kind: String(r[3]).trim(), total: r[4], start: dayNo_(r[5]), end: dayNo_(r[6]), amount: num_(r[7]) }; }).filter(function (r) { return r.no !== ''; }); } function readAtt_() { var sh = sheet_(T.att, H_ATT), n = sh.getLastRow() - 1; if (n < 1) return []; return sh.getRange(2, 1, n, H_ATT.length).getValues().map(function (r) { return { ts: r[0], ms: isDate_(r[0]) ? r[0].getTime() : NaN, day: dayNo_(r[0]), no: String(r[1]).trim(), deduct: String(r[4]).trim() === '예' }; }).filter(function (a) { return a.no !== ''; }); } function load_() { return { cfg: cfg_(), members: readMembers_(), rules: readRules_(), att: readAtt_(), today: today_() }; } // ---------- 계산 ---------- // 지금 적용되는 수강권: 시작일이 오늘 이전(또는 빈칸)인 것 가운데 시작일이 가장 늦은 것, 같으면 아래 줄 function currentRule_(rules, no, today) { var best = null, key = function (r) { return r.start == null ? -1e12 : r.start; }; rules.forEach(function (r) { if (r.no !== no || (r.start != null && r.start > today)) return; if (!best || key(r) >= key(best)) best = r; }); return best; } // 유효 만료일: [수강권] 만료일입니다. pass.gs가 같은 프로젝트에 있으면 휴회 일수를 더한 날입니다(없으면 그대로). function endOf_(L, m, rule) { if (rule.end == null) return null; return typeof pauseDays_ === 'function' ? rule.end + pauseDays_(L, m.no, rule) : rule.end; } // 남은 횟수는 숫자 또는 null(입력 안 함)입니다. 0과 null은 다릅니다. function stateOf_(L, m) { var rule = currentRule_(L.rules, m.no, L.today), s = { left: null, days: null, end: null, cell: '입력 안 함', text: '남은 횟수 입력 안 함' }; if (!rule) return s; s.end = endOf_(L, m, rule); if (s.end != null) s.days = s.end - L.today; if (rule.kind === KIND_COUNT) { var total = num_(rule.total); if (total != null) { var used = L.att.filter(function (a) { return a.no === m.no && a.deduct && a.day != null && (rule.start == null || a.day >= rule.start); }).length; s.left = total - used; s.cell = s.left; s.text = '남은 횟수 ' + s.left + '회'; } } else if (rule.kind === KIND_PERIOD && s.days != null) { s.cell = '기간 · 남은 날 ' + s.days + '일'; s.text = s.cell; } return s; } function lastDay_(L, no) { var d = null; L.att.forEach(function (a) { if (a.no === no && a.day != null && (d == null || a.day > d)) d = a.day; }); return d; } function reasonsOf_(L, m, st) { var c = L.cfg, low = num_(c[K.low]), soon = num_(c[K.soon]), gone = num_(c[K.gone]), why = []; if ((st.left != null && low != null && st.left <= low) || (st.days != null && soon != null && st.days <= soon)) why.push(R_LIMIT); var last = lastDay_(L, m.no), base = last != null ? last : m.reg; var away = base == null ? null : L.today - base; if (away != null && gone != null && away >= gone) why.push(R_GONE); if (m.consent === '' || m.consent == null) why.push(R_NOFORM); var pw = typeof pauseWhy_ === 'function' ? pauseWhy_(L, m.no) : ''; // pass.gs가 있을 때만: 날짜가 거꾸로 된 휴회 줄 if (pw) why.push(pw); return { why: why, last: last, away: away }; } function noticeOk_(m) { return m.consent !== '' && m.consent != null && m.notice !== '' && m.notice != null; } function buildToday_(L) { var now = new Date(), rows = [], targets = []; L.members.forEach(function (m) { if (m.status !== S_ACTIVE) return; var st = stateOf_(L, m), r = reasonsOf_(L, m, st); if (!r.why.length) return; var ok = noticeOk_(m); rows.push([now, m.no, m.name, st.cell, st.end == null ? '' : dayStr_(st.end), r.last == null ? '' : dayStr_(r.last), r.away == null ? '' : r.away, r.why.join(', '), ok ? '예' : '아니오']); if (ok) targets.push({ no: m.no, name: m.name, why: r.why.join(', ') }); }); return { rows: rows, targets: targets }; } // 화면 없이 목록을 다시 만듭니다(매일 자동 실행에서도 돕니다). function rebuildCore_() { return withLock_(20000, function () { var b = buildToday_(load_()), sh = sheet_(T.today, H_TODAY); sh.clearContents(); sh.getRange(1, 1, 1, H_TODAY.length).setValues([H_TODAY]).setFontWeight('bold'); if (b.rows.length) { sh.getRange(2, 1, b.rows.length, 1).setNumberFormat(ATT_FMT); sh.getRange(2, 5, b.rows.length, 2).setNumberFormat('@'); sh.getRange(2, 1, b.rows.length, H_TODAY.length).setValues(b.rows.map(function (r) { return r.map(cell_); })); } return { count: b.rows.length, targets: b.targets }; }); } function rebuildToday() { menuOnly_(); var r = rebuildCore_(); say_(r.count ? '오늘 목록을 다시 만들었습니다. 목록에 오른 회원: ' + r.count + '명' : '오늘 목록을 다시 만들었습니다. 지금 목록에 오를 회원이 없습니다.'); } function dailyRun() { // 시간 트리거는 화면이 없는 환경입니다. 알림창 대신 로그만 남깁니다. var r = rebuildCore_(); Logger.log('오늘 목록 자동 갱신: ' + r.count + '명'); } function enableDaily() { menuOnly_(); disableDaily_(); ScriptApp.newTrigger('dailyRun').timeBased().everyDays(1).atHour(0).inTimezone(TZ).create(); say_('매일 0시쯤 오늘 목록을 다시 만들도록 예약했습니다. 끄려면 메뉴의 매일 자동 끄기를 누릅니다.'); } function disableDaily_() { ScriptApp.getProjectTriggers().filter(function (t) { return t.getHandlerFunction() === 'dailyRun'; }).forEach(function (t) { ScriptApp.deleteTrigger(t); }); } function disableDaily() { menuOnly_(); disableDaily_(); say_('매일 자동 실행을 껐습니다.'); } // 재등록 안내 대상: 개인정보 동의와 마케팅 수신 동의가 모두 있는 회원만. 코드는 보내지 않고 목록만 만듭니다. function noticeTargets() { return buildToday_(load_()).targets; } function showNoticeTargets() { menuOnly_(); var t = noticeTargets(); say_(t.length ? '재등록 안내 대상 ' + t.length + '명 (마케팅 수신에 동의한 회원만)\n' + t.map(function (x) { return x.no + ' ' + x.name + ' — ' + x.why; }).join('\n') + '\n\n안내는 직접 보냅니다. 이 코드는 메일이나 문자를 보내지 않습니다.' : '재등록 안내 대상이 없습니다.'); } // ---------- 월 요약 ---------- function monthOfDay_(n) { return n == null ? null : dayStr_(n).slice(0, 7); } function monthlyRows_(L) { var months = {}, add = function (k) { if (k) months[k] = months[k] || { att: 0, who: {}, fresh: 0, again: 0, sum: null }; return k && months[k]; }; L.att.forEach(function (a) { if (!isDate_(a.ts)) return; var b = add(monthKey_(a.ts)); b.att++; b.who[a.no] = 1; }); L.members.forEach(function (m) { var b = add(monthOfDay_(m.reg)); if (b) b.fresh++; }); var seen = {}; L.rules.slice().sort(function (x, y) { return (x.issued == null ? 1e12 : x.issued) - (y.issued == null ? 1e12 : y.issued) || x.row - y.row; }).forEach(function (r) { var b = add(monthOfDay_(r.issued)); if (b) { if (seen[r.no]) b.again++; if (r.amount != null) b.sum = (b.sum || 0) + r.amount; } seen[r.no] = true; }); return Object.keys(months).sort().map(function (k) { var b = months[k]; return [k, b.att, Object.keys(b.who).length, b.fresh, b.again, b.sum == null ? '' : b.sum]; }); } function rebuildMonthly() { menuOnly_(); var rows = withLock_(20000, function () { var r = monthlyRows_(load_()), sh = sheet_(T.month, H_MONTH); sh.clearContents(); sh.getRange(1, 1, 1, H_MONTH.length).setValues([H_MONTH]).setFontWeight('bold'); if (r.length) { sh.getRange(2, 1, r.length, 1).setNumberFormat('@'); sh.getRange(2, 1, r.length, H_MONTH.length).setValues(r); } return r; }); say_('월 요약을 만들었습니다. ' + rows.length + '개 달'); } // ---------- 등록 폼 ---------- function makeRegisterForm() { menuOnly_(); var p = PropertiesService.getScriptProperties(), id = p.getProperty('FORM_ID'); if (id) { say_('등록 폼이 이미 있습니다. 회원용 등록 주소: ' + FormApp.openById(id).getPublishedUrl()); return; } var c = cfg_(), keep = num_(c[K.keep]); var form = FormApp.create(String(c[K.name] || '수업') + ' 등록'); form.setDescription('받는 정보는 부르는 이름, 연락 수단(선택), 수업 목표 한 줄뿐입니다. 건강·통증·병력은 쓰지 마세요.'); form.addTextItem().setTitle(Q.name).setRequired(true); form.addTextItem().setTitle(Q.contact).setHelpText('비워 두어도 됩니다. 이메일이나 전화 중 한 가지만 적습니다.'); form.addTextItem().setTitle(Q.goal).setHelpText('예: 체력 기르기. 건강 상태는 적지 마세요.'); form.addCheckboxItem().setTitle(Q.consent).setChoiceValues([AGREE]).setRequired(true) .setHelpText('수집 항목: 부르는 이름, 연락 수단(선택), 수업 목표 한 줄. 목적: 출석 확인과 명단 관리. 보유 기간: ' + (keep == null ? '[기관이 정한 기간을 적습니다]' : keep + '개월') + '. 동의하지 않을 권리가 있으며, 필수 항목에 동의하지 않으면 등록할 수 없습니다. 열람·삭제는 담당자에게 요청합니다. [확인필요: 정확한 문구는 법령과 기관 안내를 직접 확인]'); form.addMultipleChoiceItem().setTitle(Q.notice).setChoiceValues([AGREE, DISAGREE]) .setHelpText('광고성 안내(재등록 안내 등)를 받는 데 따로 동의하는 칸입니다. 동의하지 않아도 등록과 출석에는 영향이 없습니다.'); // 응답을 시트로 따로 보내지 않습니다: 개인정보 사본이 둘로 늘어나지 않게 코드가 [회원] 탭에만 옮깁니다. p.setProperty('FORM_ID', form.getId()); ScriptApp.newTrigger('onRegister').forForm(form).onFormSubmit().create(); say_('등록 폼을 만들었습니다. 회원용 등록 주소: ' + form.getPublishedUrl()); } function clean_(s, max) { return String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, max); } function newToken_() { return Utilities.getUuid().replace(/-/g, '').slice(0, 20); } // 폼에 건 트리거는 e.response, 시트에 건 트리거는 e.namedValues 를 줍니다. 둘 다 받습니다. function onRegister(e) { var v = answers_(e), name = clean_(first_(v[Q.name]), 40); if (!name) { Logger.log('등록하지 않았습니다: 부르는 이름이 비어 있습니다.'); return { ok: false, why: 'name' }; } if (first_(v[Q.consent]) !== AGREE) { Logger.log('등록하지 않았습니다: 개인정보 동의가 없습니다.'); return { ok: false, why: 'consent' }; } var out = addMember_(name, clean_(first_(v[Q.contact]), 100), clean_(first_(v[Q.goal]), 80), first_(v[Q.notice]) === AGREE); // 시트에 옮겼으면 폼에 남은 응답은 지웁니다(사본을 하나로). 실패해도 등록은 유지합니다. try { if (e && e.response && typeof e.response.getId === 'function') FormApp.openById(PropertiesService.getScriptProperties().getProperty('FORM_ID')).deleteResponse(e.response.getId()); } catch (err) { Logger.log('폼 응답 지우기 실패: ' + err.message); } return out; } // 회원번호: 잠금 안에서 지금 가장 큰 번호 + 1. ("마지막 줄 + 1"은 동시에 등록하면 겹칩니다.) function addMember_(name, contact, goal, notice) { return withLock_(20000, function () { var sh = sheet_(T.member, H_MEMBER), c = cfg_(), now = new Date(), no = 0; readMembers_().forEach(function (m) { var n = num_(m.no); if (n != null && n > no) no = n; }); no++; var keep = num_(c[K.keep]), expire = ''; if (keep != null) expire = addMonths_(today_(), keep); var token = newToken_(), url = ScriptApp.getService().getUrl(); row_(sh, [no, name, contact, goal, dayStr_(today_()), S_ACTIVE, now, String(c[K.ver] || ''), notice ? now : '', expire, token, url ? url + '?v=me&t=' + token : '']); return { ok: true, no: no, token: token }; }); } // 일 번호에 달(개월)을 더해 yyyy-MM-dd 글자로 돌려줍니다(없는 날짜는 그 달 마지막 날로). function addMonths_(n, months) { var d = dayStr_(n).split('-'), y = Number(d[0]), mo = Number(d[1]) - 1 + months, day = Number(d[2]); y += Math.floor(mo / 12); mo = ((mo % 12) + 12) % 12; var last = new Date(Date.UTC(y, mo + 1, 0)).getUTCDate(); return dayStr_(Date.UTC(y, mo, Math.min(day, last)) / 86400000); } // ---------- 출석 링크(토큰) ---------- function makeTokens() { menuOnly_(); var made = withLock_(20000, function () { var sh = sheet_(T.member, H_MEMBER), url = ScriptApp.getService().getUrl(), k = 0; readMembers_().forEach(function (m) { var token = m.token; if (!token) { token = newToken_(); sh.getRange(m.row, 11).setValue(token); k++; } if (url && (!m.link || m.link.indexOf(token) < 0)) sh.getRange(m.row, 12).setValue(url + '?v=me&t=' + token); }); return { k: k, url: url }; }); say_('출석 링크를 ' + made.k + '개 새로 만들었습니다.' + (made.url ? '' : '\n웹 앱으로 배포한 뒤 다시 실행하면 [회원] 탭의 출석 링크 칸이 채워집니다.')); } function updateMember_(no, col, value) { // 대상이 없으면 성공 처리하지 않고 오류를 냅니다 return withLock_(20000, function () { var sh = sheet_(T.member, H_MEMBER), r = rowOf_(sh, 1, no); if (!r) throw new Error('해당 번호의 회원이 없습니다: ' + no); sh.getRange(r, col).setValue(cell_(value)); return r; }); } function resetToken(no) { var token = newToken_(), url = ScriptApp.getService().getUrl(); updateMember_(no, 11, token); updateMember_(no, 12, url ? url + '?v=me&t=' + token : ''); return { ok: true, no: String(no) }; } function menuResetToken() { menuOnly_(); var ui = SpreadsheetApp.getUi(), r = ui.prompt('출석 링크 바꾸기', '링크가 새 나간 회원의 번호를 넣으세요.', ui.ButtonSet.OK_CANCEL); if (r.getSelectedButton() !== ui.Button.OK) return; try { resetToken(r.getResponseText().trim()); say_('출석 링크를 바꿨습니다. 옛 링크는 더 이상 열리지 않습니다.'); } catch (e) { say_(e.message); } } function setStatus(no, status) { if ([S_ACTIVE, S_SLEEP, S_DEL].indexOf(status) < 0) throw new Error('상태는 ' + S_ACTIVE + ', ' + S_SLEEP + ', ' + S_DEL + ' 가운데 하나여야 합니다.'); updateMember_(no, 6, status); return { ok: true, no: String(no), status: status }; } // 마케팅 수신 동의를 넣거나 거둡니다. 동의를 거두면 그 회원은 재등록 안내 대상에서 바로 빠집니다. function setNoticeConsent(no, agree) { updateMember_(no, 9, agree ? new Date() : ''); return { ok: true, no: String(no), agree: !!agree }; } // ---------- 보유기간과 삭제 ---------- function expiredList_(L) { var out = []; L.members.forEach(function (m) { if (m.status === S_DEL) out.push({ no: m.no, name: m.name, why: WHY_ASK }); else if (m.expire != null && m.expire < L.today) out.push({ no: m.no, name: m.name, why: WHY_KEEP }); }); return out; } function expiredList() { return expiredList_(load_()); } function showExpired() { menuOnly_(); var list = expiredList(); say_(list.length ? '삭제 대상 ' + list.length + '명\n' + list.map(function (x) { return x.no + ' ' + x.name + ' — ' + x.why; }).join('\n') + '\n\n확인한 뒤 메뉴의 보유기간 지난 회원 삭제를 누릅니다.' : '보유기간이 지났거나 삭제를 요청한 회원이 없습니다.'); } // 한 회원의 모든 줄([회원]·[수강권]·[출석])을 지우고 [삭제 기록]에는 번호·날짜·사유만 남깁니다. function eraseMember(no, why) { if ([WHY_KEEP, WHY_ASK].indexOf(why) < 0) throw new Error('삭제 사유는 ' + WHY_KEEP + ' 또는 ' + WHY_ASK + ' 이어야 합니다.'); return withLock_(30000, function () { var members = sheet_(T.member, H_MEMBER), r = rowOf_(members, 1, no); if (!r) throw new Error('해당 번호의 회원이 없습니다: ' + no); var gone = { rules: 0, att: 0 }; [[T.rule, H_RULE, 'rules'], [T.att, H_ATT, 'att']].forEach(function (x) { var sh = sheet_(x[0], x[1]); for (var i = sh.getLastRow(); i >= 2; i--) if (String(sh.getRange(i, 2).getValue()).trim() === String(no)) { sh.deleteRow(i); gone[x[2]]++; } }); members.deleteRow(r); row_(sheet_(T.erased, H_ERASED), [dayStr_(today_()), no, why, '삭제 완료']); return { ok: true, no: String(no), removed: gone }; }); } function purgeExpired() { menuOnly_(); var list = expiredList(); if (!list.length) { say_('보유기간이 지났거나 삭제를 요청한 회원이 없습니다.'); return; } var ui = SpreadsheetApp.getUi(); if (ui.alert('삭제 확인', list.length + '명의 정보를 지웁니다. 되돌릴 수 없습니다.\n' + list.map(function (x) { return x.no + ' ' + x.name; }).join('\n') + '\n\n지울까요?', ui.ButtonSet.YES_NO) !== ui.Button.YES) { say_('삭제하지 않았습니다.'); return; } var done = 0; list.forEach(function (x) { eraseMember(x.no, x.why); done++; }); say_(done + '명의 정보를 지웠습니다. [삭제 기록] 탭에는 번호와 날짜만 남습니다.'); } function processDeletion() { menuOnly_(); var ui = SpreadsheetApp.getUi(), r = ui.prompt('삭제 요청 처리', '삭제를 요청한 회원의 번호를 넣으세요.', ui.ButtonSet.OK_CANCEL); if (r.getSelectedButton() !== ui.Button.OK) return; var no = r.getResponseText().trim(); try { if (ui.alert('삭제 확인', no + '번 회원의 정보를 지웁니다. 되돌릴 수 없습니다.\n지울까요?', ui.ButtonSet.YES_NO) !== ui.Button.YES) { say_('삭제하지 않았습니다.'); return; } eraseMember(no, WHY_ASK); say_(no + '번 회원의 정보를 지웠습니다.'); } catch (e) { say_(e.message); } } function showUrls() { menuOnly_(); var url = ScriptApp.getService().getUrl(), id = PropertiesService.getScriptProperties().getProperty('FORM_ID'); say_((id ? '회원용 등록 주소: ' + FormApp.openById(id).getPublishedUrl() + '\n' : '') + (url ? '데스크 출석 화면: ' + url + '?v=desk\n데스크 PIN: ' + pin_('desk') + '\n회원마다 출석 링크는 [회원] 탭의 출석 링크 칸에 있습니다.' : '아직 웹 앱으로 배포하지 않았습니다. 배포 → 새 배포 → 웹 앱을 먼저 하세요.')); } /* ===== 회원 관리: 웹 화면(출석 링크, 데스크) ===== ?v=me&t=토큰 : 회원 본인 화면. 토큰이 맞는 한 회원의 줄만 보입니다(번호를 바꿔 넣어도 다른 회원은 안 나옵니다). ?v=desk : 데스크 화면. 데스크 PIN을 넣어야 번호 목록이 보입니다. 링크를 열기만 해서는 출석이 기록되지 않습니다(메신저 미리보기가 대신 눌러 버리는 일을 막습니다). 버튼을 눌러야 기록됩니다. */ function doGet(e) { var p = (e && e.parameter) || {}, v = p.v; if (v === 'dash' && typeof dashDoGet_ === 'function') return dashDoGet_(e); if (v === 'today' && typeof todayDoGet_ === 'function') return todayDoGet_(e); if (v === 'desk') { var dx = typeof deskExtra_ === 'function' ? deskExtra_() : { html: '', js: '' }; // slot.gs가 있으면 오늘 수업 칸을 더합니다 return page_('데스크 출석', DESK_HTML.replace('<div id="list"></div>', '<div id="list"></div>' + dx.html) + '<script>(' + deskClient_.toString() + ')();</script>' + (dx.js ? '<script>' + dx.js + '</script>' : '')); } if (v === 'me') { var mx = typeof meExtra_ === 'function' ? meExtra_() : { html: '', js: '' }; // slot.gs가 있으면 수업 예약 칸을 더합니다 return page_('내 출석', '<script>var TOKEN=' + js_(String(p.t || '')) + ';</script>' + ME_HTML.replace('<p class="mut">이 화면에는', mx.html + '<p class="mut">이 화면에는') + '<script>(' + meClient_.toString() + ')();</script>' + (mx.js ? '<script>' + mx.js + '</script>' : '')); } return page_('출석', '<h1>출석</h1><p>받은 출석 링크로 열어 주세요.</p>'); } // 실패한 토큰이 10분에 50번을 넘으면 잠시 모든 토큰 조회를 멈춥니다. function memberByToken_(token) { token = String(token || '').trim(); if (Number(CacheService.getScriptCache().get('t:badtoken') || 0) >= 50) return { locked: true }; var found = null; if (/^[0-9a-f]{20}$/.test(token)) readMembers_().forEach(function (m) { if (m.token === token) found = m; }); if (!found) { throttle_('badtoken', 50, 600); return null; } return { m: found }; } var BAD_LINK = '링크가 맞지 않습니다. 담당자에게 새 링크를 받으세요.'; // 회원 본인 화면이 부릅니다. 돌려주는 값에는 토큰 주인 한 명의 정보만 있습니다. function memberInfo(token) { var f = memberByToken_(token); if (!f || f.locked) return { ok: false, msg: f ? '잠시 뒤에 다시 열어 주세요.' : BAD_LINK }; var L = load_(), m = f.m, st = stateOf_(L, m), last = lastDay_(L, m.no); return { ok: true, name: m.name, className: String(L.cfg[K.name] || ''), leftText: st.text, last: last == null ? '' : dayStr_(last) }; } function memberCheckIn(token) { var f = memberByToken_(token); if (!f || f.locked) return { ok: false, msg: f ? '잠시 뒤에 다시 눌러 주세요.' : BAD_LINK }; return checkIn_(f.m.no, '링크', ''); } // 출석을 한 줄 기록합니다. 잠금 안에서 처리하고, 같은 회원이 설정한 분 안에 다시 누르면 새 줄을 만들지 않습니다. function checkIn_(no, method, cls) { return withLock_(20000, function () { var L = load_(), m = null; L.members.forEach(function (x) { if (x.no === String(no)) m = x; }); if (!m) return { ok: false, msg: '해당 번호의 회원이 없습니다.' }; if (m.status !== S_ACTIVE) return { ok: false, msg: '지금은 출석을 기록할 수 없습니다. 담당자에게 문의하세요.' }; if (m.consent === '' || m.consent == null) return { ok: false, msg: '개인정보 동의가 아직 없습니다. 담당자에게 문의하세요.' }; var hold = typeof pauseGate_ === 'function' ? pauseGate_(L, m.no) : ''; // pass.gs가 있을 때만: 휴회 중이면 기록·차감 없이 안내 if (hold) return { ok: false, msg: hold }; var dup = num_(L.cfg[K.dup]), nowMs = new Date().getTime(); if (dup != null && dup > 0 && L.att.some(function (a) { return a.no === m.no && !isNaN(a.ms) && nowMs - a.ms >= 0 && nowMs - a.ms < dup * 60000; })) { return { ok: true, dup: true, name: m.name, msg: '이미 출석이 기록되어 있습니다.', leftText: stateOf_(L, m).text }; } row_(sheet_(T.att, H_ATT), [new Date(), m.no, method, clean_(cls, 40), String(L.cfg[K.deduct]).trim() === '예' ? '예' : '아니오', '완료']); var L2 = load_(); return { ok: true, dup: false, name: m.name, msg: '출석했습니다.', leftText: stateOf_(L2, m).text, last: dayStr_(lastDay_(L2, m.no)) }; }); } // ---------- 데스크 ---------- function deskLogin(pin) { return auth_(pin, 'desk'); } function deskList(cred) { var a = auth_(cred, 'desk'); if (!a.ok) return a; return { ok: true, token: a.token, items: readMembers_().filter(function (m) { return m.status === S_ACTIVE; }).map(function (m) { return { no: m.no, name: m.name }; }) }; } function deskCheckIn(cred, no, cls) { var a = auth_(cred, 'desk'); if (!a.ok) return a; return checkIn_(no, '데스크', cls); } // ---------- 화면 ---------- var ME_HTML = '<h1 id="nm">…</h1><p class="mut" id="cls"></p>' + '<div class="card"><p id="left">…</p><p class="mut" id="last"></p></div>' + '<button id="go">출석하기</button><div class="msg" id="msg" hidden></div>' + '<p class="mut">이 화면에는 내 정보만 보입니다. 링크를 다른 사람에게 보내지 마세요.</p>'; function meClient_() { var $ = function (s) { return document.querySelector(s); }; function msg(t, ok) { var m = $('#msg'); m.hidden = false; m.className = ok ? 'msg ok' : 'msg'; m.textContent = t; } function fail() { msg('연결이 불안정합니다. 잠시 뒤에 다시 눌러 주세요.', false); $('#go').disabled = false; } function show(d) { $('#nm').textContent = d.name + '님'; $('#cls').textContent = d.className || ''; $('#left').textContent = d.leftText; $('#last').textContent = d.last ? '마지막 출석 ' + d.last : '아직 출석 기록이 없습니다.'; } google.script.run.withSuccessHandler(function (d) { if (!d.ok) { $('#nm').textContent = '링크를 확인해 주세요'; $('#left').textContent = ''; $('#go').hidden = true; msg(d.msg, false); return; } show(d); }).withFailureHandler(fail).memberInfo(TOKEN); $('#go').onclick = function () { $('#go').disabled = true; google.script.run.withSuccessHandler(function (d) { $('#go').disabled = false; if (!d.ok) { msg(d.msg, false); return; } $('#left').textContent = d.leftText; if (d.last) $('#last').textContent = '마지막 출석 ' + d.last; msg(d.msg, true); }).withFailureHandler(fail).memberCheckIn(TOKEN); }; } var DESK_HTML = '<h1>데스크 출석</h1>' + '<div id="login"><label for="pin">데스크 PIN</label><input id="pin" type="password" inputmode="numeric" maxlength="4" autocomplete="off"><p><button id="go">확인</button></p></div>' + '<div id="work" hidden><label for="cls">수업명(선택)</label><input id="cls" maxlength="40"><p class="mut">회원번호를 누르면 출석이 기록됩니다.</p><div id="list"></div></div>' + '<div class="msg" id="msg" hidden></div>'; // 새로고침해도 입력이 남도록: 수업명은 이 기기에 임시 저장하고, PIN을 맞힌 뒤 받은 화면용 표는 탭이 열려 있는 동안 기억합니다. function deskClient_() { var $ = function (s) { return document.querySelector(s); }; var tok = ''; try { tok = sessionStorage.getItem('deskTok') || ''; } catch (e) {} try { var saved = localStorage.getItem('deskCls'); if (saved) $('#cls').value = saved; } catch (e) {} $('#cls').oninput = function () { try { localStorage.setItem('deskCls', $('#cls').value); } catch (e) {} }; function say(t, ok) { var m = $('#msg'); m.hidden = false; m.className = ok ? 'msg ok' : 'msg'; m.textContent = t; } function fail() { say('연결이 불안정합니다. 잠시 뒤에 다시 눌러 주세요.', false); } function login(why) { tok = ''; try { sessionStorage.removeItem('deskTok'); } catch (e) {} $('#login').hidden = false; $('#work').hidden = true; if (why) say(why, false); } function draw(d) { if (!d.ok) { login(d.msg); return; } tok = d.token; try { sessionStorage.setItem('deskTok', tok); } catch (e) {} $('#login').hidden = true; $('#work').hidden = false; $('#msg').hidden = true; var l = $('#list'); l.textContent = ''; d.items.forEach(function (s) { var b = document.createElement('button'); b.className = 'ghost'; b.textContent = s.no + ' ' + s.name; b.onclick = function () { google.script.run.withSuccessHandler(function (r) { if (r.expired) { login(r.msg); return; } say(r.ok ? s.name + ' — ' + r.msg + ' ' + (r.leftText || '') : r.msg, r.ok); }).withFailureHandler(fail).deskCheckIn(tok, s.no, $('#cls').value); }; l.appendChild(b); }); } function load(t) { google.script.run.withSuccessHandler(draw).withFailureHandler(fail).deskList(t); } $('#go').onclick = function () { var pin = $('#pin').value; $('#pin').value = ''; google.script.run.withSuccessHandler(function (r) { if (!r.ok) { say(r.msg, false); return; } load(r.token); }).withFailureHandler(fail).deskLogin(pin); }; if (tok) load(tok); }
1 회원01 체력 기르기 2026-09-01 활성 2026-09-01 10:00 v1 2026-09-01 10:00 2 회원02 자세 연습 2026-09-01 활성 2026-09-01 10:00 v1 2026-09-01 10:00 3 회원03 2026-09-01 활성 2026-09-01 10:00 v1 2026-09-01 10:00 4 회원04 운동 습관 만들기 2026-09-01 활성 2026-09-01 10:00 v1 2026-09-01 10:00 5 회원05 2026-09-01 활성 2026-09-01 10:00 v1 2026-09-01 10:00 6 회원06 그룹 수업 참여 2026-09-01 활성 2026-09-01 10:00 v1 2026-09-01 10:00 7 회원07 2026-09-01 활성 2026-09-01 10:00 v1 2026-09-01 10:00 8 회원08 체력 기르기 2026-09-01 활성 2026-09-01 10:00 v1 2026-09-01 10:00 9 회원09 2026-09-01 활성 2026-09-01 10:00 v1 10 회원10 자세 연습 2026-09-01 활성 2026-09-01 10:00 v1 11 회원11 2026-09-08 활성 2026-09-01 10:00 v1 12 회원12 그룹 수업 참여 2026-09-08 활성 2026-09-01 10:00 v1 13 회원13 2026-09-08 활성 2026-09-01 10:00 v1 14 회원14 2026-09-08 활성 2026-09-01 10:00 v1 15 회원15 운동 습관 만들기 2026-09-08 활성 2026-09-01 10:00 v1 16 회원16 2026-09-08 휴면 2026-09-01 10:00 v1 17 회원17 체력 기르기 2026-09-15 활성 2026-09-01 10:00 v1 18 회원18 2026-09-15 활성 2026-09-01 10:00 v1 19 회원19 2026-09-15 활성 20 회원20 2026-09-15 활성
2026-09-01 1 그룹 수업 횟수 15 2026-09-01 2026-12-20 횟수 15회인 예 2026-09-01 2 그룹 수업 기간 2026-09-01 2026-12-20 기간만 있는 예 2026-09-01 3 그룹 수업 횟수 2026-09-01 2026-12-20 총 횟수를 아직 적지 않은 예(입력 안 함)