diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/index.html | 1192 |
1 files changed, 1192 insertions, 0 deletions
diff --git a/src/index.html b/src/index.html new file mode 100644 index 0000000..ffb3dc8 --- /dev/null +++ b/src/index.html @@ -0,0 +1,1192 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>PDF2TXT + Translator</title> + <script src="../vendor/pdf.min.js"></script> + <script> + pdfjsLib.GlobalWorkerOptions.workerSrc = '../vendor/pdf.worker.min.js'; + </script> + <style> + * { margin: 0; padding: 0; box-sizing: border-box; } + :root { + --bg: #0a0a0a; --bg2: #111111; --bg3: #1a1a1a; + --border: #2a2a2a; --text: #e5e7eb; --text2: #94a3b8; + --accent: #00ff00; --accent-dim: #00cc00; + } + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: var(--bg); color: var(--text); min-height: 100vh; + padding: 20px; line-height: 1.5; + } + .container { max-width: 900px; margin: 0 auto; } + + h1 { color: var(--accent); margin-bottom: 24px; font-size: 1.8em; } + h2 { color: var(--text2); font-size: 0.9em; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; } + + .card { + background: var(--bg2); border: 1px solid var(--border); + border-radius: 12px; padding: 20px; margin-bottom: 16px; + } + + .log-area { + background: #000; border: 1px solid #333; + border-radius: 8px; margin-bottom: 16px; + font-family: 'SF Mono', monospace; font-size: 0.8em; + } + .log-header { + display: flex; justify-content: space-between; align-items: center; + padding: 10px 12px; background: var(--bg2); border-radius: 8px 8px 0 0; + cursor: pointer; user-select: none; + } + .log-header:hover { background: var(--bg3); } + .log-header .title { color: var(--text2); font-size: 0.85em; text-transform: uppercase; letter-spacing: 1px; } + .log-header .toggle { color: var(--text2); font-size: 0.9em; } + .log-content { + padding: 12px; max-height: 150px; overflow: auto; color: #888; + } + .log-content.collapsed { display: none; } + .log-area .info { color: #48aaff; } + .log-area .success { color: var(--accent); } + .log-area .error { color: #ff4444; } + .log-area .warn { color: #ffaa00; } + + .drop-zone { + border: 2px dashed var(--border); border-radius: 12px; + padding: 40px 20px; text-align: center; cursor: pointer; + transition: all 0.2s; + } + .drop-zone:hover, .drop-zone.dragover { + border-color: var(--accent); background: rgba(0,255,0,0.05); + } + .drop-zone input { display: none; } + + .file-info { + display: flex; gap: 20px; flex-wrap: wrap; + margin-top: 12px; padding: 12px; background: var(--bg3); + border-radius: 8px; align-items: center; + } + .file-info .label { color: var(--text2); font-size: 0.85em; } + .file-info .value { color: var(--accent); font-weight: 600; } + + .btn { + padding: 12px 20px; border-radius: 8px; border: none; + cursor: pointer; font-size: 1em; font-weight: 600; + transition: all 0.2s; + } + .btn-primary { background: var(--accent); color: #000; } + .btn-primary:hover { background: var(--accent-dim); } + .btn-secondary { background: var(--bg3); color: var(--text); border: 1px solid var(--border); } + .btn-secondary:hover { border-color: var(--accent); color: var(--accent); } + .btn-group { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 16px; } + .btn:disabled { opacity: 0.5; cursor: not-allowed; } + + .form-row { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; align-items: center; } + .form-row label { color: var(--text2); font-size: 0.9em; } + + select, input[type="text"] { + padding: 10px 14px; background: var(--bg3); color: var(--text); + border: 1px solid var(--border); border-radius: 8px; font-size: 0.95em; + flex: 1; min-width: 150px; + } + select:focus, input:focus { border-color: var(--accent); outline: none; } + + .progress-container { + margin: 16px 0; display: none; + } + .progress-bar { + height: 24px; background: var(--bg3); border-radius: 12px; + overflow: hidden; position: relative; + } + .progress-fill { + height: 100%; background: var(--accent); transition: width 0.3s; + width: 0%; + } + .progress-text { + position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); + font-size: 0.85em; font-weight: 600; color: #000; + } + .progress-status { margin-top: 8px; color: var(--text2); font-size: 0.9em; } + .progress-controls { display: flex; gap: 8px; align-items: center; margin-top: 8px; } + .btn-sm { padding: 4px 10px; font-size: 0.85em; } + + .preview-area { + background: var(--bg3); border: 1px solid var(--border); + border-radius: 8px; padding: 16px; min-height: 300px; + max-height: 500px; overflow: auto; + position: relative; + } + .preview-area.fullscreen { + position: fixed; top: 0; left: 0; right: 0; bottom: 0; + max-height: 100vh; height: 100vh; z-index: 1000; + border-radius: 0; margin: 0; + } + .fullscreen-btn { + position: absolute; top: 8px; right: 8px; + background: var(--bg2); border: 1px solid var(--border); + color: var(--text2); padding: 4px 8px; border-radius: 4px; + cursor: pointer; font-size: 0.85em; + } + .fullscreen-btn:hover { color: var(--accent); border-color: var(--accent); } + .preview-area textarea { + width: 100%; height: 100%; min-height: 280px; + background: transparent; color: var(--text); + border: none; resize: none; font-family: 'SF Mono', monospace; + font-size: 0.9em; line-height: 1.6; outline: none; + } + + .actions-row { + display: flex; gap: 8px; flex-wrap: wrap; + margin-top: 12px; padding-top: 12px; + border-top: 1px solid var(--border); + } + .actions-row .btn { padding: 8px 14px; font-size: 0.9em; } + + .success-msg { color: var(--accent); padding: 8px 12px; background: rgba(0,255,0,0.1); border-radius: 8px; margin-top: 8px; } + .error-msg { color: #ff4444; padding: 8px 12px; background: rgba(255,68,68,0.1); border-radius: 8px; margin-top: 8px; } + + .tabs { display: flex; gap: 8px; margin-bottom: 20px; border-bottom: 1px solid var(--border); padding-bottom: 12px; } + .tab { padding: 10px 20px; background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; color: var(--text2); cursor: pointer; font-size: 0.95em; transition: all 0.2s; } + .tab:hover { border-color: var(--accent); color: var(--text); } + .tab.active { background: var(--accent); color: #000; border-color: var(--accent); font-weight: 600; } + .tab-content { display: none; } + .tab-content.active { display: block; } + + .model-info { + margin-left: 6px; + font-size: 0.9em; + cursor: help; + color: var(--text2); + border-bottom: 1px dotted var(--text2); + } + .model-info:hover { + color: var(--accent); + } + input[type="number"] { + padding: 10px 14px; background: var(--bg3); color: var(--text); + border: 1px solid var(--border); border-radius: 8px; font-size: 0.95em; + width: 100px; + } + input[type="number"]:focus { border-color: var(--accent); outline: none; } + + @media (max-width: 600px) { + </style> +</head> +<body> + <div class="container"> + + <div class="tabs"> + <button class="tab active" onclick="switchTab('main')">Main</button> + <button class="tab" onclick="switchTab('config')">Config</button> + </div> + + <div id="mainContent" class="tab-content active"> + <h2>1. Select File</h2> + <div class="drop-zone" id="dropZone"> + <input type="file" id="fileInput" accept=".pdf,.txt,.md,.json" /> + <div>π Click to choose or drag & drop<br><small style="color:#666;">PDF, TXT, MD, JSON</small></div> + </div> + <div id="fileInfo" class="file-info" style="display:none;"> + <div><span class="label">File:</span> <span class="value" id="fileName">-</span></div> + <div><span class="label">Pages:</span> <span class="value" id="pageCount">-</span></div> + <div><span class="label">Size:</span> <span class="value" id="fileSize">-</span></div> + </div> + <div class="btn-group"> + <button class="btn btn-primary" onclick="extractText()" id="extractBtn">Load File</button> + <button class="btn btn-secondary" onclick="clearAll()">ποΈ New</button> + </div> + + <div class="paste-section" id="pasteSection"> + <h3 style="color:var(--text2);font-size:0.85em;margin:16px 0 8px;">Or paste text directly:</h3> + <textarea id="pasteArea" placeholder="Paste text here..." style="width:100%;min-height:100px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:12px;font-family:monospace;font-size:0.9em;resize:vertical;"></textarea> + <button class="btn btn-secondary" onclick="loadPastedText()" style="margin-top:8px;">Load Pasted Text</button> + </div> + </div> + + <div class="card" id="editCard" style="display:none;"> + <h2>2. Edit & Select Parts</h2> + <div class="form-row"> + <label>Cleaning Model:<span class="model-info" title="7B local model, 8K context" data-model="llama3.2:latest">β</span></label> + <select id="cleanModel"> + <optgroup label="Local"> + <option value="llama3.2:latest">llama3.2:latest</option> + <option value="qwen2.5:7b-instruct">qwen2.5:7b-instruct</option> + <option value="gemma4:31b-cloud">gemma4:31b-cloud</option> + <option value="gemma4:31b-cloud">gemma4:31b-cloud</option> + <option value="mistral:latest">mistral:latest</option> + <option value="translategemma:latest">translategemma:latest</option> + </optgroup> + <optgroup label="Cloud (Online)"> + <option value="glm-5.3-flash:cloud">glm-5.3-flash:cloud</option> + <option value="gemma4:31b-cloud">gemma4:31b-cloud</option> + <option value="gemma4:31b-cloud">gemma4:31b-cloud</option> + <option value="gemma4:31b-cloud">gemma4:31b-cloud</option> + </optgroup> + </select> + <button class="btn btn-secondary" onclick="cleanText()" id="cleanBtn">Clean Text</button> + </div> + <p style="color:var(--text2);font-size:0.85em;margin-bottom:12px;"> + Edit the text below. Then go to Translate section to translate. + </p> + <div class="preview-area" id="previewArea"> + <button class="fullscreen-btn" onclick="toggleFullscreen()">βΆ Fullscreen</button> + <textarea id="editor" spellcheck="false"></textarea> + </div> + <div class="actions-row"> + <button class="btn btn-secondary" onclick="selectAll()">Select All</button> + <button class="btn btn-secondary" onclick="deselectAll()">Deselect All</button> + </div> + </div> + + <div class="card" id="translateCard" style="display:none;"> + <h2>3. Translate</h2> + <div class="form-row"> + <label>Translation Model:<span class="model-info" title="Fast multimodal cloud model" data-model="glm-5.3-flash:cloud">β</span></label> + <select id="translateModel"> + <optgroup label="Cloud (Fast)"> + <option value="glm-5.3-flash:cloud" selected>glm-5.3-flash:cloud</option> + <option value="gemma4:31b-cloud">gemma4:31b-cloud</option> + <option value="gemma4:31b-cloud">gemma4:31b-cloud</option> + </optgroup> + <optgroup label="Local"> + <option value="translategemma:latest">translategemma:latest</option> + <option value="llama3.2:latest">llama3.2:latest</option> + <option value="qwen2.5:7b-instruct">qwen2.5:7b-instruct</option> + <option value="gemma4:31b-cloud">gemma4:31b-cloud</option> + <option value="gemma4:31b-cloud">gemma4:31b-cloud</option> + <option value="mistral:latest">mistral:latest</option> + </optgroup> + </select> + <label>Target:</label> + <select id="targetLang"> + <option value="English">English</option> + <option value="Spanish">Spanish</option> + <option value="French">French</option> + <option value="German">German</option> + <option value="Italian">Italian</option> + <option value="Portuguese">Portuguese</option> + <option value="Chinese">Chinese</option> + <option value="Japanese">Japanese</option> + <option value="Korean">Korean</option> + <option value="Russian">Russian</option> + <option value="Arabic">Arabic</option> + <option value="Hindi">Hindi</option> + <option value="Turkish">Turkish</option> + <option value="Dutch">Dutch</option> + <option value="Polish">Polish</option> + <option value="Swedish">Swedish</option> + </select> + </div> + <div class="form-row"> + <label>Validation Model:<span class="model-info" title="7B local model, 8K context" data-model="llama3.2:latest">β</span></label> + <select id="validateModel"> + <optgroup label="Cloud"> + <option value="glm-5.3-flash:cloud">glm-5.3-flash:cloud</option> + <option value="gemma4:31b-cloud">gemma4:31b-cloud</option> + </optgroup> + <optgroup label="Local"> + <option value="llama3.2:latest" selected>llama3.2:latest</option> + <option value="qwen2.5:7b-instruct">qwen2.5:7b-instruct</option> + <option value="gemma4:31b-cloud">gemma4:31b-cloud</option> + <option value="gemma4:31b-cloud">gemma4:31b-cloud</option> + <option value="mistral:latest">mistral:latest</option> + </optgroup> + </select> + <button class="btn btn-primary" onclick="translateText()" id="translateBtn" disabled>π Translate</button> + <button class="btn btn-secondary" onclick="validateText()" id="validateBtn">β Validate</button> + </div> + <div id="translating" style="display:none;margin-top:8px;" class="success-msg"> + Translating... <span id="progressStatusText"></span> + </div> + + <div class="workflow-section"> + <h3 style="color:var(--text2);font-size:0.85em;margin:16px 0 8px;">β‘ Workflow</h3> + <div class="form-row"> + <select id="workflowSelect"> + <option value="clean_translate_validate">Clean β Translate β Validate</option> + <option value="translate_validate">Translate β Validate</option> + <option value="clean_validate">Clean β Validate</option> + <option value="translate_only">Translate Only</option> + <option value="clean_only">Clean Only</option> + </select> + <button class="btn btn-primary" onclick="runWorkflow()" id="workflowBtn">βΆοΈ Run Workflow</button> + </div> + </div> + </div> + + <div class="card"> + <div class="progress-container" id="progressContainer"> + <div class="progress-bar"> + <div class="progress-fill" id="progressFill"></div> + <div class="progress-text" id="progressText">0%</div> + </div> + <div class="progress-status" id="progressStatus"></div> + <div class="progress-controls"> + <button class="btn btn-sm" onclick="togglePause()" id="pauseBtn" style="display:none;">βΈοΈ</button> + <button class="btn btn-sm btn-danger" onclick="requestStop()" id="stopBtn" style="display:none;">βΉοΈ</button> + <span id="tokenCount" style="color:var(--accent);font-size:0.85em;margin-left:auto;">π Tokens: 0</span> + </div> + </div> + + <div class="btn-group"> + <button class="btn btn-primary" onclick="downloadTxt()" id="downloadBtn" disabled>Download TXT</button> + <button class="btn btn-secondary" onclick="copyToClipboard()" id="copyBtn" disabled>Copy All</button> + </div> + </div> + + <div class="log-area" id="logArea"> + <div class="log-header" onclick="toggleLog()"> + <span class="title">π Activity Log</span> + <span class="toggle" id="logToggle">βΌ</span> + </div> + <div class="log-content" id="logContent"> + <div class="info">Ready. Select a PDF to begin.</div> + </div> + </div> + </div> + + <div id="configContent" class="tab-content"> + <div class="card"> + <h2>βοΈ Settings</h2> + <p style="color:var(--text2);font-size:0.85em;margin-bottom:16px;"> + Configure default values. Chunk size auto-adjusts based on model type (cloud vs local). + </p> + + <div class="form-row"> + <label>API Source:</label> + <select id="apiSource" onchange="handleApiSourceChange()" style="background: var(--bg3); color: var(--text); border: 1px solid var(--border); padding: 10px; border-radius: 8px;"> + <option value="proxied">Proxied (/ollama)</option> + <option value="local">Local (localhost:11434)</option> + <option value="custom">Custom URL</option> + </select> + </div> + + <div class="form-row" id="customUrlGroup" style="display:none;"> + <label>Custom URL:</label> + <select id="customUrlSelect" onchange="handleCustomUrlSelect()" style="flex:1;"> + <option value="">-- Select or enter new --</option> + </select> + </div> + + <div class="form-row" id="customUrlInputRow" style="display:none;"> + <label></label> + <input type="text" id="ollamaUrl" placeholder="https://api.openai.com/v1" style="flex:1;"> + <span style="color:var(--text2);font-size:0.85em;">OpenAI-compatible endpoints</span> + </div> + + <div class="form-row"> + <label>API Key:</label> + <input type="password" id="ollamaApiKey" placeholder="Enter API key if required" style="flex:1;"> + </div> + + <div class="form-row"> + <label>Models:</label> + <button class="btn btn-secondary btn-sm" onclick="fetchModels()" id="refreshModelsBtn">π Refresh Models</button> + <span id="modelsStatus" style="color:var(--text2);font-size:0.85em;margin-left:8px;"></span> + </div> + + <div class="form-row"> + <label>Cleaning Model:<span class="model-info" title="7B local model, 8K context" data-model="llama3.2:latest">β</span></label> + <select id="configCleanModel" onchange="adjustChunkSize()"> + <option value="">Loading...</option> + </select> + </div> + + <div class="form-row"> + <label>Translation Model:<span class="model-info" title="Fast multimodal cloud model" data-model="glm-5.3-flash:cloud">β</span></label> + <select id="configTranslateModel" onchange="adjustChunkSize()"> + <option value="">Loading...</option> + </select> + </div> + + <div class="form-row"> + <label>Validation Model:<span class="model-info" title="7B local model, 8K context" data-model="llama3.2:latest">β</span></label> + <select id="configValidateModel"> + <option value="">Loading...</option> + </select> + </div> + + <div class="form-row"> + <label>Target Language:</label> + <select id="configTargetLang"> + <option value="English">English</option> + <option value="Spanish">Spanish</option> + <option value="French">French</option> + <option value="German">German</option> + <option value="Italian">Italian</option> + <option value="Portuguese">Portuguese</option> + <option value="Chinese">Chinese</option> + <option value="Japanese">Japanese</option> + <option value="Korean">Korean</option> + <option value="Russian">Russian</option> + <option value="Arabic">Arabic</option> + <option value="Hindi">Hindi</option> + <option value="Turkish">Turkish</option> + <option value="Dutch">Dutch</option> + <option value="Polish">Polish</option> + <option value="Swedish">Swedish</option> + </select> + </div> + + <div class="form-row"> + <label>Chunk Size:</label> + <input type="number" id="configChunkSize" min="500" max="5000" step="100" value="1500" /> + <span style="color:var(--text2);font-size:0.85em;">(500-5000, auto-adjusts with cloud models)</span> + </div> + + <div class="form-row"> + <label>Default Workflow:</label> + <select id="configWorkflow"> + <option value="clean_translate_validate">Clean β Translate β Validate</option> + <option value="translate_validate">Translate β Validate</option> + <option value="clean_validate">Clean β Validate</option> + <option value="translate_only">Translate Only</option> + <option value="clean_only">Clean Only</option> + </select> + </div> + + <div class="btn-group"> + <button class="btn btn-primary" onclick="saveConfig()">Save Config</button> + <button class="btn btn-secondary" onclick="resetConfig()">Reset to Defaults</button> + </div> + <div id="configMsg" style="margin-top:8px;"></div> + </div> + </div> + </div> + + <script> + const CONFIG_KEY = 'pdf2txt_config'; + const DEFAULT_CONFIG = { + apiSource: 'proxied', + customUrls: [], + ollamaUrl: '/ollama', + ollamaApiKey: '', + cleanModel: 'llama3.2:latest', + translateModel: 'glm-5.3-flash:cloud', + validateModel: 'llama3.2:latest', + targetLang: 'Spanish', + chunkSize: 1500, + workflow: 'clean_translate_validate' + }; + + let pdfData = null; + let extractedText = ''; + let cleanedText = ''; + let logCollapsed = false; + let isProcessing = false; + let isPaused = false; + let stopRequested = false; + let totalTokens = 0; + + function switchTab(tab) { + document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); + if (tab === 'main') { + document.querySelector('.tab:nth-child(1)').classList.add('active'); + document.getElementById('mainContent').classList.add('active'); + } else { + document.querySelector('.tab:nth-child(2)').classList.add('active'); + document.getElementById('configContent').classList.add('active'); + } + } + + function loadConfig() { + try { + const saved = localStorage.getItem(CONFIG_KEY); + if (saved) { + return { ...DEFAULT_CONFIG, ...JSON.parse(saved) }; + } + } catch (e) {} + return { ...DEFAULT_CONFIG }; + } + + function handleApiSourceChange() { + const source = document.getElementById('apiSource').value; + const customGroup = document.getElementById('customUrlGroup'); + const customInputRow = document.getElementById('customUrlInputRow'); + const urlInput = document.getElementById('ollamaUrl'); + + if (source === 'custom') { + customGroup.style.display = 'flex'; + customInputRow.style.display = 'flex'; + populateCustomUrlDropdown(); + } else { + customGroup.style.display = 'none'; + customInputRow.style.display = 'none'; + urlInput.value = source === 'local' ? 'http://localhost:11434' : '/ollama'; + } + } + + function populateCustomUrlDropdown() { + const config = loadConfig(); + const select = document.getElementById('customUrlSelect'); + select.innerHTML = '<option value="">-- Select or enter new --</option>'; + + (config.customUrls || []).forEach(url => { + const opt = document.createElement('option'); + opt.value = url; + opt.textContent = url; + select.appendChild(opt); + }); + + if (config.apiSource === 'custom' && config.ollamaUrl) { + select.value = config.ollamaUrl; + document.getElementById('ollamaUrl').value = config.ollamaUrl; + } + } + + function handleCustomUrlSelect() { + const select = document.getElementById('customUrlSelect'); + const urlInput = document.getElementById('ollamaUrl'); + if (select.value) { + urlInput.value = select.value; + } + } + + function getApiBaseUrl() { + const config = loadConfig(); + const source = config.apiSource || 'proxied'; + + if (source === 'local') return 'http://localhost:11434'; + if (source === 'custom') return config.ollamaUrl || '/ollama'; + return '/ollama'; + } + + function saveCustomUrl(url) { + if (!url) return; + const config = loadConfig(); + config.customUrls = config.customUrls || []; + + config.customUrls = config.customUrls.filter(u => u !== url); + config.customUrls.unshift(url); + config.customUrls = config.customUrls.slice(0, 3); + + localStorage.setItem(CONFIG_KEY, JSON.stringify(config)); + } + + function saveConfig() { + const source = document.getElementById('apiSource').value; + let url = document.getElementById('ollamaUrl').value.trim(); + + if (source === 'custom' && url) { + saveCustomUrl(url); + } + if (source === 'local') url = 'http://localhost:11434'; + if (source === 'proxied') url = '/ollama'; + + const config = { + apiSource: source, + ollamaUrl: url, + ollamaApiKey: document.getElementById('ollamaApiKey').value.trim(), + cleanModel: document.getElementById('configCleanModel').value, + translateModel: document.getElementById('configTranslateModel').value, + validateModel: document.getElementById('configValidateModel').value, + targetLang: document.getElementById('configTargetLang').value, + chunkSize: parseInt(document.getElementById('configChunkSize').value) || 1500, + workflow: document.getElementById('configWorkflow').value + }; + try { + localStorage.setItem(CONFIG_KEY, JSON.stringify(config)); + applyConfig(config); + showConfigMsg('Settings saved!', 'success'); + } catch (e) { + showConfigMsg('Failed to save: ' + e.message, 'error'); + } + } + + function applyConfig(config) { + document.getElementById('cleanModel').value = config.cleanModel; + document.getElementById('translateModel').value = config.translateModel; + document.getElementById('validateModel').value = config.validateModel; + document.getElementById('targetLang').value = config.targetLang; + document.getElementById('workflowSelect').value = config.workflow; + } + + function resetConfig() { + document.getElementById('apiSource').value = DEFAULT_CONFIG.apiSource; + document.getElementById('ollamaUrl').value = DEFAULT_CONFIG.ollamaUrl; + document.getElementById('ollamaApiKey').value = ''; + handleApiSourceChange(); + document.getElementById('configCleanModel').value = DEFAULT_CONFIG.cleanModel; + document.getElementById('configTranslateModel').value = DEFAULT_CONFIG.translateModel; + document.getElementById('configValidateModel').value = DEFAULT_CONFIG.validateModel; + document.getElementById('configTargetLang').value = DEFAULT_CONFIG.targetLang; + document.getElementById('configChunkSize').value = DEFAULT_CONFIG.chunkSize; + document.getElementById('configWorkflow').value = DEFAULT_CONFIG.workflow; + showConfigMsg('Reset to defaults', 'info'); + } + + function showConfigMsg(msg, type) { + const el = document.getElementById('configMsg'); + el.textContent = msg; + el.style.color = type === 'success' ? 'var(--accent)' : type === 'error' ? '#ff4444' : 'var(--text2)'; + setTimeout(() => { el.textContent = ''; }, 3000); + } + + async function fetchModels() { + const statusEl = document.getElementById('modelsStatus'); + const cleanSel = document.getElementById('configCleanModel'); + const transSel = document.getElementById('configTranslateModel'); + const validSel = document.getElementById('configValidateModel'); + + statusEl.textContent = 'Fetching...'; + document.getElementById('refreshModelsBtn').disabled = true; + + try { + const config = loadConfig(); + const headers = { 'Content-Type': 'application/json' }; + if (config.ollamaApiKey) { + headers['Authorization'] = `Bearer ${config.ollamaApiKey}`; + } + const baseUrl = getApiBaseUrl(); + const response = await fetch(`${baseUrl}/api/tags`, { headers }); + if (!response.ok) throw new Error('HTTP ' + response.status); + + const data = await response.json(); + const models = data.models || []; + const modelNames = models.map(m => m.name).sort(); + + if (modelNames.length === 0) { + statusEl.textContent = 'No models found'; + return; + } + + statusEl.textContent = modelNames.length + ' models loaded'; + + const populate = (select, currentVal) => { + select.innerHTML = ''; + modelNames.forEach(name => { + const opt = document.createElement('option'); + opt.value = name; + opt.textContent = name; + select.appendChild(opt); + }); + if (currentVal && modelNames.includes(currentVal)) { + select.value = currentVal; + } + }; + + const config = loadConfig(); + populate(cleanSel, config.cleanModel); + populate(transSel, config.translateModel); + populate(validSel, config.validateModel); + + } catch (e) { + statusEl.textContent = 'Error: ' + e.message; + } finally { + document.getElementById('refreshModelsBtn').disabled = false; + } + } + + function adjustChunkSize() { + const model = document.getElementById('configTranslateModel').value; + const chunkInput = document.getElementById('configChunkSize'); + if (model.includes('cloud')) { + chunkInput.value = 2500; + } else { + chunkInput.value = 1500; + } + } + + function initConfig() { + const config = loadConfig(); + document.getElementById('configTargetLang').value = config.targetLang; + document.getElementById('configChunkSize').value = config.chunkSize; + document.getElementById('configWorkflow').value = config.workflow; + document.getElementById('apiSource').value = config.apiSource || 'proxied'; + document.getElementById('ollamaUrl').value = config.ollamaUrl || '/ollama'; + document.getElementById('ollamaApiKey').value = config.ollamaApiKey || ''; + handleApiSourceChange(); + applyConfig(config); + fetchModels(); + } + + function saveState() { + try { + localStorage.setItem('pdf2txt_text', cleanedText || editor.value); + } catch (e) { + console.warn('saveState failed:', e); + } + } + + initConfig(); + + const logArea = document.getElementById('logArea'); + const logContent = document.getElementById('logContent'); + const dropZone = document.getElementById('dropZone'); + const fileInput = document.getElementById('fileInput'); + const editor = document.getElementById('editor'); + + function log(msg, type = 'info') { + const div = document.createElement('div'); + div.className = type; + div.textContent = '[' + new Date().toLocaleTimeString() + '] ' + msg; + logContent.appendChild(div); + logContent.scrollTop = logContent.scrollHeight; + console.log('[' + type.toUpperCase() + ']', msg); + } + + function logTokens(promptTokens, completionTokens) { + totalTokens += (promptTokens || 0) + (completionTokens || 0); + const el = document.getElementById('tokenCount'); + if (el) el.textContent = `π Tokens: ${totalTokens.toLocaleString()}`; + log(` ββ prompt: ${promptTokens || 0} | completion: ${completionTokens || 0} | total: ${totalTokens.toLocaleString()}`, 'info'); + } + + function toggleLog() { + logCollapsed = !logCollapsed; + logContent.classList.toggle('collapsed', logCollapsed); + document.getElementById('logToggle').textContent = logCollapsed ? 'βΆ' : 'βΌ'; + } + + dropZone.addEventListener('click', () => fileInput.click()); + dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('dragover'); }); + dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover')); + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('dragover'); + if (e.dataTransfer.files.length) handleFile(e.dataTransfer.files[0]); + }); + fileInput.addEventListener('change', (e) => { if (e.target.files.length) handleFile(e.target.files[0]); }); + + function handleFile(file) { + const ext = file.name.split('.').pop().toLowerCase(); + const textExts = ['txt', 'md', 'json']; + + document.getElementById('fileName').textContent = file.name; + document.getElementById('fileSize').textContent = formatSize(file.size); + document.getElementById('fileInfo').style.display = 'flex'; + + if (file.type === 'application/pdf') { + log('Loading PDF: ' + file.name, 'info'); + pdfData = file; + document.getElementById('pageCount').textContent = 'PDF'; + document.getElementById('extractBtn').disabled = false; + log('PDF ready for extraction', 'success'); + } else if (textExts.includes(ext) || file.type === 'text/plain') { + log('Loading text file: ' + file.name, 'info'); + const reader = new FileReader(); + reader.onload = (e) => { + extractedText = e.target.result; + cleanedText = extractedText; + editor.value = extractedText; + document.getElementById('pageCount').textContent = (ext === 'json' ? 'JSON' : 'Text'); + showInEditor(extractedText); + log('Loaded ' + extractedText.length + ' characters', 'success'); + }; + reader.onerror = () => log('Failed to read file', 'error'); + reader.readAsText(file); + } else { + log('Unsupported file type: ' + file.type, 'error'); + } + } + + function loadPastedText() { + const text = document.getElementById('pasteArea').value.trim(); + if (!text) { + log('No text to load', 'warn'); + return; + } + extractedText = text; + cleanedText = text; + currentFileName = 'pasted_text.txt'; + showInEditor(text); + log('Loaded ' + text.length + ' characters from paste', 'success'); + } + + function showInEditor(text) { + editor.value = text; + document.getElementById('editCard').style.display = 'block'; + document.getElementById('translateCard').style.display = 'block'; + document.getElementById('downloadBtn').disabled = false; + document.getElementById('copyBtn').disabled = false; + document.getElementById('translateBtn').disabled = false; + saveState(); + } + + async function extractText() { + const fileName = document.getElementById('fileName').textContent; + const isPdf = fileName.endsWith('.pdf'); + + if (isPdf && !pdfData) { + log('No PDF loaded', 'warn'); + return; + } + + if (!isPdf) { + log('Text already loaded', 'info'); + return; + } + + log('Starting PDF extraction...', 'info'); + showProgress(0, 'Extracting PDF...'); + document.getElementById('extractBtn').disabled = true; + + try { + const arrayBuffer = await pdfData.arrayBuffer(); + const bytes = new Uint8Array(arrayBuffer); + + const pdf = await pdfjsLib.getDocument({ data: bytes }).promise; + let fullText = ''; + const total = pdf.numPages; + + for (let i = 1; i <= total; i++) { + const page = await pdf.getPage(i); + const textContent = await page.getTextContent(); + const pageText = textContent.items.map(item => item.str).join(' '); + fullText += pageText + '\n\n'; + showProgress((i / total) * 100, `Page ${i}/${total}`); + } + + extractedText = fullText; + cleanedText = fullText; + editor.value = extractedText; + + document.getElementById('editCard').style.display = 'block'; + document.getElementById('translateCard').style.display = 'block'; + document.getElementById('downloadBtn').disabled = false; + document.getElementById('copyBtn').disabled = false; + document.getElementById('translateBtn').disabled = false; + + hideProgress(); + log('Extraction complete!', 'success'); + } catch (err) { + log('Extraction failed: ' + err.message, 'error'); + hideProgress(); + } + document.getElementById('extractBtn').disabled = false; + } + + async function cleanText() { + const textToClean = editor.value.trim(); + if (!textToClean) { + log('No text to clean', 'warn'); + return false; + } + + const model = document.getElementById('cleanModel').value; + log('Cleaning text with ' + model + '...', 'info'); + document.getElementById('cleanBtn').disabled = true; + isProcessing = true; + showProgress(50, 'Cleaning...'); + + const prompt = `Clean the following text. Remove artifacts, fix encoding issues, and make it readable. Keep the original meaning.\n\nText:\n${textToClean}`; + + try { + const config = loadConfig(); + const headers = { 'Content-Type': 'application/json' }; + if (config.ollamaApiKey) { + headers['Authorization'] = `Bearer ${config.ollamaApiKey}`; + } + const baseUrl = getApiBaseUrl(); + + const response = await fetch(`${baseUrl}/api/generate`, { + method: 'POST', + headers: headers, + body: JSON.stringify({ + model: model, + prompt: prompt, + stream: false, + options: { temperature: 0.3 } + }) + }); + + if (!response.ok) throw new Error('API error'); + const data = await response.json(); + + logTokens(data.prompt_eval_count || 0, data.eval_count || 0); + + cleanedText = data.response; + editor.value = cleanedText; + saveState(); + + hideProgress(); + log('Text cleaned!', 'success'); + return true; + } catch (err) { + log('Cleaning failed: ' + err.message, 'error'); + hideProgress(); + return false; + } finally { + document.getElementById('cleanBtn').disabled = false; + isProcessing = false; + } + } + + async function translateText() { + const textToTranslate = editor.value.trim(); + if (!textToTranslate) { + log('No text to translate', 'warn'); + return; + } + + const translateModel = document.getElementById('translateModel').value; + const targetLang = document.getElementById('targetLang').value; + const validateModel = document.getElementById('validateModel').value; + + log('Translating to ' + targetLang + ' with ' + translateModel + '...', 'info'); + document.getElementById('translateBtn').disabled = true; + document.getElementById('translating').style.display = 'block'; + + const config = loadConfig(); + const CHUNK_SIZE = config.chunkSize; + const chunks = []; + for (let i = 0; i < textToTranslate.length; i += CHUNK_SIZE) { + chunks.push(textToTranslate.substring(i, i + CHUNK_SIZE)); + } + + let translated = ''; + try { + for (let i = 0; i < chunks.length; i++) { + if (stopRequested) { + log('Translation stopped', 'warn'); + break; + } + while (isPaused) { + await new Promise(r => setTimeout(r, 100)); + if (stopRequested) break; + } + + const pct = Math.round(((i + 1) / chunks.length) * 100); + showProgress(pct, `Translating chunk ${i + 1}/${chunks.length}`); + document.getElementById('progressStatusText').textContent = `${i + 1}/${chunks.length}`; + + const prompt = `Translate to ${targetLang}. Keep meaning.\n\nText:\n${chunks[i]}`; + + const config = loadConfig(); + const headers = { 'Content-Type': 'application/json' }; + if (config.ollamaApiKey) { + headers['Authorization'] = `Bearer ${config.ollamaApiKey}`; + } + const baseUrl = getApiBaseUrl(); + + const response = await fetch(`${baseUrl}/api/generate`, { + method: 'POST', + headers: headers, + body: JSON.stringify({ + model: translateModel, + prompt: prompt, + stream: false, + options: { temperature: 0.3 } + }) + }); + + if (!response.ok) throw new Error('Translation API error'); + const data = await response.json(); + logTokens(data.prompt_eval_count || 0, data.eval_count || 0); + translated += data.response + '\n\n'; + } + + if (!stopRequested) { + editor.value = translated; + cleanedText = translated; + saveState(); + } + + hideProgress(); + log('Translation complete! ' + totalTokens + ' tokens used.', 'success'); + return true; + + } catch (err) { + log('Translation error: ' + err.message, 'error'); + hideProgress(); + return false; + } + } + + async function validateText() { + const textToValidate = editor.value.trim(); + if (!textToValidate) { + log('No text to validate', 'warn'); + return false; + } + + const model = document.getElementById('validateModel').value; + const targetLang = document.getElementById('targetLang').value; + log('Validating with ' + model + '...', 'info'); + document.getElementById('validateBtn').disabled = true; + isProcessing = true; + showProgress(50, 'Validating...'); + + const prompt = `Check if this text in ${targetLang} is accurate, natural, and well-formatted. If good, return "PASS". If issues found, correct them and return the improved version prefixed with "NEEDS FIX:"\n\nText:\n${textToValidate}`; + + try { + const config = loadConfig(); + const headers = { 'Content-Type': 'application/json' }; + if (config.ollamaApiKey) { + headers['Authorization'] = `Bearer ${config.ollamaApiKey}`; + } + const baseUrl = getApiBaseUrl(); + + const response = await fetch(`${baseUrl}/api/generate`, { + method: 'POST', + headers: headers, + body: JSON.stringify({ + model: model, + prompt: prompt, + stream: false, + options: { temperature: 0.3 } + }) + }); + + if (!response.ok) throw new Error('Validation API error'); + const data = await response.json(); + logTokens(data.prompt_eval_count || 0, data.eval_count || 0); + + const result = data.response; + if (result.startsWith('NEEDS FIX:')) { + const corrected = result.replace('NEEDS FIX:', '').trim(); + editor.value = corrected; + cleanedText = corrected; + saveState(); + log('Validation: Issues found and corrected', 'warn'); + } else { + log('Validation: PASS β', 'success'); + } + + hideProgress(); + return true; + } catch (err) { + log('Validation error: ' + err.message, 'error'); + hideProgress(); + return false; + } finally { + document.getElementById('validateBtn').disabled = false; + isProcessing = false; + } + } + + async function runWorkflow() { + const workflow = document.getElementById('workflowSelect').value; + const targetLang = document.getElementById('targetLang').value; + + log('Starting workflow: ' + workflow, 'info'); + isProcessing = true; + + const steps = { + 'clean_translate_validate': ['clean', 'translate', 'validate'], + 'translate_validate': ['translate', 'validate'], + 'clean_validate': ['clean', 'validate'], + 'translate_only': ['translate'], + 'clean_only': ['clean'] + }; + + for (const step of steps[workflow]) { + if (stopRequested) break; + while (isPaused && !stopRequested) { + await new Promise(r => setTimeout(r, 100)); + } + + switch (step) { + case 'clean': + const cleaned = await cleanText(); + if (!cleaned) { log('Workflow stopped: clean failed', 'error'); break; } + break; + case 'translate': + const translated = await translateText(); + if (!translated) { log('Workflow stopped: translate failed', 'error'); break; } + break; + case 'validate': + await validateText(); + break; + } + } + + isProcessing = false; + log('Workflow complete!', 'success'); + saveState(); + } + + function toggleFullscreen() { + const area = document.getElementById('previewArea'); + const btn = area.querySelector('.fullscreen-btn'); + area.classList.toggle('fullscreen'); + btn.textContent = area.classList.contains('fullscreen') ? 'βΆ Exit' : 'βΆ Fullscreen'; + } + + function selectAll() { + editor.select(); + } + + function deselectAll() { + editor.setSelectionRange(0, 0); + } + + function downloadTxt() { + const text = editor.value; + if (!text) return; + + const blob = new Blob([text], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'extracted.txt'; + a.click(); + URL.revokeObjectURL(url); + log('Downloaded as extracted.txt', 'success'); + } + + function copyToClipboard() { + const text = editor.value; + if (!text) return; + + navigator.clipboard.writeText(text).then(() => { + log('Copied to clipboard!', 'success'); + }).catch(() => { + log('Failed to copy', 'error'); + }); + } + + function clearAll() { + pdfData = null; + extractedText = ''; + cleanedText = ''; + editor.value = ''; + document.getElementById('fileInfo').style.display = 'none'; + document.getElementById('fileInput').value = ''; + document.getElementById('editCard').style.display = 'none'; + document.getElementById('translateCard').style.display = 'none'; + document.getElementById('downloadBtn').disabled = true; + document.getElementById('copyBtn').disabled = true; + log('Cleared', 'info'); + } + + function showProgress(percent, status) { + document.getElementById('progressContainer').style.display = 'block'; + document.getElementById('progressFill').style.width = percent + '%'; + document.getElementById('progressText').textContent = Math.round(percent) + '%'; + document.getElementById('progressStatus').textContent = status; + + if (isProcessing) { + document.getElementById('pauseBtn').style.display = 'inline-block'; + document.getElementById('stopBtn').style.display = 'inline-block'; + } + } + + function hideProgress() { + document.getElementById('progressContainer').style.display = 'none'; + document.getElementById('pauseBtn').style.display = 'none'; + document.getElementById('stopBtn').style.display = 'none'; + isProcessing = false; + } + + function togglePause() { + isPaused = !isPaused; + document.getElementById('pauseBtn').textContent = isPaused ? 'βΆοΈ' : 'βΈοΈ'; + log(isPaused ? 'Paused' : 'Resumed', 'info'); + } + + function requestStop() { + stopRequested = true; + log('Stopping...', 'warn'); + } + + function formatSize(bytes) { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + } + </script> +</body> +</html>
\ No newline at end of file |