민기.한국

충남교육청 AI 에듀테크 활용 역량강화 연수

강사
교사 김민기(민기쌤)
일시
2026-09-12
장소
캠코인재개발원

강의 자료

관련 링크

생기부 점검

Code.gs

// =================================================================
// [0] 초기 설정 및 메뉴
// =================================================================

var MAX_STUDENT_ROWS_ = 40;

function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('민기쌤 생기부 도우미')
    .addItem('🚀 사이드바 열기', 'openSidebar')
    .addToUi();
}

function renderView_(viewName, viewData) {
  var template = HtmlService.createTemplateFromFile('Sidebar');

  template.initialView = viewName || 'sidebar';
  template.initialDataJson = JSON.stringify(viewData || {});

  return template.evaluate();
}

function openSidebar() {
  ensureBannedWordSheet_();

  var html = renderView_('sidebar')
    .setTitle('민기쌤 생기부 맞춤법 도우미')
    .setWidth(300);

  SpreadsheetApp.getUi().showSidebar(html);
}

function openSpellCheckViewer() {
  assertSelectionWithinLimit_();

  var html = renderView_('spellcheck')
    .setWidth(1000)
    .setHeight(700);

  SpreadsheetApp.getUi().showModelessDialog(
    html,
    '📝 Bareun 맞춤법 검사 및 수정'
  );
}

function openFormatCheckViewer(options) {
  var normalizedOptions = normalizeFormatOptions_(options);

  if (!hasAnyFormatOption_(normalizedOptions)) {
    throw new Error('형식 검사 항목을 하나 이상 선택해 주세요.');
  }

  assertSelectionWithinLimit_();

  var html = renderView_('format', normalizedOptions)
    .setWidth(1000)
    .setHeight(700);

  SpreadsheetApp.getUi().showModelessDialog(
    html,
    '🔎 생기부 형식 검사'
  );
}


// =================================================================
// [1] 선택 영역 공통 처리 및 40명 제한
// =================================================================

function getSelectedTextCells_() {
  var spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = spreadsheet.getActiveSheet();
  var range = sheet.getActiveRange();

  if (!range) {
    throw new Error('선택된 셀이 없습니다.');
  }

  var values = range.getValues();

  var startRow = range.getRow();
  var startCol = range.getColumn();

  var rows = [];
  var nonEmptyRowIndexes = {};

  for (var i = 0; i < values.length; i++) {
    for (var j = 0; j < values[i].length; j++) {
      var value = values[i][j];

      if (value === null || value === undefined) {
        continue;
      }

      var text = String(value);

      if (text.trim() === '') {
        continue;
      }

      // 내용이 있는 행 하나를 학생 1명으로 계산
      nonEmptyRowIndexes[i] = true;

      rows.push({
        sheetName: sheet.getName(),
        row: startRow + i,
        col: startCol + j,
        original: text
      });
    }
  }

  var studentCount = Object.keys(nonEmptyRowIndexes).length;

  if (studentCount > MAX_STUDENT_ROWS_) {
    throw new Error(
      '한 번에 최대 ' +
      MAX_STUDENT_ROWS_ +
      '명까지만 검사할 수 있습니다. 현재 선택 영역에는 내용이 있는 행이 ' +
      studentCount +
      '개 있습니다.'
    );
  }

  return {
    rows: rows,
    studentCount: studentCount
  };
}

function assertSelectionWithinLimit_() {
  var selected = getSelectedTextCells_();

  if (selected.rows.length === 0) {
    throw new Error('선택 영역에 검사할 내용이 없습니다.');
  }

  return selected;
}


// =================================================================
// [2] 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;
}


// =================================================================
// [3] Bareun 맞춤법 검사
// =================================================================

function getSelectedDataForSpellCheck() {
  var selected = assertSelectionWithinLimit_();

  return selected.rows;
}

function getBareunCorrection(text) {
  var userKey = getBareunKey();

  return callBareunApi(
    text,
    userKey
  );
}

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 responseCode =
    response.getResponseCode();

  var responseText =
    response.getContentText();

  if (responseCode !== 200) {
    return {
      error:
        'Bareun API 오류: ' +
        responseCode
    };
  }

  var result;

  try {
    result = JSON.parse(
      responseText
    );
  } catch (e) {
    return {
      error:
        'Bareun API 응답을 해석하지 못했습니다.'
    };
  }

  return {
    revised:
      result.revised || text,

    helps:
      result.helps || {},

    rawBlocks:
      result.revisedBlocks ||
      result.revised_blocks ||
      [],

    error: null
  };
}


// =================================================================
// [4] 검사 결과 시트 반영
// =================================================================

function applySpellCheckResults(updates) {
  if (!Array.isArray(updates)) {
    throw new Error(
      '적용할 데이터 형식이 올바르지 않습니다.'
    );
  }

  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;
}


// =================================================================
// [5] 기재 금지어 관리
// =================================================================

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 escapeRegExp_(value) {
  return String(value)
    .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]*');
}

function createBannedMatcher_(list) {
  if (
    !list ||
    list.length === 0
  ) {
    return null;
  }

  var sorted =
    list
      .slice()
      .sort(
        function(a, b) {
          return (
            normalizeBannedWord_(b).length -
            normalizeBannedWord_(a).length
          );
        }
      );

  var canonical = {};

  sorted.forEach(
    function(keyword) {
      canonical[
        normalizeBannedWord_(keyword)
      ] = keyword;
    }
  );

  var pattern =
    new RegExp(
      '(' +
      sorted
        .map(
          buildWhitespaceInsensitivePattern_
        )
        .join('|') +
      ')',
      'gi'
    );

  return {
    pattern: pattern,
    canonical: canonical
  };
}

