Greasy Fork

Greasy Fork is available in English.

Universal Scholar BibTeX

Fetch BibTeX from Google Scholar on any site, with Overleaf integration and mirror configuration. (CSP Compliant)

当前为 2025-12-22 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Universal Scholar BibTeX
// @namespace    com.jw23.overleaf
// @version      2.1.0
// @description  Fetch BibTeX from Google Scholar on any site, with Overleaf integration and mirror configuration. (CSP Compliant)
// @author       jw23 (Refactored)
// @match        *://*/*
// @require      https://cdn.jsdelivr.net/npm/@floating-ui/[email protected]
// @require      https://cdn.jsdelivr.net/npm/@floating-ui/[email protected]
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/simple-notify.min.js
// @resource     notifycss https://cdn.jsdelivr.net/npm/simple-notify/dist/simple-notify.css
// @grant        GM_setClipboard
// @grant        GM_xmlhttpRequest
// @grant        GM_getResourceText
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @grant        GM_openInTab
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    // =========================================================================
    // 1. Configuration & State
    // =========================================================================
    const CONSTANTS = {
        DEFAULT_MIRRORS: [
            "https://scholar.google.com.hk",
            "https://scholar.lanfanshu.cn",
            "https://xs.vygc.top",
            "https://scholar.google.com"
        ],
        STORAGE_KEYS: {
            ENABLED: 'config_enabled',
            MIRROR: 'config_mirror',
            MIRROR_LIST: 'config_mirror_list',
            POS_X: 'ui_pos_x',
            POS_Y: 'ui_pos_y'
        }
    };

    const State = {
        isEnabled: GM_getValue(CONSTANTS.STORAGE_KEYS.ENABLED, true),
        currentMirror: GM_getValue(CONSTANTS.STORAGE_KEYS.MIRROR, CONSTANTS.DEFAULT_MIRRORS[0]),
        customMirrors: GM_getValue(CONSTANTS.STORAGE_KEYS.MIRROR_LIST, CONSTANTS.DEFAULT_MIRRORS),
        isOverleaf: window.location.hostname === 'www.overleaf.com',
        lang: navigator.language.startsWith('zh') ? 'zh' : 'en'
    };

    // =========================================================================
    // 2. I18N
    // =========================================================================
    const Dictionary = {
        zh: {
            title: "学术搜索助手",
            placeholder: "输入论文标题...",
            search: "搜索",
            settings: "设置",
            loading: "加载中...",
            no_result: "未找到相关文章",
            copy_success: "复制成功",
            copy_desc: "BibTeX 已复制到剪贴板",
            copy_fail: "复制失败",
            fail_desc: "无法获取 BibTeX,请检查网络或验证码",
            mirror_label: "Google 学术镜像地址",
            save: "保存",
            reset: "重置",
            toggle_on: "已开启显示 (刷新生效)",
            toggle_off: "已关闭显示 (刷新生效)",
            menu_toggle: "切换开启/关闭",
            verify_needed: "需要验证",
            verify_desc: "点击此处在新标签页完成验证",
            custom_url: "自定义网址 (如 https://site.com)",
            back: "返回"
        },
        en: {
            title: "Scholar Helper",
            placeholder: "Search paper title...",
            search: "Search",
            settings: "Settings",
            loading: "Loading...",
            no_result: "No articles found",
            copy_success: "Copied",
            copy_desc: "BibTeX copied to clipboard",
            copy_fail: "Failed",
            fail_desc: "Cannot fetch BibTeX. Check network/captcha.",
            mirror_label: "Google Scholar Mirror URL",
            save: "Save",
            reset: "Reset",
            toggle_on: "Enabled (Reload to apply)",
            toggle_off: "Disabled (Reload to apply)",
            menu_toggle: "Toggle On/Off",
            verify_needed: "Verification Needed",
            verify_desc: "Click here to verify captcha in new tab",
            custom_url: "Custom URL (e.g. https://site.com)",
            back: "Back"
        }
    };
    const t = (key) => Dictionary[State.lang][key] || key;

    // =========================================================================
    // 3. Helper Functions (DOM & Icons) - CSP SAFE
    // =========================================================================
    
    /**
     * Create DOM Element safely
     * @param {string} tag - Tag name
     * @param {object} attrs - Attributes (className, id, style, etc.)
     * @param {Array} children - Child elements or strings
     * @returns {HTMLElement}
     */
    function el(tag, attrs = {}, children = []) {
        const element = document.createElement(tag);
        Object.entries(attrs).forEach(([key, val]) => {
            if (key === 'style' && typeof val === 'object') {
                Object.assign(element.style, val);
            } else if (key === 'className') {
                element.className = val;
            } else if (key.startsWith('on') && typeof val === 'function') {
                element.addEventListener(key.substring(2).toLowerCase(), val);
            } else {
                element.setAttribute(key, val);
            }
        });
        children.forEach(child => {
            if (typeof child === 'string') {
                element.appendChild(document.createTextNode(child));
            } else if (child instanceof Node) {
                element.appendChild(child);
            }
        });
        return element;
    }

    /**
     * Create SVG Icon safely
     * @param {string} pathData - SVG Path data
     * @param {number} size - Size
     * @returns {SVGElement}
     */
    function icon(pathData, size = 24) {
        const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
        svg.setAttribute("viewBox", "0 0 24 24");
        svg.setAttribute("width", size);
        svg.setAttribute("height", size);
        svg.setAttribute("fill", "none");
        svg.setAttribute("stroke", "currentColor");
        svg.setAttribute("stroke-width", "2");
        
        // If pathData is complex (multiple paths), assumes passed as array, else string
        const paths = Array.isArray(pathData) ? pathData : [pathData];
        paths.forEach(d => {
            const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
            path.setAttribute("d", d);
            // Handling fill vs stroke based on common icon styles (simplified)
            if(d.includes("M12 3L1")) { // Scholar icon specific fix for fill
                 path.setAttribute("fill", "currentColor");
                 path.setAttribute("stroke", "none");
            }
            svg.appendChild(path);
        });
        return svg;
    }

    const Icons = {
        scholar: icon("M12 3L1 9l11 6 9-4.91V17h2V9M5 13.18v4L12 21l7-3.82v-4L12 17l-7-3.82z"), 
        search: icon(["M11 19a8 8 0 100-16 8 8 0 000 16zM21 21l-4.35-4.35"], 20),
        settings: icon(["M12 15a3 3 0 100-6 3 3 0 000 6z", "M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 01 2-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"], 18),
        back: icon("M19 12H5M12 19l-7-7 7-7", 18)
    };

    // =========================================================================
    // 4. Styles
    // =========================================================================
    const STYLES = `
        .usb-container { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; color: #333; z-index: 2147483647; }
        .usb-float-btn {
            position: fixed; width: 48px; height: 48px;
            background: #4285f4; color: white; border-radius: 50%;
            display: flex; align-items: center; justify-content: center;
            box-shadow: 0 4px 12px rgba(0,0,0,0.2); cursor: move;
            transition: transform 0.2s, background 0.2s; user-select: none;
        }
        .usb-float-btn:hover { background: #3367d6; transform: scale(1.05); }
        .usb-popup {
            position: absolute; width: 320px; background: white;
            border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.15);
            border: 1px solid #e0e0e0; display: none; flex-direction: column;
            overflow: hidden; font-size: 14px;
        }
        .usb-header {
            display: flex; justify-content: space-between; align-items: center;
            padding: 12px 16px; background: #f8f9fa; border-bottom: 1px solid #eee;
        }
        .usb-title { font-weight: 600; color: #4285f4; }
        .usb-icon-btn { 
            cursor: pointer; padding: 4px; border-radius: 4px; 
            color: #5f6368; display: flex; align-items: center; 
        }
        .usb-icon-btn:hover { background: #e8eaed; color: #333; }
        .usb-content { padding: 12px; max-height: 400px; overflow-y: auto; }
        .usb-search-box { display: flex; align-items: center; margin-bottom: 10px; border: 1px solid #dfe1e5; border-radius: 20px; padding: 4px 12px; }
        .usb-search-input { flex: 1; border: none; outline: none; padding: 6px; font-size: 14px; background: transparent; }
        .usb-search-btn { cursor: pointer; color: #4285f4; padding: 4px; }
        .usb-result-item {
            padding: 10px; border-bottom: 1px solid #eee; cursor: pointer; transition: background 0.1s;
        }
        .usb-result-item:hover { background: #f1f3f4; }
        .usb-result-item:last-child { border-bottom: none; }
        .usb-res-title { font-weight: 500; margin-bottom: 4px; color: #1a73e8; line-height: 1.4; }
        .usb-res-author { color: #5f6368; font-size: 12px; }
        .usb-settings-view { display: none; flex-direction: column; gap: 10px; }
        .usb-label { font-size: 12px; font-weight: 500; color: #5f6368; margin-bottom: 4px; }
        .usb-select, .usb-input { width: 100%; padding: 8px; border: 1px solid #dfe1e5; border-radius: 4px; margin-bottom: 8px; box-sizing: border-box; }
        .usb-btn-primary { background: #4285f4; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; width: 100%; }
        .usb-btn-primary:hover { background: #3367d6; }
        .usb-msg { text-align: center; color: #5f6368; padding: 20px; }
        .usb-error { color: #d93025; }
        .usb-hidden { display: none; }
    `;

    // =========================================================================
    // 5. API Logic
    // =========================================================================
    const API = {
        getUrl: (path) => `${State.currentMirror}${path}`,

        async search(query) {
            const url = API.getUrl(`/scholar?hl=${State.lang}&q=${encodeURIComponent(query)}`);
            return new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: "GET",
                    url: url,
                    onload: (res) => {
                        if (res.status !== 200) return reject(new Error("Network Error"));
                        const parser = new DOMParser();
                        const doc = parser.parseFromString(res.responseText, 'text/html');
                        
                        if (doc.querySelector('#gs_captcha_c') || doc.title.includes("Captcha")) {
                            return reject(new Error("CAPTCHA_REQUIRED"));
                        }

                        const items = doc.querySelectorAll('div[data-cid]');
                        const results = [];
                        items.forEach((item, idx) => {
                            if (idx > 5) return;
                            const titleEl = item.querySelector("h3");
                            const authorEl = item.querySelector("div.gs_a");
                            if (titleEl && item.getAttribute('data-cid')) {
                                results.push({
                                    id: item.getAttribute('data-cid'),
                                    title: titleEl.innerText,
                                    author: authorEl ? authorEl.innerText : "Unknown"
                                });
                            }
                        });
                        resolve(results);
                    },
                    onerror: (err) => reject(err)
                });
            });
        },

        async getBibTex(cid) {
            const refUrl = API.getUrl(`/scholar?q=info:${cid}:scholar.google.com/&output=cite&scirp=1&hl=${State.lang}`);
            const refPage = await new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: "GET",
                    url: refUrl,
                    onload: (res) => resolve(res.responseText),
                    onerror: reject
                });
            });

            const parser = new DOMParser();
            const doc = parser.parseFromString(refPage, 'text/html');
            const bibAnchor = doc.querySelector(".gs_citi");

            if (!bibAnchor) throw new Error("BibTeX Link not found");

            return new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: "GET",
                    url: bibAnchor.href,
                    onload: (res) => resolve(res.responseText),
                    onerror: reject
                });
            });
        }
    };

    // =========================================================================
    // 6. UI Implementation (CSP Safe)
    // =========================================================================
    class UI {
        constructor() {
            this.container = document.createElement('div');
            this.container.className = 'usb-container';
            document.body.appendChild(this.container);
            
            GM_addStyle(GM_getResourceText('notifycss'));
            GM_addStyle(STYLES);

            this.isShow = false;
            this.popup = null;
            this.elements = {}; // Cache UI elements
        }

        init() {
            if (State.isOverleaf) {
                this.injectOverleaf();
            }
            if (!State.isOverleaf) {
                this.renderFloatingButton();
            }
            this.createPopup();
        }

        // --- Overleaf Specific ---
        injectOverleaf() {
            const observer = new MutationObserver((_, obs) => {
                const target = document.querySelector('div.ol-cm-toolbar-button-group.ol-cm-toolbar-end');
                if (target && !document.getElementById('usb-ol-btn')) {
                    const btn = el('div', {
                        id: 'usb-ol-btn',
                        className: 'ol-cm-toolbar-button',
                        style: { display: 'flex', justifyContent: 'center', alignItems: 'center' },
                        title: t('title'),
                        onclick: () => this.togglePopup(btn)
                    }, [Icons.scholar.cloneNode(true)]);
                    
                    target.appendChild(btn);
                }
            });
            observer.observe(document.body, { childList: true, subtree: true });
        }

        // --- Floating Button ---
        renderFloatingButton() {
            const btn = el('div', {
                className: 'usb-float-btn',
                title: t('title')
            }, [Icons.scholar.cloneNode(true)]);
            
            let posX = GM_getValue(CONSTANTS.STORAGE_KEYS.POS_X, 20);
            let posY = GM_getValue(CONSTANTS.STORAGE_KEYS.POS_Y, window.innerHeight - 80);
            btn.style.left = `${posX}px`;
            btn.style.top = `${posY}px`;

            // Drag Logic
            let isDragging = false;
            let startX, startY, initialLeft, initialTop;

            const onMove = (mv) => {
                if (Math.abs(mv.clientX - startX) > 5 || Math.abs(mv.clientY - startY) > 5) isDragging = true;
                btn.style.left = `${initialLeft + mv.clientX - startX}px`;
                btn.style.top = `${initialTop + mv.clientY - startY}px`;
            };
            const onUp = () => {
                document.removeEventListener('mousemove', onMove);
                document.removeEventListener('mouseup', onUp);
                if (isDragging) {
                    GM_setValue(CONSTANTS.STORAGE_KEYS.POS_X, parseInt(btn.style.left));
                    GM_setValue(CONSTANTS.STORAGE_KEYS.POS_Y, parseInt(btn.style.top));
                }
            };

            btn.addEventListener('mousedown', (e) => {
                isDragging = false;
                startX = e.clientX;
                startY = e.clientY;
                initialLeft = btn.offsetLeft;
                initialTop = btn.offsetTop;
                document.addEventListener('mousemove', onMove);
                document.addEventListener('mouseup', onUp);
            });

            btn.addEventListener('click', () => {
                if (!isDragging) this.togglePopup(btn);
            });

            this.container.appendChild(btn);
        }

        // --- CSP Safe Popup Creation ---
        createPopup() {
            // Header Elements
            const settingsBtn = el('div', { className: 'usb-icon-btn', title: t('settings') }, [Icons.settings.cloneNode(true)]);
            const header = el('div', { className: 'usb-header' }, [
                el('span', { className: 'usb-title' }, [t('title')]),
                el('div', { style: { display: 'flex', gap: '8px' } }, [settingsBtn])
            ]);

            // Search View Elements
            const searchInput = el('input', { className: 'usb-search-input', placeholder: t('placeholder') });
            const searchBtn = el('div', { className: 'usb-search-btn' }, [Icons.search.cloneNode(true)]);
            const searchBox = el('div', { className: 'usb-search-box' }, [searchInput, searchBtn]);
            const resultsBox = el('div', { className: 'usb-results' });
            
            const searchView = el('div', { id: 'usb-view-search' }, [searchBox, resultsBox]);

            // Settings View Elements
            const backBtn = el('div', { 
                style: { marginBottom: '10px', display: 'flex', alignItems: 'center', cursor: 'pointer', color: '#4285f4' }
            }, [Icons.back.cloneNode(true), el('span', { style: { marginLeft: '4px' } }, [t('back')])]);
            
            const mirrorSelect = el('select', { className: 'usb-select' });
            const mirrorCustom = el('input', { className: 'usb-input', placeholder: t('custom_url') });
            const saveBtn = el('button', { className: 'usb-btn-primary' }, [t('save')]);
            
            const settingsView = el('div', { className: 'usb-settings-view' }, [
                backBtn,
                el('div', { className: 'usb-label' }, [t('mirror_label')]),
                mirrorSelect,
                mirrorCustom,
                saveBtn
            ]);

            // Assemble Popup
            const content = el('div', { className: 'usb-content' }, [searchView, settingsView]);
            this.popup = el('div', { className: 'usb-popup' }, [header, content]);
            
            this.container.appendChild(this.popup);

            // Store References
            this.elements = { searchInput, searchBtn, resultsBox, searchView, settingsView, mirrorSelect, mirrorCustom, saveBtn, settingsBtn, backBtn };
            
            this.bindEvents();
        }

        bindEvents() {
            const { searchInput, searchBtn, settingsBtn, backBtn, saveBtn, searchView, settingsView, mirrorSelect, mirrorCustom } = this.elements;

            const doSearch = () => this.handleSearch(searchInput.value);
            searchBtn.onclick = doSearch;
            searchInput.addEventListener('keydown', (e) => { if(e.key === 'Enter') doSearch(); });

            settingsBtn.onclick = () => {
                searchView.style.display = 'none';
                settingsView.style.display = 'flex';
                this.populateSettings();
            };

            backBtn.onclick = () => {
                settingsView.style.display = 'none';
                searchView.style.display = 'block';
            };

            mirrorSelect.onchange = () => { mirrorCustom.value = mirrorSelect.value; };

            saveBtn.onclick = () => {
                let newVal = mirrorCustom.value.trim() || mirrorSelect.value;
                if (newVal.endsWith('/')) newVal = newVal.slice(0, -1);
                
                if (!newVal.startsWith('http')) {
                    new Notify({ status: 'warning', text: 'Invalid URL', type: 'filled' });
                    return;
                }

                State.currentMirror = newVal;
                GM_setValue(CONSTANTS.STORAGE_KEYS.MIRROR, newVal);
                
                if (!State.customMirrors.includes(newVal)) {
                    State.customMirrors.push(newVal);
                    GM_setValue(CONSTANTS.STORAGE_KEYS.MIRROR_LIST, State.customMirrors);
                }
                
                new Notify({ status: 'success', text: t('save'), type: 'filled' });
                backBtn.click();
            };
        }

        populateSettings() {
            const { mirrorSelect, mirrorCustom } = this.elements;
            mirrorSelect.innerHTML = ''; // Clean (standard standard property for select options)
            State.customMirrors.forEach(m => {
                const opt = el('option', { value: m }, [m]);
                if (m === State.currentMirror) opt.selected = true;
                mirrorSelect.appendChild(opt);
            });
            mirrorCustom.value = State.currentMirror;
        }

        async handleSearch(query) {
            if (!query) return;
            const { resultsBox } = this.elements;
            
            // Clear results using DOM method
            while (resultsBox.firstChild) { resultsBox.removeChild(resultsBox.firstChild); }
            resultsBox.appendChild(el('div', { className: 'usb-msg' }, [t('loading')]));

            try {
                const results = await API.search(query);
                while (resultsBox.firstChild) { resultsBox.removeChild(resultsBox.firstChild); }

                if (results.length === 0) {
                    resultsBox.appendChild(el('div', { className: 'usb-msg' }, [t('no_result')]));
                    return;
                }
                
                results.forEach(item => {
                    const itemEl = el('div', { className: 'usb-result-item' }, [
                        el('div', { className: 'usb-res-title' }, [item.title]),
                        el('div', { className: 'usb-res-author' }, [item.author])
                    ]);
                    itemEl.onclick = () => this.handleFetchBib(item.id, itemEl);
                    resultsBox.appendChild(itemEl);
                });

            } catch (err) {
                while (resultsBox.firstChild) { resultsBox.removeChild(resultsBox.firstChild); }
                
                if (err.message === "CAPTCHA_REQUIRED") {
                    const link = el('div', { 
                        style: { fontSize: '12px', marginTop: '5px', cursor: 'pointer', textDecoration: 'underline' },
                        onclick: () => GM_openInTab(API.getUrl('/'), { active: true })
                    }, [t('verify_desc')]);

                    const msg = el('div', { className: 'usb-msg usb-error' }, [
                        el('div', {}, [t('verify_needed')]),
                        link
                    ]);
                    resultsBox.appendChild(msg);
                } else {
                    resultsBox.appendChild(el('div', { className: 'usb-msg usb-error' }, [`Error: ${err.message}`]));
                }
            }
        }

        async handleFetchBib(id, element) {
            const originalBg = element.style.background;
            element.style.background = "#e8f0fe";
            element.style.opacity = "0.7";
            
            try {
                const bib = await API.getBibTex(id);
                GM_setClipboard(bib);
                new Notify({ status: 'success', title: t('copy_success'), text: t('copy_desc'), effect: 'slide', type: 'filled', autoclose: true });
            } catch (err) {
                new Notify({ status: 'error', title: t('copy_fail'), text: t('fail_desc'), effect: 'slide', type: 'filled' });
            } finally {
                element.style.background = originalBg;
                element.style.opacity = "1";
            }
        }

        togglePopup(referenceEl) {
            if (this.isShow) {
                this.popup.style.display = 'none';
                this.isShow = false;
            } else {
                this.popup.style.display = 'flex';
                this.isShow = true;
                this.updatePopupPosition(referenceEl);
                setTimeout(() => this.elements.searchInput.focus(), 100);
            }
        }

        updatePopupPosition(referenceEl) {
            if (!referenceEl || !this.popup) return;
            FloatingUIDOM.computePosition(referenceEl, this.popup, {
                placement: 'bottom-start',
                middleware: [FloatingUICore.offset(8), FloatingUICore.flip(), FloatingUICore.shift({ padding: 10 })],
            }).then(({ x, y }) => {
                Object.assign(this.popup.style, { left: `${x}px`, top: `${y}px` });
            });
        }
    }

    // =========================================================================
    // 7. Init
    // =========================================================================
    GM_registerMenuCommand(t('menu_toggle'), () => {
        const newState = !State.isEnabled;
        GM_setValue(CONSTANTS.STORAGE_KEYS.ENABLED, newState);
        new Notify({ status: newState ? 'success' : 'warning', title: t('settings'), text: newState ? t('toggle_on') : t('toggle_off'), type: 'filled' });
    });

    if (State.isEnabled) {
        const ui = new UI();
        ui.init();
    }
})();