BareGit
import { h, render } from 'https://esm.sh/preact';
import { useState, useEffect } from 'https://esm.sh/preact/hooks';
import { signal, computed } from 'https://esm.sh/@preact/signals';
import htm from 'https://esm.sh/htm';

const html = htm.bind(h);

// --- State and Classes ---

class TranslationNode
{
    constructor(original_value, is_ignored = false)
    {
        this.original_value = original_value;
        this.translated_value = signal(null);
        this.status = signal(is_ignored ? 'ignored' : 'pending');
        this.error = signal(null);
    }
}

class TaskQueue
{
    constructor(concurrency = 3)
    {
        this.concurrency = concurrency;
        this.queue = [];
        this.active_count = 0;
    }

    async add(task)
    {
        return new Promise((resolve, reject) =>
        {
            this.queue.push({ task, resolve, reject });
            this.process();
        });
    }

    async process()
    {
        if(this.active_count >= this.concurrency || this.queue.length === 0)
        {
            return;
        }

        const { task, resolve, reject } = this.queue.shift();
        this.active_count++;

        try
        {
            const result = await task();
            resolve(result);
        }
        catch(err)
        {
            reject(err);
        }
        finally
        {
            this.active_count--;
            this.process();
        }
    }
}

// --- Global Signals ---

const STORAGE_PREFIX = 'tavern-translator-';
const getStorage = (key) => localStorage.getItem(STORAGE_PREFIX + key);
const setStorage = (key, val) => localStorage.setItem(STORAGE_PREFIX + key, val);

const config = signal({
    api_url: getStorage('api_url') || 'https://api.openai.com/v1/chat/completions',
    api_key: getStorage('api_key') || '',
    system_prompt: getStorage('system_prompt') || '作为一名资深翻译家,把用户提供的文本从英文翻译成中文,保持原文的格式和语气。注意 `{{user}}` 和 `{{char}}` 为特殊标记,不要翻译。',
    ignored_keys: getStorage('ignored_keys') || 'spec,spec_version,avatar,url,id,keys,secondary_keys,creator,version,chub,fav,talkativeness',
    concurrency: parseInt(getStorage('concurrency') || '3', 10)
});

const task_queue = new TaskQueue(config.value.concurrency);

// --- Helper Functions ---

function createProcessTree(input_node, ignored_patterns, is_parent_ignored = false)
{
    if(Array.isArray(input_node))
    {
        return input_node.map(item => createProcessTree(item, ignored_patterns, is_parent_ignored));
    }
    else if(input_node !== null && typeof input_node === 'object')
    {
        const new_obj = {};
        for(const [key, value] of Object.entries(input_node))
        {
            const is_ignored = is_parent_ignored || ignored_patterns.some(pattern =>
            {
                try
                {
                    return new RegExp(pattern.trim()).test(key);
                }
                catch(e)
                {
                    return false;
                }
            });
            new_obj[key] = createProcessTree(value, ignored_patterns, is_ignored);
        }
        return new_obj;
    }
    else if(typeof input_node === 'string')
    {
        const node = new TranslationNode(input_node, is_parent_ignored);
        if(!input_node.trim())
        {
            node.status.value = 'done';
        }
        return node;
    }
    return input_node;
}

function flattenTree(node)
{
    if(Array.isArray(node))
    {
        return node.map(flattenTree);
    }
    else if(node instanceof TranslationNode)
    {
        return node.status.value === 'done' ? node.translated_value.value : node.original_value;
    }
    else if(node !== null && typeof node === 'object')
    {
        const new_obj = {};
        for(const [key, value] of Object.entries(node))
        {
            new_obj[key] = flattenTree(value);
        }
        return new_obj;
    }
    return node;
}