function findBannedMatches_(text, matcher) {
  if (!matcher) {
    return {
      matches: [],
      counts: {}
    };
  }

  var matches = [];
  var counts = {};

  matcher.pattern.lastIndex = 0;

  var match;

  while (
    (match =
      matcher.pattern.exec(
        text
      )) !== null
  ) {
    var normalized =
      normalizeBannedWord_(
        match[0]
      );

    var keyword =
      matcher.canonical[
        normalized
      ];

    if (keyword) {
      matches.push({
        keyword: keyword,
        start: match.index,
        end:
          match.index +
          match[0].length
      });

      counts[keyword] =
        (counts[keyword] || 0) + 1;
    }

    // 혹시라도 길이 0인 정규식 일치가 생기는 경우 무한 반복 방지
    if (match[0].length === 0) {
      matcher.pattern.lastIndex++;
    }
  }

  matcher.pattern.lastIndex = 0;

  return {
    matches: matches,
    counts: counts
  };
}


// =================================================================
// [6] 형식 검사 옵션
// =================================================================

function normalizeFormatOptions_(options) {
  options = options || {};

  return {
    quotes: !!options.quotes,
    banned: !!options.banned,
    spaces: !!options.spaces,
    parentheses: !!options.parentheses
  };
}

function hasAnyFormatOption_(options) {
  return (
    options.quotes ||
    options.banned ||
    options.spaces ||
    options.parentheses
  );
}


// =================================================================
// [7] 소괄호 쌍 검사
// =================================================================

function analyzeParentheses_(text) {
  var stack = [];

  var unmatchedOpen = [];
  var unmatchedClose = [];

  for (
    var i = 0;
    i < text.length;
    i++
  ) {
    var character =
      text.charAt(i);

    if (character === '(') {
      stack.push(i);

    } else if (
      character === ')'
    ) {
      if (stack.length > 0) {
        stack.pop();

      } else {
        unmatchedClose.push(i);
      }
    }
  }

  while (
    stack.length > 0
  ) {
    unmatchedOpen.push(
      stack.pop()
    );
  }

  unmatchedOpen.sort(
    function(a, b) {
      return a - b;
    }
  );

  return {
    balanced:
      unmatchedOpen.length === 0 &&
      unmatchedClose.length === 0,

    unmatchedOpen:
      unmatchedOpen,

    unmatchedClose:
      unmatchedClose
  };
}


// =================================================================
// [8] 통합 형식 검사
// =================================================================

