DailyByte
일상과 연결되는 AI
DailyByte
일상과 연결되는 AI
← 전체 코드 목록
📖 사용 방법
구글 앱스 스크립트와 Gemini AI를 활용하여, 어떤 글이든 논문, 보고서, 요약, 공문, 발표문 등 원하는 문서 스타일에 맞춰 자동으로 변환해주는 나만의 AI 글쓰기 도우미를 만들어 보세요.
이런 분께 추천해요
- 딱딱한 글을 특정 문서 스타일에 맞춰 변환하고 싶은 분
- 앱스 스크립트(Apps Script)를 배우고 싶은 구글 시트 사용자
- AI 기술을 업무에 적용해 자동화하고 싶은 분들
완성하면 이렇게 됩니다
원하는 텍스트를 입력하고, '논문', '보고서', '요약', '공문', '발표문' 중 원하는 스타일을 선택하면, AI가 자동으로 글을 변환해 주는 웹 애플리케이션(Web Application)이 완성됩니다. 이 웹 앱은 구글 계정만 있다면 누구나 쉽게 접근하여 사용할 수 있습니다.
준비물
- 구글 계정: Gmail 계정만 있으면 충분합니다.
- Gemini API Key: 구글 AI 스튜디오(Google AI Studio) 등에서 발급받을 수 있는 Gemini API 키가 필요합니다.
따라하기
1단계: 구글 앱스 스크립트 프로젝트 만들기
- 구글 드라이브(Google Drive)에 접속합니다.
- 왼쪽 상단의 '+ 새로 만들기' 버튼을 클릭합니다.
- '더보기'를 선택한 후, 'Google Apps Script'를 클릭하여 새 앱스 스크립트 프로젝트를 생성합니다. (만약 'Google Apps Script'가 보이지 않는다면, '앱 연결하기'를 통해 추가할 수 있습니다.)
2단계: 코드 붙여넣기
Code.gs파일에 코드 붙여넣기:- 새로 만든 앱스 스크립트 프로젝트에는 기본적으로
Code.gs라는 파일이 있습니다. 이 파일의 기존 내용을 모두 지우고, 위에 제공된 자바스크립트(JavaScript) 코드를 복사하여 붙여넣으십시오.
- 새로 만든 앱스 스크립트 프로젝트에는 기본적으로
Index.html파일 추가하기:- 이 웹 앱은 사용자 인터페이스(UI)를 위해
Index.html파일이 필요합니다. 앱스 스크립트 편집기 왼쪽 메뉴에서 '파일' 옆의 '+' 버튼을 클릭한 후, 'HTML'을 선택하고 파일 이름을Index로 입력하여Index.html파일을 만드십시오. - 주의:
Index.html파일의 구체적인 내용은 이 가이드에서 제공되지 않습니다. 웹 앱을 완전히 실행하려면 해당 파일에 적절한 HTML 코드가 있어야 합니다. 이 가이드에서는 AI 변환기의 핵심 기능인 백엔드(Backend) 스크립트 설정에 집중합니다.
- 이 웹 앱은 사용자 인터페이스(UI)를 위해
3단계: Gemini API 키 설정
- API 키 발급받기: 구글 AI 스튜디오(Google AI Studio) 등에서 Gemini API 키를 발급받으십시오.
- 앱스 스크립트 편집기에서
setApiKey함수 실행:Code.gs파일 상단에 있는 함수 선택 드롭다운 메뉴에서setApiKey를 선택합니다.- 상단의 '실행' 버튼(▶ 모양)을 클릭합니다.
- 처음 실행할 때는 구글 계정 승인 요청이 나타날 수 있습니다. 안내에 따라 승인을 완료하십시오.
- 함수 실행 시 '매개변수(Parameter)' 입력 창이 나타나면, 발급받은 Gemini API 키를 입력하고 '실행'을 클릭합니다.
- 이 과정을 통해 API 키가 스크립트 속성(Properties Service)에 안전하게 저장됩니다. 이 작업은 최초 1회만 진행하면 됩니다.
4단계: 웹 앱으로 배포하기
- 앱스 스크립트 편집기 오른쪽 상단의 '배포' 버튼을 클릭한 후, '새 배포'를 선택합니다.
- '배포 유형 선택'에서 '웹 앱'을 선택합니다.
- 설정:
- 설명: 웹 앱의 이름을 입력합니다 (예: 'AI 글쓰기 도우미').
- 실행 계정: '나 자신'으로 설정합니다.
- 액세스 권한: '모든 사용자'로 설정합니다. (이렇게 해야 누구나 웹 앱에 접근할 수 있습니다.)
- '배포' 버튼을 클릭합니다.
- 배포가 완료되면 웹 앱 URL이 나타납니다. 이 URL을 복사하여 웹 브라우저에 붙여넣으면 웹 앱이 실행됩니다.
자주 막히는 부분
- "API Key가 없습니다." 또는 "API Key가 비어 있습니다." 에러: 3단계의 'Gemini API 키 설정'을 다시 확인하고,
setApiKey함수를 통해 키가 올바르게 저장되었는지 확인하십시오. - "유효하지 않은 필터입니다." 에러: 웹 앱에서 선택한 스타일(필터)이 코드에 정의된
CFG.FILTERS(논문, 보고서, 요약, 공문, 발표문) 중 하나인지 확인하십시오. - "원문이 비어 있습니다." 에러: 변환할 텍스트를 입력하지 않고 변환을 시도했을 때 발생합니다. 텍스트 입력란이 비어있지 않은지 확인하십시오.
- 웹 앱이 실행되지 않거나 흰 화면만 나옵니다:
Index.html파일이 없거나 내용이 비어있을 가능성이 큽니다. 2단계의Index.html파일 추가 부분을 다시 확인하십시오.
이렇게도 써보세요 (응용)
- 새로운 변환 스타일 추가:
CFG.FILTERS배열에 '이메일', '블로그 글' 등 새로운 스타일을 추가하고,buildPrompt_함수에 해당 스타일에 맞는 프롬프트(Prompt) 규칙을 정의하여 더 다양한 변환 기능을 만들 수 있습니다. - AI의 창의성 조절:
callGemini_함수 내generationConfig: { temperature: 0.3, ... }부분에서temperature값을 조절해 보세요. 값을 높이면 AI의 답변이 더 창의적이고 다양해지고, 낮추면 더 일관적이고 보수적인 답변을 얻을 수 있습니다. - 구글 시트와 연동: 현재는 웹 앱 형태로 작동하지만,
transformText함수를 구글 시트의 사용자 정의 함수로 만들거나, 시트의 특정 셀에 있는 텍스트를 자동으로 변환하여 다른 셀에 결과를 출력하는 방식으로 응용할 수도 있습니다.
한 줄 정리
구글 앱스 스크립트와 Gemini AI로 나만의 글쓰기 비서, AI 문서 스타일 변환기를 쉽고 빠르게 만들어 보세요!
💻 코드
const CFG = {
MODEL: 'gemini-2.5-flash',
FILTERS: ['논문', '보고서', '요약', '공문', '발표문'],
};
function doGet() {
return HtmlService.createHtmlOutputFromFile('Index')
.setTitle('AI 변환 웹앱')
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}
// (선택) 최초 1회 키 저장용
function setApiKey(key) {
key = (key || '').trim();
if (!key) throw new Error('API Key가 비어 있습니다.');
PropertiesService.getScriptProperties().setProperty('GEMINI_API_KEY', key);
return true;
}
function getFilters() {
return CFG.FILTERS.slice();
}
function transformText(source, filter) {
source = String(source || '').trim();
filter = String(filter || '').trim();
if (!source) throw new Error('원문이 비어 있습니다.');
if (!CFG.FILTERS.includes(filter)) throw new Error('유효하지 않은 필터입니다.');
const apiKey = PropertiesService.getScriptProperties().getProperty('GEMINI_API_KEY');
if (!apiKey) throw new Error('API Key가 없습니다. (스크립트 속성 GEMINI_API_KEY)');
const prompt = buildPrompt_(source, filter);
const out = callGemini_(apiKey, prompt, 2048);
let finalOut = out;
// 끊김 감지 → 이어쓰기 (논문/보고서/발표문)
const trimmed = finalOut.trim();
const looksCut = trimmed.length >= 1200 && !/[.?!…]|[。?!]|ns*$/.test(trimmed);
if (looksCut && (filter === '발표문' || filter === '보고서' || filter === '논문')) {
const cont = continueGeneration_(apiKey, finalOut);
if (cont) finalOut = (finalOut + 'n' + cont).trim();
}
// 요약 후처리(명사형 종결 보정)
if (filter === '요약') finalOut = postprocessSummaryToNounEnding_(finalOut);
return finalOut;
}
function decideSummaryBullets_(source) {
const n = (source || '').replace(/s+/g, ' ').trim().length;
if (n <= 80) return 2;
if (n <= 200) return 3;
if (n <= 450) return 5;
if (n <= 900) return 7;
return 9;
}
function buildPrompt_(source, filter) {
const commonRules = [
'출력은 한국어로만 작성.',
'원문에 없는 사실/수치/근거를 임의로 추가하지 말 것.',
'고유명사/수치/전문용어는 가능한 한 원문을 유지.',
'불필요한 사족(서론, 자기소개, 메타 발언) 금지.',
'요청된 형식/문체만 출력.'
].join('n');
const summaryBullets = decideSummaryBullets_(source);
const styleMap = {
'논문': [
'문체: 학술 논문체(객관적, 서술은 "~다/~이다" 계열).',
'표현: 구어체/감탄/주관적 평가 최소화.',
'구성: 필요 시 (정의)-(특징)-(함의) 순으로 간결 재구성.',
'제한: 원문에 없는 참고문헌/저자/연도/주장 추가 금지.'
].join('n'),
'보고서': [
'문체: 실무 보고서체(명료, 단락/불릿 허용).',
'구성: (요약)-(핵심 내용)-(시사점/권고) 순서 권장.',
'표현: 실행 가능 문장 중심.'
].join('n'),
'요약': [
`형식: 불릿 ${summaryBullets}개 내외.`,
'✅ 각 불릿은 명사형/체언형으로 종결(예: "~탐구", "~경계", "~가능성", "~미래상").',
'✅ "이다/입니다/합니다/된다/되다" 등 서술어 종결 금지.',
'중복 제거, 과장 금지, 원문 범위 내 핵심만.'
].join('n'),
'공문': [
'문체: 공문/행정 문체(격식, 간결).',
'형식: 목적-내용-요청(또는 협조) 순.',
'필요 시 날짜/수신/참조는 [대괄호] placeholder 처리.'
].join('n'),
'발표문': [
'문체: 발표 스크립트(과도한 구어체 금지, 흐름 중심).',
'구성: 도입-핵심 3포인트-정리.',
'마무리: 끝맺음 문장으로 자연스럽게 종료.'
].join('n')
};
const style = styleMap[filter] || `문체: "${filter}"에 맞춰 자연스럽게 변환.`;
return [
'너는 한국어 글쓰기 편집자다.',
commonRules,
'',
'요청 스타일:',
style,
'',
'원문:',
source
].join('n');
}
function callGemini_(apiKey, prompt, maxOutputTokens) {
const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(CFG.MODEL)}:generateContent`;
const payload = {
contents: [{ role: 'user', parts: [{ text: prompt }] }],
generationConfig: { temperature: 0.3, maxOutputTokens: maxOutputTokens || 1024 }
};
const res = UrlFetchApp.fetch(url, {
method: 'post',
contentType: 'application/json; charset=utf-8',
headers: { 'x-goog-api-key': apiKey },
payload: JSON.stringify(payload),
muteHttpExceptions: true
});
const code = res.getResponseCode();
const text = res.getContentText();
if (code < 200 || code >= 300) throw new Error(`Gemini API HTTP ${code}: ${text}`);
const json = JSON.parse(text);
const out = (json.candidates?.[0]?.content?.parts || []).map(p => p.text || '').join('').trim();
if (!out) throw new Error('빈 응답이 반환되었습니다.');
return out;
}
function continueGeneration_(apiKey, partialText) {
const prompt =
`아래 글이 중간에서 끊겼다. 직전 문장 흐름을 이어서 자연스럽게 끝까지 완성하라.
- 기존 문장 반복 최소화
- 출력은 이어지는 부분만
- 마지막은 마무리 문장으로 종결
[이미 작성된 내용]
${partialText}
[이어서 작성]`;
try {
return callGemini_(apiKey, prompt, 1024);
} catch (e) {
return '';
}
}
function postprocessSummaryToNounEnding_(text) {
const lines = String(text || '').split('n');
const fixed = lines.map(line => {
let s = line;
const isBullet = /^s*([*-•]|d+[.)])s+/.test(s);
if (!isBullet) return s;
s = s
.replace(/s*(입니다|이다|합니다|된다|되다).?s*$/u, '')
.replace(/.s*$/u, '');
return s;
});
return fixed.join('n').trim();
}
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>AI 변환</title>
<!-- Google-ish system font stack -->
<style>
:root{
--bg:#f6f8fc;
--card:#ffffff;
--text:#1f1f1f;
--sub:#5f6368;
--border:#e0e3e7;
--shadow: 0 1px 2px rgba(60,64,67,.10), 0 1px 3px rgba(60,64,67,.08);
--radius:16px;
--focus: 0 0 0 3px rgba(26,115,232,.20);
--blue:#1a73e8;
--blue2:#1557b0;
--chip:#eef3fd;
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans KR", Arial, sans-serif;
color:var(--text);
background:var(--bg);
line-height:1.5;
}
/* Top app bar */
.appbar{
position:sticky; top:0; z-index:10;
background:rgba(246,248,252,.85);
backdrop-filter:saturate(1.2) blur(10px);
border-bottom:1px solid var(--border);
}
.appbar-inner{
max-width:1080px;
margin:0 auto;
padding:14px 16px;
display:flex;
align-items:center;
justify-content:space-between;
gap:12px;
}
.brand{
display:flex; align-items:center; gap:10px;
min-width:0;
}
.logo{
width:34px; height:34px;
border-radius:10px;
background:linear-gradient(135deg,#e8f0fe,#ffffff);
border:1px solid var(--border);
display:grid; place-items:center;
box-shadow: var(--shadow);
flex:0 0 auto;
}
.logo svg{width:18px;height:18px;fill:var(--blue)}
.title{
display:flex; flex-direction:column; min-width:0;
}
.title strong{font-size:14px; letter-spacing:.2px}
.title span{font-size:12px; color:var(--sub); white-space:nowrap; overflow:hidden; text-overflow:ellipsis}
.bar-actions{display:flex; gap:8px; flex-wrap:wrap; justify-content:flex-end}
/* Layout */
.wrap{
max-width:1080px;
margin:18px auto 60px;
padding:0 16px;
display:grid;
grid-template-columns: 1.15fr .85fr;
gap:14px;
}
@media (max-width: 980px){
.wrap{grid-template-columns:1fr}
}
.card{
background:var(--card);
border:1px solid var(--border);
border-radius:var(--radius);
box-shadow: var(--shadow);
overflow:hidden;
}
.card-hd{
padding:14px 16px;
border-bottom:1px solid var(--border);
display:flex;
align-items:center;
justify-content:space-between;
gap:10px;
}
.card-hd .hd-left{
display:flex; flex-direction:column; gap:2px;
min-width:0;
}
.card-hd .hd-left b{font-size:13px}
.card-hd .hd-left small{font-size:12px; color:var(--sub)}
.card-bd{padding:14px 16px; display:grid; gap:10px}
/* Controls */
label{font-size:12px; color:var(--sub)}
.row{display:flex; gap:10px; flex-wrap:wrap; align-items:center}
.row > * {flex:0 0 auto}
.select, .input, .textarea{
width:100%;
border:1px solid var(--border);
border-radius:12px;
padding:10px 12px;
background:#fff;
color:var(--text);
outline:none;
transition: box-shadow .12s ease, border-color .12s ease;
font-size:14px;
}
.select:focus, .input:focus, .textarea:focus{
border-color: rgba(26,115,232,.55);
box-shadow: var(--focus);
}
.textarea{min-height:240px; resize:vertical}
.btn{
border:1px solid var(--border);
background:#fff;
color:var(--text);
padding:10px 12px;
border-radius:12px;
font-size:14px;
cursor:pointer;
transition: transform .04s ease, background .12s ease, border-color .12s ease;
display:inline-flex; align-items:center; gap:8px;
user-select:none;
}
.btn:hover{background:#f8f9fa}
.btn:active{transform: translateY(1px)}
.btn:focus{outline:none; box-shadow: var(--focus)}
.btn-primary{
background: var(--blue);
color:#fff;
border-color: transparent;
}
.btn-primary:hover{background: var(--blue2)}
.btn-primary:disabled{
opacity:.6;
cursor:not-allowed;
transform:none;
}
.btn-ghost{
background:transparent;
border-color:transparent;
color:var(--sub);
}
.btn-ghost:hover{background:#f1f3f4; color:var(--text)}
.btn-ghost:focus{box-shadow: var(--focus); border-color: rgba(26,115,232,.25)}
.pill{
display:inline-flex;
align-items:center;
gap:8px;
padding:8px 10px;
border-radius:999px;
background:var(--chip);
border:1px solid rgba(26,115,232,.12);
color:#174ea6;
font-size:12px;
max-width:100%;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
/* Output box */
.output{
border:1px dashed var(--border);
border-radius:12px;
padding:12px;
min-height:240px;
white-space:pre-wrap;
background: #fcfdff;
}
.muted{color:var(--sub); font-size:12px}
.sep{height:1px; background:var(--border); margin:4px 0}
/* Toast */
.toast{
position: fixed;
left: 50%;
bottom: 18px;
transform: translateX(-50%);
background: rgba(32,33,36,.92);
color:#fff;
padding:10px 12px;
border-radius:999px;
font-size:13px;
box-shadow: 0 6px 18px rgba(0,0,0,.18);
opacity:0;
pointer-events:none;
transition: opacity .18s ease, transform .18s ease;
}
.toast.show{
opacity:1;
transform: translateX(-50%) translateY(-4px);
}
/* Small icon */
.icon{width:16px;height:16px;display:inline-block}
.spin{animation: spin 1s linear infinite}
@keyframes spin{to{transform: rotate(360deg)}}
</style>
</head>
<body>
<!-- Appbar -->
<header class="appbar">
<div class="appbar-inner">
<div class="brand">
<div class="logo" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M12 2a7 7 0 0 0-7 7v3a5 5 0 0 0 5 5h1v3H8a1 1 0 1 0 0 2h8a1 1 0 1 0 0-2h-3v-3h1a5 5 0 0 0 5-5V9a7 7 0 0 0-7-7Zm5 10a3 3 0 0 1-3 3h-4a3 3 0 0 1-3-3V9a5 5 0 0 1 10 0v3Z"/></svg>
</div>
<div class="title">
<strong>AI 변환</strong>
<span>원문 + 필터 선택 → 결과 생성 (Gemini)</span>
</div>
</div>
<div class="bar-actions">
<button class="btn btn-ghost" id="clearTop" title="비우기">비우기</button>
<button class="btn btn-primary" id="runTop">
<svg class="icon" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
변환
</button>
</div>
</div>
</header>
<main class="wrap">
<!-- Left: Input -->
<section class="card">
<div class="card-hd">
<div class="hd-left">
<b>입력</b>
<small>필터는 고정 목록에서만 선택</small>
</div>
<div class="pill" id="statusPill">대기</div>
</div>
<div class="card-bd">
<div class="row" style="gap:12px">
<div style="min-width:220px; flex: 1 1 240px">
<label for="filter">필터</label>
<select class="select" id="filter"></select>
</div>
<div style="flex:1 1 220px; min-width:220px">
<label>도움말</label>
<div class="pill" title="요약은 원문 길이에 따라 항목 수가 자동 조절됩니다.">
요약: 가변 길이 · 명사형 종결
</div>
</div>
</div>
<div>
<label for="src">원문</label>
<textarea class="textarea" id="src" placeholder="텍스트를 입력하세요."></textarea>
<div class="muted" id="counter">0자</div>
</div>
<div class="row">
<button class="btn btn-primary" id="run">
<svg class="icon" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
변환
</button>
<button class="btn" id="copy">
<svg class="icon" viewBox="0 0 24 24" fill="currentColor"><path d="M16 1H4a2 2 0 0 0-2 2v12h2V3h12V1zm4 4H8a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2zm0 16H8V7h12v14z"/></svg>
결과 복사
</button>
<button class="btn" id="clear">
<svg class="icon" viewBox="0 0 24 24" fill="currentColor"><path d="M19 6.4 17.6 5 12 10.6 6.4 5 5 6.4 10.6 12 5 17.6 6.4 19 12 13.4 17.6 19 19 17.6 13.4 12z"/></svg>
비우기
</button>
</div>
<div class="muted">
• “논문”은 학술체(~다/~이다) • “발표문”은 끊김 시 자동 이어쓰기
</div>
</div>
</section>
<!-- Right: Output + Key -->
<aside class="card">
<div class="card-hd">
<div class="hd-left">
<b>결과</b>
<small>생성된 텍스트가 여기에 표시</small>
</div>
<div class="muted" id="latency"></div>
</div>
<div class="card-bd">
<div class="output" id="out"></div>
<div class="sep"></div>
<details>
<summary class="muted">API Key 저장(최초 1회)</summary>
<div style="margin-top:10px; display:grid; gap:10px">
<input class="input" id="key" type="password" placeholder="Gemini API Key" autocomplete="off">
<div class="row">
<button class="btn" id="saveKey">
<svg class="icon" viewBox="0 0 24 24" fill="currentColor"><path d="M17 3H5a2 2 0 0 0-2 2v14h18V7l-4-4zm-2 16H9v-6h6v6zm3-10H5V5h11.17L18 6.83V9z"/></svg>
저장
</button>
<span class="muted">키는 서버 ScriptProperties에 저장</span>
</div>
</div>
</details>
</div>
</aside>
</main>
<div class="toast" id="toast"></div>
<script>
const $ = (id) => document.getElementById(id);
function toast(msg){
const t = $("toast");
t.textContent = msg;
t.classList.add("show");
setTimeout(()=>t.classList.remove("show"), 1800);
}
function setStatus(text, loading=false){
const pill = $("statusPill");
pill.textContent = text;
pill.style.background = loading ? "#e8f0fe" : "var(--chip)";
pill.style.borderColor = loading ? "rgba(26,115,232,.25)" : "rgba(26,115,232,.12)";
pill.style.color = loading ? "#174ea6" : "#174ea6";
}
function setLoading(on){
$("run").disabled = on;
$("runTop").disabled = on;
if(on){
$("out").textContent = "⏳ 생성 중...";
setStatus("생성 중…", true);
$("latency").textContent = "";
} else {
setStatus("대기");
}
}
function loadFilters(){
google.script.run.withSuccessHandler(filters => {
const sel = $("filter");
sel.innerHTML = "";
filters.forEach(f => {
const opt = document.createElement("option");
opt.value = f;
opt.textContent = f;
sel.appendChild(opt);
});
}).withFailureHandler(err => {
$("out").textContent = "⚠️ 필터 로딩 실패: " + (err?.message || err);
}).getFilters();
}
function runTransform(){
const src = $("src").value.trim();
const filter = $("filter").value;
if(!src){ $("out").textContent = "⚠️ 원문을 입력하세요."; toast("원문이 비어 있습니다"); return; }
const t0 = performance.now();
setLoading(true);
google.script.run
.withSuccessHandler(res => {
$("out").textContent = res || "";
const dt = Math.round(performance.now() - t0);
$("latency").textContent = dt ? `${dt}ms` : "";
setLoading(false);
toast("완료");
})
.withFailureHandler(err => {
$("out").textContent = "⚠️ " + (err?.message || err);
setLoading(false);
toast("오류");
})
.transformText(src, filter);
}
function clearAll(){
$("src").value = "";
$("out").textContent = "";
$("counter").textContent = "0자";
setStatus("대기");
toast("초기화");
}
$("run").addEventListener("click", runTransform);
$("runTop").addEventListener("click", runTransform);
$("clear").addEventListener("click", clearAll);
$("clearTop").addEventListener("click", clearAll);
$("copy").addEventListener("click", async () => {
const text = $("out").textContent || "";
if(!text.trim()){ toast("복사할 내용이 없습니다"); return; }
try{
await navigator.clipboard.writeText(text);
toast("복사됨");
}catch(e){
toast("복사 실패(브라우저 권한)");
}
});
$("saveKey").addEventListener("click", () => {
const key = $("key").value.trim();
if(!key){ toast("API Key를 입력하세요"); return; }
google.script.run
.withSuccessHandler(() => {
$("key").value = "";
toast("API Key 저장 완료");
})
.withFailureHandler(err => {
$("out").textContent = "⚠️ " + (err?.message || err);
toast("저장 실패");
})
.setApiKey(key);
});
$("src").addEventListener("input", () => {
const n = $("src").value.length;
$("counter").textContent = `${n.toLocaleString()}자`;
});
// init
loadFilters();
setStatus("대기");
</script>
</body>
</html>