NovelAI Tag联想 (Danbooru-API版)

使用所有Danbooru Tag进行联想,并附带部分中文翻译和中文联想功能。

// ==UserScript==
// @name         NovelAI Tag联想 (Danbooru-API版)
// @namespace    http://tampermonkey.net/
// @version      1.5
// @description  使用所有Danbooru Tag进行联想,并附带部分中文翻译和中文联想功能。
// @author       Takoro
// @match        https://novelai.net/image*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        GM_openInTab
// @grant        GM_getValue
// @grant        GM_setValue
// @connect      danbooru.donmai.us
// @connect      raw.githubusercontent.com
// @license      MIT
// ==/UserScript==

// Gemini No.1 !!
(function() {
    'use strict';

    // --- 配置项 ---
    const MAX_SUGGESTIONS = 10;
    const DEBOUNCE_DELAY = 250;
    const API_BASE_URL = 'https://danbooru.donmai.us';
    const TRANSLATION_URL = 'https://raw.githubusercontent.com/Yellow-Rush/zh_CN-Tags/main/danbooru.csv'; // 翻译文件URL
    const CACHE_KEY = 'danbooru_translations_cache';
    const CACHE_DURATION_MS = 7 * 24 * 60 * 60 * 1000; // 缓存时间
    const HINT_MESSAGES = [
        "- 按住Ctrl+左键单击Tag可以打开Wiki页面。",
        "- 标紫的是系列/IP名,标绿的是角色名哦(使用英文查询时)。",
        "- Tag后面是数字是对应图片数量~",
        "- Tab和Enter以及方向键都可以试试看。",
        "- Takoro"
    ];
    const TITLE_COLOR = '#e3dccc';

    // --- 全局变量 ---
    let popup = null;
    let selectedIndex = -1;
    let currentMatches = [];
    let isPopupActive = false;
    let isKeyboardNavigation = false;
    let apiAbortController = null;
    let lastKnownRange = null;
    let translations = new Map();

    // --- 样式注入 ---
    GM_addStyle(`
        .autocomplete-container { position: absolute; background: #0e0f21; border: 1px solid #3B3B52; border-radius: 8px; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3); max-height: 450px; display: flex; flex-direction: column; z-index: 100000; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; color: #EAEAEB; min-width: 350px; max-width: 500px; }
        .suggestion-info-display { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: #1a1c30; border-bottom: 1px solid #3B3B52; font-size: 13px; line-height: 1.6; min-height: 17px; font-weight: bold; }
        .info-hint { color: #8a8a9e; font-weight: normal; font-size: 12px; margin-left: 10px; white-space: nowrap; }
        .info-title { color: ${TITLE_COLOR}; }
        .suggestion-scroll-wrapper { padding: 7px; overflow-y: auto; }
        .suggestion-flex { display: flex; flex-wrap: wrap; gap: 6px; }
        .suggestion-item { display: flex; align-items: center; justify-content: space-between; padding: 4px 10px; background: #22253f; border: 1px solid transparent; border-radius: 4px; cursor: pointer; transition: all 0.2s ease; font-size: 13px; height: 26px; white-space: nowrap; flex-shrink: 0; }
        .suggestion-item:hover, .suggestion-item.selected { background: #34395f; border-color: #F5F3C2; }
        .suggestion-text { overflow: hidden; text-overflow: ellipsis; color: #EAEAEB; }
        .suggestion-count { color: #8a8a9e; margin-left: 12px; font-size: 12px; }
        .suggestion-item[data-category='4'] .suggestion-text { color: #a6f3a6; } /* Character */
        .suggestion-item[data-category='3'] .suggestion-text { color: #d6bcf5; } /* Copyright/Series */
    `);

    // --- 核心功能函数 ---
    function loadTranslations() {
        // console.log("正在加载中文翻译...");
        const cachedData = GM_getValue(CACHE_KEY);
        if (cachedData && cachedData.timestamp && (Date.now() - cachedData.timestamp < CACHE_DURATION_MS)) {
            // console.log("从缓存加载翻译。");
            translations = new Map(cachedData.translations);
            return;
        }

        // console.log("从网络获取最新翻译...");
        GM_xmlhttpRequest({
            method: "GET",
            url: TRANSLATION_URL,
            onload: function(response) {
                if (response.status === 200) {
                    const lines = response.responseText.split('\n').filter(line => line.trim());
                    lines.forEach(line => {
                        const columns = line.split(/,(?=(?:(?:[^"]*"){2})*[^"]*$)/);
                        if (columns.length >= 2) {
                            const en = (columns[0] || '').trim().replace(/^"|"$/g, '');
                            const zh = (columns[1] || '').trim().replace(/^"|"$/g, '');
                            if (en && zh) { translations.set(en, zh); }
                        }
                    });
                    GM_setValue(CACHE_KEY, { translations: Array.from(translations.entries()), timestamp: Date.now() });
                    // console.log(`翻译加载完成,共 ${translations.size} 条。`);
                } else { console.error("加载翻译文件失败:", response.statusText); }
            },
            onerror: function(error) { console.error("加载翻译文件时发生网络错误:", error); }
        });
    }

    function openWikiPage(tagName) { if (!tagName) return; GM_openInTab(`${API_BASE_URL}/wiki_pages/show_or_new?title=${tagName}`, { active: true }); }

    function getRandomHint() {
        return Math.random() < 0.5 ? "" : HINT_MESSAGES[Math.floor(Math.random() * HINT_MESSAGES.length)];
    }

    function searchLocalSuggestions(query, input) {
        if (!translations.size) return;

        const queryLower = query.toLowerCase();
        const rankedItems = [];
        for (const [en, zh] of translations.entries()) {
            const zhLower = zh.toLowerCase();
            let score = 0;
            if (zhLower.startsWith(queryLower)) score = 1;
            else if (zhLower.includes(queryLower)) score = 2;

            if (score > 0) {
                rankedItems.push({
                    score: score,
                    data: { en: en, count: undefined, category: 0 }
                });
            }
        }

        const finalMatches = rankedItems
            .sort((a, b) => a.score - b.score)
            .map(item => item.data)
            .slice(0, MAX_SUGGESTIONS);

        updatePopup(input, finalMatches);
    }

    function fetchSuggestions(query, input) {
        if (apiAbortController) apiAbortController.abort();
        apiAbortController = new AbortController();
        const params = new URLSearchParams({'search[name_matches]': `${query}*`, 'search[order]': 'count', 'limit': MAX_SUGGESTIONS, 'search[hide_empty]': 'true' });
        GM_xmlhttpRequest({
            method: "GET", url: `${API_BASE_URL}/tags.json?${params.toString()}`,
            signal: apiAbortController.signal,
            onload: function(response) {
                if (response.status === 200) {
                    const matches = JSON.parse(response.responseText).map(tag => ({ en: tag.name, count: tag.post_count, category: tag.category }));
                    updatePopup(input, matches);
                } else { hidePopup(); }
            },
            onerror: (error) => { if(error.readyState !== 0) hidePopup(); },
        });
    }

    function updatePopup(input, matches) {
        createPopup();

        const hintText = getRandomHint();
        const hintHTML = hintText ? `<span class="info-hint">${hintText}</span>` : '';

        popup.innerHTML = `<div class="suggestion-info-display">
                                  <span class="info-title">Did you mean...?</span>
                                  ${hintHTML}
                              </div>
                              <div class="suggestion-scroll-wrapper"><div class="suggestion-flex"></div></div>`;

        const flexContainer = popup.querySelector('.suggestion-flex');
        currentMatches = matches;
        if (matches.length === 0) { hidePopup(); return; }

        matches.forEach((tag, index) => {
            const item = document.createElement('div');
            item.className = 'suggestion-item';
            item.dataset.category = tag.category || '0';

            const enText = tag.en.replace(/_/g, ' ');
            const zhText = translations.get(tag.en);
            const displayText = zhText ? `${enText} (${zhText})` : enText;

            let countHTML = '';
            if (tag.count !== undefined && tag.count !== null) {
                const countText = tag.count > 1000 ? `${(tag.count / 1000).toFixed(1)}k` : tag.count;
                countHTML = `<span class="suggestion-count">${countText}</span>`;
            }
            item.innerHTML = `<span class="suggestion-text">${displayText}</span>${countHTML}`;

            item.addEventListener('mouseover', () => {
                isKeyboardNavigation = false;
                if (selectedIndex !== index) {
                    selectedIndex = index;
                    updateSelectionUI();
                }
            });

            item.addEventListener('mousedown', (e) => {
                e.preventDefault();
                selectedIndex = index;
                updateSelectionUI();
                if (e.ctrlKey) {
                    openWikiPage(tag.en);
                } else {
                    const currentInput = getActiveInputElement();
                    if (currentInput) {
                        currentInput.focus();
                        setTimeout(() => insertTag(currentInput, tag.en, lastKnownRange), 0);
                    }
                }
            });
            flexContainer.appendChild(item);
        });

        positionPopup(input);
        popup.style.display = 'flex';
        isPopupActive = true;
        if (matches.length > 0) {
            selectedIndex = 0;
            updateSelectionUI();
        }
    }

    function insertTag(input, tag, rangeToRestore) {
        tag = tag.replace(/_/g, ' ');
        const selection = window.getSelection();
        if (rangeToRestore) { selection.removeAllRanges(); selection.addRange(rangeToRestore); }
        if (!selection.rangeCount) return;

        const range = selection.getRangeAt(0);
        const node = range.startContainer;
        if (node.nodeType !== Node.TEXT_NODE) return;

        const fullText = node.textContent;
        const breakChars = ',{}[]():.';
        let start = range.startOffset; while (start > 0 && !breakChars.includes(fullText[start - 1])) start--;
        let end = range.startOffset; while (end < fullText.length && !breakChars.includes(fullText[end])) end++;

        range.setStart(node, start);
        range.setEnd(node, end);
        range.deleteContents();

        let textToInsert = tag;
        const textBeforeInsertion = fullText.substring(0, start);
        const lastCharBefore = textBeforeInsertion.slice(-1);
        if (lastCharBefore && !/[\s({[]/.test(lastCharBefore)) {
            textToInsert = ' ' + textToInsert;
        }

        const textAfter = fullText.substring(end);
        textToInsert += !/^\s*[,)\]}]/.test(textAfter) ? ', ' : '';

        const textNode = document.createTextNode(textToInsert);
        range.insertNode(textNode);
        range.setStartAfter(textNode);
        range.collapse(true);
        selection.removeAllRanges();
        selection.addRange(range);
        hidePopup();
    }

    // --- 通用辅助函数 ---
    function createPopup() { if (!popup) { popup = document.createElement('div'); popup.className = 'autocomplete-container'; document.body.appendChild(popup); } }
    function hidePopup() { if (popup) popup.style.display = 'none'; if(apiAbortController) apiAbortController.abort(); selectedIndex = -1; isPopupActive = false; currentMatches = []; isKeyboardNavigation = false; }
    const debounce = (func, delay) => { let timeout; return (...args) => { clearTimeout(timeout); timeout = setTimeout(() => func(...args), delay); }; };
    function getActiveInputElement() { const selection = window.getSelection(); if (!selection.rangeCount) return null; const node = selection.focusNode; const pElement = node.nodeType === 3 ? node.parentElement.closest('p') : node.closest('p'); if (pElement && (pElement.closest('[class*="prompt-input"]') || pElement.closest('[class*="character-prompt-input"]'))) { return pElement; } return null; }
    function positionPopup(input) { let rect; const selection = window.getSelection(); if (selection.rangeCount > 0) { const range = selection.getRangeAt(0).cloneRange(); if (range.collapsed) { const tempSpan = document.createElement('span'); tempSpan.appendChild(document.createTextNode('\u200b')); range.insertNode(tempSpan); rect = tempSpan.getBoundingClientRect(); tempSpan.remove(); } else { rect = range.getBoundingClientRect(); } } if (!rect || (rect.width === 0 && rect.height === 0)) { rect = input.getBoundingClientRect(); } popup.style.top = `${rect.bottom + window.scrollY + 5}px`; popup.style.left = `${rect.left + window.scrollX}px`; }
    function updateSelectionUI() { const items = popup.querySelectorAll('.suggestion-item'); items.forEach((item, index) => item.classList.toggle('selected', index === selectedIndex)); const selectedEl = popup.querySelector('.selected'); if (selectedEl) selectedEl.scrollIntoView({ block: 'nearest' }); }
    function saveSelection() { const selection = window.getSelection(); const activeInput = getActiveInputElement(); if (activeInput && selection.rangeCount > 0) { const range = selection.getRangeAt(0); if (activeInput.contains(range.commonAncestorContainer)) { lastKnownRange = range; } } }

    const handleInput = debounce((e) => {
        const input = getActiveInputElement(); if (!input) { hidePopup(); return; }
        const textBeforeCursor = (() => { const sel = window.getSelection(); if (!sel.rangeCount) return ''; const range = sel.getRangeAt(0).cloneRange(); const parent = sel.focusNode.parentElement; if (!parent) return ''; range.selectNodeContents(parent); range.setEnd(sel.focusNode, sel.focusOffset); return range.toString(); })();
        if (textBeforeCursor.endsWith(',')) { hidePopup(); return; }

        const lastDelimiterIndex = Math.max(textBeforeCursor.lastIndexOf(','), textBeforeCursor.lastIndexOf(':'), textBeforeCursor.lastIndexOf('['), textBeforeCursor.lastIndexOf('('), textBeforeCursor.lastIndexOf('{'), textBeforeCursor.lastIndexOf('.'));
        const currentQuery = textBeforeCursor.substring(lastDelimiterIndex + 1).trim();

        if (currentQuery.length < 1) { hidePopup(); return; }

        // --- 最终修复逻辑 ---
        // 如果当前输入的词只包含数字和点,就判断为权重输入,不显示联想。
        if (/^[\d.]+$/.test(currentQuery)) {
            hidePopup();
            return;
        }
        // --- 修复结束 ---

        if (/[\u4e00-\u9fa5]/.test(currentQuery)) {
            searchLocalSuggestions(currentQuery, input);
        } else {
            fetchSuggestions(currentQuery, input);
        }
    }, DEBOUNCE_DELAY);

    function handleKeydown(e) {
        if (!isPopupActive) return;
        const keyMap = { 'ArrowDown': 1, 'ArrowRight': 1, 'ArrowUp': -1, 'ArrowLeft': -1 };
        if (keyMap[e.key] !== undefined) {
            e.preventDefault();
            isKeyboardNavigation = true;
            selectedIndex = (selectedIndex + keyMap[e.key] + currentMatches.length) % currentMatches.length;
            updateSelectionUI();
        } else if (e.key === 'Enter' || e.key === 'Tab') {
            e.preventDefault(); e.stopPropagation();
            if (selectedIndex >= 0 && currentMatches[selectedIndex]) {
                const input = getActiveInputElement();
                if(input) insertTag(input, currentMatches[selectedIndex].en, window.getSelection().getRangeAt(0));
            } else { hidePopup(); }
        } else if (e.key === 'Escape') {
            e.preventDefault();
            hidePopup();
        } else { isKeyboardNavigation = false; }
    }

    function handleClickOutside(e) { const input = getActiveInputElement(); if (isPopupActive && popup && !popup.contains(e.target) && !input?.contains(e.target)) { hidePopup(); } }

    function init() {
        console.log('NovelAI Tag联想 (Danbooru-API版) 加载成功~');
        loadTranslations();
        document.addEventListener('input', handleInput);
        document.addEventListener('keydown', handleKeydown, true);
        document.addEventListener('mousedown', handleClickOutside);
        document.addEventListener('keyup', saveSelection);
        document.addEventListener('mouseup', saveSelection);
    }

    if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); }
})();