async function translateNode(node, force = false)
{
    if(node.status.value === 'translating') return;
    if(!force && node.status.value === 'done') return;

    node.status.value = 'translating';
    node.error.value = null;

    try
    {
        const result = await task_queue.add(async () =>
        {
            const response = await fetch(config.value.api_url, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    'Authorization': `Bearer ${config.value.api_key}`
                },
                body: JSON.stringify({
                    model: 'gpt-3.5-turbo',
                    messages: [
                        { role: 'system', content: config.value.system_prompt },
                        { role: 'user', content: node.original_value }
                    ],
                    temperature: 0.3
                })
            });

            if(!response.ok)
            {
                const error_data = await response.json();
                throw new Error(error_data.error?.message || 'API Error');
            }

            const data = await response.json();
            return data.choices[0].message.content;
        });

        node.translated_value.value = result;
        node.status.value = 'done';
    }
    catch(err)
    {
        node.status.value = 'error';
        node.error.value = err.message;
    }
}

// --- Components ---

function TreeNode({ label, value, depth = 0 })
{
    const [is_open, set_is_open] = useState(depth < 2);

    if(value instanceof TranslationNode)
    {
        const can_revert = value.status.value === 'done' || value.status.value === 'error';
        const can_retry = value.status.value === 'done' || value.status.value === 'error';

        return html`
            <div class="tree-item">
                <span class="key-name">${label}: </span>
                <span class="status-badge status-${value.status.value}">${value.status.value}</span>
                <div class="translation-box">
                    <div class="original-text">${value.original_value}</div>
                    <div class="translated-text">${value.status.value === 'done' ? value.translated_value.value : '(Pending/Original)'}</div>
                    ${value.error.value && html`<div class="error-text" style="color: var(--error-color); font-size: 11px;">${value.error.value}</div>`}
                    <div class="node-actions">
                        <button onclick=${() => translateNode(value)} disabled=${value.status.value === 'done'}>Translate</button>
                        <button class="secondary-btn" onclick=${() => { value.translated_value.value = null; value.status.value = 'pending'; }} disabled=${!can_revert}>Revert</button>
                        <button class="secondary-btn" onclick=${() => translateNode(value, true)} disabled=${!can_retry}>Retry</button>
                    </div>
                </div>
            </div>
        `;
    }

    if(Array.isArray(value) || (value !== null && typeof value === 'object'))
    {
        return html`
            <div class="tree-node-container">
                <div class="tree-item" onclick=${() => set_is_open(!is_open)} style="cursor: pointer;">
                    <span class="key-name">${is_open ? '▼' : '▶'} ${label}</span>
                </div>
                ${is_open && html`
                    <div class="tree-node">
                        ${Object.entries(value).map(([k, v]) => html`<${TreeNode} label=${k} value=${v} depth=${depth + 1} />`)}
                    </div>
                `}
            </div>
        `;
    }

    return html`
        <div class="tree-item">
            <span class="key-name">${label}: </span>
            <span>${String(value)}</span>
        </div>
    `;
}

function OptionsDialog({ close })
{
    const [local_config, set_local_config] = useState({ ...config.value });

    const save = () =>
    {
        config.value = local_config;
        Object.entries(local_config).forEach(([k, v]) => setStorage(k, v));
        task_queue.concurrency = local_config.concurrency;
        close();
    };

    return html`
        <div class="modal-overlay">
            <div class="modal-content">
                <h3>Options</h3>
                <div class="form-group">
                    <label>API Endpoint URL</label>
                    <input type="text" value=${local_config.api_url} oninput=${(e) => set_local_config({ ...local_config, api_url: e.target.value })} />
                </div>
                <div class="form-group">
                    <label>API Key</label>
                    <input type="password" value=${local_config.api_key} oninput=${(e) => set_local_config({ ...local_config, api_key: e.target.value })} />
                </div>
                <div class="form-group">
                    <label>Concurrency (Max active requests)</label>
                    <input type="number" min="1" max="20" value=${local_config.concurrency} oninput=${(e) => set_local_config({ ...local_config, concurrency: parseInt(e.target.value, 10) })} />
                </div>
                <div class="form-group">
                    <label>System Prompt</label>
                    <textarea rows="4" value=${local_config.system_prompt} oninput=${(e) => set_local_config({ ...local_config, system_prompt: e.target.value })}></textarea>
                </div>
                <div class="form-group">
                    <label>Ignored Keys (Regex, one per line)</label>
                    <textarea rows="4" value=${local_config.ignored_keys.split(',').join('\n')} oninput=${(e) => set_local_config({ ...local_config, ignored_keys: e.target.value.split('\n').join(',') })}></textarea>
                </div>
                <div class="modal-actions">
                    <button class="secondary-btn" onclick=${close}>Cancel</button>
                    <button onclick=${save}>Save</button>
                </div>
            </div>
        </div>
    `;
}

