Jisho2Anki

Capture Jisho data and send to Anki via AnkiConnect

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Jisho2Anki
// @namespace    https://github.com/tontyoutoure/jisho2anki
// @version      1.0.1
// @description  Capture Jisho data and send to Anki via AnkiConnect
// @author       https://www.github.com/tontyoutoure with help from Gemini
// @match        https://jisho.org/search/*
// @match        https://jisho.org/word/*
// @grant        GM_xmlhttpRequest
// @connect      127.0.0.1
// @connect      localhost
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    // --- Configuration Constants ---
    const STORAGE_KEY = 'jisho2anki_config';
    const DEFAULT_CONFIG = {
        ankiUrl: 'http://127.0.0.1:8765',
        deckName: '',
        modelName: '',
        tags: 'jisho.org',
        fieldMapping: {}
    };

    // --- State Management ---
    let currentConfig = loadConfig();
    let availableDecks = [];
    let availableModels = [];
    let currentModelFields = [];

    // --- UI Constants ---
    const BUTTON_SIZE = '20px';
    const ICON_COLOR = '#999';
    const ICON_HOVER_COLOR = '#555';

    // Icons
    const UPLOAD_ICON = `<svg viewBox="0 0 24 24" width="${BUTTON_SIZE}" height="${BUTTON_SIZE}" stroke="${ICON_COLOR}" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"></line><polyline points="5 12 12 5 19 12"></polyline></svg>`;
    const CONFIG_ICON = `<svg viewBox="0 0 24 24" width="${BUTTON_SIZE}" height="${BUTTON_SIZE}" stroke="${ICON_COLOR}" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>`;
    const SUCCESS_ICON = `<svg viewBox="0 0 24 24" width="${BUTTON_SIZE}" height="${BUTTON_SIZE}" stroke="#47DB27" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>`;
    const ERROR_ICON = `<svg viewBox="0 0 24 24" width="${BUTTON_SIZE}" height="${BUTTON_SIZE}" stroke="#FF0000" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>`;
    const LOADING_ICON = `<svg viewBox="0 0 24 24" width="${BUTTON_SIZE}" height="${BUTTON_SIZE}" stroke="#FFA500" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="2" x2="12" y2="6"></line><line x1="12" y1="18" x2="12" y2="22"></line><line x1="4.93" y1="4.93" x2="7.76" y2="7.76"></line><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"></line><line x1="2" y1="12" x2="6" y2="12"></line><line x1="18" y1="12" x2="22" y2="12"></line><line x1="4.93" y1="19.07" x2="7.76" y2="16.24"></line><line x1="16.24" y1="7.76" x2="19.07" y2="4.93"></line></svg>`;

    // --- AnkiConnect Helper Functions ---

    function ankiInvoke(action, params = {}) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "POST",
                url: currentConfig.ankiUrl,
                data: JSON.stringify({ action, version: 6, params }),
                headers: { "Content-Type": "application/json" },
                onload: (response) => {
                    try {
                        const res = JSON.parse(response.responseText);
                        if (res.error) {
                            reject(res.error);
                        } else {
                            resolve(res.result);
                        }
                    } catch (e) {
                        reject("Failed to parse response: " + response.responseText);
                    }
                },
                onerror: (err) => {
                    reject("Network Error: Ensure Anki is running and AnkiConnect is installed.");
                }
            });
        });
    }

    async function checkConnection() {
        try {
            await ankiInvoke('version');
            return true;
        } catch (e) {
            return false;
        }
    }

    async function fetchAnkiData() {
        try {
            const [decks, models] = await Promise.all([
                ankiInvoke('deckNames'),
                ankiInvoke('modelNames')
            ]);
            availableDecks = decks || [];
            availableModels = models || [];
            return true;
        } catch (e) {
            console.error("Failed to fetch Anki data:", e);
            return false;
        }
    }

    async function fetchModelFields(modelName) {
        try {
            const fields = await ankiInvoke('modelFieldNames', { modelName });
            currentModelFields = fields || [];
            return currentModelFields;
        } catch (e) {
            console.error(`Failed to fetch fields for model ${modelName}:`, e);
            return [];
        }
    }

    // --- Local Storage Helpers ---
    function loadConfig() {
        const stored = localStorage.getItem(STORAGE_KEY);
        return stored ? { ...DEFAULT_CONFIG, ...JSON.parse(stored) } : DEFAULT_CONFIG;
    }

    function saveConfig() {
        localStorage.setItem(STORAGE_KEY, JSON.stringify(currentConfig));
    }

    // --- Data Extraction Logic (Fixed Reading Logic) ---
    function extractJishoData(context) {
        if (!context) return null;

        const textElement = context.querySelector('.concept_light-representation .text');
        const expression = textElement ? textElement.textContent.trim() : "";

        // --- Reading Extraction (Robust Fix) ---
        let reading = "";
        const furiganaElement = context.querySelector('.concept_light-representation .furigana');

        if (furiganaElement && textElement) {
            const fChildren = Array.from(furiganaElement.children);
            // 移除空白字符以确保字符索引对齐
            const cleanExpression = expression.replace(/\s+/g, '');

            // 策略 A: 字符数与假名块数完全一致 (适用于 "大切", "思い", "海沿い")
            if (fChildren.length === cleanExpression.length) {
                for (let i = 0; i < fChildren.length; i++) {
                    const fText = fChildren[i].textContent.trim();
                    if (fText) {
                        // 有假名就用假名 (例如 "たい", "おも")
                        reading += fText;
                    } else {
                        // 假名为空,说明是送假名,回退到使用原文对应的字符 (例如 "い")
                        reading += cleanExpression[i];
                    }
                }
            }
            // 策略 B: 数量不一致 (例如熟字训 "大人" -> "おとな": 1个假名块 vs 2个字符)
            // 此时回退到基于 DOM 文本节点的旧逻辑,或者简单拼接所有假名
            else {
                const tChildren = Array.from(textElement.childNodes).filter(node => {
                    return node.textContent.trim().length > 0;
                });

                for (let i = 0; i < fChildren.length; i++) {
                    const fText = fChildren[i].textContent.trim();
                    if (fText) {
                        reading += fText;
                    } else {
                        // 尝试匹配 DOM 节点
                        if (i < tChildren.length) {
                            reading += tChildren[i].textContent.trim();
                        }
                    }
                }
            }
        } else {
            reading = expression;
        }

        let formattedMeanings = [];
        let otherFormsRaw = [];
        let notesRaw = [];

        const meaningsWrapper = context.querySelector('.meanings-wrapper');
        if (meaningsWrapper) {
            let currentPOS = "";

            for (const child of meaningsWrapper.children) {
                if (child.classList.contains('meaning-tags')) {
                    const tagText = child.textContent.trim();
                    if (tagText === 'Notes') currentPOS = 'Notes';
                    else if (tagText.includes('Other forms')) currentPOS = 'Other forms';
                    else currentPOS = tagText;
                }
                else if (child.classList.contains('meaning-wrapper')) {
                    if (currentPOS === 'Other forms') {
                        const formText = child.querySelector('.meaning-meaning');
                        if (formText) otherFormsRaw.push(formText.textContent.trim());
                    }
                    else if (currentPOS === 'Notes') {
                        const defSection = child.querySelector('.meaning-definition');
                        if (defSection) {
                            const clone = defSection.cloneNode(true);
                            const readMore = clone.querySelector('a');
                            if (readMore && readMore.textContent.includes('Read more')) readMore.remove();
                            notesRaw.push(clone.textContent.trim());
                        }
                    }
                    else {
                        // --- Building Meaning Entry (HTML) ---
                        let entryHtml = `<div style="text-align: left; margin-bottom: 12px;">`;

                        if (currentPOS) {
                            entryHtml += `<div style="font-size: 0.85em; color: #666; margin-bottom: 2px;">[${currentPOS}]</div>`;
                        }

                        const defSection = child.querySelector('.meaning-definition');
                        if (defSection) {
                            const readMoreLink = defSection.querySelector('a');
                            if (readMoreLink && readMoreLink.textContent.includes('Read more')) readMoreLink.remove();

                            const meaningTextElem = defSection.querySelector('.meaning-meaning');
                            let defText = meaningTextElem ? meaningTextElem.textContent.trim() : defSection.textContent.trim();
                            const defNumberElement = defSection.querySelector('.meaning-definition-section_divider');
                            const defNumber = defNumberElement ? defNumberElement.textContent.trim() + " " : "";

                            entryHtml += `<div>${defNumber}${defText}</div>`;
                        }

                        const sentenceDiv = child.querySelector('.sentence');
                        if (sentenceDiv) {
                            const japSentence = sentenceDiv.querySelector('.japanese');
                            const engSentence = sentenceDiv.querySelector('.english');
                            if (japSentence && engSentence) {
                                const jClone = japSentence.cloneNode(true);
                                jClone.querySelectorAll('.furigana').forEach(f => f.remove());

                                const jpText = jClone.textContent.replace(/\s+/g, '').trim();

                                entryHtml += `<div style="margin-top: 5px; padding-left: 10px; border-left: 2px solid #ddd; font-size: 0.9em;">
                                                <div style="margin-bottom: 2px;">${jpText}</div>
                                                <div style="color: #555; font-style: italic;">${engSentence.textContent.trim()}</div>
                                              </div>`;
                            }
                        }

                        entryHtml += `</div>`;
                        formattedMeanings.push(entryHtml);
                    }
                }
            }
        }

        let otherFormsFormatted = "";
        if (otherFormsRaw.length > 0) {
            otherFormsFormatted = `<div style="text-align: left; margin-bottom: 5px;">
                                     <strong>Other forms:</strong> ${otherFormsRaw.join('; ')}
                                   </div>`;
        }

        let notesFormatted = "";
        if (notesRaw.length > 0) {
            notesFormatted = `<div style="text-align: left; margin-bottom: 5px;">
                                <strong>Notes:</strong><br>${notesRaw.join('<br>')}
                              </div>`;
        }

        return {
            expression,
            reading,
            meanings: formattedMeanings.join(''),
            otherForms: otherFormsFormatted,
            notes: notesFormatted
        };
    }

    // --- Step 4: Upload Logic ---

    async function uploadToAnki(entry, btnElement) {
        if (!currentConfig.deckName || !currentConfig.modelName) {
            alert("❌ Please configure Deck and Model first by clicking the gear icon.");
            createModal();
            return;
        }

        const jishoData = extractJishoData(entry);
        if (!jishoData) {
            alert("❌ Failed to extract data from this entry.");
            return;
        }

        const originalIcon = btnElement.innerHTML;
        btnElement.innerHTML = LOADING_ICON;

        try {
            const modelMapping = currentConfig.fieldMapping[currentConfig.modelName];
            const ankiFields = {};

            if (!modelMapping) {
                throw new Error(`No field mapping found for model: ${currentConfig.modelName}. Please configure mapping.`);
            }

            for (const [ankiField, jishoKeys] of Object.entries(modelMapping)) {
                if (Array.isArray(jishoKeys) && jishoKeys.length > 0) {
                    const values = jishoKeys
                        .map(key => jishoData[key])
                        .filter(val => val && val.trim() !== "");

                    if (values.length > 0) {
                        ankiFields[ankiField] = values.join('<br>');
                    }
                }
            }

            const tags = currentConfig.tags.split(' ').map(t => t.trim()).filter(t => t !== "");
            tags.push('jisho2anki');

            const note = {
                deckName: currentConfig.deckName,
                modelName: currentConfig.modelName,
                fields: ankiFields,
                options: {
                    allowDuplicate: false,
                    duplicateScope: "deck",
                    duplicateScopeOptions: {
                        deckName: currentConfig.deckName,
                        checkChildren: false,
                        checkAllModels: false
                    }
                },
                tags: tags
            };

            console.log("[Jisho2Anki] Sending Note:", note);

            const noteId = await ankiInvoke('addNote', { note });

            if (noteId === null) {
                throw new Error("Duplicate note likely exists.");
            }

            console.log(`[Jisho2Anki] Success! Note ID: ${noteId}`);
            btnElement.innerHTML = SUCCESS_ICON;

        } catch (e) {
            console.error("[Jisho2Anki] Upload Failed:", e);
            btnElement.innerHTML = ERROR_ICON;
            alert(`❌ Upload Failed:\n${e}`);
            setTimeout(() => { btnElement.innerHTML = originalIcon; }, 3000);
        }
    }

    // --- UI Helpers for Dynamic Mapping ---

    const JISHO_KEYS = ['expression', 'reading', 'meanings', 'otherForms', 'notes'];

    function createMappingSelect(selectedValue = "") {
        const sel = document.createElement('select');
        // Core modification: Add boxSizing and margin: 0 to eliminate alignment errors
        Object.assign(sel.style, {
            width: '100%',             // Fill the container
            height: '30px',            // Fixed height
            boxSizing: 'border-box',   // Include padding and border in width
            margin: '0',               // Remove default browser margin
            padding: '0 5px',
            border: '1px solid #ccc',
            borderRadius: '4px',
            backgroundColor: '#fff',
            fontSize: '13px',
            verticalAlign: 'middle'
        });

        const emptyOpt = document.createElement('option');
        emptyOpt.text = '-- Ignore --';
        emptyOpt.value = '';
        sel.appendChild(emptyOpt);

        JISHO_KEYS.forEach(key => {
            const opt = document.createElement('option');
            opt.value = key;
            opt.text = key;
            if (selectedValue === key) opt.selected = true;
            sel.appendChild(opt);
        });
        return sel;
    }

    function createMappingRow(ankiField, initialValues = []) {
        const container = document.createElement('div');
        Object.assign(container.style, {
            marginBottom: '10px',
            borderBottom: '1px dashed #eee',
            paddingBottom: '8px'
        });
        container.dataset.ankiField = ankiField;

        // Label
        const label = document.createElement('div');
        label.textContent = ankiField;
        Object.assign(label.style, {
            fontWeight: 'bold',
            fontSize: '0.9em',
            marginBottom: '4px',
            color: '#333'
        });
        container.appendChild(label);

        // Input control container
        const inputsDiv = document.createElement('div');
        container.appendChild(inputsDiv);

        // Style constants
        const ROW_STYLE = {
            display: 'flex',
            alignItems: 'center',
            gap: '5px',
            width: '100%',
            marginBottom: '5px'
        };

        const BUTTON_STYLE = {
            flex: '0 0 30px',          // Fixed width 30px
            width: '30px',
            height: '30px',
            boxSizing: 'border-box',
            margin: '0',
            padding: '0',
            cursor: 'pointer',
            backgroundColor: '#f8f8f8',
            border: '1px solid #ccc',
            borderRadius: '4px',
            display: 'flex',
            justifyContent: 'center',
            alignItems: 'center',
            fontSize: '16px',
            lineHeight: '1',
            color: '#555'
        };

        // Helper function: Create a single row of input (with optional right element)
        const createRowElement = (selectedValue, rightElement) => {
            const row = document.createElement('div');
            Object.assign(row.style, ROW_STYLE);

            const select = createMappingSelect(selectedValue);
            select.style.flex = '1'; // Automatically fill remaining space

            row.appendChild(select);
            if (rightElement) {
                row.appendChild(rightElement);
            }
            return row;
        };

        // Helper function: Create spacer (for aligning rows without buttons)
        const createSpacer = () => {
            const spacer = document.createElement('div');
            // Copy button layout attributes but make it invisible
            Object.assign(spacer.style, BUTTON_STYLE, {
                backgroundColor: 'transparent',
                border: 'none',
                cursor: 'default',
                visibility: 'hidden' // Key: Invisible but occupies space
            });
            return spacer;
        };

        // --- First row: Dropdown + Real "+" button ---
        const values = (initialValues && initialValues.length > 0) ? initialValues : [''];

        const addBtn = document.createElement('button');
        addBtn.textContent = '+';
        addBtn.title = "Add another source field";
        Object.assign(addBtn.style, BUTTON_STYLE);

        // Button interaction
        addBtn.onmouseover = () => {
            addBtn.style.backgroundColor = '#e8e8e8';
            addBtn.style.borderColor = '#bbb';
        };
        addBtn.onmouseout = () => {
            addBtn.style.backgroundColor = '#f8f8f8';
            addBtn.style.borderColor = '#ccc';
        };

        // Add first row
        inputsDiv.appendChild(createRowElement(values[0], addBtn));

        // --- Subsequent rows: Dropdown + Spacer ---
        for (let i = 1; i < values.length; i++) {
            inputsDiv.appendChild(createRowElement(values[i], createSpacer()));
        }

        // --- Button Click Event: Add new row (with spacer) ---
        addBtn.onclick = () => {
            // New rows must also have a spacer to maintain alignment
            inputsDiv.appendChild(createRowElement('', createSpacer()));
        };

        return container;
    }

    // --- Modal UI Construction ---

    function createModal() {
        if (document.getElementById('jisho2anki-modal')) return;

        const modalOverlay = document.createElement('div');
        modalOverlay.id = 'jisho2anki-modal';
        Object.assign(modalOverlay.style, {
            position: 'fixed', top: '0', left: '0', width: '100%', height: '100%',
            backgroundColor: 'rgba(0,0,0,0.5)', zIndex: '10000', display: 'flex',
            justifyContent: 'center', alignItems: 'center'
        });

        const modalContent = document.createElement('div');
        Object.assign(modalContent.style, {
            backgroundColor: 'white', padding: '20px', borderRadius: '8px',
            width: '500px', maxWidth: '90%', maxHeight: '90vh',
            boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
            fontFamily: 'sans-serif', fontSize: '14px',
            display: 'flex', flexDirection: 'column'
        });

        const header = document.createElement('h2');
        header.textContent = 'Jisho2Anki Configuration';
        header.style.marginTop = '0';
        header.style.borderBottom = '1px solid #eee';
        header.style.paddingBottom = '10px';
        header.style.flexShrink = '0';

        const scrollableContent = document.createElement('div');
        Object.assign(scrollableContent.style, {
            overflowY: 'auto', paddingRight: '5px', flexGrow: '1'
        });

        const urlGroup = createInputGroup('AnkiConnect URL:', 'text', currentConfig.ankiUrl);
        const urlInput = urlGroup.querySelector('input');
        const statusSpan = document.createElement('span');
        statusSpan.style.marginLeft = '10px';
        statusSpan.style.fontWeight = 'bold';
        urlGroup.appendChild(statusSpan);

        const updateStatus = (isConnected, msg) => {
            statusSpan.textContent = msg;
            statusSpan.style.color = isConnected ? 'green' : 'red';
            deckSelect.disabled = !isConnected;
            modelSelect.disabled = !isConnected;
            if (isConnected) refreshDropdowns();
        };

        const tryConnect = async () => {
            statusSpan.textContent = 'Checking...';
            statusSpan.style.color = 'orange';
            currentConfig.ankiUrl = urlInput.value;
            const connected = await checkConnection();
            if (connected) {
                const dataFetched = await fetchAnkiData();
                if (dataFetched) {
                    updateStatus(true, 'Connected!');
                } else {
                    updateStatus(false, 'Connected, but failed to fetch data.');
                }
            } else {
                updateStatus(false, 'Connection Failed');
            }
        };

        urlInput.addEventListener('blur', tryConnect);

        const deckGroup = createSelectGroup('Target Deck:', 'Loading...');
        const deckSelect = deckGroup.querySelector('select');
        deckSelect.disabled = true;

        const modelGroup = createSelectGroup('Note Type (Model):', 'Loading...');
        const modelSelect = modelGroup.querySelector('select');
        modelSelect.disabled = true;

        const tagGroup = createInputGroup('Tags (space separated):', 'text', currentConfig.tags);
        const tagInput = tagGroup.querySelector('input');

        const mappingContainer = document.createElement('div');
        Object.assign(mappingContainer.style, {
            marginTop: '15px', padding: '10px', backgroundColor: '#f9f9f9',
            border: '1px solid #eee',
            maxHeight: '300px', overflowY: 'auto'
        });
        mappingContainer.innerHTML = '<strong>Field Mapping</strong><br><small style="color:#666">Select a Model first to map fields.</small>';

        const refreshDropdowns = () => {
            deckSelect.innerHTML = '';
            availableDecks.forEach(d => {
                const opt = document.createElement('option');
                opt.value = d;
                opt.textContent = d;
                if (d === currentConfig.deckName) opt.selected = true;
                deckSelect.appendChild(opt);
            });

            modelSelect.innerHTML = '';
            availableModels.forEach(m => {
                const opt = document.createElement('option');
                opt.value = m;
                opt.textContent = m;
                if (m === currentConfig.modelName) opt.selected = true;
                modelSelect.appendChild(opt);
            });

            if (modelSelect.value) generateMappingInputs(modelSelect.value);
        };

        const generateMappingInputs = async (modelName) => {
            mappingContainer.innerHTML = 'Loading fields...';
            const fields = await fetchModelFields(modelName);
            mappingContainer.innerHTML = `<strong>Mapping for: ${modelName}</strong><br><small>Click [+] to map multiple sources to one field (joined by newlines).</small><hr style="margin:5px 0 10px 0; border:0; border-top:1px solid #ddd;">`;

            if (fields.length === 0) {
                mappingContainer.innerHTML += '<span style="color:red">No fields found.</span>';
                return;
            }

            const savedMapping = currentConfig.fieldMapping[modelName] || {};

            fields.forEach(field => {
                const row = createMappingRow(field, savedMapping[field]);
                mappingContainer.appendChild(row);
            });
        };

        modelSelect.addEventListener('change', (e) => {
            generateMappingInputs(e.target.value);
        });

        const footer = document.createElement('div');
        Object.assign(footer.style, { marginTop: '20px', textAlign: 'right', flexShrink: '0', paddingTop: '10px', borderTop: '1px solid #eee' });

        const saveBtn = document.createElement('button');
        saveBtn.textContent = 'Save & Close';
        Object.assign(saveBtn.style, {
            padding: '8px 16px', backgroundColor: '#47DB27', color: 'white',
            border: 'none', borderRadius: '4px', cursor: 'pointer', fontWeight: 'bold'
        });

        const cancelBtn = document.createElement('button');
        cancelBtn.textContent = 'Cancel';
        Object.assign(cancelBtn.style, {
            padding: '8px 16px', backgroundColor: '#ccc', color: 'white',
            border: 'none', borderRadius: '4px', cursor: 'pointer', marginRight: '10px'
        });

        saveBtn.onclick = () => {
            currentConfig.ankiUrl = urlInput.value;
            currentConfig.deckName = deckSelect.value;
            currentConfig.modelName = modelSelect.value;
            currentConfig.tags = tagInput.value;

            if (modelSelect.value) {
                if (!currentConfig.fieldMapping) currentConfig.fieldMapping = {};
                const newModelMapping = {};
                const rows = mappingContainer.querySelectorAll('div[data-anki-field]');
                rows.forEach(row => {
                    const ankiField = row.dataset.ankiField;
                    const selects = row.querySelectorAll('select');
                    const values = [];
                    selects.forEach(s => {
                        if (s.value) values.push(s.value);
                    });
                    if (values.length > 0) newModelMapping[ankiField] = values;
                });
                currentConfig.fieldMapping[modelSelect.value] = newModelMapping;
            }

            saveConfig();
            document.body.removeChild(modalOverlay);
        };

        cancelBtn.onclick = () => {
            document.body.removeChild(modalOverlay);
        };

        footer.appendChild(cancelBtn);
        footer.appendChild(saveBtn);

        scrollableContent.appendChild(urlGroup);
        scrollableContent.appendChild(deckGroup);
        scrollableContent.appendChild(modelGroup);
        scrollableContent.appendChild(tagGroup);
        scrollableContent.appendChild(mappingContainer);

        modalContent.appendChild(header);
        modalContent.appendChild(scrollableContent);
        modalContent.appendChild(footer);

        modalOverlay.appendChild(modalContent);
        document.body.appendChild(modalOverlay);

        tryConnect();
    }

    function createInputGroup(labelText, type, value) {
        const div = document.createElement('div');
        div.style.marginBottom = '10px';
        const label = document.createElement('label');
        label.textContent = labelText;
        label.style.display = 'block';
        label.style.marginBottom = '5px';
        label.style.fontWeight = 'bold';

        const input = document.createElement('input');
        input.type = type;
        input.value = value || '';
        input.style.width = '100%';
        input.style.padding = '5px';
        input.style.boxSizing = 'border-box';

        div.appendChild(label);
        div.appendChild(input);
        return div;
    }

    function createSelectGroup(labelText, placeholder) {
        const div = document.createElement('div');
        div.style.marginBottom = '10px';
        const label = document.createElement('label');
        label.textContent = labelText;
        label.style.display = 'block';
        label.style.marginBottom = '5px';
        label.style.fontWeight = 'bold';

        const select = document.createElement('select');
        select.style.width = '100%';
        select.style.padding = '5px';

        const opt = document.createElement('option');
        opt.textContent = placeholder;
        select.appendChild(opt);

        div.appendChild(label);
        div.appendChild(select);
        return div;
    }

    function createButton(htmlIcon, title, onClickHandler) {
        const btn = document.createElement('button');
        btn.innerHTML = htmlIcon;
        btn.title = title;
        btn.className = 'jisho2anki-btn';
        Object.assign(btn.style, {
            background: 'none', border: 'none', cursor: 'pointer',
            padding: '2px 5px', transition: 'all 0.2s ease', verticalAlign: 'middle'
        });

        btn.onmouseover = () => {
            const svg = btn.querySelector('svg');
            if (svg && svg.style.stroke === 'rgb(153, 153, 153)')
                svg.style.stroke = ICON_HOVER_COLOR;
        };
        btn.onmouseout = () => {
            const svg = btn.querySelector('svg');
            if (svg && svg.style.stroke === 'rgb(85, 85, 85)')
                svg.style.stroke = ICON_COLOR;
        };

        btn.addEventListener('click', (e) => {
            e.preventDefault();
            e.stopPropagation();
            onClickHandler(e, btn);
        });

        return btn;
    }

    function injectControls() {
        const wordEntries = document.querySelectorAll('.concept_light');

        wordEntries.forEach(entry => {
            if (entry.querySelector('.jisho2anki-controls')) return;

            const container = document.createElement('div');
            container.className = 'jisho2anki-controls';
            Object.assign(container.style, {
                display: 'inline-block', marginLeft: '10px'
            });

            const uploadBtn = createButton(UPLOAD_ICON, "Upload this word to Anki", (e, btn) => {
                uploadToAnki(entry, btn);
            });

            const configBtn = createButton(CONFIG_ICON, "Configure AnkiConnect", () => {
                createModal();
            });

            container.appendChild(uploadBtn);
            container.appendChild(configBtn);

            const statusDiv = entry.querySelector('.concept_light-status');
            if (statusDiv) {
                statusDiv.insertBefore(container, statusDiv.firstChild);
            } else {
                const wrapper = entry.querySelector('.concept_light-wrapper');
                if (wrapper) wrapper.appendChild(container);
            }
        });
    }

    const observer = new MutationObserver((mutations) => {
        injectControls();
    });

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', () => {
            injectControls();
            observer.observe(document.body, { childList: true, subtree: true });
        });
    } else {
        injectControls();
        observer.observe(document.body, { childList: true, subtree: true });
    }

})();