216 lines
8.8 KiB
JavaScript
216 lines
8.8 KiB
JavaScript
/**
|
|
* 십자말풀이 스테이지 관리 도구 - Google Apps Script
|
|
*/
|
|
|
|
function onOpen() {
|
|
const ui = SpreadsheetApp.getUi();
|
|
ui.createMenu('🧩 십자말풀이 관리')
|
|
.addItem('1. stages.json 생성 및 검증', 'exportStagesJson')
|
|
.addSeparator()
|
|
.addItem('2. 샘플 데이터 입력', 'populateSampleData')
|
|
.addToUi();
|
|
}
|
|
|
|
/**
|
|
* 시트 데이터를 읽어 검증 후 JSON 모달 창을 띄웁니다.
|
|
*/
|
|
function exportStagesJson() {
|
|
const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
|
|
const data = sheet.getDataRange().getValues();
|
|
|
|
if (data.length < 2) {
|
|
SpreadsheetApp.getUi().alert('데이터가 없습니다. 먼저 스테이지 데이터를 입력해 주세요.');
|
|
return;
|
|
}
|
|
|
|
const stagesMap = new Map();
|
|
const errors = [];
|
|
|
|
// 2행부터 데이터 파싱
|
|
for (let i = 1; i < data.length; i++) {
|
|
const row = data[i];
|
|
const rowNum = i + 1;
|
|
|
|
const stageNumber = parseInt(row[0]);
|
|
if (isNaN(stageNumber)) continue;
|
|
|
|
const title = String(row[1] || '').trim();
|
|
const gridSize = parseInt(row[2]) || 6;
|
|
const difficulty = parseInt(row[3]) || 1;
|
|
|
|
const clueId = String(row[4] || `c${stageNumber}_${row[5]}`).trim();
|
|
const number = parseInt(row[5]);
|
|
const direction = String(row[6] || 'ACROSS').trim().toUpperCase();
|
|
const word = String(row[7] || '').trim().replace(/\s+/g, '');
|
|
const clue = String(row[8] || '').trim();
|
|
const startRow = parseInt(row[9]);
|
|
const startCol = parseInt(row[10]);
|
|
const length = parseInt(row[11]) || word.length;
|
|
|
|
// 1. 유효성 검사
|
|
if (word.length !== length) {
|
|
errors.push(`[${rowNum}행] '${word}'의 글자 수(${word.length})와 length(${length})가 불일치합니다.`);
|
|
}
|
|
|
|
if (direction === 'ACROSS') {
|
|
if (startCol + length > gridSize) {
|
|
errors.push(`[${rowNum}행 Stage ${stageNumber}] '${word}' 가로 단어가 gridSize(${gridSize}) 범위를 벗어납니다. (startCol: ${startCol} + length: ${length} = ${startCol + length})`);
|
|
}
|
|
} else if (direction === 'DOWN') {
|
|
if (startRow + length > gridSize) {
|
|
errors.push(`[${rowNum}행 Stage ${stageNumber}] '${word}' 세로 단어가 gridSize(${gridSize}) 범위를 벗어납니다. (startRow: ${startRow} + length: ${length} = ${startRow + length})`);
|
|
}
|
|
}
|
|
|
|
if (!stagesMap.has(stageNumber)) {
|
|
stagesMap.set(stageNumber, {
|
|
stageNumber: stageNumber,
|
|
title: title,
|
|
gridSize: gridSize,
|
|
difficulty: difficulty,
|
|
clues: []
|
|
});
|
|
}
|
|
|
|
stagesMap.get(stageNumber).clues.push({
|
|
id: clueId,
|
|
number: number,
|
|
direction: direction,
|
|
word: word,
|
|
clue: clue,
|
|
startRow: startRow,
|
|
startCol: startCol,
|
|
length: length
|
|
});
|
|
}
|
|
|
|
// 2. 스테이지별 교차점 글자 충돌 검사
|
|
stagesMap.forEach((stage, stageNum) => {
|
|
const gridMap = new Map();
|
|
stage.clues.forEach(clue => {
|
|
for (let idx = 0; idx < clue.word.length; idx++) {
|
|
const r = clue.direction === 'ACROSS' ? clue.startRow : clue.startRow + idx;
|
|
const c = clue.direction === 'ACROSS' ? clue.startCol + idx : clue.startCol;
|
|
const key = `${r},${c}`;
|
|
const char = clue.word[idx];
|
|
|
|
if (gridMap.has(key)) {
|
|
const existing = gridMap.get(key);
|
|
if (existing.char !== char) {
|
|
errors.push(`[Stage ${stageNum}] 교차점 (${r}, ${c}) 글자 충돌: '${existing.word}'의 '${existing.char}' vs '${clue.word}'의 '${char}'`);
|
|
}
|
|
} else {
|
|
gridMap.set(key, { char: char, word: clue.word });
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
const stagesArray = Array.from(stagesMap.values());
|
|
const jsonString = JSON.stringify(stagesArray, null, 2);
|
|
|
|
// 모달 팝업 표시
|
|
showJsonDialog(jsonString, errors);
|
|
}
|
|
|
|
/**
|
|
* JSON 결과 및 복사 버튼 팝업 UI
|
|
*/
|
|
function showJsonDialog(jsonString, errors) {
|
|
let errorHtml = '';
|
|
if (errors.length > 0) {
|
|
errorHtml = `
|
|
<div style="background:#FFEBEE; border:1px solid #EF5350; padding:10px; border-radius:6px; margin-bottom:12px; color:#C62828;">
|
|
<b>⚠️ 검증 오류 발견 (${errors.length}건):</b><br/>
|
|
<ul style="margin:5px 0 0 15px; padding:0; font-size:12px;">
|
|
${errors.map(e => `<li>${e}</li>`).join('')}
|
|
</ul>
|
|
</div>
|
|
`;
|
|
} else {
|
|
errorHtml = `
|
|
<div style="background:#E8F5E9; border:1px solid #66BB6A; padding:10px; border-radius:6px; margin-bottom:12px; color:#2E7D32;">
|
|
<b>✅ 모든 데이터 및 교차점 검증 완료 (오류 없음)</b>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
const html = `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<base target="_top">
|
|
<style>
|
|
body { font-family: sans-serif; margin: 15px; }
|
|
textarea { width: 100%; height: 350px; font-family: monospace; font-size: 11px; padding: 8px; box-sizing: border-box; }
|
|
.btn { background: #4F46E5; color: white; border: none; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-weight: bold; }
|
|
.btn:hover { background: #4338CA; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
${errorHtml}
|
|
<textarea id="jsonArea" readonly>${jsonString}</textarea>
|
|
<div style="margin-top: 10px; display: flex; justify-content: space-between; align-items: center;">
|
|
<button class="btn" onclick="copyJson()">📋 클립보드에 전체 복사</button>
|
|
<span id="copyMsg" style="color: #4F46E5; font-size: 12px; font-weight: bold;"></span>
|
|
</div>
|
|
|
|
<script>
|
|
function copyJson() {
|
|
const copyText = document.getElementById("jsonArea");
|
|
copyText.select();
|
|
document.execCommand("copy");
|
|
document.getElementById("copyMsg").innerText = "복사되었습니다! Firebase 또는 파일에 붙여넣으세요.";
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|
|
`;
|
|
|
|
const htmlOutput = HtmlService.createHtmlOutput(html)
|
|
.setWidth(680)
|
|
.setHeight(520);
|
|
SpreadsheetApp.getUi().showModalDialog(htmlOutput, 'stages.json 내보내기');
|
|
}
|
|
|
|
/**
|
|
* 1~4 스테이지 샘플 데이터를 시트에 자동으로 채워줍니다.
|
|
*/
|
|
function populateSampleData() {
|
|
const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
|
|
sheet.clear();
|
|
|
|
const headers = [
|
|
'stageNumber', 'title', 'gridSize', 'difficulty',
|
|
'clueId', 'number', 'direction', 'word', 'clue', 'startRow', 'startCol', 'length'
|
|
];
|
|
|
|
const sampleRows = [
|
|
[1, '첫걸음 (자연과 일상)', 6, 1, 'c1_1', 1, 'ACROSS', '가을하늘', '가을철의 맑고 푸른 하늘.', 1, 0, 4],
|
|
[1, '첫걸음 (자연과 일상)', 6, 1, 'c1_2', 2, 'DOWN', '하늘나라', '천국을 이르는 말.', 1, 2, 4],
|
|
[1, '첫걸음 (자연과 일상)', 6, 1, 'c1_3', 3, 'ACROSS', '나비효과', '미세한 변화가 엄청난 결과를 낳는 현상.', 3, 2, 4],
|
|
|
|
[2, '동네 한바퀴', 6, 1, 'c2_1', 1, 'ACROSS', '과일가게', '여러 가지 과일을 파는 상점.', 0, 1, 4],
|
|
[2, '동네 한바퀴', 6, 1, 'c2_2', 2, 'DOWN', '가면무도회', '참가자가 가면을 쓰고 춤을 추는 연회.', 0, 3, 5],
|
|
[2, '동네 한바퀴', 6, 1, 'c2_3', 3, 'ACROSS', '무지개', '비 갠 하늘에 나타나는 일곱 빛깔 호선.', 2, 3, 3],
|
|
[2, '동네 한바퀴', 6, 1, 'c2_4', 4, 'DOWN', '개구쟁이', '궂은 장난을 자주 치는 귀여운 아이.', 2, 5, 4],
|
|
|
|
[3, '사자성어와 지혜', 6, 2, 'c3_1', 1, 'ACROSS', '일석이조', '돌 하나로 새 두 마리를 잡는다는 사자성어.', 1, 0, 4],
|
|
[3, '사자성어와 지혜', 6, 2, 'c3_2', 2, 'DOWN', '조약돌', '물에 씻기어 둥글고 반드러워진 작은 돌.', 1, 3, 3],
|
|
[3, '사자성어와 지혜', 6, 2, 'c3_3', 3, 'ACROSS', '돌다리', '돌로 놓은 다리. 신중함을 뜻하는 속담 소재.', 3, 3, 3],
|
|
[3, '사자성어와 지혜', 6, 2, 'c3_4', 4, 'DOWN', '다락방', '지붕 바로 밑이나 천장 위에 꾸며 놓은 방.', 3, 4, 3],
|
|
|
|
[4, '문화와 예술', 6, 2, 'c4_1', 1, 'ACROSS', '방방곡곡', '전국 방방곡곡, 한 곳도 빠짐없는 모든 곳.', 1, 0, 4],
|
|
[4, '문화와 예술', 6, 2, 'c4_2', 2, 'DOWN', '곡예사', '서커스나 줄타기 따위 곡예를 하는 사람.', 1, 3, 3],
|
|
[4, '문화와 예술', 6, 2, 'c4_3', 3, 'ACROSS', '고사성어', '옛날 이야기나 고사에서 유래된 사자성어 및 숙어.', 3, 2, 4],
|
|
[4, '문화와 예술', 6, 2, 'c4_4', 4, 'DOWN', '어린이', '어린아이를 대접하여 부르는 말.', 3, 5, 3],
|
|
[4, '문화와 예술', 6, 2, 'c4_5', 5, 'ACROSS', '호랑이', '고양이과의 사나운 맹수, 산군.', 5, 3, 3]
|
|
];
|
|
|
|
sheet.getRange(1, 1, 1, headers.length).setValues([headers]).setFontWeight('bold').setBackground('#E0E7FF');
|
|
sheet.getRange(2, 1, sampleRows.length, headers.length).setValues(sampleRows);
|
|
sheet.autoResizeColumns(1, headers.length);
|
|
|
|
SpreadsheetApp.getUi().alert('샘플 데이터가 생성되었습니다!');
|
|
}
|