에펨코리아 메모

FM코리아 게시글 작성자에 대해 메모를 달 수 있는 확장 프로그램

当前为 2025-06-17 提交的版本,查看 最新版本

// ==UserScript==
// @name         에펨코리아 메모
// @name:ko      에펨코리아 메모
// @namespace    https://fmkorea.com
// @author       에펨코리아 메모
// @version      2506172
// @description    FM코리아 게시글 작성자에 대해 메모를 달 수 있는 확장 프로그램
// @description:ko FM코리아 게시글 작성자에 대해 메모를 달 수 있는 확장 프로그램
// @match        https://www.fmkorea.com/*
// @icon         https://www.fmkorea.com/favicon.ico?2
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_listValues
// @grant        GM_addStyle
// @grant        GM_registerMenuCommand
// @run-at       document-body
// @license      MIT
// ==/UserScript==

(async () => {
    'use strict';

    const SCRIPT_NAME = 'FMK-MEMO-TM';
    const DEBUG_MODE = false;

    const log = (...args) => DEBUG_MODE && console.log(`[${SCRIPT_NAME}]`, ...args);
    const error = (...args) => console.error(`[${SCRIPT_NAME}]`, ...args);

    if (window.__fmkMemoAlreadyLoaded) {
        log('스크립트가 이미 로드되어 있어 중복 실행을 방지합니다.');
        return;
    }
    window.__fmkMemoAlreadyLoaded = true;
    log('스크립트 실행 시작');

    const CONSTANTS = {
        SORT_BY_DATE: 'date',
        SORT_BY_NAME: 'name',
        MEMBER_CLASS_PREFIX: 'member_',
        PROCESSED_ATTR: 'data-fmk-processed',
        INLINE_MEMO_CLASS: 'fmk-inline-memo'
    };

    GM_addStyle(`
        body:not(.night_mode) {
            --fmk-bg-primary: #ffffff;
            --fmk-bg-secondary: #ffffff;
            --fmk-bg-tertiary: #f0f2f5;
            --fmk-text-primary: #1c1e21;
            --fmk-text-secondary: #65676b;
            --fmk-border-primary: #dce0e4;
            --fmk-border-secondary: #bec3c9;
            --fmk-shadow-color: rgba(0, 0, 0, 0.15);
            --fmk-button-bg: #f5f6f7;
            --fmk-button-hover-bg: #e9eaec;
            --fmk-button-action-bg: #007bff;
            --fmk-button-action-text: #ffffff;
        }
        body.night_mode {
            --fmk-bg-primary: #2a2a2a;
            --fmk-bg-secondary: #3a3a3a;
            --fmk-bg-tertiary: #4a4a4a;
            --fmk-text-primary: #e4e6eb;
            --fmk-text-secondary: #b0b3b8;
            --fmk-border-primary: #555555;
            --fmk-border-secondary: #666666;
            --fmk-shadow-color: rgba(0, 0, 0, 0.6);
            --fmk-button-bg: #444444;
            --fmk-button-hover-bg: #555555;
            --fmk-button-action-bg: #4b87ff;
            --fmk-button-action-text: #ffffff;
        }
        #fmk-memo-manager-overlay {
            position: fixed; top: 0; left: 0; width: 100%; height: 100%;
            background-color: rgba(0, 0, 0, 0.6);
            z-index: 999998; display: flex; align-items: center; justify-content: center;
            backdrop-filter: blur(2px);
        }
        #fmk-memo-manager-container {
            font-family: 'Segoe UI', Arial, sans-serif;
            background-color: var(--fmk-bg-primary);
            color: var(--fmk-text-primary);
            width: 350px; max-height: 90vh; border-radius: 8px;
            box-shadow: 0 5px 25px var(--fmk-shadow-color);
            display: flex; flex-direction: column;
            animation: fmk-manager-fadein 0.2s ease-out;
            border: 1px solid var(--fmk-border-primary);
        }
        @keyframes fmk-manager-fadein { from { opacity: 0; transform: scale(0.98); } to { opacity: 1; transform: scale(1); } }
        #fmk-memo-manager-container .manager-header { padding: 16px; border-bottom: 1px solid var(--fmk-border-primary); }
        #fmk-memo-manager-container h1 { font-size: 18px; margin: 0; font-weight: 600; color: var(--fmk-text-primary); }
        #fmk-memo-manager-container .manager-content { padding: 16px; overflow-y: auto; flex-grow: 1; }
        #searchMemo {
            width: 100%; box-sizing: border-box; margin-bottom: 12px; padding: 8px 12px;
            background-color: var(--fmk-bg-secondary);
            color: var(--fmk-text-primary);
            border: 1px solid var(--fmk-border-primary);
            border-radius: 6px; font-size: 14px; transition: all 0.2s;
        }
        #searchMemo::placeholder { color: var(--fmk-text-secondary); }
        #searchMemo:focus { outline: none; border-color: var(--fmk-border-secondary); }
        .items-container {
            max-height: 280px; overflow-y: auto; margin-bottom: 16px;
            border: 1px solid var(--fmk-border-primary);
            border-radius: 8px; padding: 10px;
            background-color: var(--fmk-bg-secondary);
        }
        .item {
            display: flex; align-items: center; justify-content: space-between;
            margin-bottom: 2px; padding: 4px; border-radius: 6px;
            transition: background-color 0.2s; cursor: pointer;
        }
        .item:hover { background-color: var(--fmk-bg-tertiary); }
        .item span { flex: 1; word-break: break-word; color: var(--fmk-text-primary); }
        .import-export-area { display: flex; gap: 8px; }
        .import-export-area button, #fmk-memo-manager-container .manager-footer button {
            flex: 1; background-color: var(--fmk-button-bg); color: var(--fmk-text-primary);
            border: 1px solid var(--fmk-border-primary); border-radius: 6px;
            padding: 6px 12px; cursor: pointer; transition: all 0.2s; font-size: 12px;
        }
        .import-export-area button:hover, #fmk-memo-manager-container .manager-footer button:hover {
            background-color: var(--fmk-button-hover-bg); border-color: var(--fmk-border-secondary);
        }
        #fmk-memo-manager-container .manager-footer { padding: 12px 16px; border-top: 1px solid var(--fmk-border-primary); text-align: right; }
        .fmk-context-popup {
            position: absolute; z-index: 999999;
            background: var(--fmk-bg-primary);
            color: var(--fmk-text-primary);
            border: 1px solid var(--fmk-border-primary);
            border-radius: 8px; padding: 12px; width: 260px; font-size: 13px;
            box-shadow: 0 4px 12px var(--fmk-shadow-color);
            animation: fmk-popup-fadein 0.15s ease-out;
            display: flex; flex-direction: column; gap: 8px;
        }
        .fmk-popup-title {
            font-weight:700; margin-bottom:4px;
            border-bottom:1px solid var(--fmk-border-primary); padding-bottom:8px;
        }
        .fmk-context-popup textarea {
            width:100%; box-sizing:border-box; background:var(--fmk-bg-secondary);
            color:var(--fmk-text-primary); border:1px solid var(--fmk-border-primary);
            border-radius:6px; padding:6px; resize:vertical;
        }
        .fmk-context-popup textarea::placeholder { color: var(--fmk-text-secondary); }
        .fmk-history-container {
            margin-top: 4px; border-top: 1px solid var(--fmk-border-primary); padding-top: 8px;
        }
        .fmk-history-title {
            font-weight: bold; font-size: 12px; margin-bottom: 5px; color: var(--fmk-text-secondary);
        }
        .fmk-history-container ul {
            list-style: none; padding: 0; margin: 0; max-height: 80px; overflow-y: auto;
            font-size: 12px; color: var(--fmk-text-primary);
        }
        .fmk-history-container ul li { margin-bottom: 3px; }
        .fmk-color-wrap { margin-top: 4px; font-size: 12px; }
        .fmk-popup-buttons { display:flex; gap:8px; margin-top: 4px; }
        .fmk-popup-buttons button {
            flex: 1; padding: 8px 0; border-radius: 6px; font-weight: 600;
            font-size: 13px; cursor: pointer;
            background: var(--fmk-button-bg);
            border: 1px solid var(--fmk-border-primary);
            color: var(--fmk-text-primary);
            transition: all .15s;
        }
        .fmk-popup-buttons button:hover {
            background: var(--fmk-button-hover-bg);
            border-color: var(--fmk-border-secondary);
        }
        .fmk-popup-buttons button:first-child:hover {
            background: var(--fmk-button-action-bg);
            border-color: var(--fmk-button-action-bg);
            color: var(--fmk-button-action-text);
        }
        .fmk-info-panel {
            background: var(--fmk-bg-secondary);
            color: var(--fmk-text-primary);
            border: 1px solid var(--fmk-border-primary);
            margin: 32px 0; padding: 20px; border-radius: 8px;
            display: flex; gap: 24px; flex-wrap: nowrap;
            max-height: 250px; overflow: hidden;
        }
        .fmk-info-panel-box {
            flex: 1 1 auto;
            border: 1px solid var(--fmk-border-primary);
            background: var(--fmk-bg-primary);
            padding: 14px; min-width: 300px; overflow-y: auto;
            display: flex; flex-direction: column; border-radius: 6px;
        }
        .fmk-info-panel-left { flex-grow: 0; flex-shrink: 0; flex-basis: 320px; }
        .fmk-info-panel-right { overflow: hidden; }
        .fmk-info-panel-title { font-weight: 700; margin: 0 0 12px; color: var(--fmk-text-primary); flex-shrink: 0; }
        .fmk-info-panel-content { list-style: disc; padding-left: 18px; margin: 0; flex-grow: 1; overflow-y: auto; }
        .fmk-info-panel-content a { color: #4ea6ff; text-decoration: none; }
        .fmk-info-panel-content a:hover { text-decoration: underline; }
        .fmk-info-panel-content .fmk-post-meta { color: var(--fmk-text-secondary); font-size: 12px; }
        .fmk-info-panel-box table th,
        .fmk-info-panel-box table td {
            background-color: transparent !important;
            color: var(--fmk-text-primary) !important;
        }
        body.night_mode .fmk-info-panel {
            background: #1e1e1e;
            border: 1px solid #444;
        }
        body.night_mode .fmk-info-panel-box {
            background: #1e1e1e;
            border-color: #555;
        }
        @keyframes fmk-popup-fadein { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }
        .fmk-inline-memo { display: inline; font-weight: bold; padding: 1px 2px; border-radius: 4px; }
        .author > span { display: inline !important; }
        li a .fmk-inline-memo { margin-left: 0px !important; }
        li a .fmk-inline-memo:before, .fmk-inline-memo:before { content: "["; margin-right: 1px; }
        li a .fmk-inline-memo:after, .fmk-inline-memo:after { content: "]"; margin-left: 1px; }
        @keyframes fmk-nick-blink {
          0%,100% { outline: 2px solid #ffb84d; outline-offset:2px; }
          50% { outline-color: transparent; }
        }
        .fmk-nick-blink { animation: fmk-nick-blink 1.5s ease-in-out 1; }
        ::-webkit-scrollbar { width: 8px; }
        ::-webkit-scrollbar-track { background: var(--fmk-bg-secondary); border-radius: 4px; }
        ::-webkit-scrollbar-thumb { background: var(--fmk-bg-tertiary); border-radius: 4px; }
        ::-webkit-scrollbar-thumb:hover { background: var(--fmk-border-secondary); }
        input[type="color"] {
            vertical-align: middle; border-radius: 4px; border: 1px solid var(--fmk-border-primary);
            width: 35px; height: 20px; cursor: pointer; background-color: var(--fmk-bg-secondary); padding: 0;
        }
        .fmk-sort-controls {
            margin-bottom: 12px;
            font-size: 12px;
            display: flex;
            align-items: center;
            gap: 8px;
            color: var(--fmk-text-secondary);
        }
        .fmk-sort-controls button {
            background: none;
            border: 1px solid var(--fmk-border-primary);
            color: var(--fmk-text-secondary);
            padding: 3px 8px;
            border-radius: 12px;
            cursor: pointer;
            transition: all 0.2s;
        }
        .fmk-sort-controls button:hover {
            border-color: var(--fmk-border-secondary);
            color: var(--fmk-text-primary);
        }
        .fmk-sort-controls button.active {
            border-color: var(--fmk-button-action-bg);
            background-color: var(--fmk-button-action-bg);
            color: var(--fmk-button-action-text);
        }
        .item-wrapper {
            display: flex;
            flex-direction: column;
            border-radius: 6px;
            transition: background-color 0.2s;
        }
        .item-wrapper:hover {
             background-color: var(--fmk-bg-tertiary);
        }
        .item { cursor: pointer; }
        .history-toggle-btn {
             margin-left: 6px; background-color: transparent; color: var(--fmk-text-secondary);
             border: 1px solid var(--fmk-border-secondary); border-radius: 6px; padding: 2px 6px;
             cursor: pointer; transition: all 0.2s; font-size: 10px; flex-shrink: 0;
        }
        .history-toggle-btn:hover { background-color: var(--fmk-button-hover-bg); }
        .item-history-container {
            padding: 8px 10px 4px 10px;
            margin: 0 4px 4px 4px;
            border-top: 1px solid var(--fmk-border-primary);
            animation: fmk-history-fadein 0.3s ease;
        }
        @keyframes fmk-history-fadein { from { opacity: 0; } to { opacity: 1; } }
        .item-history-title {
            font-weight: bold; font-size: 11px; margin-bottom: 5px; color: var(--fmk-text-secondary);
        }
        .item-history-container ul { list-style: none; padding: 0; margin: 0; font-size: 11px; color: var(--fmk-text-secondary); }
        .item-history-container ul li { margin-bottom: 3px; }
        #fmk-memo-import-overlay {
            position: fixed; top: 0; left: 0; width: 100%; height: 100%;
            background-color: rgba(0, 0, 0, 0.7);
            z-index: 999999;
            display: flex; align-items: center; justify-content: center;
            backdrop-filter: blur(4px);
        }
        #fmk-memo-import-popup {
            background-color: var(--fmk-bg-primary);
            color: var(--fmk-text-primary);
            width: 320px;
            border-radius: 8px;
            box-shadow: 0 5px 25px var(--fmk-shadow-color);
            display: flex;
            flex-direction: column;
            animation: fmk-manager-fadein 0.2s ease-out;
            border: 1px solid var(--fmk-border-primary);
        }
        #fmk-memo-import-popup .popup-header {
            padding: 14px;
            border-bottom: 1px solid var(--fmk-border-primary);
            font-size: 16px;
            font-weight: 600;
        }
        #fmk-memo-import-popup .popup-content {
            padding: 16px;
        }
        #fmk-memo-import-popup #importPopupText {
            width: 100%;
            height: 120px;
            box-sizing: border-box;
            background: var(--fmk-bg-secondary);
            color: var(--fmk-text-primary);
            border: 1px solid var(--fmk-border-primary);
            border-radius: 6px;
            padding: 8px;
            resize: vertical;
            font-size: 13px;
        }
        #fmk-memo-import-popup .popup-footer {
            padding: 12px 16px;
            border-top: 1px solid var(--fmk-border-primary);
            display: flex;
            justify-content: flex-end;
            gap: 8px;
        }
        #fmk-memo-import-popup .popup-footer button {
            background-color: var(--fmk-button-bg);
            color: var(--fmk-text-primary);
            border: 1px solid var(--fmk-border-primary);
            border-radius: 6px;
            padding: 6px 14px;
            cursor: pointer;
            transition: all 0.2s;
            font-size: 13px;
        }
        #fmk-memo-import-popup .popup-footer button#doImportBtn {
            background-color: var(--fmk-button-action-bg);
            border-color: var(--fmk-button-action-bg);
            color: var(--fmk-button-action-text);
        }
    `);
    log('CSS 스타일 주입 완료');

    const cached = {};
    let panelInserted = false;
    let currentContextPopup = null;
    let lastRun = 0;

    const today = () => {
        const d = new Date();
        return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
    };

    const getThemeAwareRandomColor = () => {
        const isDarkMode = document.body.classList.contains('night_mode');
        let r, g, b;
        if (isDarkMode) {
            r = Math.floor(128 + Math.random() * 128);
            g = Math.floor(128 + Math.random() * 128);
            b = Math.floor(128 + Math.random() * 128);
        } else {
            r = Math.floor(Math.random() * 128);
            g = Math.floor(Math.random() * 128);
            b = Math.floor(Math.random() * 128);
        }
        return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).padStart(6, '0')}`;
    };

    const getNick = el => el.innerText.trim().replace(/^\/\s*/, '') || 'Unknown';

    const clampPopup = p => {
        const r = p.getBoundingClientRect(), pad = 8;
        let left = parseInt(p.style.left, 10);
        let top = parseInt(p.style.top, 10);
        if (r.right > window.innerWidth - pad) left = window.innerWidth - pad - r.width;
        if (r.bottom > window.innerHeight - pad) top = window.innerHeight - pad - r.height;
        if (left < pad) left = pad;
        if (top < pad) top = pad;
        p.style.left = left + 'px';
        p.style.top = top + 'px';
    };

    function updNick(u, newNick) {
        u.nickHistory ??= [];
        const last = u.nickHistory.at(-1);
        const currentDate = today();
        if (!last || last.nick !== newNick) {
            const todayLogIndex = u.nickHistory.findIndex(entry => entry.date === currentDate);
            if (todayLogIndex > -1) {
                return false;
            }
            log(`닉네임 변경 감지: '${last?.nick || '(없음)'}' -> '${newNick}'`);
            u.nickHistory.push({ date: currentDate, nick: newNick });
            if (u.nickHistory.length > 30) u.nickHistory.splice(0, u.nickHistory.length - 30);
            u.nickname = newNick;
            return true;
        }
        return false;
    }

    function normalizeMemo(m) {
        if (!m) return null;
        if (!Array.isArray(m.history)) m.history = m.text ? [{ date: m.lastUpdate || today(), text: m.text }] : [];
        if (!Array.isArray(m.nickHistory)) {
            const seeds = Array.isArray(m.nicknames) ? m.nicknames : m.nickname ? [m.nickname] : [];
            m.nickHistory = seeds.map(n => ({ date: m.lastUpdate || today(), nick: n }));
        }
        if (!m.nickname && m.nickHistory.length) m.nickname = m.nickHistory.at(-1).nick;
        return m;
    }

    const setMemo = (el, t, c) => {
        let sp = el.querySelector(`.${CONSTANTS.INLINE_MEMO_CLASS}`);
        if (!sp) {
            sp = document.createElement('span');
            sp.className = CONSTANTS.INLINE_MEMO_CLASS;
            el.appendChild(sp);
        }
        sp.textContent = t;
        Object.assign(sp.style, { color: c || '#ff7676', marginLeft: '2px', display: 'inline' });
    };

    const delMemo = el => el.querySelector(`.${CONSTANTS.INLINE_MEMO_CLASS}`)?.remove();

    function closeContextPopup() {
        if (currentContextPopup) {
            document.body.removeChild(currentContextPopup);
            currentContextPopup = null;
            document.removeEventListener('mousedown', outsideContextPopup);
        }
    }

    const outsideContextPopup = e => {
        if (currentContextPopup && !currentContextPopup.contains(e.target)) {
            closeContextPopup();
        }
    };

    async function showMemoContextPopup(uid, u, x, y, cb) {
        closeContextPopup();
        log(`우클릭 메모 팝업 열기: 사용자 ID ${uid}`);
        const p = document.createElement('div');
        p.className = 'fmk-context-popup';
        Object.assign(p.style, { left: x + 'px', top: y + 'px' });
        p.innerHTML = `<div class="fmk-popup-title">${u.nickname || 'Unknown'}[${uid}]</div>`;
        const main = document.createElement('textarea');
        main.rows = 2;
        main.maxLength = 20;
        main.placeholder = '메모 (20자)';
        main.value = u.text || '';
        p.appendChild(main);
        const detail = document.createElement('textarea');
        detail.rows = 5;
        detail.placeholder = '세부 메모';
        detail.value = u.detail || '';
        p.appendChild(detail);
        const historyContainer = document.createElement('div');
        historyContainer.className = 'fmk-history-container';
        const historyTitle = document.createElement('div');
        historyTitle.className = 'fmk-history-title';
        historyTitle.textContent = '닉네임 변경 이력';
        historyContainer.appendChild(historyTitle);
        const historyList = document.createElement('ul');
        if (u.nickHistory && u.nickHistory.length > 1) {
            u.nickHistory.slice().reverse().forEach(entry => {
                const li = document.createElement('li');
                li.textContent = `• ${entry.date}: ${entry.nick}`;
                historyList.appendChild(li);
            });
        } else {
            const li = document.createElement('li');
            li.textContent = '변경 이력이 없습니다.';
            li.style.fontStyle = 'italic';
            historyList.appendChild(li);
        }
        historyContainer.appendChild(historyList);
        p.appendChild(historyContainer);
        const colorWrap = document.createElement('div');
        colorWrap.className = 'fmk-color-wrap';
        colorWrap.innerHTML = '글자 색상: ';
        const colorI = document.createElement('input');
        colorI.type = 'color';
        colorI.value = u.color || getThemeAwareRandomColor();
        colorWrap.appendChild(colorI);
        p.appendChild(colorWrap);
        const btns = document.createElement('div');
        btns.className = 'fmk-popup-buttons';
        const save = document.createElement('button');
        save.textContent = '저장';
        const del = document.createElement('button');
        del.textContent = '삭제';
        const cancel = document.createElement('button');
        cancel.textContent = '취소';
        if (!u.text) del.hidden = true;
        btns.append(save, del, cancel);
        p.appendChild(btns);
        document.body.appendChild(p);
        currentContextPopup = p;
        document.addEventListener('mousedown', outsideContextPopup);
        clampPopup(p);

        save.onclick = async () => {
            const t = main.value.trim().slice(0, 20);
            if (!t) {
                del.onclick();
                return;
            }
            if (u.text !== t) {
                u.history ??= [];
                u.history.push({ date: today(), text: t });
                if (u.history.length > 50) u.history.splice(0, u.history.length - 50);
            }
            u.text = t;
            u.detail = detail.value.trim();
            u.color = colorI.value;
            u.lastUpdate = today();
            cached[uid] = u;
            log(`메모 저장: ID ${uid}, 내용 '${t}'`);
            await GM_setValue(uid, u);
            closeContextPopup();
            cb?.({ text: t, color: u.color }, false);
        };
        del.onclick = async () => {
            log(`메모 삭제: ID ${uid}`);
            await GM_deleteValue(uid);
            delete cached[uid];
            closeContextPopup();
            cb?.(null, true);
        };
        cancel.onclick = closeContextPopup;
    }

    function showImportPopup(onSuccess) {
        if (document.getElementById('fmk-memo-import-overlay')) return;

        const overlay = document.createElement('div');
        overlay.id = 'fmk-memo-import-overlay';

        const popup = document.createElement('div');
        popup.id = 'fmk-memo-import-popup';
        popup.innerHTML = `
            <div class="popup-header">메모 가져오기</div>
            <div class="popup-content">
                <textarea id="importPopupText" placeholder="백업된 JSON 데이터를 여기에 붙여넣으세요..."></textarea>
            </div>
            <div class="popup-footer">
                <button id="cancelImportBtn">취소</button>
                <button id="doImportBtn">가져오기</button>
            </div>
        `;
        overlay.appendChild(popup);
        document.body.appendChild(overlay);

        const closePopup = () => document.body.removeChild(overlay);
        overlay.addEventListener('click', (e) => {
            if (e.target === overlay) {
                closePopup();
            }
        });
        popup.querySelector('#cancelImportBtn').addEventListener('click', closePopup);
        popup.querySelector('#doImportBtn').addEventListener('click', async () => {
            const importText = popup.querySelector('#importPopupText');
            const jsonStr = importText.value.trim();
            if (!jsonStr) {
                alert('가져올 데이터가 없습니다. 텍스트 영역에 JSON 데이터를 붙여넣어 주세요.');
                return;
            }
            try {
                const newData = JSON.parse(jsonStr);
                if (typeof newData !== 'object' || newData === null) {
                    throw new Error('올바른 JSON 객체 형식이 아닙니다.');
                }
                const keys = Object.keys(newData);
                if (keys.length === 0) {
                    alert('가져올 데이터가 비어있습니다.');
                    return;
                }
                const merge = confirm('기존 메모에 가져온 데이터를 병합(추가/덮어쓰기)하시겠습니까?\n[취소]를 누르면 모든 기존 메모를 삭제하고 새로 가져옵니다.');
                if (!merge) {
                    log('기존 데이터 삭제 후 가져오기 시작');
                    const allKeys = await GM_listValues();
                    for (const key of allKeys) {
                        await GM_deleteValue(key);
                    }
                    Object.keys(cached).forEach(key => delete cached[key]);
                }
                log(`메모 가져오기: ${keys.length}개 항목 처리 시작`);
                let importedCount = 0;
                for (const key of keys) {
                    if (isNaN(key)) continue;
                    const normalized = normalizeMemo(newData[key]);
                    if (normalized) {
                        cached[key] = normalized;
                        await GM_setValue(key, normalized);
                        importedCount++;
                    }
                }
                alert(`성공적으로 ${importedCount}개의 메모를 가져왔습니다.`);
                log('메모 가져오기 완료.');
                onSuccess?.(newData);
                closePopup();
            } catch (err) {
                error('데이터 가져오기 실패:', err);
                alert(`데이터를 가져오는 데 실패했습니다. JSON 형식이 올바른지 확인해주세요.\n\n오류: ${err.message}`);
            }
        });
    }

    async function showManagementPanel() {
        log('메모 관리자 패널 열기');
        if (document.getElementById('fmk-memo-manager-overlay')) return;

        let currentSortBy = CONSTANTS.SORT_BY_DATE;

        const overlay = document.createElement('div');
        overlay.id = 'fmk-memo-manager-overlay';

        const container = document.createElement('div');
        container.id = 'fmk-memo-manager-container';
        container.innerHTML = `
            <div class="manager-header">
                <h1>메모 관리</h1>
            </div>
            <div class="manager-content">
                <input type="text" id="searchMemo" placeholder="메모 검색..." />
                <div class="fmk-sort-controls">
                    <span>정렬:</span>
                    <button data-sort="${CONSTANTS.SORT_BY_DATE}" class="active">최신순</button>
                    <button data-sort="${CONSTANTS.SORT_BY_NAME}">이름순</button>
                </div>
                <div class="items-container" id="memoList"></div>
                <div class="import-export-area">
                    <button id="exportBtn">클립보드로 복사</button>
                    <button id="showImportPopupBtn">붙여넣기로 가져오기</button>
                </div>
            </div>
            <div class="manager-footer">
                <button id="closeManagerBtn">닫기</button>
            </div>
        `;
        overlay.appendChild(container);
        document.body.appendChild(overlay);

        const searchInput = container.querySelector('#searchMemo');
        const sortButtons = container.querySelectorAll('.fmk-sort-controls button');
        const exportBtn = container.querySelector("#exportBtn");
        const closeManagerBtn = container.querySelector("#closeManagerBtn");
        const showImportPopupBtn = container.querySelector("#showImportPopupBtn");

        const closePanel = () => {
            log('메모 관리자 패널 닫기');
            document.body.removeChild(overlay);
        };

        closeManagerBtn.addEventListener('click', closePanel);
        overlay.addEventListener('click', (e) => { if (e.target === overlay) closePanel(); });

        showImportPopupBtn.addEventListener("click", () => {
            showImportPopup(() => {
                renderMemoList(cached, searchInput.value.trim().toLowerCase(), currentSortBy);
                run();
            });
        });

        exportBtn.addEventListener("click", async () => {
            const dataToExport = {};
            Object.keys(cached).forEach(key => { if (!isNaN(key)) { dataToExport[key] = cached[key]; } });
            const jsonStr = JSON.stringify(dataToExport, null, 2);
            try {
                await navigator.clipboard.writeText(jsonStr);
                log('메모 데이터가 클립보드에 복사되었습니다.');
                const originalText = exportBtn.textContent;
                exportBtn.textContent = '복사 완료!';
                exportBtn.disabled = true;
                setTimeout(() => {
                    exportBtn.textContent = originalText;
                    exportBtn.disabled = false;
                }, 2000);
            } catch (err) {
                error('클립보드 복사 실패:', err);
                alert('클립보드 복사에 실패했습니다. 브라우저 콘솔을 확인해주세요.');
            }
        });

        searchInput.addEventListener("input", () => {
            renderMemoList(cached, searchInput.value.trim().toLowerCase(), currentSortBy);
        });

        sortButtons.forEach(button => {
            button.addEventListener('click', () => {
                currentSortBy = button.dataset.sort;
                log(`정렬 순서 변경: ${currentSortBy}`);
                sortButtons.forEach(btn => btn.classList.remove('active'));
                button.classList.add('active');
                renderMemoList(cached, searchInput.value.trim().toLowerCase(), currentSortBy);
            });
        });

        renderMemoList(cached, '', currentSortBy);
    }

    function renderMemoList(data, keyword = "", sortBy = CONSTANTS.SORT_BY_DATE) {
        const memoListEl = document.getElementById("memoList");
        if (!memoListEl) return;
        memoListEl.innerHTML = "";
        const searchInput = document.getElementById('searchMemo');
        let filteredData = Object.entries(data).filter(([key, val]) => {
            if (isNaN(key)) return false;
            const text = val?.text ?? "";
            const detail = val?.detail ?? "";
            const nickname = val?.nickname ?? "";
            const combinedText = `${key} ${nickname} ${text} ${detail}`.toLowerCase();
            return !keyword || combinedText.includes(keyword);
        });
        if (sortBy === CONSTANTS.SORT_BY_NAME) {
            filteredData.sort(([, a], [, b]) => (a.nickname || '').localeCompare(b.nickname || ''));
        } else {
            filteredData.sort(([, a], [, b]) => new Date(b.lastUpdate || 0) - new Date(a.lastUpdate || 0));
        }
        if (filteredData.length === 0) {
            const emptyMsg = document.createElement('div');
            emptyMsg.textContent = keyword ? "일치하는 메모가 없습니다." : "저장된 메모가 없습니다.";
            emptyMsg.style.padding = "10px";
            emptyMsg.style.textAlign = "center";
            emptyMsg.style.color = "var(--fmk-text-secondary)";
            memoListEl.appendChild(emptyMsg);
            return;
        }
        for (const [key, val] of filteredData) {
            const itemEl = document.createElement('div');
            itemEl.className = 'item-wrapper';
            let historyHtml = '';
            if (val.history && val.history.length > 1) {
                const historyItems = val.history.slice().reverse().map(h => `<li>• ${h.date}: ${h.text}</li>`).join('');
                historyHtml = `
                    <div class="item-history-container">
                        <div class="item-history-title">메모 변경 이력</div>
                        <ul>${historyItems}</ul>
                    </div>`;
            }
            itemEl.innerHTML = `
                <div class="item">
                    <span></span>
                    <button class="history-toggle-btn" ${!historyHtml ? 'style="display:none;"' : ''}>이력</button>
                </div>
                ${historyHtml}
            `;
            const nickSpan = itemEl.querySelector('.item span');
            const displayName = `${val.nickname || 'Unknown'}[${key}]`;
            nickSpan.textContent = `${displayName} : ${val.text}`;
            if (val.color) {
                nickSpan.style.borderLeft = `3px solid ${val.color}`;
                nickSpan.style.paddingLeft = '6px';
            }
            itemEl.querySelector('.item').addEventListener('click', (e) => {
                e.stopPropagation();
                showMemoContextPopup(key, val, e.pageX, e.pageY, () => {
                    log(`메모가 관리자 패널에서 수정되어 목록과 페이지를 새로고침합니다.`);
                    const currentSortBy = document.querySelector('.fmk-sort-controls button.active')?.dataset.sort || CONSTANTS.SORT_BY_DATE;
                    renderMemoList(cached, searchInput.value.trim().toLowerCase(), currentSortBy);
                    run();
                });
            });
            const historyToggleBtn = itemEl.querySelector('.history-toggle-btn');
            const historyContainer = itemEl.querySelector('.item-history-container');
            if (historyToggleBtn && historyContainer) {
                historyContainer.style.display = 'none';
                historyToggleBtn.addEventListener('click', (e) => {
                    e.stopPropagation();
                    const isVisible = historyContainer.style.display !== 'none';
                    historyContainer.style.display = isVisible ? 'none' : 'block';
                });
            }
            memoListEl.appendChild(itemEl);
        }
    }

    GM_registerMenuCommand('메모 관리자 열기', showManagementPanel);
    log('Tampermonkey 메뉴에 "메모 관리자 열기" 등록 완료');

    function bindPlate(a) {
        if (a.getAttribute(CONSTANTS.PROCESSED_ATTR)) return;
        a.setAttribute(CONSTANTS.PROCESSED_ATTR, '1');
        const m = a.className.match(/member_(\d+)/);
        if (!m) return;
        const uid = m[1];
        const nick = getNick(a);
        let u = cached[uid];
        if (u) {
            if (updNick(u, nick)) {
                GM_setValue(uid, u);
                a.classList.add('fmk-nick-blink');
                setTimeout(() => a.classList.remove('fmk-nick-blink'), 1500);
            }
            if (u.lastUpdate !== today()) {
                u.lastUpdate = today();
                GM_setValue(uid, u);
            }
            u.text && setMemo(a, u.text, u.color);
        }
        a.addEventListener('contextmenu', e => {
            e.preventDefault();
            u = cached[uid] ||= { color: getThemeAwareRandomColor(), nickname: nick, nickHistory: [{ date: today(), nick }], history: [], lastUpdate: today() };
            showMemoContextPopup(uid, u, e.pageX, e.pageY, (up, rm) => {
                document.querySelectorAll(`.${CONSTANTS.MEMBER_CLASS_PREFIX}${uid}`).forEach(el => {
                    if (rm) {
                        delMemo(el);
                    } else if (up?.text) {
                        setMemo(el, up.text, up.color);
                    }
                });
            });
        });
    }

    const bindAuthor = el => {
        if (el.getAttribute(CONSTANTS.PROCESSED_ATTR)) return;
        el.setAttribute(CONSTANTS.PROCESSED_ATTR, '1');
        const n = getNick(el);
        for (const [uid, u] of Object.entries(cached)) {
            if (u.nickname === n && u.text) {
                setMemo(el, u.text, u.color);
                break;
            }
        }
    };


    const detectMid = () => {
        const IGNORE_FOR_ORIGIN = ['best', 'best2', 'best_day'];

        if (window.__fm_best_config?.target_mid) {
            return window.__fm_best_config.target_mid;
        }

        let mid = new URL(location.href).searchParams.get('mid');
        if (mid && !IGNORE_FOR_ORIGIN.includes(mid)) {
            return mid;
        }
        if (window.document_mid && !IGNORE_FOR_ORIGIN.includes(window.document_mid)) {
            return window.document_mid;
        }
        const docLink = document.querySelector('.bd_tl h1 a[href^="/"]:not([href="/"])');
        if (docLink) {
            const m = docLink.getAttribute('href').match(/^\/([^/?#]+)/);
            if (m && !IGNORE_FOR_ORIGIN.includes(m[1])) {
                return m[1];
            }
        }

        const p = location.pathname.split('/').filter(Boolean);
        if (p.length > 0 && isNaN(p[0])) {
            return p[0];
        }
        if (p.length > 1 && isNaN(p[1])) {
            return p[1];
        }

        const og = document.querySelector('meta[property="og:url"]')?.content;
        const mOg = og?.match(/^https?:\/\/[^/]+\/([^/?#]+)/);
        if (mOg) {
            return mOg[1];
        }

        log('모든 방법으로 mid를 찾지 못해 "stock"을 기본값으로 사용합니다.');
        return 'stock';
    };

    const authorAnchor = () => document.querySelector('.rd_hd .btm_area a.member_plate[class*="member_"]') || document.querySelector('.author a[class*="member_"]');

    async function injectPanel() {
        if (panelInserted) return;
        const body = document.querySelector('.rd_body');
        if (!body) return;
        const anc = authorAnchor();
        const m = anc?.className.match(/member_(\d+)/);
        if (!m) return;
        panelInserted = true;
        const memberId = m[1];
        const searchMid = detectMid();
        const pageContextMid = new URL(location.href).searchParams.get('mid') || searchMid;
        log(`패널 데이터 요청 시작: 회원정보(mid=${pageContextMid}), 최근글(mid=${searchMid}), 회원번호(${memberId})`);
        const wrap = document.createElement('div');
        wrap.className = 'fmk-info-panel';
        const leftBox = document.createElement('div');
        leftBox.className = 'fmk-info-panel-box fmk-info-panel-left';
        leftBox.innerHTML = `<p class="fmk-info-panel-title">🛈 회원 정보</p>`;
        const rightBox = document.createElement('div');
        rightBox.className = 'fmk-info-panel-box fmk-info-panel-right';
        rightBox.innerHTML = `<p class="fmk-info-panel-title">📝 최근 작성글</p>`;
        wrap.append(leftBox, rightBox);
        body.parentNode.insertBefore(wrap, body.nextSibling);

        try {
            const [infoHtml, postHtmlRaw] = await Promise.all([
                fetch(`https://www.fmkorea.com/index.php?mid=${pageContextMid}&act=dispMemberInfo&member_srl=${memberId}`, { credentials: 'include' }).then(r => r.text()),
                fetch(`https://www.fmkorea.com/search.php?mid=${searchMid}&search_target=member_srl&search_keyword=${memberId}`, { credentials: 'include' }).then(r => r.text())
            ]);
            const infoDoc = new DOMParser().parseFromString(infoHtml, 'text/html');
            const infoTbl = infoDoc.querySelector('table.table.row');

            if (infoTbl) {
                const isLoginForm = infoTbl.querySelector('input[name="password"]');

                if (isLoginForm) {
                    log('로그인되지 않은 상태 감지. 회원 정보 패널에 안내 메시지를 표시합니다.');
                    const loginMsg = document.createElement('p');
                    loginMsg.textContent = '회원 정보를 보려면 로그인이 필요합니다.';
                    loginMsg.style.color = 'var(--fmk-text-secondary)';
                    loginMsg.style.padding = '20px';
                    loginMsg.style.textAlign = 'center';
                    leftBox.appendChild(loginMsg);
                } else {
                    log('회원 정보 테이블 파싱 성공');
                    const nickElement = infoTbl.querySelector('a.member_plate > b, a.member_plate');
                    if (nickElement) {
                        const latestNick = nickElement.innerText.trim();
                        const u = cached[memberId];
                        if (u && u.nickname !== latestNick) {
                            log(`[교정] 닉네임 강제 교정: '${u.nickname}' -> '${latestNick}'`);
                            u.nickname = latestNick;
                            const currentDate = today();
                            const todayLogIndex = u.nickHistory.findIndex(entry => entry.date === currentDate);
                            if (todayLogIndex > -1) {
                                u.nickHistory[todayLogIndex].nick = latestNick;
                            } else {
                                u.nickHistory.push({ date: currentDate, nick: latestNick });
                            }
                            await GM_setValue(memberId, u);
                        }
                    }
                    infoTbl.querySelectorAll('th').forEach(th => { if (th.textContent.trim().startsWith('블라인드 유저')) th.textContent = '블라인드 유저'; });
                    infoTbl.style.tableLayout = 'fixed';
                    infoTbl.querySelectorAll('th').forEach(th => (th.style.width = '90px'));
                    infoTbl.querySelector('td.profile_image')?.parentElement?.remove();
                    [...infoTbl.rows].find(tr => tr.querySelector('th[colspan]')?.innerText.trim() === '기본 정보')?.remove();
                    infoTbl.querySelectorAll('th,td').forEach(c => (c.style.padding = '4px 10px'));
                    leftBox.appendChild(infoTbl);
                }
            } else {
                log('회원 정보 테이블 파싱 실패');
                leftBox.appendChild(document.createTextNode('정보를 불러올 수 없습니다.'));
            }

            let postHtml = postHtmlRaw;
            if (!/list_?tbody|tbody/i.test(postHtmlRaw)) {
                log('최근 글 목록이 없어 전역 재검색 시도');
                postHtml = await fetch(`https://www.fmkorea.com/search.php?search_target=member_srl&search_keyword=${memberId}`, { credentials: 'include' }).then(r => r.text());
            }
            const postDoc = new DOMParser().parseFromString(postHtml, 'text/html');
            const rows = [...postDoc.querySelectorAll('tbody tr, .list_tbody .list_row, .fm_best_widget li.li')].filter(el => el.querySelector('.title a, h3.title a, td.title a'));
            log(`최근 글 파싱 완료. ${rows.length}개 항목 발견.`);
            const ul = document.createElement('ul');
            ul.className = 'fmk-info-panel-content';
            if (rows.length > 0) {
                rows.forEach(el => {
                    const a = el.querySelector('.title a, h3.title a, td.title a');
                    if (!a) return;
                    const id = (a.href.match(/document_srl=(\d+)/) || [])[1];
                    const href = id ? `/${id}` : a.href;
                    const date = (el.querySelector('.time, td.time, .regdate')?.textContent || '').trim();
                    const views = (el.querySelector('.m_no, td.m_no')?.textContent || '').trim();
                    const votes = (el.querySelector('.m_no_voted, td.m_no_voted, .pc_voted_count .count')?.textContent || '').trim();
                    const meta = [date, views && `조회 ${views}`, votes && `추천 ${votes}`].filter(Boolean).join(' · ');
                    const li = document.createElement('li');
                    li.innerHTML = `<a href="${href}" target="_blank">${a.textContent.trim()}</a>${meta ? ` <span class="fmk-post-meta">· ${meta}</span>` : ''}`;
                    ul.appendChild(li);
                });
            } else {
                ul.textContent = '최근 작성한 글이 없습니다.';
                ul.style.listStyle = 'none';
                ul.style.paddingLeft = '0';
            }
            rightBox.appendChild(ul);
        } catch (e) {
            error('작성자 정보 패널 데이터 로딩 중 오류 발생:', e);
            leftBox.appendChild(document.createTextNode('정보 로딩 중 오류가 발생했습니다.'));
            rightBox.appendChild(document.createTextNode('정보 로딩 중 오류가 발생했습니다.'));
        }
    }

    function run(container = document.body) {
        container.querySelectorAll('a[data-class]:not([class*="member_"])').forEach(a => {
            const id = a.getAttribute('data-class');
            if (id) {
                a.classList.add(`${CONSTANTS.MEMBER_CLASS_PREFIX}${id}`);
            }
        });
        container.querySelectorAll(`[class*="${CONSTANTS.MEMBER_CLASS_PREFIX}"]`).forEach(bindPlate);
        container.querySelectorAll('.author:not(:has(.member_plate))').forEach(bindAuthor);
        if (!panelInserted) {
            injectPanel();
        }
    }

    async function initialize() {
        log('데이터 초기화 시작...');
        const keys = await GM_listValues();
        log(`저장된 키 ${keys.length}개 발견`);
        for (const key of keys) {
            if (!isNaN(key)) {
                const memo = await GM_getValue(key);
                cached[key] = normalizeMemo(memo);
            }
        }
        log(`데이터 캐싱 완료. ${Object.keys(cached).length}개의 메모 로드.`);
        run();
        const observer = new MutationObserver(mutations => {
            let processed = false;
            const runOnce = () => {
                if (processed) return;
                run(document.body);
                processed = true;
                setTimeout(() => { processed = false; }, 0);
            };
            for (const mutation of mutations) {
                if (mutation.type === 'childList') {
                    runOnce();
                    break;
                }
                if (mutation.type === 'attributes') {
                    if (mutation.attributeName === 'style' && mutation.target.style.display !== 'none') {
                        runOnce();
                        break;
                    }
                }
            }
        });
        observer.observe(document.body, { childList: true, subtree: true, attributes: true });
        log('최적화된 MutationObserver 시작. 페이지 변경 및 속성 변경을 감시합니다.');
    }

    initialize();
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址