function App()
{
    const [show_options_modal, set_show_options_modal] = useState(false);
    const [tree, set_tree] = useState(null);
    const [translating, set_translating] = useState(false);
    const [json_input, set_json_input] = useState('');
    const [progress, set_progress] = useState({ current: 0, total: 0 });

    const handleFileUpload = (e) =>
    {
        const file = e.target.files[0];
        if(!file) return;

        const reader = new FileReader();
        reader.onload = (event) =>
        {
            set_json_input(event.target.result);
            loadJson(event.target.result);
        };
        reader.readAsText(file);
    };

    const loadJson = async (input = json_input) =>
    {
        try
        {
            const parsed = JSON.parse(input);
            const patterns = config.value.ignored_keys.split(',').filter(p => p.trim());
            const new_tree = createProcessTree(parsed, patterns);
            set_tree(new_tree);

            // Start translation immediately
            await startAutoTranslate(new_tree);
        }
        catch(err)
        {
            alert('Invalid JSON: ' + err.message);
        }
    };

    const startAutoTranslate = async (current_tree) =>
    {
        const target_tree = current_tree || tree;
        if(!target_tree) return;

        set_translating(true);

        const nodes = [];
        const findNodes = (obj) =>
        {
            if(obj instanceof TranslationNode)
            {
                if(obj.status.value === 'pending') nodes.push(obj);
            }
            else if(Array.isArray(obj))
            {
                obj.forEach(findNodes);
            }
            else if(obj !== null && typeof obj === 'object')
            {
                Object.values(obj).forEach(findNodes);
            }
        };

        findNodes(target_tree);

        set_progress({ current: 0, total: nodes.length });

        let completed = 0;
        const tasks = nodes.map(async (node) =>
        {
            await translateNode(node);
            completed++;
            set_progress({ current: completed, total: nodes.length });
        });

        await Promise.all(tasks);
        set_translating(false);
    };

    const downloadResult = () =>
    {
        const flattened = flattenTree(tree);
        const blob = new Blob([JSON.stringify(flattened, null, 4)], { type: 'application/json' });
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = 'translated_card.json';
        a.click();
    };

    const progress_percent = progress.total > 0 ? (progress.current / progress.total) * 100 : 0;

    return html`
        <div>
            <header>
                <h1>SillyTavern Card Translator</h1>
                <button class="secondary-btn" onclick=${() => set_show_options_modal(true)}>Options</button>
            </header>

            <main>
                <div class="input-section">
                    <div class="form-group">
                        <label>Upload SillyTavern JSON</label>
                        <input type="file" accept=".json" onchange=${handleFileUpload} />
                    </div>
                    <div class="form-group">
                        <label>Or Paste JSON</label>
                        <textarea rows="5" value=${json_input} oninput=${(e) => set_json_input(e.target.value)}></textarea>
                    </div>
                    <button onclick=${() => loadJson()}>Load and Translate</button>
                </div>

                ${tree && html`
                    <div class="controls">
                        ${translating && html`
                            <div style="flex-grow: 1;">
                                <div class="progress-container">
                                    <div class="progress-bar" style="width: ${progress_percent}%"></div>
                                    <div class="progress-text">${progress.current} / ${progress.total}</div>
                                </div>
                            </div>
                        `}
                        <button onclick=${() => startAutoTranslate()} disabled=${translating}>
                            ${translating ? 'Translating...' : 'Retry All Pending'}
                        </button>
                        <button class="secondary-btn" onclick=${downloadResult} disabled=${translating}>Download JSON</button>
                    </div>

                    <div class="tree-container">
                        <${TreeNode} label="Root" value=${tree} />
                    </div>
                `}
            </main>

            ${show_options_modal && html`<${OptionsDialog} close=${() => set_show_options_modal(false)} />`}
        </div>
    `;
}

render(html`<${App} />`, document.getElementById('app'));