연습 화면입니다. 실제 구글 화면이 아니며 아무 데도 보내지 않습니다.
할 일로 건너뛰기
← 연습 목록
3-1
출석 명단 만들기(앞부분)
책 3-1 · 227쪽
0/9
처음부터
주소창
연습용 시트
· 알림
연습용 코드 편집기
폰 미리보기
주소창(연습)
이동
브라우저에서 새 탭을 연 모습입니다. 연습실에서는 sheets.new 만 열리고, 실제 주소로는 나가지 않습니다.
실제 이름·연락처를 넣지 마세요(이 기기에만 저장됩니다).
{"id":"edu/3-1","book":"edu","chapter":"3-1","title":"출석 명단 만들기(앞부분)","minutes":15,"anchor":"3-1","webappSlug":"attend","assets":{"code":{"attend.gs":"examples/attend.gs"},"data":{"student_rows":{"src":"examples/sheets/student_rows.tsv","part":"all"}}},"start":[{"a":"panel","p":"address"}],"steps":[{"id":"sheet","do":"주소창(연습)에 sheets.new 를 입력하고 Enter를 누른 뒤, 파일 이름을 내-실습-출석으로 바꿉니다.","ok":"빈 새 시트가 열리고 왼쪽 위 파일 이름이 내-실습-출석이면 성공입니다(실제 구글에서는 브라우저 탭 제목도 같이 바뀝니다).","anchor":"3-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":233},{"id":"copy","do":"카드의 [코드 복사] 단추로 attend.gs 코드를 복사합니다(책에서는 예제 쪽의 코드 복사 단추).","ok":"복사되었다는 알림이 뜨면 성공입니다.","anchor":"3-1-h2-2","bookStep":"① 2","check":[{"t":"copied","id":"attend.gs"}],"hints":["이 카드 아래의 [코드 복사] 단추를 누릅니다. 눌렀을 때 복사했다는 알림이 나옵니다.","테두리가 둘러진 [코드 복사] 단추를 누르세요."],"target":"copy:attend.gs","preset":[{"a":"panel","p":"address"},{"a":"address","text":"sheets.new"},{"a":"rename","name":"내-실습-출석"}],"show":[{"a":"copy","id":"attend.gs"}],"copy":["attend.gs"],"page":233},{"id":"paste","do":"확장 프로그램 → Apps Script를 열고, 편집기 안을 눌러 Ctrl+A로 모두 지운 뒤 Ctrl+V로 붙이고 Ctrl+S로 저장합니다. 맥은 Ctrl 대신 ⌘를 씁니다. 함수 칸이 start인지 봅니다.","ok":"편집기에 방금 붙인 코드만 남아 있고 저장되어 함수 칸에 start가 보이면 성공입니다.","anchor":"3-1-h2-2","bookStep":"① 3","check":[{"t":"code","file":"Code.gs","sameAs":"attend.gs"},{"t":"saved"},{"t":"selectedFn","eq":"start"}],"msg":{"0":"코드 칸의 글이 아직 attend.gs와 같지 않습니다. 전체를 지운 뒤(Ctrl+A, Delete) 다시 붙여 넣으세요. 먼저 [코드 복사]를 눌렀는지도 보세요.","1":"아직 저장하지 않았습니다. 저장 단추를 누르세요.","2":"함수 칸에서 start를 아직 고르지 않았습니다. 저장한 뒤 함수 칸을 눌러 start를 고르세요."},"hints":["시트 위쪽 메뉴 확장 프로그램 → Apps Script로 편집기를 열고, 안을 누른 뒤 Ctrl+A, Delete, Ctrl+V, Ctrl+S 순서입니다.","테두리가 둘러진 코드 칸 안을 눌러 지우고 붙인 뒤 저장하세요. 이전 글이 조금이라도 남으면 판정이 맞지 않습니다."],"target":"menuitem:확장 프로그램/Apps Script","preset":[{"a":"panel","p":"address"},{"a":"address","text":"sheets.new"},{"a":"rename","name":"내-실습-출석"},{"a":"copy","id":"attend.gs"}],"show":[{"a":"openEditor"},{"a":"code","file":"Code.gs","src":"attend.gs"},{"a":"save"}],"page":233},{"id":"run","do":"함수 칸에서 start를 고르고 ▶ 실행을 누릅니다. 권한 검토 창이 뜨면 계정 선택 → 고급 → (안전하지 않음)으로 이동 → 허용 순서로 누릅니다(창 제목에 1/4~4/4가 보입니다).","ok":"권한 창이 닫히고 실행 로그에 준비 끝이 보이면 성공입니다.","anchor":"3-1-h2-2","bookStep":"① 4·5","check":[{"t":"authorized"},{"t":"ran","fn":"start"}],"msg":{"0":"권한 검토 창에서 허용을 아직 누르지 않았습니다. 계정 선택 → 고급 → (안전하지 않음)으로 이동 → 허용 순서입니다.","1":"start를 아직 끝까지 실행하지 않았습니다. 시트 창에 알림창이 떠 있다면 확인을 눌러야 실행이 이어집니다."},"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":"attend.gs"},{"a":"save"}],"show":[{"a":"authorize"},{"a":"run","fn":"start"}],"page":233},{"id":"ready","do":"시트 탭으로 돌아와 아래쪽 탭과 위쪽 메뉴를 봅니다. 알림창이 떠 있으면 확인을 누릅니다.","ok":"탭 설정·과목·학생·출석이 보이고 메뉴 출석 관리가 생기면 성공입니다(코드가 만드는 일곱 탭 중 앞쪽 탭을 봅니다). 메뉴가 안 보이면 파일 → 다시 열기를 합니다.","anchor":"3-1-h2-2","bookStep":"① 6","check":[{"t":"menu","name":"출석 관리"},{"t":"tab","name":"설정"},{"t":"tab","name":"과목"},{"t":"tab","name":"학생"},{"t":"tab","name":"출석"},{"t":"panel","eq":"sheet"}],"msg":{"0":"메뉴 “출석 관리”가 아직 보이지 않습니다. 파일 → 다시 열기를 누르거나 start를 다시 실행하세요.","5":"지금은 ‘시트’ 창이 아닙니다. 창 탭에서 연습용 시트를 누르세요."},"hints":["시트 창의 맨 위 메뉴 줄 끝에 출석 관리가 생기고, 아래 탭 줄에 설정·과목·학생·출석 등이 생깁니다.","테두리가 둘러진 메뉴 출석 관리를 확인하세요. 안 보이면 파일 → 다시 열기를 누릅니다."],"target":"menu:출석 관리","preset":[{"a":"panel","p":"address"},{"a":"address","text":"sheets.new"},{"a":"rename","name":"내-실습-출석"},{"a":"openEditor"},{"a":"code","file":"Code.gs","src":"attend.gs"},{"a":"save"},{"a":"authorize"},{"a":"run","fn":"start"}],"show":[{"a":"panel","p":"sheet"}],"page":233},{"id":"dept","do":"[설정] 탭에서 학과 이름 줄의 값 칸(B2)을 가상학과로 바꿉니다.","ok":"값 칸이 처음 값(내 학과)에서 바뀌어 내가 적은 이름이 보이면 성공입니다. 연습에서는 가상학과, 책에서는 내 학과 이름을 적습니다.","anchor":"3-1-h2-2","bookStep":"① 7","check":[{"t":"cfg","tab":"설정","label":"학과 이름","nonEmpty":true,"notEq":"내 학과"}],"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":"attend.gs"},{"a":"save"},{"a":"authorize"},{"a":"run","fn":"start"},{"a":"panel","p":"sheet"}],"show":[{"a":"set","tab":"설정","at":"B2","value":"가상학과"}],"page":233},{"id":"course","do":"[과목] 탭 B2에 실습A-1분반, C2에 15, D2에 3을 적고, B3에 실습A-2분반, C3에 15를 적습니다. A2·A3과 D3은 비워 둡니다.","ok":"세 칸에 값이 들어가고 A2가 비어 있으며 D3이 빈칸이면 성공입니다. D3 빈칸은 결석 한도를 정하지 않은 특강 같은 과목을 흉내 내는 줄입니다.","anchor":"3-1-h2-3","bookStep":"② 1·2","check":[{"t":"rowsInclude","tab":"과목","rows":[{"B":"실습A-1분반","C":15,"D":3},{"B":"실습A-2분반","C":15}]}],"msg":{"0":"[과목] 탭에 기대한 줄이 아직 다 들어 있지 않습니다. B2=실습A-1분반, C2=15, D2=3 / B3=실습A-2분반, C3=15 인지 보세요."},"hints":["[과목] 탭의 머리줄은 과목 번호, 과목·분반 이름, 총 회차, 결석 한도, 지난 회차, 담당입니다. B열에 이름, C열에 총 회차, D열에 결석 한도를 적습니다.","테두리가 둘러진 [과목] 탭을 열어 B2부터 차례로 적으세요. 숫자는 글자가 아니라 숫자로 들어갑니다."],"target":"tab:과목","preset":[{"a":"panel","p":"address"},{"a":"address","text":"sheets.new"},{"a":"rename","name":"내-실습-출석"},{"a":"openEditor"},{"a":"code","file":"Code.gs","src":"attend.gs"},{"a":"save"},{"a":"authorize"},{"a":"run","fn":"start"},{"a":"panel","p":"sheet"},{"a":"set","tab":"설정","at":"B2","value":"가상학과"}],"show":[{"a":"set","tab":"과목","at":"B2","value":"실습A-1분반"},{"a":"set","tab":"과목","at":"C2","value":"15"},{"a":"set","tab":"과목","at":"D2","value":"3"},{"a":"set","tab":"과목","at":"B3","value":"실습A-2분반"},{"a":"set","tab":"과목","at":"C3","value":"15"}],"page":241},{"id":"number","do":"메뉴 출석 관리 → 과목 번호 채우기를 누릅니다.","ok":"“과목 번호를 2개 채웠습니다.”가 보이고 A2와 A3에 1과 2가 채워지면 성공입니다.","anchor":"3-1-h2-3","bookStep":"② 3","check":[{"t":"colFilled","tab":"과목","col":"A","from":2,"to":3}],"msg":{"0":"[과목] 탭 A2~A3에 아직 번호가 없습니다. 메뉴 출석 관리 → 과목 번호 채우기를 누르세요. B열에 과목 이름이 먼저 있어야 합니다."},"hints":["시트 위쪽 메뉴 줄의 출석 관리를 열면 과목 번호 채우기가 있습니다.","테두리가 둘러진 메뉴 항목 과목 번호 채우기를 누르세요."],"target":"menuitem:출석 관리/과목 번호 채우기","preset":[{"a":"panel","p":"address"},{"a":"address","text":"sheets.new"},{"a":"rename","name":"내-실습-출석"},{"a":"openEditor"},{"a":"code","file":"Code.gs","src":"attend.gs"},{"a":"save"},{"a":"authorize"},{"a":"run","fn":"start"},{"a":"panel","p":"sheet"},{"a":"set","tab":"설정","at":"B2","value":"가상학과"},{"a":"set","tab":"과목","at":"B2","value":"실습A-1분반"},{"a":"set","tab":"과목","at":"C2","value":"15"},{"a":"set","tab":"과목","at":"D2","value":"3"},{"a":"set","tab":"과목","at":"B3","value":"실습A-2분반"},{"a":"set","tab":"과목","at":"C3","value":"15"}],"show":[{"a":"menu","fn":"numberCourses"}],"page":241},{"id":"students","do":"카드의 [복사]로 가짜 학생 20명 표를 복사해, [학생] 탭 A2 칸을 누르고 붙여 넣습니다. 표에는 머리줄이 없고 11열입니다.","ok":"학생01~학생20 스무 줄이 들어가고 학번 칸이 비어 있으면 성공입니다.","anchor":"3-1-h2-3","bookStep":"② 4·5","check":[{"t":"lastRow","tab":"학생","eq":21},{"t":"cell","tab":"학생","at":"A2","in":[1,"1"]}],"msg":{"0":"[학생] 탭의 마지막 줄이 21행이 아닙니다. 머리줄(1행) 아래 A2에 스무 줄을 붙여 넣었는지 보세요.","1":"[학생] 탭 A2 칸에 첫 학생 번호(1)가 없습니다. A2 칸을 눌러 붙여 넣으세요."},"hints":["[복사]한 표를 [학생] 탭의 A2 칸(머리줄 바로 아래 왼쪽 칸)에 붙여 넣습니다. A1에 붙이면 머리줄이 덮입니다.","테두리가 둘러진 [복사] 단추를 누르고, [학생] 탭 A2 칸을 누른 뒤 붙여 넣기 창에서 붙여 넣으세요."],"target":"copy:student_rows","preset":[{"a":"panel","p":"address"},{"a":"address","text":"sheets.new"},{"a":"rename","name":"내-실습-출석"},{"a":"openEditor"},{"a":"code","file":"Code.gs","src":"attend.gs"},{"a":"save"},{"a":"authorize"},{"a":"run","fn":"start"},{"a":"panel","p":"sheet"},{"a":"set","tab":"설정","at":"B2","value":"가상학과"},{"a":"set","tab":"과목","at":"B2","value":"실습A-1분반"},{"a":"set","tab":"과목","at":"C2","value":"15"},{"a":"set","tab":"과목","at":"D2","value":"3"},{"a":"set","tab":"과목","at":"B3","value":"실습A-2분반"},{"a":"set","tab":"과목","at":"C3","value":"15"},{"a":"menu","fn":"numberCourses"}],"show":[{"a":"paste","tab":"학생","at":"A2","data":"student_rows"}],"copy":["student_rows"],"page":241}],"end":{"did":["attend.gs를 붙여 넣고 start를 실행해 일곱 탭과 메뉴 출석 관리를 만들었습니다.","[설정] 학과 이름과 [과목] 두 줄을 적고 과목 번호 채우기를 실행했습니다.","가짜 학생 20명(학생01~학생20)을 [학생] 탭에 붙여 넣었습니다."],"diff":["실제 화면은 모양과 글이 다릅니다. 같은 뜻의 단추를 찾으세요.","등록 폼 만들기·출석 링크 만들기·데스크 화면은 이 연습에 없습니다(책 3-1 ③~⑤).","학생 명단은 학교 개인정보 지침을 확인한 뒤 실제 계정에 둡니다. 연습 화면에는 가상 자료만 넣으세요."],"note":"나머지 단계(등록 폼·출석 링크)는 책을 보며 실제 시트에서 이어 갑니다."},"page":227,"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로 일하는 교수》 예제 | 3-1장 수업·실습 출석과 명단 | attend.gs 라이선스: MIT (저자 변경 가능) 과목 등록, 학생 등록 폼(개인정보 동의 필수), 출석 링크·데스크, 결석 계산, 오늘 목록, 학기 요약, 보유기간 삭제. 공식 출석부는 학사 시스템이 기준입니다. 메일·문자는 학생에게 보내지 않습니다. [확인필요: 실제 구글 계정 실행 미확인] 이 코드는 Node 모의 시험으로만 확인했습니다. 모의 시험은 실제 구글 동작을 보장하지 않습니다. 쓰는 법: 구글 시트 → 확장 프로그램 → Apps Script → 편집기의 글을 모두 지우고 이 파일 전체를 붙여 넣은 뒤 저장 → 함수 start 실행. 화면 글자(메뉴, 알림)는 서비스 업데이트로 달라질 수 있으니 같은 뜻을 찾으세요. AI에 넣지 않습니다: 이름·연락처 같은 개인정보, 건강정보, 비밀번호, API 키, 성적. 비밀값(PIN, 토큰)은 코드나 시트 칸이 아니라 스크립트 속성에만 둡니다. */ /* ===== 공통 도우미 ① 기본: 알림, 날짜(한국 시간), 시트 찾기, 잠금 ===== say_ : 편집기 실행 로그와 시트 알림창에 같이 알립니다(자동 실행 중에는 로그만 남습니다). ymd_ dayNo_ today_ : 날짜는 모두 한국 시간(Asia/Seoul)으로 자릅니다. UTC로 자르면 하루씩 어긋납니다. num_ : 빈칸은 null, 0은 0. "입력 안 함"과 "0"을 구분하는 읽기 도구입니다. withLock_ : 한꺼번에 들어오는 쓰기를 한 줄씩 차례로 처리합니다. */ var TZ = 'Asia/Seoul'; // 편집기의 ▶ 실행이 맨 처음 함수를 고르므로 start 를 가장 위에 둡니다: 시트 탭, 메뉴, 자동 실행을 한 번에 준비합니다. function start() { if (typeof setup !== 'function') { say_('이 코드에는 start 단계가 없습니다. 만들어진 함수를 직접 실행하세요.'); return; } setup(); if (typeof onOpen === 'function') onOpen(); } // 편집기 실행 로그와 시트 알림창에 같이 알립니다(자동 실행 중에는 알림창이 없어서 로그만 남습니다). function say_(msg) { Logger.log(msg); try { SpreadsheetApp.getUi().alert(msg); } catch (e) {} } function isDate_(v) { return Object.prototype.toString.call(v) === '[object Date]' && !isNaN(v.getTime()); } function ymd_(d) { return Utilities.formatDate(d, TZ, 'yyyy-MM-dd'); } function stamp_(d) { return Utilities.formatDate(d, TZ, 'yyyy-MM-dd HH:mm'); } function monthKey_(d) { return Utilities.formatDate(d, TZ, 'yyyy-MM'); } // 날짜 칸의 값(Date 또는 2026-03-02 같은 글자)을 '한국 날짜의 일 번호'로 바꿉니다. 비었거나 날짜가 아니면 null. function dayNo_(v) { if (v === '' || v == null) return null; var s = isDate_(v) ? ymd_(v) : String(v).trim(); var m = /^(\d{4})[-./]\s*(\d{1,2})[-./]\s*(\d{1,2})/.exec(s); if (!m) return null; var y = Number(m[1]), mo = Number(m[2]), d = Number(m[3]), t = Date.UTC(y, mo - 1, d), c = new Date(t); if (c.getUTCFullYear() !== y || c.getUTCMonth() !== mo - 1 || c.getUTCDate() !== d) return null; return t / 86400000; } function dayStr_(n) { var c = new Date(n * 86400000), p = function (x) { return (x < 10 ? '0' : '') + x; }; return c.getUTCFullYear() + '-' + p(c.getUTCMonth() + 1) + '-' + p(c.getUTCDate()); } function today_() { return dayNo_(new Date()); } // 칸의 값을 숫자로 읽습니다. 빈칸이나 숫자가 아닌 값은 null(입력 안 함), 0은 0입니다. function num_(v) { if (v === '' || v == null || typeof v === 'boolean' || isDate_(v)) return null; if (typeof v === 'number') return isFinite(v) ? v : null; var s = String(v).trim().replace(/,/g, ''); if (s === '') return null; var n = Number(s); return isFinite(n) ? n : null; } function ss_() { var p = PropertiesService.getScriptProperties(), id = p.getProperty('SS_ID'); if (id) return SpreadsheetApp.openById(id); var a = SpreadsheetApp.getActive(); if (!a) throw new Error('시트 메뉴에서 "처음 준비"를 먼저 실행하세요.'); p.setProperty('SS_ID', a.getId()); return a; } function sheet_(name, header) { var ss = ss_(), sh = ss.getSheetByName(name); if (!sh) { sh = ss.insertSheet(name); if (header) { sh.getRange(1, 1, 1, header.length).setValues([header]).setFontWeight('bold'); sh.setFrozenRows(1); } } return sh; } // 사람이 쓴 글을 시트에 넣을 때: = + - @ 로 시작하면 시트가 수식으로 계산하므로, 앞에 ' 를 붙여 글자 그대로 둡니다. function cell_(v) { return typeof v === 'string' && /^[=+\-@]/.test(v) ? "'" + v : v; } function row_(sh, arr) { sh.appendRow(arr.map(cell_)); } // 첫 열(col)의 값이 key와 같은 첫 데이터 줄 번호(2 이상)를 돌려줍니다. 없으면 0. function rowOf_(sh, col, key) { var n = sh.getLastRow() - 1; if (n < 1) return 0; var v = sh.getRange(2, col, n, 1).getValues(), k = String(key); for (var i = 0; i < n; i++) if (String(v[i][0]) === k) return i + 2; return 0; } function withLock_(ms, fn) { var lock = LockService.getScriptLock(); lock.waitLock(ms); try { return fn(); } finally { lock.releaseLock(); } } // 폼 제출 이벤트에서 답을 꺼냅니다. 폼에 건 트리거에는 e.response 만 있고, 시트에 건 트리거에만 e.namedValues 가 있습니다. 둘 다 받습니다. // 돌려주는 값: { 문항 제목: [답, ...] } (체크박스는 답이 여러 개일 수 있어 항상 배열) function answers_(e) { var out = {}; if (e && e.namedValues) { Object.keys(e.namedValues).forEach(function (k) { out[k] = [].concat(e.namedValues[k]).map(String); }); return out; } if (e && e.response) e.response.getItemResponses().forEach(function (r) { out[r.getItem().getTitle()] = [].concat(r.getResponse()).map(String); }); return out; } function first_(arr) { return arr && arr.length ? String(arr[0]).trim() : ''; } /* ===== 공통 도우미 ② 메일 ===== mail_ : 메일 한 통을 보냅니다. 받는 사람은 항상 코드가 정한 한 사람(내 메일)입니다. 이 책의 예제는 학생이나 신청자 모두에게 코드가 메일을 마음대로 보내지 않습니다(접수 확인처럼 본인이 직접 신청한 일에 대한 안내 한 통만 예외입니다). */ function mail_(to, subject, text) { var esc = function (x) { return String(x).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }; var clean = function (x) { return String(x).replace(/[\r\n]+/g, ' ').trim(); }; if (MailApp.getRemainingDailyQuota() < 1) throw new Error('오늘 보낼 수 있는 메일을 다 썼습니다. 내일 다시 하세요.'); var body = String(text).split('\n').map(function (l) { if (!l.trim()) return '<div style="height:10px"></div>'; return '<div>' + esc(l).replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" style="color:#1a1a1a">$1</a>') + '</div>'; }).join(''); var html = '<div style="max-width:560px;padding:8px 2px 28px;font:15px/1.75 \'Malgun Gothic\',\'Apple SD Gothic Neo\',sans-serif;color:#1a1a1a">' + '<div style="border-top:3px solid #1a1a1a;padding-top:14px;margin-bottom:16px;font-size:20px;line-height:1.35;font-weight:700">' + esc(clean(subject)) + '</div>' + body + '</div>'; MailApp.sendEmail(to, clean(subject), text, { htmlBody: html }); } /* ===== 공통 도우미 ③ 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: '설정', course: '과목', stu: '학생', att: '출석', today: '오늘 목록', term: '학기 요약', erased: '삭제 기록' }; var H_CFG = ['항목', '값', '설명']; var H_COURSE = ['과목 번호', '과목·분반 이름', '총 회차', '결석 한도', '지난 회차', '담당']; var H_STU = ['학생 번호', '표시 이름', '과목 번호', '학번(선택)', '등록일', '상태', '개인정보 동의 시각', '동의 문구 판', '보유 기한', '출석 토큰', '출석 링크']; var H_ATT = ['시각', '학생 번호', '과목 번호', '방법', '처리']; var H_TODAY = ['갱신 시각', '학생 번호', '표시 이름', '과목·분반', '출석', '결석', '결석 한도', '남은 결석 가능', '마지막 출석', '사유']; var H_TERM = ['주 시작일', '과목·분반', '지난 회차', '평균 출석률(%)', '한도 근접 학생 수', '신규 등록', '삭제']; var H_ERASED = ['삭제일', '학생 번호', '과목 번호', '사유', '처리']; var K = { dept: '학과 이름', near: '결석 한도 근접 기준(남은 횟수)', dup: '출석 중복 막기(분)', ver: '동의 문구 판', keep: '보유기간(개월)', mail: '알림 메일' }; // [설정] 첫 칸에 넣는 값입니다. 계산은 시트에 적힌 값만 읽습니다. 보유기간은 기관 기준이라 비워 둡니다. var SEED = [ [K.dept, '내 학과', '출석 화면 맨 위에 보이는 이름입니다.'], [K.near, 1, '남은 결석 가능 횟수가 이 값 이하이면 [오늘 목록]에 올립니다. 비우면 한도를 넘은 학생만 올립니다. 학교 규정이 아니라 내가 정하는 알림 기준입니다.'], [K.dup, 60, '같은 학생이 이 분 안에 다시 누르면 출석 줄을 새로 만들지 않습니다. 비우면 막지 않습니다.'], [K.ver, 'v1', '개인정보 동의 문구를 바꾸면 판 번호도 바꿉니다.'], [K.keep, '', '기관이 정한 보유기간(개월)을 적습니다. 비어 있으면 보유 기한을 계산하지 않습니다. [확인필요: 학교·기관 기준 확인]'], [K.mail, '', '오늘 목록에 학생이 있을 때 인원수만 받을 내 메일입니다. 비우면 메일을 보내지 않습니다. 학생에게는 보내지 않습니다.'] ]; var Q = { name: '표시 이름', course: '과목·분반', consent: '개인정보 수집·이용에 동의합니다(필수)' }; var AGREE = '동의합니다'; var S_ACTIVE = '수강', S_LEAVE = '휴학', S_DEL = '삭제 요청'; var M_LINK = '링크', M_DESK = '데스크', P_REC = '기록'; // 목록 사유는 이 셋입니다(한도까지 N회 / 한도 넘음 / 한도 입력 안 함). 결석 0회와 한도 입력 안 함은 다릅니다. var R_OVER = '한도 넘음', R_NOLIMIT = '한도 입력 안 함'; function reasonNear_(n) { return '한도까지 ' + n + '회'; } var WHY_KEEP = '보유기간', WHY_ASK = '본인 요청', WHY_DROP = '수강 취소'; var ATT_FMT = 'yyyy-MM-dd HH:mm'; function onOpen() { var menu = SpreadsheetApp.getUi().createMenu('출석 관리') .addItem('처음 준비', 'setup').addItem('과목 번호 채우기', 'numberCourses').addItem('등록 폼 만들기', 'makeRegisterForm') .addItem('출석 링크 만들기', 'makeTokens').addItem('출석 링크 바꾸기', 'menuResetToken') .addItem('오늘 목록 다시 만들기', 'rebuildToday').addItem('학기 요약', 'rebuildTerm') .addItem('매일 자동 켜기', 'enableDaily').addItem('매일 자동 끄기', 'disableDaily') .addItem('보유기간 지난 학생 보기', 'showExpired').addItem('보유기간 지난 학생 삭제', 'purgeExpired') .addItem('삭제 요청 처리', 'processDeletion').addItem('수강 취소 학생 삭제', 'menuDropStudent') .addItem('주소와 데스크 PIN 보기', 'showUrls'); if (typeof dashMenu_ === 'function') dashMenu_(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.course, H_COURSE); sheet_(T.stu, H_STU); sheet_(T.att, H_ATT); sheet_(T.today, H_TODAY); sheet_(T.term, H_TERM); sheet_(T.erased, H_ERASED); fmt_(T.stu, [7], ATT_FMT); fmt_(T.att, [1], ATT_FMT); fmt_(T.today, [1], ATT_FMT); fmt_(T.term, [1], '@'); fmt_(T.erased, [1], '@'); if (typeof dashSetup_ === 'function') dashSetup_(); 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 readCourses_() { var sh = sheet_(T.course, H_COURSE), n = sh.getLastRow() - 1; if (n < 1) return []; return sh.getRange(2, 1, n, H_COURSE.length).getValues().map(function (r, i) { return { row: i + 2, no: String(r[0]).trim(), name: String(r[1]).trim(), total: num_(r[2]), limit: num_(r[3]), past: num_(r[4]), owner: String(r[5]) }; }).filter(function (c) { return c.no !== '' || c.name !== ''; }); } function readStudents_() { var sh = sheet_(T.stu, H_STU), n = sh.getLastRow() - 1; if (n < 1) return []; return sh.getRange(2, 1, n, H_STU.length).getValues().map(function (r, i) { return { row: i + 2, no: String(r[0]).trim(), name: String(r[1]), course: String(r[2]).trim(), reg: dayNo_(r[4]), status: String(r[5]).trim(), consent: r[6], ver: r[7], expire: dayNo_(r[8]), token: String(r[9]).trim(), link: String(r[10]) }; }).filter(function (s) { return s.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(), course: String(r[2]).trim() }; }).filter(function (a) { return a.no !== ''; }); } function readErased_() { var sh = sheet_(T.erased, H_ERASED), n = sh.getLastRow() - 1; if (n < 1) return []; return sh.getRange(2, 1, n, H_ERASED.length).getValues().map(function (r) { return { day: dayNo_(r[0]), no: String(r[1]).trim(), course: String(r[2]).trim() }; }); } function load_() { return { cfg: cfg_(), courses: readCourses_(), stus: readStudents_(), att: readAtt_(), today: today_() }; } // ---------- 과목 번호 ---------- // 번호가 빈 과목 줄에 번호를 채웁니다: 잠금 안에서 지금 가장 큰 번호 + 1. function numberCourses_() { return withLock_(20000, function () { var sh = sheet_(T.course, H_COURSE), cs = readCourses_(), max = 0, k = 0; cs.forEach(function (c) { var n = num_(c.no); if (n != null && n > max) max = n; }); cs.forEach(function (c) { if (c.no === '' && c.name !== '') { max++; sh.getRange(c.row, 1).setValue(max); k++; } }); return k; }); } function numberCourses() { menuOnly_(); var k = numberCourses_(); say_(k ? '과목 번호를 ' + k + '개 채웠습니다.' : '새로 채울 과목 번호가 없습니다. [과목] 탭에 과목·분반 이름을 먼저 적으세요.'); } // ---------- 계산 ---------- function courseOf_(L, no) { var found = null; L.courses.forEach(function (c) { if (c.no !== '' && c.no === String(no)) found = c; }); return found; } // 지난 회차: [과목]의 지난 회차 칸에 값이 있으면 그 값, 비어 있으면 [출석]에 기록이 있는 날짜 수. function pastOf_(L, c) { if (c.past != null) return c.past; var days = {}; L.att.forEach(function (a) { if (a.course === c.no && a.day != null) days[a.day] = 1; }); return Object.keys(days).length; } // 학생 한 명의 숫자. 결석 = 지난 회차 - 출석한 서로 다른 날짜 수(0 아래로 내려가지 않음). 한도가 비어 있으면 limit·left 는 null(입력 안 함)이고 0이 아닙니다. function stateOf_(L, s) { var c = courseOf_(L, s.course), days = {}, last = null; L.att.forEach(function (a) { if (a.no !== s.no || a.day == null) return; days[a.day] = 1; if (last == null || a.day > last) last = a.day; }); var attended = Object.keys(days).length, past = c ? pastOf_(L, c) : null; var absent = past == null ? null : Math.max(0, past - attended); var limit = c ? c.limit : null; var left = limit != null && absent != null ? limit - absent : null; return { course: c, attended: attended, past: past, absent: absent, limit: limit, left: left, last: last }; } function reasonOf_(L, st) { if (st.limit == null || st.left == null) return R_NOLIMIT; if (st.left < 0) return R_OVER; var near = num_(L.cfg[K.near]); if (near != null && st.left <= near) return reasonNear_(st.left); return ''; } function buildToday_(L) { var now = new Date(), rows = []; L.stus.forEach(function (s) { if (s.status !== S_ACTIVE) return; var st = stateOf_(L, s), why = reasonOf_(L, st); if (!why) return; rows.push([now, s.no, s.name, st.course ? st.course.name : '', st.attended, st.absent == null ? '' : st.absent, st.limit == null ? '' : st.limit, st.left == null ? '' : st.left, st.last == null ? '' : dayStr_(st.last), why]); }); return rows; } // 화면 없이 목록을 다시 만듭니다(매일 자동 실행에서도 돕니다). 삭제한 학생은 [학생] 탭에 없으므로 다시 나오지 않습니다. function rebuildCore_() { return withLock_(20000, function () { var rows = buildToday_(load_()), sh = sheet_(T.today, H_TODAY); sh.clearContents(); sh.getRange(1, 1, 1, H_TODAY.length).setValues([H_TODAY]).setFontWeight('bold'); if (rows.length) { sh.getRange(2, 1, rows.length, 1).setNumberFormat(ATT_FMT); sh.getRange(2, 9, rows.length, 1).setNumberFormat('@'); sh.getRange(2, 1, rows.length, H_TODAY.length).setValues(rows.map(function (r) { return r.map(cell_); })); } return { count: rows.length }; }); } function rebuildToday() { menuOnly_(); var r = rebuildCore_(); say_(r.count ? '오늘 목록을 다시 만들었습니다. 목록에 오른 학생: ' + r.count + '명' : '오늘 목록을 다시 만들었습니다. 지금 목록에 오를 학생이 없습니다.'); } // ---------- 학기 요약 ---------- // 일 번호의 그 주 월요일. 1970-01-01(일 번호 0)은 목요일입니다. function weekStart_(n) { return n - (((n % 7) + 10) % 7); } // 주 하나 × 과목 하나가 한 줄입니다. 학생 이름은 쓰지 않습니다. function termRows_(L) { var w = weekStart_(L.today), erased = readErased_(), near = num_(L.cfg[K.near]), rows = []; L.courses.forEach(function (c) { if (c.no === '') return; var past = pastOf_(L, c), mine = L.stus.filter(function (s) { return s.course === c.no && s.status === S_ACTIVE; }); var sum = 0, close = 0; mine.forEach(function (s) { var st = stateOf_(L, s); if (past > 0) sum += Math.min(1, st.attended / past); if (st.left != null && (st.left < 0 || (near != null && st.left <= near))) close++; }); var rate = past > 0 && mine.length ? Math.round(sum / mine.length * 1000) / 10 : ''; var fresh = L.stus.filter(function (s) { return s.course === c.no && s.reg != null && s.reg >= w && s.reg <= w + 6; }).length; var gone = erased.filter(function (x) { return x.course === c.no && x.day != null && x.day >= w && x.day <= w + 6; }).length; rows.push([dayStr_(w), c.name, past, rate, close, fresh, gone]); }); return rows; } // 이번 주 줄은 덮어쓰고, 지난 주 줄은 그대로 둡니다(매주 쌓입니다). function rebuildTermCore_() { return withLock_(20000, function () { var rows = termRows_(load_()), sh = sheet_(T.term, H_TERM), n = sh.getLastRow() - 1; var have = n > 0 ? sh.getRange(2, 1, n, 2).getValues() : []; rows.forEach(function (r) { var at = 0; have.forEach(function (h, i) { if (dayNo_(h[0]) === dayNo_(r[0]) && String(h[1]) === String(r[1])) at = i + 2; }); if (at) sh.getRange(at, 1, 1, H_TERM.length).setValues([r.map(cell_)]); else { sh.getRange(sh.getLastRow() + 1, 1, 1, H_TERM.length).setValues([r.map(cell_)]); have.push([r[0], r[1]]); } }); return rows.length; }); } function rebuildTerm() { menuOnly_(); var k = rebuildTermCore_(); say_(k ? '학기 요약에 이번 주 ' + k + '줄을 만들었습니다. 이름은 들어 있지 않습니다.' : '요약할 과목이 없습니다. [과목] 탭에 과목을 적고 과목 번호 채우기를 먼저 하세요.'); } // ---------- 매일 0시 ---------- function dailyRun() { // 시간 트리거는 화면이 없는 환경입니다. 알림창 대신 로그만 남깁니다. numberCourses_(); var r = rebuildCore_(), k = rebuildTermCore_(), to = String(cfg_()[K.mail] || '').trim(); Logger.log('오늘 목록 자동 갱신: ' + r.count + '명, 학기 요약 ' + k + '줄'); if (r.count && isEmail_(to)) { try { mail_(to, '[출석 관리] 오늘 목록 ' + r.count + '명', '오늘 목록에 오른 학생: ' + r.count + '명\n이름은 메일에 담지 않았습니다. 시트의 [오늘 목록] 탭에서 확인하세요.'); } catch (err) { Logger.log('알림 메일 실패: ' + err.message); } } } function isEmail_(s) { return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(s)); } 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 makeRegisterForm() { menuOnly_(); var p = PropertiesService.getScriptProperties(), id = p.getProperty('FORM_ID'); if (id) { say_('등록 폼이 이미 있습니다. 학생용 등록 주소: ' + FormApp.openById(id).getPublishedUrl() + '\n과목을 더했다면 폼에서 과목·분반 선택지를 직접 고칩니다.'); return; } numberCourses_(); var names = readCourses_().filter(function (c) { return c.no !== '' && c.name !== ''; }).map(function (c) { return c.name; }); if (!names.length) { say_('[과목] 탭에 과목·분반 이름을 먼저 적고 과목 번호 채우기를 누르세요.'); return; } var c = cfg_(), keep = num_(c[K.keep]); var form = FormApp.create(String(c[K.dept] || '학과') + ' 출석 등록'); form.setDescription('받는 정보는 표시 이름과 과목뿐입니다. 학번·연락처·건강 정보는 묻지 않습니다. 이름 대신 별칭을 써도 됩니다.'); form.addTextItem().setTitle(Q.name).setRequired(true); form.addListItem().setTitle(Q.course).setChoiceValues(names).setRequired(true); form.addCheckboxItem().setTitle(Q.consent).setChoiceValues([AGREE]).setRequired(true) .setHelpText('수집 항목: 표시 이름, 과목. 목적: 출석 확인과 명단 관리(공식 출석부는 학사 시스템). 보유 기간: ' + (keep == null ? '[기관이 정한 기간을 적습니다]' : keep + '개월') + '. 동의하지 않을 권리가 있으며, 필수 항목에 동의하지 않으면 등록할 수 없습니다. 열람·삭제는 담당자에게 요청합니다. [확인필요: 정확한 문구는 법령과 기관 안내를 직접 확인]'); // 응답을 시트로 따로 보내지 않습니다: 개인정보 사본이 둘로 늘어나지 않게 코드가 [학생] 탭에만 옮깁니다. 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), courseName = first_(v[Q.course]); if (!name) { Logger.log('등록하지 않았습니다: 표시 이름이 비어 있습니다.'); return { ok: false, why: 'name' }; } if (first_(v[Q.consent]) !== AGREE) { Logger.log('등록하지 않았습니다: 개인정보 동의가 없습니다.'); return { ok: false, why: 'consent' }; } var course = null; readCourses_().forEach(function (c) { if (c.no !== '' && c.name === courseName) course = c; }); if (!course) { Logger.log('등록하지 않았습니다: [과목] 탭에 없는 과목입니다.'); return { ok: false, why: 'course' }; } var out = addStudent_(name, course.no); // 시트에 옮겼으면 폼에 남은 응답은 지웁니다(사본을 하나로). 실패해도 등록은 유지합니다. 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 addStudent_(name, courseNo) { return withLock_(20000, function () { var sh = sheet_(T.stu, H_STU), c = cfg_(), now = new Date(), no = 0; readStudents_().forEach(function (s) { var n = num_(s.no); if (n != null && n > no) no = n; }); readErased_().forEach(function (x) { var n = num_(x.no); if (n != null && n > no) no = n; }); // 지운 학생의 번호도 다시 쓰지 않습니다 no++; var keep = num_(c[K.keep]), expire = keep == null ? '' : addMonths_(today_(), keep); var token = newToken_(), url = ScriptApp.getService().getUrl(); row_(sh, [no, name, courseNo, '', dayStr_(today_()), S_ACTIVE, now, String(c[K.ver] || ''), 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.stu, H_STU), url = ScriptApp.getService().getUrl(), k = 0; readStudents_().forEach(function (s) { var token = s.token; if (!token) { token = newToken_(); sh.getRange(s.row, 10).setValue(token); k++; } if (url && (!s.link || s.link.indexOf(token) < 0)) sh.getRange(s.row, 11).setValue(url + '?v=me&t=' + token); }); return { k: k, url: url }; }); say_('출석 링크를 ' + made.k + '개 새로 만들었습니다.' + (made.url ? '' : '\n웹 앱으로 배포한 뒤 다시 실행하면 [학생] 탭의 출석 링크 칸이 채워집니다.')); } function updateStudent_(no, col, value) { // 대상이 없으면 성공 처리하지 않고 오류를 냅니다 return withLock_(20000, function () { var sh = sheet_(T.stu, H_STU), 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(); updateStudent_(no, 10, token); updateStudent_(no, 11, 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_LEAVE, S_DEL].indexOf(status) < 0) throw new Error('상태는 ' + S_ACTIVE + ', ' + S_LEAVE + ', ' + S_DEL + ' 가운데 하나여야 합니다.'); updateStudent_(no, 6, status); return { ok: true, no: String(no), status: status }; } // ---------- 보유기간과 삭제 ---------- function expiredList_(L) { var out = []; L.stus.forEach(function (s) { if (s.status === S_DEL) out.push({ no: s.no, name: s.name, why: WHY_ASK }); else if (s.expire != null && s.expire < L.today) out.push({ no: s.no, name: s.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 eraseCore_(no, why) { if ([WHY_KEEP, WHY_ASK, WHY_DROP].indexOf(why) < 0) throw new Error('삭제 사유는 ' + WHY_KEEP + ', ' + WHY_ASK + ', ' + WHY_DROP + ' 가운데 하나여야 합니다.'); return withLock_(30000, function () { var stu = sheet_(T.stu, H_STU), r = rowOf_(stu, 1, no); if (!r) throw new Error('해당 번호의 학생이 없습니다: ' + no); var courseNo = String(stu.getRange(r, 3).getValue()).trim(), att = sheet_(T.att, H_ATT), gone = 0; for (var i = att.getLastRow(); i >= 2; i--) if (String(att.getRange(i, 2).getValue()).trim() === String(no)) { att.deleteRow(i); gone++; } stu.deleteRow(r); row_(sheet_(T.erased, H_ERASED), [dayStr_(today_()), no, courseNo, why, '삭제 완료']); return { ok: true, no: String(no), removed: gone }; }); } function refreshLists_() { rebuildCore_(); rebuildTermCore_(); } // 지운 학생이 오늘 목록과 학기 요약에 남지 않게 바로 다시 만듭니다 function eraseStudent(no, why) { var out = eraseCore_(no, why); refreshLists_(); return out; } 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) { eraseCore_(x.no, x.why); done++; }); refreshLists_(); say_(done + '명의 정보를 지웠습니다. [삭제 기록] 탭에는 번호와 날짜만 남습니다.'); } function eraseByPrompt_(title, why) { var ui = SpreadsheetApp.getUi(), r = ui.prompt(title, '지울 학생의 번호를 넣으세요.', 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; } eraseStudent(no, why); say_(no + '번 학생의 정보를 지웠습니다.'); } catch (e) { say_(e.message); } } function processDeletion() { menuOnly_(); eraseByPrompt_('삭제 요청 처리', WHY_ASK); } function menuDropStudent() { menuOnly_(); eraseByPrompt_('수강 취소 학생 삭제', WHY_DROP); } 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 === 'desk') return page_('데스크 출석', DESK_HTML + '<script>(' + deskClient_.toString() + ')();</script>'); if (v === 'me') return page_('내 출석', '<script>var TOKEN=' + js_(String(p.t || '')) + ';</script>' + ME_HTML + '<script>(' + meClient_.toString() + ')();</script>'); return page_('출석', '<h1>출석</h1><p>받은 출석 링크로 열어 주세요.</p>'); } // 실패한 토큰이 10분에 50번을 넘으면 잠시 모든 토큰 조회를 멈춥니다. function studentByToken_(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)) readStudents_().forEach(function (s) { if (s.token === token) found = s; }); if (!found) { throttle_('badtoken', 50, 600); return null; } return { s: found }; } var BAD_LINK = '링크가 맞지 않습니다. 담당자에게 새 링크를 받으세요.'; // 학생 한 명의 화면 값. 이 학생의 숫자만 담습니다. function view_(L, s) { var st = stateOf_(L, s), lines = []; lines.push(st.absent == null ? '출석 ' + st.attended + '회' : '출석 ' + st.attended + '회 · 결석 ' + st.absent + '회'); if (st.limit == null) lines.push('결석 한도 입력 안 함'); else if (st.left == null) lines.push('결석 한도 ' + st.limit + '회'); else if (st.left < 0) lines.push('결석 한도 ' + st.limit + '회 · 한도를 넘었습니다'); else lines.push('결석 한도 ' + st.limit + '회 · 남은 결석 가능 ' + st.left + '회'); return { name: s.name, dept: String(L.cfg[K.dept] || ''), courseName: st.course ? st.course.name : '', text: lines, last: st.last == null ? '' : dayStr_(st.last) }; } // 학생 본인 화면이 부릅니다. 돌려주는 값에는 토큰 주인 한 명의 정보만 있습니다. function studentInfo(token) { var f = studentByToken_(token); if (!f || f.locked) return { ok: false, msg: f ? '잠시 뒤에 다시 열어 주세요.' : BAD_LINK }; var out = view_(load_(), f.s); out.ok = true; return out; } function studentCheckIn(token) { var f = studentByToken_(token); if (!f || f.locked) return { ok: false, msg: f ? '잠시 뒤에 다시 눌러 주세요.' : BAD_LINK }; return checkIn_(f.s.no, M_LINK); } // 출석을 한 줄 기록합니다. 잠금 안에서 처리하고, 같은 학생이 설정한 분 안에 다시 누르면 새 줄을 만들지 않습니다. function checkIn_(no, method) { return withLock_(20000, function () { var L = load_(), s = null; L.stus.forEach(function (x) { if (x.no === String(no)) s = x; }); if (!s) return { ok: false, msg: '해당 번호의 학생이 없습니다.' }; if (s.status !== S_ACTIVE) return { ok: false, msg: '지금은 출석을 기록할 수 없습니다. 담당자에게 문의하세요.' }; if (s.consent === '' || s.consent == null) return { ok: false, msg: '개인정보 동의가 아직 없습니다. 담당자에게 문의하세요.' }; if (!courseOf_(L, s.course)) return { ok: false, msg: '과목 정보를 찾을 수 없습니다. 담당자에게 문의하세요.' }; var dup = num_(L.cfg[K.dup]), nowMs = new Date().getTime(); if (dup != null && dup > 0 && L.att.some(function (a) { return a.no === s.no && !isNaN(a.ms) && nowMs - a.ms >= 0 && nowMs - a.ms < dup * 60000; })) { var again = view_(L, s); again.ok = true; again.dup = true; again.msg = '이미 출석이 기록되어 있습니다.'; return again; } row_(sheet_(T.att, H_ATT), [new Date(), s.no, s.course, method, P_REC]); var L2 = load_(), out = view_(L2, s); out.ok = true; out.dup = false; out.msg = '출석했습니다.'; return out; }); } // ---------- 데스크 ---------- function deskLogin(pin) { return auth_(pin, 'desk'); } function deskList(cred, courseNo) { var a = auth_(cred, 'desk'); if (!a.ok) return a; var L = load_(), courses = L.courses.filter(function (c) { return c.no !== ''; }).map(function (c) { return { no: c.no, name: c.name }; }); var pick = String(courseNo || ''); if (!courses.some(function (c) { return c.no === pick; })) pick = courses.length ? courses[0].no : ''; return { ok: true, token: a.token, courses: courses, course: pick, items: L.stus.filter(function (s) { return s.status === S_ACTIVE && s.course === pick; }).map(function (s) { return { no: s.no, name: s.name }; }) }; } function deskCheckIn(cred, no) { var a = auth_(cred, 'desk'); if (!a.ok) return a; return checkIn_(no, M_DESK); } // ---------- 화면 ---------- var ME_HTML = '<h1 id="nm">…</h1><p class="mut" id="cls"></p>' + '<div class="card"><p id="l1">…</p><p id="l2"></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.dept, d.courseName].filter(function (x) { return x; }).join(' · '); $('#l1').textContent = d.text[0] || ''; $('#l2').textContent = d.text[1] || ''; $('#last').textContent = d.last ? '마지막 출석 ' + d.last : '아직 출석 기록이 없습니다.'; } google.script.run.withSuccessHandler(function (d) { if (!d.ok) { $('#nm').textContent = '링크를 확인해 주세요'; $('#l1').textContent = ''; $('#go').hidden = true; msg(d.msg, false); return; } show(d); }).withFailureHandler(fail).studentInfo(TOKEN); $('#go').onclick = function () { $('#go').disabled = true; google.script.run.withSuccessHandler(function (d) { $('#go').disabled = false; if (!d.ok) { msg(d.msg, false); return; } show(d); msg(d.msg, true); }).withFailureHandler(fail).studentCheckIn(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="course">과목·분반</label><select id="course"></select><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 = '', filling = false; try { tok = sessionStorage.getItem('deskTok') || ''; } 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; filling = true; var sel = $('#course'); sel.textContent = ''; d.courses.forEach(function (c) { var o = document.createElement('option'); o.value = c.no; o.textContent = c.name; sel.appendChild(o); }); sel.value = d.course; filling = false; try { localStorage.setItem('deskCourse', d.course); } catch (e) {} 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.text ? r.text.join(' · ') : '') : r.msg, r.ok); }).withFailureHandler(fail).deskCheckIn(tok, s.no); }; l.appendChild(b); }); } function load(t, course) { google.script.run.withSuccessHandler(draw).withFailureHandler(fail).deskList(t, course); } $('#course').onchange = function () { if (!filling) load(tok, $('#course').value); }; $('#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) { var saved = ''; try { saved = localStorage.getItem('deskCourse') || ''; } catch (e) {} load(tok, saved); } }
1 학생01 1 2026-09-01 수강 2026-09-01 10:00 v1 2 학생02 1 2026-09-01 수강 2026-09-01 10:00 v1 3 학생03 1 2026-09-01 수강 2026-09-01 10:00 v1 4 학생04 1 2026-09-01 수강 2026-09-01 10:00 v1 5 학생05 1 2026-09-01 수강 2026-09-01 10:00 v1 6 학생06 1 2026-09-01 수강 2026-09-01 10:00 v1 7 학생07 1 2026-09-01 수강 2026-09-01 10:00 v1 8 학생08 1 2026-09-01 수강 2026-09-01 10:00 v1 9 학생09 1 2026-09-01 수강 2026-09-01 10:00 v1 10 학생10 1 2026-09-01 수강 2026-09-01 10:00 v1 11 학생11 2 2026-09-08 수강 2026-09-01 10:00 v1 12 학생12 2 2026-09-08 수강 2026-09-01 10:00 v1 13 학생13 2 2026-09-08 수강 2026-09-01 10:00 v1 14 학생14 2 2026-09-08 수강 2026-09-01 10:00 v1 15 학생15 2 2026-09-08 수강 2026-09-01 10:00 v1 16 학생16 2 2026-09-08 휴학 2026-09-01 10:00 v1 17 학생17 2 2026-09-15 수강 2026-09-01 10:00 v1 18 학생18 2 2026-09-15 수강 2026-09-01 10:00 v1 19 학생19 2 2026-09-15 수강 20 학생20 2 2026-09-15 수강