Code.gs
// =================================================================
// [0] 초기 설정 및 메뉴
// =================================================================
function onOpen() {
SpreadsheetApp.getUi().createMenu('민기쌤 생기부 도우미')
.addItem('🚀 사이드바 열기', 'openSidebar')
.addToUi();
}
function renderView_(viewName) {
var template = HtmlService.createTemplateFromFile('Sidebar');
template.initialView = viewName || 'sidebar';
return template.evaluate();
}
function openSidebar() {
ensureBannedWordSheet_();
var html = renderView_('sidebar')
.setTitle('민기쌤 생기부 맞춤법 도우미')
.setWidth(300);
SpreadsheetApp.getUi().showSidebar(html);
}
function openSpellCheckViewer() {
var html = renderView_('spellcheck')
.setWidth(1000)
.setHeight(700);
SpreadsheetApp.getUi().showModelessDialog(html, '📝 Bareun 맞춤법 검사 및 수정');
}
function openBannedWordViewer() {
var html = renderView_('banned')
.setWidth(1000)
.setHeight(700);
SpreadsheetApp.getUi().showModelessDialog(html, '🚫 기재 금지어 점검');
}
// =================================================================
// [1] Bareun API 키 및 UI 설정
// =================================================================
function getBareunUserProperties_() {
ensureBannedWordSheet_();
var props = PropertiesService.getUserProperties();
var currentPolicy = 'BAREUN_ONLY_KEYS_V3';
var previousPolicy = props.getProperty('API_KEY_POLICY_VERSION');
// 정책 변경 시 현재 기능에서 쓰지 않는 예전 사용자 설정을 한 번 정리합니다.
if (previousPolicy !== currentPolicy) {
var allowedProperties = {
UI_STATE: true,
API_KEY_POLICY_VERSION: true
};
// 바로 이전 사용자 입력형 정책에서 저장한 키만 보존합니다.
if (previousPolicy === 'USER_SUPPLIED_KEYS_V1' ||
previousPolicy === 'BAREUN_ONLY_KEYS_V1' ||
previousPolicy === 'BAREUN_ONLY_KEYS_V2') {
allowedProperties.USER_BAREUN_KEY = true;
}
Object.keys(props.getProperties()).forEach(function(propertyName) {
if (!allowedProperties[propertyName]) props.deleteProperty(propertyName);
});
var previousUiState = {};
try {
previousUiState = JSON.parse(props.getProperty('UI_STATE') || '{}');
} catch (e) {
previousUiState = {};
}
var currentUiState = {};
['card-api', 'sub-api-settings', 'card-util'].forEach(function(id) {
if (previousUiState[id]) currentUiState[id] = true;
});
props.setProperty('UI_STATE', JSON.stringify(currentUiState));
props.setProperty('API_KEY_POLICY_VERSION', currentPolicy);
}
return props;
}
function saveUserBareunKey(bareunKey) {
var props = getBareunUserProperties_();
var cleanBareunKey = String(bareunKey || '').trim();
if (!cleanBareunKey) {
throw new Error('Bareun API 키를 입력해 주세요. 이 도우미는 API 키를 제공하지 않습니다.');
}
props.setProperty('USER_BAREUN_KEY', cleanBareunKey);
return { hasBareun: true };
}
function checkUserBareunStatus() {
var props = getBareunUserProperties_();
return { hasBareun: !!props.getProperty('USER_BAREUN_KEY') };
}
function clearUserBareunKey() {
var props = getBareunUserProperties_();
props.deleteProperty('USER_BAREUN_KEY');
return { hasBareun: false };
}
function getBareunKey() {
var key = getBareunUserProperties_().getProperty('USER_BAREUN_KEY');
if (!key) throw new Error("Bareun API 키가 없습니다. 사이드바 상단에서 등록해주세요.");
return key;
}
// =================================================================
// [2] Bareun 맞춤법 검사
// =================================================================
function getSelectedDataForSpellCheck() {
var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
var sheetName = sheet.getName();
var range = sheet.getActiveRange();
if (!range) throw new Error("선택된 셀이 없습니다.");
var rows = [];
var startRow = range.getRow();
var startCol = range.getColumn();
var values = range.getValues();
for (var i = 0; i < values.length; i++) {
for (var j = 0; j < values[0].length; j++) {
var text = values[i][j].toString();
if (text.trim() !== "") {
rows.push({ sheetName: sheetName, row: startRow + i, col: startCol + j, original: text });
}
}
}
return rows;
}
function getBareunCorrection(text) {
var userKey = getBareunKey();
var result = callBareunApi(text, userKey);
return result;
}
function applySpellCheckResults(updates) {
var spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
updates.forEach(function(item) {
var sheet = spreadsheet.getSheetByName(item.sheetName);
if (!sheet) throw new Error('검사한 시트를 찾을 수 없습니다: ' + item.sheetName);
sheet.getRange(item.row, item.col).setValue(item.text);
});
return updates.length;
}
function callBareunApi(text, userKey) {
var url = "https://api.bareun.ai/bareun.RevisionService/CorrectError";
var options = {
"method": "post", "contentType": "application/json", "headers": { "api-key": userKey }, "muteHttpExceptions": true,
"payload": JSON.stringify({ "document": { "content": text, "language": "ko-KR" }, "encoding_type": "UTF16" })
};
var response = UrlFetchApp.fetch(url, options);
var result = JSON.parse(response.getContentText());
if (response.getResponseCode() !== 200) {
return { error: "API 오류: " + response.getResponseCode() };
}
return { revised: result.revised, helps: result.helps || {}, rawBlocks: result.revisedBlocks || result.revised_blocks || [], error: null };
}
// =================================================================
// [3] 기재 금지어 관리
// =================================================================
var BANNED_WORD_SHEET_NAME_ = '기재금지어관리';
var BANNED_WORD_HEADERS_ = ['번호', '기재 금지어'];
var LEGACY_BANNED_WORD_HEADERS_ = ['유형', '기재 금지어', '대체 표현'];
var ONE_COLUMN_BANNED_WORD_HEADERS_ = ['기재 금지어'];
function getLegacyBannedWordList_() {
var saved = PropertiesService.getUserProperties().getProperty('BANNED_WORDS_DB');
if (!saved) return getDefaultBannedList_();
try {
var parsed = JSON.parse(saved);
if (!Array.isArray(parsed)) return getDefaultBannedList_();
return parsed
.map(function(item) {
return String(item && item.k || '').trim();
})
.filter(function(keyword) { return keyword !== ''; });
} catch (e) {
return getDefaultBannedList_();
}
}
function initializeBannedWordSheet_(sheet, list) {
sheet.getRange(1, 1, 1, BANNED_WORD_HEADERS_.length)
.setValues([BANNED_WORD_HEADERS_])
.setBackground('#fce8e6')
.setFontColor('#a50e0e')
.setFontWeight('bold')
.setHorizontalAlignment('center');
var rows = list.map(function(keyword, index) {
return [index + 1, keyword];
});
if (rows.length > 0) sheet.getRange(2, 1, rows.length, 2).setValues(rows);
sheet.setFrozenRows(1);
sheet.setColumnWidth(1, 60);
sheet.setColumnWidth(2, 260);
sheet.setTabColor('#d93025');
}
function migrateLegacyBannedWordSheet_(sheet) {
var lastColumn = sheet.getLastColumn();
var legacyColumn;
var clearColumns;
if (lastColumn >= 3 && sheet.getRange(1, 1, 1, 3).getDisplayValues()[0].join('|') === LEGACY_BANNED_WORD_HEADERS_.join('|')) {
legacyColumn = 2;
clearColumns = 3;
} else if (sheet.getRange(1, 1, 1, 1).getDisplayValues()[0][0] === ONE_COLUMN_BANNED_WORD_HEADERS_[0]) {
legacyColumn = 1;
clearColumns = 1;
} else {
return false;
}
var lastRow = sheet.getLastRow();
var keywords = lastRow < 2 ? [] : sheet.getRange(2, legacyColumn, lastRow - 1, 1)
.getDisplayValues()
.map(function(row) { return String(row[0] || '').trim(); })
.filter(function(keyword) { return keyword !== ''; });
var clearRows = Math.max(sheet.getLastRow(), 1);
sheet.getRange(1, 1, clearRows, clearColumns).clearContent();
initializeBannedWordSheet_(sheet, keywords);
return true;
}
function ensureBannedWordSheet_() {
var spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
if (!spreadsheet) throw new Error('활성 스프레드시트를 찾을 수 없습니다.');
var originalSheet = spreadsheet.getActiveSheet();
var originalRange = spreadsheet.getActiveRange();
var lock = LockService.getDocumentLock() || LockService.getScriptLock();
lock.waitLock(30000);
var sheet;
try {
sheet = spreadsheet.getSheetByName(BANNED_WORD_SHEET_NAME_);
if (!sheet) {
sheet = spreadsheet.insertSheet(BANNED_WORD_SHEET_NAME_);
initializeBannedWordSheet_(sheet, getLegacyBannedWordList_());
} else {
// 이전 형식은 금지어만 보존하여 번호·기재금지어 두 열 목록으로 안전하게 바꿉니다.
migrateLegacyBannedWordSheet_(sheet);
}
// 시트로 옮긴 뒤 예전 사용자별 저장값은 더 이상 별도 원본으로 남기지 않습니다.
PropertiesService.getUserProperties().deleteProperty('BANNED_WORDS_DB');
return sheet;
} finally {
if (originalSheet && sheet && originalSheet.getSheetId() !== sheet.getSheetId()) {
spreadsheet.setActiveSheet(originalSheet);
if (originalRange) spreadsheet.setActiveRange(originalRange);
}
lock.releaseLock();
}
}
function openBannedWordManagementSheet() {
var sheet = ensureBannedWordSheet_();
sheet.activate();
sheet.getRange('A1').activate();
return sheet.getName();
}
function getDefaultBannedList_() {
return [
"MBTI", "커리어넷", "메이저맵", "패들렛", "띵커벨", "알로",
"미리캔버스", "망고보드", "유튜버", "유튜브", "넷플릭스", "왓챠",
"티빙", "네이버 폼", "구글 폼", "OECD", "UN", "유네스코", "WTO",
"WHO", "그린피스", "앰네스티", "IMF", "인스타그램", "페이스북",
"카카오톡", "네이버", "구글", "클래스 카드", "구글 클래스룸", "게더타운",
"제페토", "아이패드", "갤럭시 탭", "비트코인", "TED", "반크", "TOEIC",
"TOEFL", "TEPS", "대회", "수상", "한자능력검정"
];
}
function getBannedWordList_() {
var sheet = ensureBannedWordSheet_();
var header = sheet.getRange(1, 1, 1, 2).getDisplayValues()[0];
if (header.join('|') !== BANNED_WORD_HEADERS_.join('|')) {
throw new Error('기재금지어관리 시트의 1행은 "번호 | 기재 금지어" 순서여야 합니다.');
}
var lastRow = sheet.getLastRow();
if (lastRow < 2) return [];
var values = sheet.getRange(2, 1, lastRow - 1, 2).getDisplayValues();
var seen = {};
var list = [];
values.forEach(function(row, index) {
var rowNumber = index + 2;
var number = String(row[0] || '').trim();
var keyword = String(row[1] || '').trim();
if (!number && !keyword) return;
if (!/^[1-9]\d*$/.test(number)) {
throw new Error('기재금지어관리 시트 ' + rowNumber + '행의 번호(A열)는 1 이상의 정수여야 합니다.');
}
if (!keyword) throw new Error('기재금지어관리 시트 ' + rowNumber + '행의 기재 금지어(B열)를 입력해 주세요.');
var duplicateKey = normalizeBannedWord_(keyword);
if (seen[duplicateKey]) {
throw new Error('기재금지어관리 시트 ' + rowNumber + '행의 기재 금지어가 ' + seen[duplicateKey] + '행과 중복됩니다: ' + keyword);
}
seen[duplicateKey] = rowNumber;
list.push(keyword);
});
return list;
}
function runBannedWordCheck() {
var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
var sheetName = sheet.getName();
var range = sheet.getActiveRange();
if (!range) throw new Error("선택된 셀이 없습니다.");
var values = range.getValues();
var startRow = range.getRow();
var startCol = range.getColumn();
var db = getBannedWordList_();
if (db.length === 0) return [];
db.sort(function(a, b) { return normalizeBannedWord_(b).length - normalizeBannedWord_(a).length; });
var canonicalByNormalizedKeyword = {};
db.forEach(function(keyword) { canonicalByNormalizedKeyword[normalizeBannedWord_(keyword)] = keyword; });
var combinedPattern = new RegExp('(' + db.map(buildWhitespaceInsensitivePattern_).join('|') + ')', 'gi');
var results = [];
for (var i = 0; i < values.length; i++) {
for (var j = 0; j < values[0].length; j++) {
var original = values[i][j].toString();
if (!original.trim()) continue;
var hasIssue = false;
var logs = [];
var counts = {};
var match;
while ((match = combinedPattern.exec(original)) !== null) {
var normalizedMatch = normalizeBannedWord_(match[0]);
var keyword = canonicalByNormalizedKeyword[normalizedMatch];
if (keyword) {
hasIssue = true;
counts[keyword] = (counts[keyword] || 0) + 1;
}
}
Object.keys(counts).forEach(function(keyword) {
logs.push({ k: keyword, count: counts[keyword] });
});
if (hasIssue) {
results.push({
sheetName: sheetName,
row: startRow + i,
col: startCol + j,
original: original,
logs: logs
});
}
}
}
return results;
}
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function normalizeBannedWord_(value) {
return String(value || '').replace(/\s+/g, '').toLocaleLowerCase();
}
function buildWhitespaceInsensitivePattern_(keyword) {
var compact = normalizeBannedWord_(keyword);
if (!compact) throw new Error('비어 있는 기재 금지어는 검사할 수 없습니다.');
return compact.split('').map(function(character) {
return escapeRegExp(character);
}).join('[\\s\\u00A0]*');
}
// =================================================================
// [4] 로컬 검토 유틸리티
// =================================================================
function unifyToStraightQuotes() {
var range = SpreadsheetApp.getActiveRange();
var ui = SpreadsheetApp.getUi();
if (!range) throw new Error('선택된 셀이 없습니다.');
var response = ui.alert('따옴표 변환', '선택 영역의 둥근 따옴표를 곧은 따옴표로 변환하시겠습니까?', ui.ButtonSet.YES_NO);
if (response != ui.Button.YES) return;
try {
range.createTextFinder('[‘’]').useRegularExpression(true).matchEntireCell(false).replaceAllWith("'");
range.createTextFinder('[“”]').useRegularExpression(true).matchEntireCell(false).replaceAllWith('"');
SpreadsheetApp.flush();
ui.alert('✅ 변환 완료');
} catch (e) { ui.alert('오류: ' + e.toString()); }
}
// [신규] 선택 영역 스페이스바 실수(2칸 이상) 수정 함수
function fixDoubleSpaces() {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
const range = sheet.getActiveRange();
if (!range) throw new Error("선택된 셀이 없습니다.");
const values = range.getValues();
let fixCount = 0; // 수정된 셀 개수 카운트
const newValues = values.map(row => {
return row.map(cell => {
let str = String(cell);
// 공백이 2번 이상 연속( / +/)으로 있으면 1번으로 변경
// 줄바꿈(\n)은 건드리지 않도록 일반 공백( )만 타겟팅
if (/ {2,}/.test(str)) {
fixCount++;
return str.replace(/ {2,}/g, ' ');
}
return cell;
});
});
if (fixCount > 0) {
range.setValues(newValues);
}
return fixCount;
}
// =================================================================
// [5] UI 상태 저장/불러오기
// =================================================================
function saveUiState(cardId, isExpanded) {
var props = PropertiesService.getUserProperties();
var state = JSON.parse(props.getProperty('UI_STATE') || '{}');
state[cardId] = isExpanded;
props.setProperty('UI_STATE', JSON.stringify(state));
}
function getUiState() {
var props = PropertiesService.getUserProperties();
return JSON.parse(props.getProperty('UI_STATE') || '{}');
}
Sidebar.html
<? var allowedViews = ["sidebar", "spellcheck", "banned"]; ?>
<? var activeView = allowedViews.indexOf(initialView) >= 0 ? initialView : "sidebar"; ?>
<? if (activeView === 'sidebar') { ?>
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<style>
* { box-sizing: border-box; }
body {
margin: 0;
padding: 10px 15px 10px 2px;
background: #f1f3f4;
color: #202124;
font-family: 'Noto Sans KR', sans-serif;
}
.card {
width: 100%;
margin: 0 0 16px;
overflow: hidden;
border-radius: 12px;
background: #fff;
box-shadow: 0 2px 6px rgba(60,64,67,.15);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 15px 20px;
cursor: pointer;
user-select: none;
border-bottom: 1px solid transparent;
}
.card-header:hover { background: #f8f9fa; }
.card.expanded .card-header { border-bottom-color: #f1f3f4; }
.header-title { font-size: 15px; font-weight: 700; }
.toggle-icon { color: #5f6368; transition: transform .2s; }
.card.expanded .toggle-icon { transform: rotate(180deg); }
.card-body { display: none; padding: 20px; }
.card.expanded .card-body { display: block; }
.sub-section { overflow: hidden; border: 1px solid #eee; border-radius: 8px; }
.sub-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 15px;
background: #f8f9fa;
color: #3c4043;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.sub-body { display: none; padding: 15px; }
.sub-section.expanded .sub-body { display: block; }
.sub-icon { color: #5f6368; font-size: 18px; transition: transform .2s; }
.sub-section.expanded .sub-icon { transform: rotate(180deg); }
.hidden { display: none !important; }
.input-label { display: block; margin-bottom: 5px; color: #5f6368; font-size: 12px; font-weight: 600; }
input[type="password"] {
width: 100%;
padding: 10px;
border: 2px solid #dadce0;
border-radius: 6px;
outline: none;
font-family: inherit;
font-size: 14px;
}
input[type="password"]:focus { border-color: #1a73e8; }
button {
width: 100%;
margin-top: 6px;
padding: 12px 0;
border: 0;
border-radius: 8px;
font-family: inherit;
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
button:disabled { cursor: not-allowed; background: #dadce0 !important; color: #5f6368 !important; }
.btn-auth { background: #34a853; color: #fff; }
.btn-primary { background: #1a73e8; color: #fff; }
.btn-warning { background: #fbbc04; color: #202124; }
.btn-secondary { margin-bottom: 8px; border: 1px solid #dadce0; background: #fff; color: #3c4043; }
.btn-secondary:hover { border-color: #1a73e8; color: #1a73e8; }
.api-status { margin-bottom: 12px; padding: 10px; border-radius: 8px; background: #f8f9fa; font-size: 12px; }
.api-status .ok { color: #188038; font-weight: 700; }
.api-status .missing { color: #c5221f; font-weight: 700; }
.key-help { margin: 7px 0 10px; color: #5f6368; font-size: 11px; line-height: 1.5; }
.status-box { min-height: 18px; margin-top: 8px; color: #e37400; font-size: 12px; text-align: center; }
</style>
</head>
<body>
<div class="card" id="card-api">
<div class="card-header" onclick="toggleCard(this)">
<div class="header-title">🔑 Bareun API 키 설정</div>
<span class="material-icons toggle-icon">expand_more</span>
</div>
<div class="card-body">
<div class="sub-section" id="sub-api-settings">
<div class="sub-header" onclick="toggleSub(this)">
<span>내 Bareun API 키</span>
<span class="material-icons sub-icon">expand_more</span>
</div>
<div class="sub-body">
<div id="api-loading" class="api-status">⌛ API 키 설정 확인 중...</div>
<div id="api-settings" class="hidden">
<div id="apiStatusSummary" class="api-status"></div>
<label class="input-label" for="bareunKeyInput">Bareun API 키</label>
<input type="password" id="bareunKeyInput" autocomplete="off" placeholder="본인이 발급받은 키를 입력하세요">
<div class="key-help">공용 키는 제공하지 않습니다. 키는 사용자별 설정에 저장되고 원문을 화면으로 다시 불러오지 않습니다.</div>
<button class="btn-auth" id="saveKeyBtn" onclick="saveBareunKey()">내 Bareun 키 저장</button>
<button class="btn-secondary hidden" id="clearKeyBtn" onclick="resetBareunKey()">저장된 Bareun 키 삭제</button>
</div>
</div>
</div>
</div>
</div>
<div class="card expanded" id="card-util">
<div class="card-header" onclick="toggleCard(this)">
<div class="header-title">📝 선택 영역 검사 및 정리</div>
<span class="material-icons toggle-icon">expand_more</span>
</div>
<div class="card-body">
<button class="btn-warning" id="checkBtn" onclick="runSpellCheck()">선택 영역 Bareun 맞춤법 검사 🔍</button>
<div id="checkStatus" class="status-box"></div>
<button class="btn-secondary" onclick="runSpaceFix(this)">선택 영역 연속 공백 정리</button>
<button class="btn-secondary" onclick="convertQuotes(this)">선택 영역 따옴표 통일 (' ")</button>
<button class="btn-secondary" onclick="runBannedCheck()">선택 영역 기재 금지어 검사</button>
<button class="btn-secondary" onclick="openBannedWordManagement()">기재금지어관리 시트 열기</button>
</div>
</div>
<script>
let currentApiStatus = { hasBareun: false };
function updateApiState(status) {
currentApiStatus = { hasBareun: !!(status && status.hasBareun) };
const summary = document.getElementById('apiStatusSummary');
const clearButton = document.getElementById('clearKeyBtn');
const spellCheckButton = document.getElementById('checkBtn');
const apiCard = document.getElementById('card-api');
const apiSub = document.getElementById('sub-api-settings');
summary.innerHTML = currentApiStatus.hasBareun
? '<div class="ok">✅ Bareun 키 저장됨</div>'
: '<div class="missing">❌ 맞춤법 검사를 사용하려면 Bareun 키가 필요합니다.</div>';
clearButton.classList.toggle('hidden', !currentApiStatus.hasBareun);
spellCheckButton.disabled = !currentApiStatus.hasBareun;
spellCheckButton.title = currentApiStatus.hasBareun ? '' : 'Bareun API 키를 먼저 저장해 주세요.';
apiCard.classList.toggle('expanded', !currentApiStatus.hasBareun);
apiSub.classList.toggle('expanded', !currentApiStatus.hasBareun);
}
function toggleCard(header) {
const card = header.parentElement;
const isExpanded = card.classList.toggle('expanded');
google.script.run.saveUiState(card.id, isExpanded);
}
function toggleSub(header) {
const section = header.parentElement;
const isExpanded = section.classList.toggle('expanded');
google.script.run.saveUiState(section.id, isExpanded);
}
function saveBareunKey() {
const button = document.getElementById('saveKeyBtn');
const keyInput = document.getElementById('bareunKeyInput');
const key = keyInput.value.trim();
if (!key) {
alert('Bareun API 키를 입력해 주세요.');
return;
}
const originalText = button.innerText;
button.disabled = true;
button.innerText = '저장 중...';
google.script.run
.withSuccessHandler(function(status) {
keyInput.value = '';
updateApiState(status);
button.disabled = false;
button.innerText = originalText;
alert('✅ Bareun API 키를 사용자 설정에 저장했습니다.');
})
.withFailureHandler(function(error) {
button.disabled = false;
button.innerText = originalText;
alert('Bareun API 키 저장 오류: ' + error);
})
.saveUserBareunKey(key);
}
function resetBareunKey() {
if (!confirm('저장된 Bareun API 키를 삭제하시겠습니까?')) return;
google.script.run
.withSuccessHandler(function(status) {
document.getElementById('bareunKeyInput').value = '';
updateApiState(status);
alert('저장된 Bareun API 키를 삭제했습니다.');
})
.withFailureHandler(function(error) {
alert('Bareun API 키 삭제 오류: ' + error);
})
.clearUserBareunKey();
}
function runSpellCheck() {
if (!currentApiStatus.hasBareun) {
alert('Bareun API 키를 먼저 저장해 주세요.');
return;
}
document.getElementById('checkStatus').innerText = '맞춤법 검사 창을 여는 중...';
google.script.run
.withSuccessHandler(function() {
document.getElementById('checkStatus').innerText = '';
})
.withFailureHandler(function(error) {
document.getElementById('checkStatus').innerText = '';
alert('오류 발생: ' + error);
})
.openSpellCheckViewer();
}
function runSpaceFix(button) {
const originalText = button.innerText;
button.disabled = true;
button.innerText = '정리 중...';
google.script.run
.withSuccessHandler(function(count) {
button.disabled = false;
button.innerText = originalText;
alert(count ? '✅ 총 ' + count + '개 셀의 연속 공백을 정리했습니다.' : '수정할 연속 공백이 없습니다.');
})
.withFailureHandler(function(error) {
button.disabled = false;
button.innerText = originalText;
alert('오류 발생: ' + error);
})
.fixDoubleSpaces();
}
function convertQuotes(button) {
const originalText = button.innerText;
button.disabled = true;
button.innerText = '변환 중...';
google.script.run
.withSuccessHandler(function() {
button.disabled = false;
button.innerText = originalText;
})
.withFailureHandler(function(error) {
button.disabled = false;
button.innerText = originalText;
alert('오류 발생: ' + error);
})
.unifyToStraightQuotes();
}
function runBannedCheck() {
google.script.run
.withFailureHandler(function(error) { alert('오류 발생: ' + error); })
.openBannedWordViewer();
}
function openBannedWordManagement() {
google.script.run
.withFailureHandler(function(error) { alert('오류 발생: ' + error); })
.openBannedWordManagementSheet();
}
window.onload = function() {
google.script.run
.withSuccessHandler(function(status) {
document.getElementById('api-loading').classList.add('hidden');
document.getElementById('api-settings').classList.remove('hidden');
updateApiState(status);
google.script.run.withSuccessHandler(function(states) {
Object.keys(states || {}).forEach(function(id) {
const element = document.getElementById(id);
if (id === 'card-api' || id === 'sub-api-settings') return;
if (element && states[id]) element.classList.add('expanded');
});
updateApiState(status);
}).getUiState();
})
.withFailureHandler(function(error) {
document.getElementById('api-loading').classList.add('hidden');
document.getElementById('api-settings').classList.remove('hidden');
updateApiState({ hasBareun: false });
document.getElementById('apiStatusSummary').textContent = '설정 확인 오류: ' + error;
})
.checkUserBareunStatus();
};
</script>
</body>
</html>
<? } else if (activeView === 'spellcheck') { ?>
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/diff_match_patch/20121119/diff_match_patch.js"></script>
<style>
body { padding: 20px; font-family: 'Noto Sans KR', sans-serif; background: #f8f9fa; margin: 0; height: 100vh; display: flex; flex-direction: column; box-sizing: border-box; }
.header-area { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; }
.title { font-size: 20px; font-weight: 700; color: #1a73e8; display: flex; align-items: center; gap: 8px; }
.content-area { flex: 1; overflow-y: auto; padding-right: 10px; position: relative; }
.check-item { background: white; border: 1px solid #dadce0; border-radius: 8px; margin-bottom: 25px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); overflow: hidden; }
.item-header { background: #f1f3f4; padding: 10px 15px; font-size: 14px; font-weight: 700; color: #3c4043; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; }
.split-view { display: flex; min-height: 120px; }
.pane {
flex: 1; padding: 15px; font-size: 14px; line-height: 1.8; color: #3c4043;
white-space: pre-wrap; word-break: keep-all; text-align: justify;
}
.pane-left { border-right: 1px solid #eee; background: #fff; }
.pane-right { background: #f8fbff; outline: none; }
.pane-right:focus { background: #fff; box-shadow: inset 0 0 0 2px #1a73e8; }
/* [하이라이트 스타일] */
.diff-del {
background-color: #ffe6e6; color: #c5221f; border-bottom: 2px solid #c5221f;
text-decoration: none; padding: 1px 0; font-weight: 500;
}
.diff-add {
background-color: #e6f4ea; color: #188038; border-bottom: 2px solid #188038;
text-decoration: none; padding: 1px 0; font-weight: 500;
}
.analysis-box { border-top: 1px solid #eee; background: #fafafa; padding: 10px 15px; font-size: 12px; }
.analysis-title { font-weight: bold; color: #5f6368; margin-bottom: 8px; display: flex; align-items: center; gap: 5px; }
.analysis-list { list-style: none; padding: 0; margin: 0; }
.analysis-row { display: flex; gap: 10px; padding: 6px 0; border-bottom: 1px dashed #eee; align-items: center; }
.analysis-row:last-child { border-bottom: none; }
.err-word { color: #d93025; text-decoration: line-through; }
.arrow { color: #999; font-size: 10px; }
.fix-word { color: #188038; font-weight: bold; }
.fix-reason { color: #1a73e8; margin-left: auto; background: #e8f0fe; padding: 2px 6px; border-radius: 4px; font-size: 11px; }
.byte-info { display: flex; border-top: 1px solid #eee; font-size: 12px; color: #5f6368; background: #fff; }
.byte-box { flex: 1; padding: 5px 15px; text-align: right; }
.byte-val { font-weight: bold; color: #1a73e8; }
.footer-bar { margin-top: 15px; padding-top: 15px; border-top: 1px solid #dadce0; display: flex; justify-content: flex-end; gap: 10px; }
.btn { padding: 10px 20px; border-radius: 4px; border: none; font-weight: bold; cursor: pointer; font-size: 14px; }
.btn-cancel { background: #f1f3f4; color: #3c4043; }
.btn-apply { background: #1a73e8; color: white; box-shadow: 0 2px 5px rgba(26,115,232,0.3); }
.btn-apply:hover { background: #1765cc; }
.btn:disabled { background: #dadce0; cursor: not-allowed; box-shadow: none; color: #fff; }
.loader-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: white; z-index: 100; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #5f6368; font-size: 14px; }
.spinner { border: 4px solid #f3f3f3; border-top: 4px solid #1a73e8; border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin-bottom: 15px; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.error-msg { color: #d93025; font-weight: bold; padding: 20px; text-align: center; background: #fce8e6; border-radius: 8px; }
</style>
</head>
<body>
<div class="header-area">
<div class="title"><span class="material-icons">fact_check</span> 맞춤법 검사 및 수정</div>
<div style="font-size:12px; color:#5f6368; display:flex; gap:15px;">
<div><span style="background:#ffe6e6; color:#c5221f; border-bottom:2px solid #c5221f; padding:0 4px; font-weight:500;">원본 오류</span></div>
<div><span style="background:#e6f4ea; color:#188038; border-bottom:2px solid #188038; padding:0 4px; font-weight:500;">수정 제안</span></div>
</div>
</div>
<div id="mainContent" class="content-area">
<div id="loaderOverlay" class="loader-overlay">
<div class="spinner"></div>
<div id="loadingText">데이터를 불러오는 중입니다...</div>
</div>
<div id="cardsContainer"></div>
</div>
<div class="footer-bar">
<button class="btn btn-cancel" onclick="google.script.host.close()">닫기</button>
<button class="btn btn-apply" id="btnApply" onclick="applyAll()" disabled>적용하기 (0건)</button>
</div>
<script>
let itemData = [];
let isFirstRender = true;
window.onload = function() {
google.script.run
.withSuccessHandler(processData)
.withFailureHandler(function(err) {
document.getElementById('loaderOverlay').innerHTML =
`<div class="error-msg">❌ 초기화 오류<br>${err}<br>선택된 셀이 있는지 확인해주세요.</div>`;
})
.getSelectedDataForSpellCheck();
};
function processData(rows) {
if (rows.length === 0) {
document.getElementById('loaderOverlay').innerHTML = '<div class="error-msg">선택된 셀이 없거나 빈 셀입니다.</div>';
return;
}
document.getElementById('loadingText').innerText = `총 ${rows.length}개 항목 분석 중...`;
const container = document.getElementById('cardsContainer');
container.innerHTML = "";
let processedCount = 0;
const total = rows.length;
rows.forEach((row, index) => {
const div = document.createElement('div');
div.id = `item-${index}`;
div.className = 'check-item';
div.innerHTML = `
<div class="item-header"><span>행 ${row.row} (대기 중...)</span></div>
<div class="split-view"><div class="pane pane-left">...</div><div class="pane pane-right">...</div></div>
`;
container.appendChild(div);
google.script.run
.withSuccessHandler((res) => {
renderResult(index, row, res);
processedCount++;
updateApplyButton(processedCount, total);
})
.withFailureHandler((err) => {
renderError(index, row, err);
processedCount++;
updateApplyButton(processedCount, total);
})
.getBareunCorrection(row.original);
});
}
function renderError(index, rowData, errorMsg) {
if (isFirstRender) {
document.getElementById('loaderOverlay').style.display = 'none';
isFirstRender = false;
}
const itemDiv = document.getElementById(`item-${index}`);
if (!itemDiv) return;
itemDiv.innerHTML = `<div style="padding:20px; color:red;">❌ 분석 실패: ${errorMsg}</div>`;
}
function renderResult(index, rowData, apiResult) {
if (isFirstRender) {
document.getElementById('loaderOverlay').style.display = 'none';
isFirstRender = false;
}
const itemDiv = document.getElementById(`item-${index}`);
if (!itemDiv) return;
if (apiResult.error) {
renderError(index, rowData, apiResult.error);
return;
}
// [핵심] 라이브러리 사용 Diff
let diff;
try {
diff = diffWordsLib(rowData.original, apiResult.revised);
} catch(e) {
console.error(e);
diff = { left: rowData.original, right: apiResult.revised }; // 실패 시 원문 표시
}
let helpListHtml = "";
const analysisList = [];
if (apiResult.rawBlocks && apiResult.rawBlocks.length > 0) {
traverseBlocks(apiResult.rawBlocks, apiResult.helps, analysisList);
}
if (analysisList.length > 0) {
const listItems = analysisList.map(item => `
<li class="analysis-row">
<span class="err-word">${item.origin}</span>
<span class="arrow">→</span>
<span class="fix-word">${item.revised}</span>
<span class="fix-reason">${item.msg}</span>
</li>
`).join("");
helpListHtml = `
<div class="analysis-box">
<div class="analysis-title"><span class="material-icons" style="font-size:14px;">info</span> 상세 수정 내역 (${analysisList.length}건)</div>
<ul class="analysis-list">${listItems}</ul>
</div>
`;
}
itemDiv.innerHTML = `
<div class="item-header">
<span>행 ${rowData.row}</span>
<span style="color:#1a73e8;">분석 완료</span>
</div>
<div class="split-view">
<div class="pane pane-left">${diff.left}</div>
<div class="pane pane-right" contenteditable="true" spellcheck="false" id="edit-${index}" oninput="updateBytes(${index})">${diff.right}</div>
</div>
${helpListHtml}
<div class="byte-info">
<div class="byte-box" style="border-right:1px solid #eee;">원본: ${getByteLength(rowData.original)} byte</div>
<div class="byte-box">수정본: <span id="byte-${index}" class="byte-val">${getByteLength(apiResult.revised)}</span> byte</div>
</div>
`;
itemData[index] = { sheetName: rowData.sheetName, row: rowData.row, col: rowData.col };
}
function traverseBlocks(blocks, helps, resultsArray) {
if (!blocks) return;
blocks.forEach(function(block) {
if (block.nested && block.nested.length > 0) {
traverseBlocks(block.nested, helps, resultsArray);
} else {
const origin = block.origin.content || "";
const revised = block.revised || "";
if (origin === revised) return;
let msg = "교정";
if (block.revisions && block.revisions.length > 0) {
const helpId = block.revisions[0].helpId || block.revisions[0].help_id;
if (helps && !helps[helpId]) return;
if (helpId && helps && helps[helpId]) msg = helps[helpId].comment;
}
resultsArray.push({ origin: origin, revised: revised, msg: msg });
}
});
}
// [핵심] 라이브러리 활용 단어 단위 Diff (안정성 확보)
function diffWordsLib(text1, text2) {
// CDN 라이브러리가 로드되었는지 확인
if (typeof diff_match_patch === 'undefined') {
return { left: text1, right: text2 };
}
var dmp = new diff_match_patch();
// 1. 단어(공백 포함) 단위로 토큰화
// 정규식: 공백이나 문장부호 등을 기준으로 쪼개되, 분리자도 포함(capture group)
function tokenize(text) {
return text.split(/([^\S\r\n]+|[.,;!?])/g);
}
var t1 = tokenize(text1);
var t2 = tokenize(text2);
// 2. 토큰을 유니코드 문자로 1:1 매핑 (LineMode Diff 기법 응용)
var tokenMap = {};
var tokenArr = [];
var count = 0;
function encode(tokens) {
var chars = "";
for (var i = 0; i < tokens.length; i++) {
var t = tokens[i];
if (!tokenMap.hasOwnProperty(t)) {
tokenMap[t] = String.fromCharCode(count);
tokenArr[count] = t;
count++;
}
chars += tokenMap[t];
}
return chars;
}
var chars1 = encode(t1);
var chars2 = encode(t2);
// 3. Diff 실행 (글자가 아닌, 매핑된 코드로 비교)
var diffs = dmp.diff_main(chars1, chars2, false);
dmp.diff_cleanupSemantic(diffs);
// 4. HTML 생성
let leftHtml = "";
let rightHtml = "";
diffs.forEach(function(part) {
var op = part[0];
var chars = part[1];
var text = "";
// 유니코드를 다시 원래 단어로 복원
for (var i = 0; i < chars.length; i++) {
text += tokenArr[chars.charCodeAt(i)];
}
if (op === 0) {
leftHtml += text; rightHtml += text;
} else if (op === -1) {
leftHtml += `<span class="diff-del">${text}</span>`;
} else if (op === 1) {
rightHtml += `<span class="diff-add">${text}</span>`;
}
});
return { left: leftHtml, right: rightHtml };
}
function getByteLength(s) {
let b = 0; if (!s) return 0;
for (let i = 0; i < s.length; i++) {
const c = s.charCodeAt(i);
if (c === 10) b += 2; else if (c >> 11) b += 3; else if (c >> 7) b += 2; else b += 1;
}
return b;
}
function updateBytes(index) {
const text = document.getElementById(`edit-${index}`).innerText;
document.getElementById(`byte-${index}`).innerText = getByteLength(text);
}
function updateApplyButton(current, total) {
const btn = document.getElementById('btnApply');
btn.innerText = `적용하기 (${current} / ${total})`;
if (current === total) btn.disabled = false;
}
function applyAll() {
if (!confirm("검사된 내용으로 원본 데이터를 덮어쓰시겠습니까?\n(되돌릴 수 없습니다)")) return;
const updates = [];
itemData.forEach((item, index) => {
if (item) {
const el = document.getElementById(`edit-${index}`);
if (el) updates.push({ sheetName: item.sheetName, row: item.row, col: item.col, text: el.innerText });
}
});
const btn = document.getElementById('btnApply');
btn.disabled = true; btn.innerText = "저장 중...";
google.script.run.withSuccessHandler((count) => {
alert(`✅ 총 ${count}건이 수정되었습니다.`);
google.script.host.close();
}).applySpellCheckResults(updates);
}
</script>
</body>
</html>
<? } else if (activeView === 'banned') { ?>
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<style>
body { padding: 20px; font-family: 'Noto Sans KR', sans-serif; background: #f8f9fa; margin: 0; height: 100vh; display: flex; flex-direction: column; box-sizing: border-box; }
.header-area { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; }
.title { font-size: 20px; font-weight: 700; color: #d93025; display: flex; align-items: center; gap: 8px; }
.content-area { flex: 1; overflow-y: auto; padding-right: 10px; position: relative; }
.check-item { background: white; border: 1px solid #dadce0; border-radius: 8px; margin-bottom: 25px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); overflow: hidden; }
.item-header { background: #f1f3f4; padding: 10px 15px; font-size: 14px; font-weight: 700; color: #3c4043; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; }
.split-view { display: flex; min-height: 120px; }
.pane {
flex: 1; padding: 15px; font-size: 14px; line-height: 1.8; color: #3c4043;
white-space: pre-wrap; word-break: keep-all; text-align: justify;
}
.pane-left { border-right: 1px solid #eee; background: #fff; }
.pane-right { background: #f8fbff; outline: none; }
.pane-right:focus { background: #fff; box-shadow: inset 0 0 0 2px #1a73e8; }
/* 하이라이트 스타일 */
.diff-del { background-color: #ffe6e6; color: #c5221f; border-bottom: 2px solid #c5221f; text-decoration: none; padding: 1px 0; font-weight: 600; }
.diff-add { background-color: #e6f4ea; color: #188038; border-bottom: 2px solid #188038; text-decoration: none; padding: 1px 0; font-weight: 600; }
/* [신규] 상세 설명 박스 스타일 */
.analysis-box { border-top: 1px solid #eee; background: #fafafa; padding: 12px 15px; font-size: 13px; color: #444; }
.analysis-list { list-style: none; padding: 0; margin: 0; }
.analysis-row { padding: 4px 0; display: flex; align-items: flex-start; gap: 6px; }
.analysis-row:before { content: "•"; color: #d93025; font-weight: bold; margin-top: 1px; }
.err-word { color: #d93025; font-weight: bold; }
.fix-word { color: #188038; font-weight: bold; }
.footer-bar { margin-top: 15px; padding-top: 15px; border-top: 1px solid #dadce0; display: flex; justify-content: flex-end; gap: 10px; }
.btn { padding: 10px 20px; border-radius: 4px; border: none; font-weight: bold; cursor: pointer; font-size: 14px; }
.btn-cancel { background: #f1f3f4; color: #3c4043; }
.btn-apply { background: #1a73e8; color: white; box-shadow: 0 2px 5px rgba(26,115,232,0.3); }
.btn-apply:hover { background: #1765cc; }
.btn:disabled { background: #dadce0; cursor: not-allowed; box-shadow: none; color: #fff; }
.loader-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: white; z-index: 100; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #5f6368; font-size: 14px; }
.spinner { border: 4px solid #f3f3f3; border-top: 4px solid #1a73e8; border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin-bottom: 15px; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
</style>
</head>
<body>
<div class="header-area">
<div class="title"><span class="material-icons">block</span> 기재 금지어 검사</div>
<div id="checkLegend" style="font-size:12px; color:#5f6368; display:flex; gap:15px;">
<div><span style="background:#ffe6e6; color:#c5221f; border-bottom:2px solid #c5221f; padding:0 4px; font-weight:500;">금지어 발견</span></div>
<div>오른쪽 수정안에서 문맥에 맞게 직접 고쳐 주세요.</div>
</div>
</div>
<div id="checkView" class="content-area">
<div id="loaderOverlay" class="loader-overlay">
<div class="spinner"></div>
<div id="loadingText">데이터를 분석 중입니다...</div>
</div>
<div id="cardsContainer"></div>
</div>
<div class="footer-bar" id="footerCheck">
<button class="btn btn-cancel" onclick="google.script.host.close()">닫기</button>
<button class="btn btn-apply" id="btnApply" onclick="applyAll()" disabled>적용하기 (0건)</button>
</div>
<script>
let itemData = [];
let renderQueue = [];
const CHUNK_SIZE = 20;
window.onload = function() {
runCheck();
};
function runCheck() {
document.getElementById('loaderOverlay').style.display = 'flex';
document.getElementById('loadingText').innerText = "데이터를 분석 중입니다...";
document.getElementById('cardsContainer').innerHTML = "";
google.script.run
.withSuccessHandler(startRenderSequence)
.withFailureHandler(err => alert("오류: " + err))
.runBannedWordCheck();
}
function startRenderSequence(rows) {
document.getElementById('loaderOverlay').style.display = 'none';
const container = document.getElementById('cardsContainer');
if (!rows || rows.length === 0) {
container.innerHTML = '<div style="text-align:center; padding:50px; color:#5f6368;">금지어가 발견되지 않았습니다. 🎉</div>';
document.getElementById('btnApply').disabled = true;
return;
}
itemData = rows;
renderQueue = [...rows];
processRenderQueue();
document.getElementById('btnApply').innerText = `적용하기 (${rows.length}건)`;
document.getElementById('btnApply').disabled = false;
}
function processRenderQueue() {
if (renderQueue.length === 0) return;
const container = document.getElementById('cardsContainer');
const chunk = renderQueue.splice(0, CHUNK_SIZE);
chunk.forEach((row) => {
const originalIndex = itemData.findIndex(r => r.row === row.row && r.col === row.col);
let logHtml = "";
if (row.logs && row.logs.length > 0) {
const listItems = row.logs.map(log => {
const count = log.count > 1 ? ` (${log.count}회)` : '';
return `<li class="analysis-row"><span><span class="err-word">${escapeHtml(log.k)}</span>${count} 기재금지어가 발견되었습니다. 오른쪽 수정안에서 문맥에 맞게 직접 고쳐 주세요.</span></li>`;
}).join("");
logHtml = `
<div class="analysis-box">
<ul class="analysis-list">${listItems}</ul>
</div>
`;
}
const div = document.createElement('div');
div.className = 'check-item';
div.innerHTML = `
<div class="item-header">
<span>행 ${row.row}</span>
<span style="color:#d93025; font-weight:bold;">금지어 발견</span>
</div>
<div class="split-view">
<div class="pane pane-left">${highlightBannedWords(row.original, row.logs)}</div>
<div class="pane pane-right" contenteditable="true" spellcheck="false" id="edit-${originalIndex}"></div>
</div>
${logHtml}
`;
container.appendChild(div);
// 원문을 그대로 넣습니다. 기재금지어 검사는 자동 대체·삭제를 전혀 하지 않습니다.
document.getElementById(`edit-${originalIndex}`).textContent = row.original;
});
if (renderQueue.length > 0) {
requestAnimationFrame(processRenderQueue);
}
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function highlightBannedWords(text, logs) {
const keywords = (logs || []).map(log => log.k).filter(Boolean);
if (keywords.length === 0) return escapeHtml(text);
const fragments = keywords
.sort((a, b) => compactBannedWord(b).length - compactBannedWord(a).length)
.map(buildWhitespaceInsensitivePattern);
const pattern = new RegExp(`(${fragments.join('|')})`, 'gi');
return String(text).split(pattern).map((part, index) => {
const safeText = escapeHtml(part);
return index % 2 === 1 ? `<span class="diff-del">${safeText}</span>` : safeText;
}).join('');
}
function compactBannedWord(value) {
return String(value || '').replace(/\s+/g, '').toLocaleLowerCase();
}
function buildWhitespaceInsensitivePattern(keyword) {
return compactBannedWord(keyword)
.split('')
.map(escapeRegExp)
.join('[\\s\\u00A0]*');
}
function applyAll() {
if (!confirm("수정된 내용으로 원본을 덮어쓰시겠습니까?")) return;
const updates = [];
itemData.forEach((item, index) => {
const el = document.getElementById(`edit-${index}`);
if (el) updates.push({ sheetName: item.sheetName, row: item.row, col: item.col, text: el.innerText });
});
google.script.run.withSuccessHandler((c) => {
alert(`${c}건 수정 완료!`);
google.script.host.close();
}).applySpellCheckResults(updates);
}
</script>
</body>
</html>
<? } ?>