function runFormatCheck(options) {
  var normalizedOptions =
    normalizeFormatOptions_(
      options
    );

  if (
    !hasAnyFormatOption_(
      normalizedOptions
    )
  ) {
    throw new Error(
      '형식 검사 항목을 하나 이상 선택해 주세요.'
    );
  }

  var selected =
    assertSelectionWithinLimit_();

  var bannedMatcher = null;

  if (
    normalizedOptions.banned
  ) {
    bannedMatcher =
      createBannedMatcher_(
        getBannedWordList_()
      );
  }

  var results = [];

  selected.rows.forEach(
    function(item) {
      var original =
        item.original;

      // 형식 검사는 자동 수정하지 않음
      // 오른쪽 수정안도 항상 원문 그대로 시작
      var suggested =
        original;

      var issues = [];
      var ranges = [];

      // -------------------------------------------------------------
      // 1. 따옴표 통일 검사
      // -------------------------------------------------------------

      if (
        normalizedOptions.quotes
      ) {
        var quoteCount = 0;

        for (
          var q = 0;
          q < original.length;
          q++
        ) {
          var quoteChar =
            original.charAt(q);

          if (
            quoteChar === '‘' ||
            quoteChar === '’' ||
            quoteChar === '“' ||
            quoteChar === '”'
          ) {
            quoteCount++;

            ranges.push({
              start: q,
              end: q + 1,
              type: 'quotes'
            });
          }
        }

        if (quoteCount > 0) {
          issues.push({
            type: 'quotes',
            title: '따옴표 통일',
            message:
              '둥근 따옴표 ' +
              quoteCount +
              '개가 발견되었습니다. 하이라이팅된 부분을 확인한 뒤 수정안에서 직접 고쳐 주세요.'
          });
        }
      }


      // -------------------------------------------------------------
      // 2. 스페이스바 실수 검사
      // 일반 공백이 2칸 이상 연속된 경우
      // -------------------------------------------------------------

      if (
        normalizedOptions.spaces
      ) {
        var spaceRegex =
          / {2,}/g;

        var spaceMatches = [];
        var spaceMatch;

        while (
          (spaceMatch =
            spaceRegex.exec(
              original
            )) !== null
        ) {
          spaceMatches.push({
            start:
              spaceMatch.index,

            end:
              spaceMatch.index +
              spaceMatch[0].length,

            length:
              spaceMatch[0].length
          });

          if (
            spaceMatch[0].length === 0
          ) {
            spaceRegex.lastIndex++;
          }
        }

        if (
          spaceMatches.length > 0
        ) {
          spaceMatches.forEach(
            function(matchInfo) {
              ranges.push({
                start:
                  matchInfo.start,

                end:
                  matchInfo.end,

                type:
                  'spaces'
              });
            }
          );

          issues.push({
            type: 'spaces',
            title: '스페이스바 실수',
            message:
              '두 칸 이상 연속된 공백이 ' +
              spaceMatches.length +
              '곳 발견되었습니다. 하이라이팅된 부분을 확인한 뒤 수정안에서 직접 고쳐 주세요.'
          });
        }
      }


      // -------------------------------------------------------------
      // 3. 기재 금지어 검사
      // -------------------------------------------------------------

      if (
        normalizedOptions.banned &&
        bannedMatcher
      ) {
        var bannedResult =
          findBannedMatches_(
            original,
            bannedMatcher
          );

        bannedResult.matches.forEach(
          function(matchInfo) {
            ranges.push({
              start:
                matchInfo.start,

              end:
                matchInfo.end,

              type:
                'banned'
            });
          }
        );

        Object.keys(
          bannedResult.counts
        ).forEach(
          function(keyword) {
            var count =
              bannedResult.counts[
                keyword
              ];

            issues.push({
              type:
                'banned',

              title:
                '기재 금지어',

              message:
                '"' +
                keyword +
                '"이(가) ' +
                count +
                '회 발견되었습니다. 하이라이팅된 부분을 확인한 뒤 수정안에서 문맥에 맞게 직접 고쳐 주세요.'
            });
          }
        );
      }


      // -------------------------------------------------------------
      // 4. 소괄호 쌍 검사
      // 개수뿐 아니라 등장 순서까지 검사
      // -------------------------------------------------------------

      if (
        normalizedOptions.parentheses
      ) {
        var parenResult =
          analyzeParentheses_(
            original
          );

        if (
          !parenResult.balanced
        ) {
          parenResult.unmatchedOpen.forEach(
            function(position) {
              ranges.push({
                start:
                  position,

                end:
                  position + 1,

                type:
                  'parentheses'
              });
            }
          );

          parenResult.unmatchedClose.forEach(
            function(position) {
              ranges.push({
                start:
                  position,

                end:
                  position + 1,

                type:
                  'parentheses'
              });
            }
          );

          var parenthesisMessages = [];

          if (
            parenResult.unmatchedOpen.length > 0
          ) {
            parenthesisMessages.push(
              '짝이 없는 여는 괄호 ( ' +
              parenResult.unmatchedOpen.length +
              '개'
            );
          }

          if (
            parenResult.unmatchedClose.length > 0
          ) {
            parenthesisMessages.push(
              '짝이 없는 닫는 괄호 ) ' +
              parenResult.unmatchedClose.length +
              '개'
            );
          }

          issues.push({
            type:
              'parentheses',

            title:
              '괄호 쌍 검사',

            message:
              parenthesisMessages.join(
                ', '
              ) +
              '가 발견되었습니다. 빨간색으로 표시된 괄호를 확인한 뒤 수정안에서 직접 고쳐 주세요.'
          });
        }
      }


      // 문제 있는 셀만 결과 창에 표시
      if (
        issues.length > 0
      ) {
        results.push({
          sheetName:
            item.sheetName,

          row:
            item.row,

          col:
            item.col,

          original:
            original,

          // 자동 수정 없이 원문 그대로
          suggested:
            suggested,

          issues:
            issues,

          ranges:
            ranges
        });
      }
    }
  );

  return {
    studentCount:
      selected.studentCount,

    checkedCellCount:
      selected.rows.length,

    issueCellCount:
      results.length,

    rows:
      results,

    options:
      normalizedOptions
  };
}


// =================================================================
// [9] 호환용 기재 금지어 검사 함수
// 기존 코드에서 이 함수를 직접 호출하는 경우를 대비
// =================================================================

function runBannedWordCheck() {
  var result =
    runFormatCheck({
      quotes: false,
      banned: true,
      spaces: false,
      parentheses: false
    });

  return result.rows;
}


// =================================================================
// [10] UI 상태 저장 및 불러오기
// =================================================================

function saveUiState(
  cardId,
  isExpanded
) {
  var props =
    PropertiesService
      .getUserProperties();

  var state = {};

  try {
    state =
      JSON.parse(
        props.getProperty(
          'UI_STATE'
        ) || '{}'
      );

  } catch (e) {
    state = {};
  }

  state[cardId] =
    isExpanded;

  props.setProperty(
    'UI_STATE',
    JSON.stringify(
      state
    )
  );
}

function getUiState() {
  var props =
    PropertiesService
      .getUserProperties();

  try {
    return JSON.parse(
      props.getProperty(
        'UI_STATE'
      ) || '{}'
    );

  } catch (e) {
    return {};
  }
}

Sidebar.html

