문제 필터링 탭

전체보기/틀린문제 보기 탭 추가

// ==UserScript==
// @name         문제 필터링 탭
// @license MIT
// @namespace    http://tampermonkey.net/
// @version      1.1
// @description  전체보기/틀린문제 보기 탭 추가
// @match        https://docs.google.com/forms/d/e/*/viewscore*
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function () {
    'use strict';

    // 상수 정의
    const CONSTANTS = {
        SELECTORS: {
            LIST_CONTAINER: 'div[role="list"]',
            WRONG_LABEL: '[aria-label="틀림"], [aria-label="Incorrect"]',
            RESULT_SPAN: '.result-span'
        },
        INDICES: {
            DEFAULT_CAROUSEL_START: 3,
            WRONG_MODE_CAROUSEL_START: 4
        },
        TIMEOUTS: {
            ELEMENT_WAIT: 10000
        },
        MODES: {
            ALL: 'all',
            WRONG: 'wrong'
        }
    };

    // CSS 색상 정의
    const COLORS = {
        PRIMARY: '#007bff',
        PRIMARY_HOVER: '#0056b3',
        SUCCESS: '#28a745',
        SECONDARY: '#6c757d',
        INFO: '#2196F3',
        INFO_DARK: '#1976D2',
        LIGHT_BLUE: '#e3f2fd',
        GRAY_LIGHT: '#f5f5f5',
        BORDER: '#ddd'
    };

    console.log('[문제 필터링 탭] 스크립트 시작!');
    console.log('[문제 필터링 탭] 현재 URL:', window.location.href);

    // 비즈니스 로직 함수들
    const businessLogic = {
        // 틀린 문제 번호 추출
        extractWrongNumbers(listContainer) {
            const items = listContainer.children;
            const wrongNumbers = [];

            for (let i = CONSTANTS.INDICES.DEFAULT_CAROUSEL_START; i < items.length; i++) {
                const number = this.extractNumberFromItem(items[i]);
                if (number) {
                    wrongNumbers.push(number);
                }
            }

            return wrongNumbers;
        },

        // 개별 아이템에서 틀린 문제 번호 추출
        extractNumberFromItem(item) {
            const wrongLabel = item.querySelector(CONSTANTS.SELECTORS.WRONG_LABEL);
            if (!wrongLabel) return null;

            const nextSibling = wrongLabel.nextElementSibling;
            if (!nextSibling) return null;

            const boldText = nextSibling.querySelector('span > b');
            if (!boldText) return null;

            return boldText.textContent.replace(/\[|\]/g, '');
        },

        // 결과 UI 생성 및 삽입
        createAndInsertResultUI(listContainer, wrongNumbers, state, updateCarousel, chipButtons) {
            // 기존 결과 제거
            const existing = document.querySelector(CONSTANTS.SELECTORS.RESULT_SPAN);
            if (existing) existing.remove();

            if (listContainer.children.length < CONSTANTS.INDICES.WRONG_MODE_CAROUSEL_START) {
                return;
            }

            const referenceElement = listContainer.children[CONSTANTS.INDICES.DEFAULT_CAROUSEL_START];
            const resultElement = this.createResultElement(wrongNumbers, state, updateCarousel, chipButtons);
            listContainer.insertBefore(resultElement, referenceElement);
        },

        // 결과 요소 생성
        createResultElement(wrongNumbers, state, updateCarousel, chipButtons) {
            const resultSpan = utils.createContainer({
                display: 'block',
                margin: '10px 0',
                padding: '15px',
                backgroundColor: COLORS.LIGHT_BLUE,
                borderRadius: '5px',
                fontWeight: 'bold',
                flexShrink: '0'
            });
            resultSpan.className = 'result-span';

            // 제목 추가
            const title = this.createTitle(wrongNumbers.length);
            resultSpan.appendChild(title);

            // 칩 컨테이너 추가 (틀린 문제가 있는 경우)
            if (wrongNumbers.length > 0) {
                const chipContainer = this.createChipContainer(wrongNumbers, state, updateCarousel);
                resultSpan.appendChild(chipContainer);

                // chipButtons 배열 업데이트
                chipButtons.length = 0; // 기존 배열 초기화
                chipContainer.querySelectorAll('button').forEach(button => {
                    chipButtons.push(button);
                });
            }

            return resultSpan;
        },

        // 제목 요소 생성
        createTitle(wrongCount) {
            const title = document.createElement('div');
            title.style.marginBottom = '10px';
            title.textContent = wrongCount > 0
                ? `틀린문제(${wrongCount}개):`
                : '틀린문제 없음';
            return title;
        },

        // 칩 컨테이너 생성
        createChipContainer(wrongNumbers, state, updateCarousel) {
            const container = utils.createContainer({
                display: 'flex',
                flexWrap: 'wrap',
                gap: '8px'
            });

            wrongNumbers.forEach(number => {
                const chip = this.createChip(number, state, updateCarousel);
                container.appendChild(chip);
            });

            return container;
        },

        // 개별 칩 생성
        createChip(number, state, updateCarousel) {
            const chip = utils.createButton(`${number}번`, {
                padding: '6px 12px',
                borderRadius: '16px',
                backgroundColor: COLORS.INFO,
                fontSize: '14px',
                transition: 'all 0.2s',
                boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
            });

            this.setupChipEvents(chip, number, state, updateCarousel);
            return chip;
        },

        // 칩 이벤트 설정
        setupChipEvents(chip, number, state, updateCarousel) {
            chip.addEventListener('mouseenter', () => {
                // Early return: 캐러셀이 비활성화된 경우
                if (!state.carouselEnabled) {
                    utils.applyStyles(chip, {
                        cursor: 'not-allowed',
                        opacity: '0.6'
                    });
                    return;
                }

                // Early return: 현재 활성 칩인 경우 (변화 없음)
                if (parseInt(number) === state.currentIndex + 1) return;

                utils.applyStyles(chip, {
                    backgroundColor: COLORS.INFO_DARK,
                    transform: 'scale(1.05)'
                });
            });

            chip.addEventListener('mouseleave', () => {
                // Early return: 캐러셀이 비활성화된 경우
                if (!state.carouselEnabled) {
                    utils.applyStyles(chip, {
                        cursor: 'pointer',
                        opacity: '1'
                    });
                    return;
                }

                // Early return: 현재 활성 칩인 경우 (변화 없음)
                if (parseInt(number) === state.currentIndex + 1) return;

                utils.applyStyles(chip, {
                    backgroundColor: COLORS.INFO,
                    transform: 'scale(1)'
                });
            });

            chip.addEventListener('click', () => {
                // Early return: 캐러셀이 비활성화된 경우
                if (!state.carouselEnabled) return;

                updateCarousel(parseInt(number) - 1);
            });
        }
    };

    // 유틸리티 함수들
    const utils = {
        // CSS 스타일 적용 헬퍼
        applyStyles(element, styles) {
            Object.assign(element.style, styles);
        },

        // 버튼 생성 헬퍼
        createButton(text, styles = {}, clickHandler = null) {
            const button = document.createElement('button');
            button.textContent = text;
            this.applyStyles(button, {
                padding: '8px 16px',
                border: 'none',
                borderRadius: '4px',
                cursor: 'pointer',
                color: 'white',
                ...styles
            });

            // Early return: 클릭 핸들러가 없는 경우
            if (!clickHandler) return button;

            button.addEventListener('click', clickHandler);
            return button;
        },

        // 컨테이너 생성 헬퍼
        createContainer(styles = {}) {
            const container = document.createElement('div');
            this.applyStyles(container, styles);
            return container;
        }
    };

    function waitForElement(selector, callback) {
        console.log('[문제 필터링 탭] 요소 대기 중:', selector);

        const element = document.querySelector(selector);
        if (element) {
            console.log('[문제 필터링 탭] 요소를 즉시 찾음!');
            callback(element);
            return;
        }

        console.log('[문제 필터링 탭] 요소를 찾지 못함. MutationObserver 시작...');

        const observer = new MutationObserver((mutations, obs) => {
            const element = document.querySelector(selector);
            if (element) {
                console.log('[문제 필터링 탭] MutationObserver가 요소를 찾음!');
                obs.disconnect();
                callback(element);
            }
        });

        observer.observe(document.body, {
            childList: true,
            subtree: true
        });

        // 타임아웃
        setTimeout(() => {
            console.log('[문제 필터링 탭] 10초 경과 - 요소를 찾지 못함');
        }, CONSTANTS.TIMEOUTS.ELEMENT_WAIT);
    }

    // 리스트 요소를 찾을 때까지 대기
    waitForElement(CONSTANTS.SELECTORS.LIST_CONTAINER, (listContainer) => {
        console.log('[문제 필터링 탭] 리스트 컨테이너 찾음!');

        if (!listContainer) {
            console.log('리스트를 찾을 수 없습니다.');
            return;
        }

        // 리스트 컨테이너에 flex 적용
        listContainer.style.display = 'flex';
        listContainer.style.flexDirection = 'column';
        listContainer.style.gap = '10px';

        let currentIndex = 0;
        let carouselStartIndex = CONSTANTS.INDICES.DEFAULT_CAROUSEL_START;
        let currentMode = CONSTANTS.MODES.ALL;
        let wrongNumbersList = [];
        let chipButtons = [];
        let carouselEnabled = false;

        function updateCarousel(index) {
            const allChildren = Array.from(listContainer.children);
            const carouselItems = allChildren.slice(carouselStartIndex);

            // Early return: 캐러셀이 비활성화된 경우
            if (!carouselEnabled) {
                applyFilteringOnly(allChildren);
                return;
            }

            // 순환 처리
            if (index < 0) index = carouselItems.length - 1;
            if (index >= carouselItems.length) index = 0;
            currentIndex = index;

            applyCarouselMode(allChildren);
            updateChipStates();
        }

        // 필터링 전용 모드 (캐러셀 비활성화)
        function applyFilteringOnly(allChildren) {
            allChildren.forEach((item, i) => {
                // Early return: 탭바와 결과 영역은 항상 표시
                if (i < carouselStartIndex) {
                    item.style.display = '';
                    resetItemStyles(item);
                    return;
                }

                // 틀린문제 모드인 경우에만 필터링 적용
                if (currentMode === CONSTANTS.MODES.WRONG) {
                    const questionNumber = i - carouselStartIndex + 1;
                    item.style.display = wrongNumbersList.includes(questionNumber.toString()) ? '' : 'none';
                } else {
                    item.style.display = '';
                }

                resetItemStyles(item);
            });
        }

        // 캐러셀 모드 적용
        function applyCarouselMode(allChildren) {
            allChildren.forEach((item, i) => {
                // Early return: 캐러셀 시작 전 요소들
                if (i < carouselStartIndex) {
                    item.style.display = '';
                    item.style.position = 'relative';
                    item.style.opacity = '1';
                    item.style.flexShrink = '0';
                    return;
                }

                const carouselIdx = i - carouselStartIndex;
                const isCurrentItem = carouselIdx === currentIndex;

                item.style.display = isCurrentItem ? '' : 'none';
                if (isCurrentItem) {
                    item.style.position = 'relative';
                    item.style.opacity = '1';
                    item.style.flexShrink = '1';
                }
            });
        }

        // 아이템 스타일 초기화 헬퍼
        function resetItemStyles(item) {
            item.style.position = 'relative';
            item.style.opacity = '1';
            item.style.flexShrink = '';
        }

        function updateChipStates() {
            // Early return: 틀린문제 모드가 아닌 경우
            if (currentMode !== CONSTANTS.MODES.WRONG) return;

            const currentNumber = currentIndex + 1;
            chipButtons.forEach((chip, idx) => {
                const isActive = parseInt(wrongNumbersList[idx]) === currentNumber;
                const styles = isActive
                    ? { backgroundColor: COLORS.INFO_DARK, fontWeight: 'bold', transform: 'scale(1.1)' }
                    : { backgroundColor: COLORS.INFO, fontWeight: 'normal', transform: 'scale(1)' };

                utils.applyStyles(chip, styles);
            });
        }

        function getNextIndex() {
            // Early return: 전체 모드인 경우
            if (currentMode === CONSTANTS.MODES.ALL) {
                return currentIndex + 1;
            }

            // 틀린문제 모드: 다음 틀린 문제 찾기
            const currentNumber = currentIndex + 1;
            for (let i = 0; i < wrongNumbersList.length; i++) {
                if (parseInt(wrongNumbersList[i]) > currentNumber) {
                    return parseInt(wrongNumbersList[i]) - 1;
                }
            }

            // 마지막이면 처음으로
            return parseInt(wrongNumbersList[0]) - 1;
        }

        function getPrevIndex() {
            // Early return: 전체 모드인 경우
            if (currentMode === CONSTANTS.MODES.ALL) {
                return currentIndex - 1;
            }

            // 틀린문제 모드: 이전 틀린 문제 찾기
            const currentNumber = currentIndex + 1;
            for (let i = wrongNumbersList.length - 1; i >= 0; i--) {
                if (parseInt(wrongNumbersList[i]) < currentNumber) {
                    return parseInt(wrongNumbersList[i]) - 1;
                }
            }

            // 처음이면 마지막으로
            return parseInt(wrongNumbersList[wrongNumbersList.length - 1]) - 1;
        }

        // 탭 바 생성
        const tabBar = utils.createContainer({
            display: 'flex',
            gap: '10px',
            marginBottom: '15px',
            padding: '10px',
            backgroundColor: COLORS.GRAY_LIGHT,
            borderRadius: '5px'
        });

        // 전체보기 탭
        const allTab = utils.createButton('전체', {
            backgroundColor: COLORS.PRIMARY,
            fontWeight: 'bold'
        });

        // 틀린문제 보기 탭
        const wrongTab = utils.createButton('틀린문제', {
            backgroundColor: COLORS.SECONDARY
        });

        // 캐러셀 모드 체크박스
        const carouselToggleContainer = document.createElement('label');
        carouselToggleContainer.style.cssText = `
            display: inline-flex;
            align-items: center;
            gap: 5px;
            padding: 8px;
            cursor: pointer;
            user-select: none;
            flex-shrink: 0;
            white-space: nowrap;
        `;

        const carouselCheckbox = document.createElement('input');
        carouselCheckbox.type = 'checkbox';
        carouselCheckbox.checked = false;
        carouselCheckbox.style.cssText = `
            cursor: pointer;
            width: 16px;
            height: 16px;
        `;

        const carouselLabel = document.createElement('span');
        carouselLabel.textContent = '하나씩 보기';
        carouselLabel.style.cssText = `
            font-size: 14px;
            font-weight: bold;
        `;

        carouselCheckbox.addEventListener('change', () => {
            carouselEnabled = carouselCheckbox.checked;

            // 네비게이션 버튼 표시/숨김
            const displayValue = carouselEnabled ? '' : 'none';
            const flexValue = carouselEnabled ? 'flex' : 'none';

            prevBtn.style.display = displayValue;
            nextBtn.style.display = displayValue;
            numberInputGroup.style.display = flexValue;

            // 캐러셀 업데이트
            updateCarousel(carouselEnabled ? currentIndex : 0);
        });

        carouselToggleContainer.appendChild(carouselCheckbox);
        carouselToggleContainer.appendChild(carouselLabel);

        // 이전/다음 버튼
        const prevBtn = utils.createButton('◀', {
            backgroundColor: COLORS.SUCCESS,
            fontWeight: 'bold',
            display: 'none'
        }, () => updateCarousel(getPrevIndex()));

        const nextBtn = utils.createButton('▶', {
            backgroundColor: COLORS.SUCCESS,
            fontWeight: 'bold',
            display: 'none'
        }, () => updateCarousel(getNextIndex()));

        // 탭 활성화 스타일 함수
        function setActiveTab(activeButton, inactiveButton) {
            utils.applyStyles(activeButton, {
                backgroundColor: COLORS.PRIMARY,
                fontWeight: 'bold'
            });
            utils.applyStyles(inactiveButton, {
                backgroundColor: COLORS.SECONDARY,
                fontWeight: 'normal'
            });
        }

        // 문제 번호 입력 그룹 생성
        const numberInputGroup = utils.createContainer({
            display: 'none',
            alignItems: 'center',
            gap: '5px',
            marginLeft: 'auto'
        });

        const numberInput = document.createElement('input');
        numberInput.type = 'number';
        numberInput.min = '1';
        numberInput.placeholder = '번호';
        utils.applyStyles(numberInput, {
            padding: '6px 10px',
            border: `2px solid ${COLORS.BORDER}`,
            borderRadius: '4px',
            fontSize: '14px',
            width: '70px',
            outline: 'none'
        });

        numberInput.addEventListener('focus', () => {
            numberInput.style.borderColor = COLORS.PRIMARY;
        });

        numberInput.addEventListener('blur', () => {
            numberInput.style.borderColor = COLORS.BORDER;
        });

        const goButton = utils.createButton('이동', {
            padding: '6px 12px',
            backgroundColor: COLORS.PRIMARY,
            fontWeight: 'bold',
            transition: 'background-color 0.2s',
            fontSize: '14px'
        });

        goButton.addEventListener('mouseenter', () => {
            goButton.style.backgroundColor = COLORS.PRIMARY_HOVER;
        });

        goButton.addEventListener('mouseleave', () => {
            goButton.style.backgroundColor = COLORS.PRIMARY;
        });

        const goToNumber = () => {
            const number = parseInt(numberInput.value);

            // Early return: 유효하지 않은 번호이거나 캐러셀이 비활성화된 경우
            if (!number || number < 1 || !carouselEnabled) return;

            updateCarousel(number - 1);
        };

        goButton.addEventListener('click', goToNumber);

        numberInput.addEventListener('keypress', (e) => {
            if (e.key === 'Enter') {
                goToNumber();
            }
        });

        numberInputGroup.appendChild(numberInput);
        numberInputGroup.appendChild(goButton);

        // 전체보기 클릭 이벤트
        allTab.addEventListener('click', () => {
            const existing = document.querySelector(CONSTANTS.SELECTORS.RESULT_SPAN);
            if (existing) existing.remove();

            currentMode = CONSTANTS.MODES.ALL;
            wrongNumbersList = [];
            chipButtons = [];
            carouselStartIndex = CONSTANTS.INDICES.DEFAULT_CAROUSEL_START;

            updateCarousel(0);
            setActiveTab(allTab, wrongTab);
        });

        // 틀린문제 보기 클릭 이벤트  
        wrongTab.addEventListener('click', () => {
            // 1. 틀린 문제 번호 추출
            const wrongNumbers = businessLogic.extractWrongNumbers(listContainer);

            // 2. 상태 객체 생성 
            const state = { carouselEnabled, currentIndex };

            // 3. 결과 UI 생성 및 삽입
            businessLogic.createAndInsertResultUI(listContainer, wrongNumbers, state, updateCarousel, chipButtons);

            // 4. 모드 설정
            currentMode = CONSTANTS.MODES.WRONG;
            wrongNumbersList = wrongNumbers;
            carouselStartIndex = CONSTANTS.INDICES.WRONG_MODE_CAROUSEL_START;

            // 5. 첫 번째 틀린 문제로 이동 (early return 적용된 삼항연산자)
            const targetIndex = wrongNumbers.length > 0 ? parseInt(wrongNumbers[0]) - 1 : 0;
            updateCarousel(targetIndex);

            // 6. 탭 활성화
            setActiveTab(wrongTab, allTab);
        });

        // 탭 바에 버튼 추가
        tabBar.appendChild(allTab);
        tabBar.appendChild(wrongTab);
        tabBar.appendChild(prevBtn);
        tabBar.appendChild(carouselToggleContainer);
        tabBar.appendChild(nextBtn);
        tabBar.appendChild(numberInputGroup);

        // 리스트의 세 번째 자식으로 탭 바 삽입
        if (listContainer.children.length >= 3) {
            listContainer.insertBefore(tabBar, listContainer.children[2]);
        } else {
            listContainer.appendChild(tabBar);
        }

        // 초기에는 캐러셀 적용 안 함 (모든 항목 보이기)
        const allChildren = Array.from(listContainer.children);
        allChildren.forEach((item) => {
            item.style.display = '';
        });
    });

})();