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 });
    }

})();