<? var allowedViews = ["sidebar", "spellcheck", "format"]; ?>
<? 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: 8px;
      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-warning {
      background: #fbbc04;
      color: #202124;
    }

    .btn-primary {
      background: #1a73e8;
      color: #fff;
    }

    .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;
    }

    .format-options {
      margin-top: 12px;
      padding: 12px 12px 9px;
      border-radius: 9px;
      background: #f8f9fa;
      border: 1px solid #e8eaed;
    }

    .format-options-title {
      margin-bottom: 9px;
      color: #3c4043;
      font-size: 12px;
      font-weight: 700;
    }

    .check-row {
      display: flex;
      align-items: center;
      gap: 8px;
      margin: 0 0 9px;
      color: #3c4043;
      font-size: 12px;
      cursor: pointer;
      line-height: 1.4;
    }

    .check-row input {
      width: 16px;
      height: 16px;
      margin: 0;
      accent-color: #1a73e8;
      flex-shrink: 0;
    }

    .option-note {
      margin-top: 9px;
      padding-top: 9px;
      border-top: 1px solid #e8eaed;
      color: #5f6368;
      font-size: 10px;
      line-height: 1.6;
    }

    .limit-note {
      margin-top: 11px;
      color: #5f6368;
      font-size: 11px;
      line-height: 1.5;
      text-align: center;
    }

    .text-action {
      display: block;
      margin-top: 13px;
      color: #5f6368;
      font-size: 11px;
      text-align: center;
      text-decoration: underline;
      cursor: pointer;
    }

    .text-action:hover {
      color: #1a73e8;
    }
  </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="본인이 발급받은 키를 입력하세요"
            >


            <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()"
      >
        맞춤법 검사
      </button>


      <button
        class="btn-primary"
        id="formatCheckBtn"
        onclick="runFormatCheck()"
      >
        형식 검사
      </button>


      <div class="format-options">

        <div class="format-options-title">
          형식 검사 항목
        </div>


        <label class="check-row">
          <input
            type="checkbox"
            id="optionQuotes"
            checked
          >

          <span>
            따옴표 통일 (' ")
          </span>
        </label>


        <label class="check-row">
          <input
            type="checkbox"
            id="optionBanned"
            checked
          >

          <span>
            기재 금지어 검사
          </span>
        </label>


        <label class="check-row">
          <input
            type="checkbox"
            id="optionSpaces"
            checked
          >

          <span>
            스페이스바 실수 검사
          </span>
        </label>


        <label class="check-row">
          <input
            type="checkbox"
            id="optionParentheses"
            checked
          >

          <span>
            괄호 쌍 검사
          </span>
        </label>

      </div>


      <div
        id="checkStatus"
        class="status-box"
      ></div>


      <div class="limit-note">
        한 번에 최대 40명까지 검사할 수 있습니다.<br>
      </div>


      <span
        class="text-action"
        onclick="openBannedWordManagement()"
      >
        기재금지어관리 시트 열기
      </span>

    </div>
  </div>


  <script>
    let currentApiStatus = {
      hasBareun: false
    };


    function getErrorMessage(error) {
      if (!error) {
        return '알 수 없는 오류가 발생했습니다.';
      }

      if (error.message) {
        return error.message;
      }

      return String(error)
        .replace(/^Error:\s*/, '');
    }


    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 키 저장 오류: ' +
              getErrorMessage(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 키 삭제 오류: ' +
              getErrorMessage(error)
            );
          }
        )

        .clearUserBareunKey();
    }


    function runSpellCheck() {
      if (
        !currentApiStatus.hasBareun
      ) {
        alert(
          'Bareun API 키를 먼저 저장해 주세요.'
        );

        return;
      }


      const statusBox =
        document.getElementById(
          'checkStatus'
        );

      statusBox.innerText =
        '맞춤법 검사 창을 여는 중...';


      google.script.run

        .withSuccessHandler(
          function() {
            statusBox.innerText =
              '';
          }
        )

        .withFailureHandler(
          function(error) {
            statusBox.innerText =
              '';

            alert(
              getErrorMessage(error)
            );
          }
        )

        .openSpellCheckViewer();
    }


    function runFormatCheck() {
      const options = {
        quotes:
          document
            .getElementById(
              'optionQuotes'
            )
            .checked,

        banned:
          document
            .getElementById(
              'optionBanned'
            )
            .checked,

        spaces:
          document
            .getElementById(
              'optionSpaces'
            )
            .checked,

        parentheses:
          document
            .getElementById(
              'optionParentheses'
            )
            .checked
      };


      if (
        !options.quotes &&
        !options.banned &&
        !options.spaces &&
        !options.parentheses
      ) {
        alert(
          '형식 검사 항목을 하나 이상 선택해 주세요.'
        );

        return;
      }


      const statusBox =
        document.getElementById(
          'checkStatus'
        );

      statusBox.innerText =
        '형식 검사 창을 여는 중...';


      google.script.run

        .withSuccessHandler(
          function() {
            statusBox.innerText =
              '';
          }
        )

        .withFailureHandler(
          function(error) {
            statusBox.innerText =
              '';

            alert(
              getErrorMessage(error)
            );
          }
        )

        .openFormatCheckViewer(
          options
        );
    }


    function openBannedWordManagement() {
      google.script.run

        .withFailureHandler(
          function(error) {
            alert(
              '오류 발생: ' +
              getErrorMessage(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 =
                '설정 확인 오류: ' +
                getErrorMessage(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,.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,.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 spellRows = [];

    let processedCount = 0;

    let nextIndex = 0;

    let activeCount = 0;

    let isFirstRender = true;

    const MAX_CONCURRENT = 5;


    function escapeHtml(value) {
      return String(value)

        .replace(
          /&/g,
          '&amp;'
        )

        .replace(
          /</g,
          '&lt;'
        )

        .replace(
          />/g,
          '&gt;'
        )

        .replace(
          /"/g,
          '&quot;'
        )

        .replace(
          /'/g,
          '&#39;'
        );
    }


    function getErrorMessage(error) {
      if (!error) {
        return '알 수 없는 오류가 발생했습니다.';
      }

      if (error.message) {
        return error.message;
      }

      return String(error)
        .replace(/^Error:\s*/, '');
    }


    window.onload = function() {
      google.script.run

        .withSuccessHandler(
          processData
        )

        .withFailureHandler(
          function(error) {
            document
              .getElementById(
                'loaderOverlay'
              )
              .innerHTML =
                '<div class="error-msg">' +
                '❌ 초기화 오류<br>' +
                escapeHtml(
                  getErrorMessage(error)
                ) +
                '<br>선택된 셀을 확인해 주세요.' +
                '</div>';
          }
        )

        .getSelectedDataForSpellCheck();
    };


    function processData(rows) {
      if (
        !rows ||
        rows.length === 0
      ) {
        document
          .getElementById(
            'loaderOverlay'
          )
          .innerHTML =
            '<div class="error-msg">' +
            '선택 영역에 검사할 내용이 없습니다.' +
            '</div>';

        return;
      }


      spellRows = rows;

      processedCount = 0;

      nextIndex = 0;

      activeCount = 0;


      document
        .getElementById(
          'loadingText'
        )
        .innerText =
          '총 ' +
          rows.length +
          '개 항목 분석 중...';


      const container =
        document.getElementById(
          'cardsContainer'
        );

      container.innerHTML = '';


      rows.forEach(
        function(row, index) {
          const div =
            document.createElement(
              'div'
            );

          div.id =
            'item-' + index;

          div.className =
            'check-item';


          div.innerHTML =
            '<div class="item-header">' +
              '<span>' +
                row.row +
                '행 / ' +
                row.col +
                '열 (대기 중...)' +
              '</span>' +
            '</div>' +

            '<div class="split-view">' +
              '<div class="pane pane-left">...</div>' +
              '<div class="pane pane-right">...</div>' +
            '</div>';


          container.appendChild(
            div
          );
        }
      );


      pumpQueue();
    }


    function pumpQueue() {
      while (
        activeCount < MAX_CONCURRENT &&
        nextIndex < spellRows.length
      ) {
        const index =
          nextIndex++;

        const row =
          spellRows[index];

        activeCount++;


        google.script.run

          .withSuccessHandler(
            function(result) {
              renderResult(
                index,
                row,
                result
              );

              activeCount--;

              processedCount++;

              updateApplyButton(
                processedCount,
                spellRows.length
              );

              pumpQueue();
            }
          )

          .withFailureHandler(
            function(error) {
              renderError(
                index,
                row,
                getErrorMessage(error)
              );

              activeCount--;

              processedCount++;

              updateApplyButton(
                processedCount,
                spellRows.length
              );

              pumpQueue();
            }
          )

          .getBareunCorrection(
            row.original
          );
      }
    }


    function hideLoaderOnce() {
      if (!isFirstRender) {
        return;
      }

      document
        .getElementById(
          'loaderOverlay'
        )
        .style
        .display = 'none';

      isFirstRender = false;
    }


    function renderError(
      index,
      rowData,
      errorMsg
    ) {
      hideLoaderOnce();


      const itemDiv =
        document.getElementById(
          'item-' + index
        );

      if (!itemDiv) {
        return;
      }


      itemDiv.innerHTML =
        '<div style="padding:20px; color:#d93025;">' +
          '❌ 분석 실패: ' +
          escapeHtml(
            errorMsg
          ) +
        '</div>';
    }


    function renderResult(
      index,
      rowData,
      apiResult
    ) {
      hideLoaderOnce();


      const itemDiv =
        document.getElementById(
          'item-' + index
        );

      if (!itemDiv) {
        return;
      }


      if (
        !apiResult ||
        apiResult.error
      ) {
        renderError(
          index,
          rowData,
          apiResult
            ? apiResult.error
            : '검사 결과가 없습니다.'
        );

        return;
      }


      let diff;

      try {
        diff =
          diffWordsLib(
            rowData.original,
            apiResult.revised
          );

      } catch (error) {
        diff = {
          left:
            escapeHtml(
              rowData.original
            ),

          right:
            escapeHtml(
              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(
              function(item) {
                return (
                  '<li class="analysis-row">' +

                    '<span class="err-word">' +
                      escapeHtml(
                        item.origin
                      ) +
                    '</span>' +

                    '<span class="arrow">' +
                      '→' +
                    '</span>' +

                    '<span class="fix-word">' +
                      escapeHtml(
                        item.revised
                      ) +
                    '</span>' +

                    '<span class="fix-reason">' +
                      escapeHtml(
                        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 +
            '행 / ' +
            rowData.col +
            '열' +
          '</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
            );

            return;
          }


          let origin = '';

          if (
            typeof block.origin ===
            'string'
          ) {
            origin =
              block.origin;

          } else if (
            block.origin &&
            block.origin.content !==
              undefined
          ) {
            origin =
              block.origin.content;
          }


          const revised =
            block.revised || '';


          if (
            origin === revised
          ) {
            return;
          }


          let msg =
            '교정';


          if (
            block.revisions &&
            block.revisions.length > 0
          ) {
            const revision =
              block.revisions[0];

            const helpId =
              revision.helpId ||
              revision.help_id;


            if (
              helpId &&
              helps &&
              helps[helpId]
            ) {
              msg =
                helps[helpId].comment ||
                '교정';
            }
          }


          resultsArray.push({
            origin:
              origin,

            revised:
              revised,

            msg:
              msg
          });
        }
      );
    }


    function diffWordsLib(
      text1,
      text2
    ) {
      if (
        typeof diff_match_patch ===
        'undefined'
      ) {
        return {
          left:
            escapeHtml(
              text1
            ),

          right:
            escapeHtml(
              text2
            )
        };
      }


      const dmp =
        new diff_match_patch();


      function tokenize(text) {
        return text.split(
          /([^\S\r\n]+|[.,;!?])/g
        );
      }


      const t1 =
        tokenize(
          text1
        );

      const t2 =
        tokenize(
          text2
        );


      const tokenMap = {};

      const tokenArr = [];

      let count = 0;


      function encode(tokens) {
        let chars = '';


        for (
          let i = 0;
          i < tokens.length;
          i++
        ) {
          const token =
            tokens[i];


          if (
            !Object.prototype
              .hasOwnProperty
              .call(
                tokenMap,
                token
              )
          ) {
            tokenMap[token] =
              String.fromCharCode(
                count
              );

            tokenArr[count] =
              token;

            count++;
          }


          chars +=
            tokenMap[token];
        }


        return chars;
      }


      const chars1 =
        encode(
          t1
        );

      const chars2 =
        encode(
          t2
        );


      const diffs =
        dmp.diff_main(
          chars1,
          chars2,
          false
        );


      dmp.diff_cleanupSemantic(
        diffs
      );


      let leftHtml = '';

      let rightHtml = '';


      diffs.forEach(
        function(part) {
          const operation =
            part[0];

          const chars =
            part[1];

          let text = '';


          for (
            let i = 0;
            i < chars.length;
            i++
          ) {
            text +=
              tokenArr[
                chars.charCodeAt(i)
              ];
          }


          const safeText =
            escapeHtml(
              text
            );


          if (
            operation === 0
          ) {
            leftHtml +=
              safeText;

            rightHtml +=
              safeText;

          } else if (
            operation === -1
          ) {
            leftHtml +=
              '<span class="diff-del">' +
                safeText +
              '</span>';

          } else if (
            operation === 1
          ) {
            rightHtml +=
              '<span class="diff-add">' +
                safeText +
              '</span>';
          }
        }
      );


      return {
        left:
          leftHtml,

        right:
          rightHtml
      };
    }


    function getByteLength(value) {
      let byteLength = 0;

      if (!value) {
        return 0;
      }


      for (
        let i = 0;
        i < value.length;
        i++
      ) {
        const code =
          value.charCodeAt(i);


        if (
          code === 10
        ) {
          byteLength += 2;

        } else if (
          code >> 11
        ) {
          byteLength += 3;

        } else if (
          code >> 7
        ) {
          byteLength += 2;

        } else {
          byteLength += 1;
        }
      }


      return byteLength;
    }


    function updateBytes(index) {
      const element =
        document.getElementById(
          'edit-' + index
        );

      const byteElement =
        document.getElementById(
          'byte-' + index
        );


      if (
        !element ||
        !byteElement
      ) {
        return;
      }


      byteElement.innerText =
        getByteLength(
          element.innerText
        );
    }


    function updateApplyButton(
      current,
      total
    ) {
      const button =
        document.getElementById(
          'btnApply'
        );


      button.innerText =
        '적용하기 (' +
        current +
        ' / ' +
        total +
        ')';


      if (
        current === total
      ) {
        button.disabled =
          false;
      }
    }


    function applyAll() {
      if (
        !confirm(
          '검사된 수정본을 원본 셀에 적용하시겠습니까?\n적용 전에 오른쪽 수정 내용을 확인해 주세요.'
        )
      ) {
        return;
      }


      const updates = [];


      itemData.forEach(
        function(item, index) {
          if (!item) {
            return;
          }


          const element =
            document.getElementById(
              'edit-' + index
            );


          if (!element) {
            return;
          }


          updates.push({
            sheetName:
              item.sheetName,

            row:
              item.row,

            col:
              item.col,

            text:
              element.innerText
          });
        }
      );


      const button =
        document.getElementById(
          'btnApply'
        );


      button.disabled =
        true;

      button.innerText =
        '저장 중...';


      google.script.run

        .withSuccessHandler(
          function(count) {
            alert(
              '✅ 총 ' +
              count +
              '건이 수정되었습니다.'
            );

            google.script.host.close();
          }
        )

        .withFailureHandler(
          function(error) {
            button.disabled =
              false;

            button.innerText =
              '적용하기';

            alert(
              '저장 오류: ' +
              getErrorMessage(error)
            );
          }
        )

        .applySpellCheckResults(
          updates
        );
    }
  </script>

</body>
</html>


<? } else if (activeView === 'format') { ?>


<!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 {
      padding: 20px;
      font-family: 'Noto Sans KR', sans-serif;
      background: #f8f9fa;
      margin: 0;
      height: 100vh;
      display: flex;
      flex-direction: column;
      color: #202124;
    }

    .header-area {
      display: flex;
      justify-content: space-between;
      align-items: flex-start;
      gap: 20px;
      margin-bottom: 15px;
    }

    .title {
      font-size: 20px;
      font-weight: 700;
      color: #1a73e8;
      display: flex;
      align-items: center;
      gap: 8px;
    }

    .summary {
      color: #5f6368;
      font-size: 12px;
      line-height: 1.6;
      text-align: right;
    }

    .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: 24px;
      box-shadow:
        0 1px 3px rgba(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;
      gap: 15px;
    }

    .problem-count {
      color: #d93025;
    }

    .split-view {
      display: flex;
      min-height: 130px;
    }

    .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;
    }

    .hl-quotes {
      background: #f3e8fd;
      color: #7b1fa2;
      border-bottom: 2px solid #8e24aa;
      font-weight: 700;
    }

    .hl-spaces {
      background: #fff3cd;
      border-bottom: 2px solid #e37400;
    }

    .hl-banned {
      background: #ffe6e6;
      color: #c5221f;
      border-bottom: 2px solid #c5221f;
      font-weight: 700;
    }

    .hl-parentheses {
      background: #fce8e6;
      color: #b31412;
      border: 1px solid #d93025;
      border-radius: 3px;
      font-weight: 700;
    }

    .analysis-box {
      border-top: 1px solid #eee;
      background: #fafafa;
      padding: 12px 15px;
    }

    .analysis-title {
      margin-bottom: 6px;
      color: #5f6368;
      font-size: 12px;
      font-weight: 700;
    }

    .issue-row {
      display: flex;
      align-items: flex-start;
      gap: 8px;
      padding: 7px 0;
      border-bottom: 1px dashed #e8eaed;
      font-size: 12px;
      line-height: 1.6;
    }

    .issue-row:last-child {
      border-bottom: none;
    }

    .issue-badge {
      flex-shrink: 0;
      padding: 2px 7px;
      border-radius: 4px;
      font-size: 11px;
      font-weight: 700;
    }

    .issue-quotes {
      color: #7b1fa2;
      background: #f3e8fd;
    }

    .issue-spaces {
      color: #b06000;
      background: #fff3cd;
    }

    .issue-banned {
      color: #b31412;
      background: #fce8e6;
    }

    .issue-parentheses {
      color: #b31412;
      background: #fce8e6;
    }

    .issue-message {
      color: #3c4043;
    }

    .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: 700;
      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,.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);
      }
    }

    .empty-result {
      margin-top: 30px;
      padding: 45px 20px;
      border-radius: 10px;
      background: #fff;
      color: #5f6368;
      text-align: center;
      font-size: 14px;
      line-height: 1.8;
      border: 1px solid #e8eaed;
    }

    .empty-result strong {
      color: #188038;
      font-size: 16px;
    }

    .error-msg {
      color: #d93025;
      font-weight: 700;
      padding: 20px;
      text-align: center;
      background: #fce8e6;
      border-radius: 8px;
    }
  </style>
</head>


<body>

  <div class="header-area">

    <div class="title">
      <span class="material-icons">
        rule
      </span>

      형식 검사 및 수정
    </div>


    <div
      class="summary"
      id="summaryText"
    >
      검사 준비 중...
    </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>
    const FORMAT_OPTIONS =
      <?!= initialDataJson ?>;


    let itemData = [];

    let renderQueue = [];

    const CHUNK_SIZE = 15;


    function escapeHtml(value) {
      return String(value)

        .replace(
          /&/g,
          '&amp;'
        )

        .replace(
          /</g,
          '&lt;'
        )

        .replace(
          />/g,
          '&gt;'
        )

        .replace(
          /"/g,
          '&quot;'
        )

        .replace(
          /'/g,
          '&#39;'
        );
    }


    function getErrorMessage(error) {
      if (!error) {
        return '알 수 없는 오류가 발생했습니다.';
      }

      if (error.message) {
        return error.message;
      }

      return String(error)
        .replace(/^Error:\s*/, '');
    }


    window.onload = function() {
      runCheck();
    };


    function runCheck() {
      document
        .getElementById(
          'loaderOverlay'
        )
        .style
        .display = 'flex';


      document
        .getElementById(
          'loadingText'
        )
        .innerText =
          '형식을 검사하는 중입니다...';


      google.script.run

        .withSuccessHandler(
          startRenderSequence
        )

        .withFailureHandler(
          function(error) {
            document
              .getElementById(
                'loaderOverlay'
              )
              .innerHTML =
                '<div class="error-msg">' +
                  '❌ 검사 오류<br><br>' +
                  escapeHtml(
                    getErrorMessage(error)
                  ) +
                '</div>';
          }
        )

        .runFormatCheck(
          FORMAT_OPTIONS
        );
    }


    function getSelectedOptionNames() {
      const names = [];


      if (
        FORMAT_OPTIONS.quotes
      ) {
        names.push(
          '따옴표'
        );
      }


      if (
        FORMAT_OPTIONS.banned
      ) {
        names.push(
          '기재 금지어'
        );
      }


      if (
        FORMAT_OPTIONS.spaces
      ) {
        names.push(
          '연속 공백'
        );
      }


      if (
        FORMAT_OPTIONS.parentheses
      ) {
        names.push(
          '괄호 쌍'
        );
      }


      return names;
    }


    function startRenderSequence(payload) {
      document
        .getElementById(
          'loaderOverlay'
        )
        .style
        .display = 'none';


      const container =
        document.getElementById(
          'cardsContainer'
        );


      const rows =
        payload &&
        payload.rows
          ? payload.rows
          : [];


      const studentCount =
        payload &&
        payload.studentCount
          ? payload.studentCount
          : 0;


      const checkedCellCount =
        payload &&
        payload.checkedCellCount
          ? payload.checkedCellCount
          : 0;


      document
        .getElementById(
          'summaryText'
        )
        .innerHTML =
          '검사 대상 ' +
          studentCount +
          '명, ' +
          checkedCellCount +
          '개 셀<br>' +
          escapeHtml(
            getSelectedOptionNames()
              .join(' / ')
          );


      if (
        rows.length === 0
      ) {
        container.innerHTML =
          '<div class="empty-result">' +

            '<strong>✓ 형식 문제가 발견되지 않았습니다.</strong>' +

            '<br><br>' +

            '선택한 검사 항목을 기준으로 ' +
            studentCount +
            '명의 내용을 확인했습니다.' +

          '</div>';


        document
          .getElementById(
            'btnApply'
          )
          .disabled = true;

        return;
      }


      itemData =
        rows;


      renderQueue =
        rows.map(
          function(row, index) {
            return {
              row:
                row,

              index:
                index
            };
          }
        );


      document
        .getElementById(
          'btnApply'
        )
        .innerText =
          '적용하기 (' +
          rows.length +
          '건)';


      document
        .getElementById(
          'btnApply'
        )
        .disabled =
          false;


      processRenderQueue();
    }


    function processRenderQueue() {
      if (
        renderQueue.length === 0
      ) {
        return;
      }


      const container =
        document.getElementById(
          'cardsContainer'
        );


      const chunk =
        renderQueue.splice(
          0,
          CHUNK_SIZE
        );


      chunk.forEach(
        function(entry) {
          renderFormatItem(
            container,
            entry.row,
            entry.index
          );
        }
      );


      if (
        renderQueue.length > 0
      ) {
        requestAnimationFrame(
          processRenderQueue
        );
      }
    }


    function renderFormatItem(
      container,
      row,
      index
    ) {
      const issueHtml =
        buildIssueHtml(
          row.issues || []
        );


      const highlightedOriginal =
        buildHighlightedHtml(
          row.original,
          row.ranges || []
        );


      const div =
        document.createElement(
          'div'
        );


      div.className =
        'check-item';


      div.innerHTML =

        '<div class="item-header">' +

          '<span>' +
            row.row +
            '행 / ' +
            row.col +
            '열' +
          '</span>' +

          '<span class="problem-count">' +
            '형식 문제 ' +
            row.issues.length +
            '건' +
          '</span>' +

        '</div>' +


        '<div class="split-view">' +

          '<div class="pane pane-left">' +
            highlightedOriginal +
          '</div>' +

          '<div ' +
            'class="pane pane-right" ' +
            'contenteditable="true" ' +
            'spellcheck="false" ' +
            'id="edit-' +
            index +
            '"' +
          '></div>' +

        '</div>' +


        issueHtml;


      container.appendChild(
        div
      );


      const editable =
        document.getElementById(
          'edit-' + index
        );


      // 자동 수정 없이 원문 그대로 제시
      editable.textContent =
        row.suggested;
    }


    function buildIssueHtml(issues) {
      if (
        !issues ||
        issues.length === 0
      ) {
        return '';
      }


      const rows =
        issues
          .map(
            function(issue) {
              return (

                '<div class="issue-row">' +

                  '<span class="issue-badge issue-' +
                    escapeHtml(
                      issue.type
                    ) +
                  '">' +

                    escapeHtml(
                      issue.title
                    ) +

                  '</span>' +

                  '<span class="issue-message">' +

                    escapeHtml(
                      issue.message
                    ) +

                  '</span>' +

                '</div>'
              );
            }
          )
          .join('');


      return (
        '<div class="analysis-box">' +

          '<div class="analysis-title">' +
            '검사 결과' +
          '</div>' +

          rows +

        '</div>'
      );
    }


    function getHighlightClass(type) {
      if (
        type === 'quotes'
      ) {
        return 'hl-quotes';
      }


      if (
        type === 'spaces'
      ) {
        return 'hl-spaces';
      }


      if (
        type === 'banned'
      ) {
        return 'hl-banned';
      }


      if (
        type === 'parentheses'
      ) {
        return 'hl-parentheses';
      }


      return '';
    }


    function buildHighlightedHtml(
      text,
      ranges
    ) {
      text =
        String(
          text || ''
        );


      if (
        !ranges ||
        ranges.length === 0
      ) {
        return escapeHtml(
          text
        );
      }


      const markers =
        new Array(
          text.length
        );


      for (
        let i = 0;
        i < markers.length;
        i++
      ) {
        markers[i] =
          [];
      }


      ranges.forEach(
        function(range) {
          const className =
            getHighlightClass(
              range.type
            );


          if (!className) {
            return;
          }


          const start =
            Math.max(
              0,
              range.start
            );


          const end =
            Math.min(
              text.length,
              range.end
            );


          for (
            let i = start;
            i < end;
            i++
          ) {
            if (
              markers[i]
                .indexOf(
                  className
                ) === -1
            ) {
              markers[i].push(
                className
              );
            }
          }
        }
      );


      let html = '';

      let segmentStart = 0;


      let previousKey =
        markers.length > 0
          ? markers[0]
              .slice()
              .sort()
              .join(' ')
          : '';


      for (
        let i = 1;
        i <= text.length;
        i++
      ) {
        const currentKey =
          i < text.length
            ? markers[i]
                .slice()
                .sort()
                .join(' ')
            : null;


        if (
          currentKey !==
          previousKey
        ) {
          const segment =
            text.slice(
              segmentStart,
              i
            );


          const safeSegment =
            escapeHtml(
              segment
            );


          if (
            previousKey
          ) {
            html +=
              '<span class="' +
              previousKey +
              '">' +
              safeSegment +
              '</span>';

          } else {
            html +=
              safeSegment;
          }


          segmentStart =
            i;

          previousKey =
            currentKey;
        }
      }


      return html;
    }


    function applyAll() {
      if (
        !confirm(
          '오른쪽 수정안으로 원본 셀을 덮어쓰시겠습니까?\n형식 검사는 자동 수정하지 않으므로 직접 고친 내용을 확인해 주세요.'
        )
      ) {
        return;
      }


      const updates =
        [];


      itemData.forEach(
        function(item, index) {
          const element =
            document.getElementById(
              'edit-' + index
            );


          if (!element) {
            return;
          }


          updates.push({
            sheetName:
              item.sheetName,

            row:
              item.row,

            col:
              item.col,

            text:
              element.innerText
          });
        }
      );


      const button =
        document.getElementById(
          'btnApply'
        );


      button.disabled =
        true;

      button.innerText =
        '저장 중...';


      google.script.run

        .withSuccessHandler(
          function(count) {
            alert(
              '✅ 총 ' +
              count +
              '개 셀에 수정 내용을 적용했습니다.'
            );

            google.script.host.close();
          }
        )

        .withFailureHandler(
          function(error) {
            button.disabled =
              false;

            button.innerText =
              '적용하기 (' +
              itemData.length +
              '건)';

            alert(
              '저장 오류: ' +
              getErrorMessage(error)
            );
          }
        )

        .applySpellCheckResults(
          updates
        );
    }
  </script>

</body>
</html>


<? } ?>