diff options
| author | Agando <roduccomm@gmail.com> | 2026-11-09 15:36:11 +0100 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-11-09 15:36:11 +0100 |
| commit | fa527774c2dfec90920d8b1befbefc3ac8241f22 (patch) | |
| tree | 3fe1030aa75bd43abeea4541eff85f289371a77b /src/index.html | |
| parent | 896ae37a4297f703bc4fa3fe155c8a35d49a326d (diff) | |
A review of all three products in Chromium found defects that only execution exposes.
pdf2txt 1.0.0 DID NOT RUN. Both the packaged file and the live demo contained a duplicated
const declaration inside fetchModels, so the inline script threw a SyntaxError at parse time and
no handler was ever bound: no extraction, no translation, no config. Proven by extracting both
inline scripts from the committed file and from spaces/chatstudio/pdf2txt.html and running
node --check (SyntaxError: Identifier 'config' has already been declared). The two published
versions of this product were dead on arrival.
pdf2txt 1.0.1 also stops writing the API key and the whole extracted document to localStorage
(sessionStorage for the key, nothing for the text), keeps paragraphs (hasEOL), splits chunks on
word boundaries, reports a non-JSON 200 or a missing response field instead of writing "undefined"
into the editor, re-enables Translate after a run, resets stopRequested, stops a workflow on a
failed step, warns on a PDF with no text layer, recognises .PDF, fixes an unterminated mobile
media query, stops the Fullscreen button covering line 1, and makes the drop zone and all 39
controls reachable and named.
shiftplanner 1.0.1 fixes seven demonstrated defects: the settings modal recursed until the stack
overflowed, "+ Worker" prefilled the last edited worker id and overwrote that person, date maths
was off by one west of UTC (local-noon Date formatted with toISOString), same-day and overnight
shift overlaps were never flagged, the generator sliders never reached state.settings, CSV export
could execute a formula and broke on a quote, and the header never wrapped (390 px viewport gave a
1139 px document). Plus four data-integrity guards: unparseable saved plans are copied to a
recovery key instead of silently overwritten, wrong-typed fields are normalised, unknown top-level
fields survive a save, and a storage event syncs another tab.
chat-widget 1.0.1 closes a critical attribute-injection XSS. escapeHtml() round-tripped through
textContent, which does not escape a double quote, so a reply containing
https://x.test/"onclick="alert(1)// produced a real <a onclick=...> and fired alert(1) when
clicked. Proven both ways: the 1.0.0 file fired a dialog, the fixed one renders only
href/target/rel and stays silent. The launcher also covered the Send button at 390 px, so the
widget could not be used on a phone at all.
Also in this commit:
- Landing pages and demos are generated (build-product-pages.py). The "Try the demo" button used
to point at spaces/chatstudio/pdf2txt.html (the dead copy above) and at a v2.1 shiftplanner with
no AI panel: a demo that was a different build than the ZIP. Demos are now built into
/demos/<slug>/ from products/<slug>/, with the ../vendor/ prefix rewritten, and the publish
wrapper regenerates them before every publish.
- publish-public-repo.py refuses to publish a subtree with uncommitted changes. It reads git, so
it silently republished the old tip while rc was 0 for every slug.
Diffstat (limited to 'src/index.html')
| -rw-r--r-- | src/index.html | 359 |
1 files changed, 257 insertions, 102 deletions
diff --git a/src/index.html b/src/index.html index ffb3dc8..1848aca 100644 --- a/src/index.html +++ b/src/index.html @@ -60,7 +60,15 @@ .drop-zone:hover, .drop-zone.dragover { border-color: var(--accent); background: rgba(0,255,0,0.05); } - .drop-zone input { display: none; } + /* The file input stays in the accessibility tree (not display:none) so it can + still be labelled and triggered from the keyboard; it is only visually hidden. */ + .visually-hidden { + position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; + overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; + } + .drop-zone:focus-visible, .log-header:focus-visible { + outline: 2px solid var(--accent); outline-offset: 2px; + } .file-info { display: flex; gap: 20px; flex-wrap: wrap; @@ -131,6 +139,10 @@ .fullscreen-btn:hover { color: var(--accent); border-color: var(--accent); } .preview-area textarea { width: 100%; height: 100%; min-height: 280px; + /* Top padding keeps the first line clear of the absolutely positioned + fullscreen button on desktop; on narrow screens the button is moved + into normal flow instead. */ + padding: 34px 4px 4px; background: transparent; color: var(--text); border: none; resize: none; font-family: 'SF Mono', monospace; font-size: 0.9em; line-height: 1.6; outline: none; @@ -171,21 +183,46 @@ input[type="number"]:focus { border-color: var(--accent); outline: none; } @media (max-width: 600px) { + body { padding: 12px; } + .container { max-width: 100%; } + h1 { font-size: 1.4em; margin-bottom: 16px; } + .card { padding: 12px; margin-bottom: 12px; } + .tabs { gap: 6px; padding-bottom: 8px; margin-bottom: 14px; } + .tab { padding: 8px 12px; font-size: 0.85em; } + .drop-zone { padding: 24px 12px; } + .form-row { gap: 8px; } + .form-row > label { flex: 1 0 100%; } + select, input[type="text"], input[type="password"], input[type="number"] { + min-width: 0; width: 100%; flex: 1 1 100%; + } + .btn { padding: 10px 14px; font-size: 0.9em; } + .btn-group { gap: 8px; } + .btn-group .btn { flex: 1 1 auto; } + .actions-row .btn { flex: 1 1 auto; } + .preview-area { padding: 10px; min-height: 260px; } + /* The fullscreen toggle must not float over the editor's first line: + on narrow screens it sits in normal flow above the text. */ + .fullscreen-btn { position: static; display: block; width: 100%; margin: 0 0 8px; } + .preview-area textarea { min-height: 240px; padding-top: 4px; } + .log-content { max-height: 120px; } + } </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> + <button class="tab active" onclick="switchTab('main')" aria-label="Main tab">Main</button> + <button class="tab" onclick="switchTab('config')" aria-label="Config tab">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 class="drop-zone" id="dropZone" role="button" tabindex="0" + aria-label="Choose a file to extract text from, or drag and drop a PDF, TXT, MD or JSON file here"> + <input type="file" id="fileInput" accept=".pdf,.txt,.md,.json" class="visually-hidden" tabindex="-1" + aria-label="Choose a file to extract text from" /> + <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> @@ -193,21 +230,21 @@ <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> + <button class="btn btn-primary" onclick="extractText()" id="extractBtn" aria-label="Load file">Load File</button> + <button class="btn btn-secondary" onclick="clearAll()" aria-label="New: clear the loaded file and editor">🗑️ 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> + <textarea id="pasteArea" aria-label="Paste text to load into the editor" 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()" aria-label="Load pasted text" 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> + <label for="cleanModel">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> @@ -224,25 +261,25 @@ <option value="gemma4:31b-cloud">gemma4:31b-cloud</option> </optgroup> </select> - <button class="btn btn-secondary" onclick="cleanText()" id="cleanBtn">Clean Text</button> + <button class="btn btn-secondary" onclick="cleanText()" id="cleanBtn" aria-label="Clean text">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> + <button class="fullscreen-btn" onclick="toggleFullscreen()" aria-label="Toggle fullscreen editor">⛶ Fullscreen</button> + <textarea id="editor" spellcheck="false" aria-label="Extracted text editor"></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> + <button class="btn btn-secondary" onclick="selectAll()" aria-label="Select all text">Select All</button> + <button class="btn btn-secondary" onclick="deselectAll()" aria-label="Deselect all text">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> + <label for="translateModel">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> @@ -258,7 +295,7 @@ <option value="mistral:latest">mistral:latest</option> </optgroup> </select> - <label>Target:</label> + <label for="targetLang">Target:</label> <select id="targetLang"> <option value="English">English</option> <option value="Spanish">Spanish</option> @@ -279,7 +316,7 @@ </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> + <label for="validateModel">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> @@ -293,8 +330,8 @@ <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> + <button class="btn btn-primary" onclick="translateText()" id="translateBtn" aria-label="Translate text" disabled>🔄 Translate</button> + <button class="btn btn-secondary" onclick="validateText()" id="validateBtn" aria-label="Validate translation">✓ Validate</button> </div> <div id="translating" style="display:none;margin-top:8px;" class="success-msg"> Translating... <span id="progressStatusText"></span> @@ -303,14 +340,14 @@ <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"> + <select id="workflowSelect" aria-label="Workflow preset"> <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> + <button class="btn btn-primary" onclick="runWorkflow()" id="workflowBtn" aria-label="Run workflow">▶️ Run Workflow</button> </div> </div> </div> @@ -323,20 +360,20 @@ </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> + <button class="btn btn-sm" onclick="togglePause()" id="pauseBtn" aria-label="Pause translation" style="display:none;">⏸️</button> + <button class="btn btn-sm btn-danger" onclick="requestStop()" id="stopBtn" aria-label="Stop translation" 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> + <button class="btn btn-primary" onclick="downloadTxt()" id="downloadBtn" aria-label="Download extracted text as TXT" disabled>Download TXT</button> + <button class="btn btn-secondary" onclick="copyToClipboard()" id="copyBtn" aria-label="Copy all text to clipboard" disabled>Copy All</button> </div> </div> <div class="log-area" id="logArea"> - <div class="log-header" onclick="toggleLog()"> + <div class="log-header" onclick="toggleLog()" role="button" tabindex="0" aria-label="Toggle activity log" aria-expanded="true"> <span class="title">📋 Activity Log</span> <span class="toggle" id="logToggle">▼</span> </div> @@ -354,7 +391,7 @@ </p> <div class="form-row"> - <label>API Source:</label> + <label for="apiSource">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> @@ -363,52 +400,52 @@ </div> <div class="form-row" id="customUrlGroup" style="display:none;"> - <label>Custom URL:</label> + <label for="customUrlSelect">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> + <label for="ollamaUrl">Ollama URL:</label> + <input type="text" id="ollamaUrl" placeholder="https://host/ollama" style="flex:1;"> + <span style="color:var(--text2);font-size:0.85em;">Ollama-compatible base URL</span> </div> <div class="form-row"> - <label>API Key:</label> + <label for="ollamaApiKey">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 class="group-label" style="color:var(--text2);font-size:0.9em;">Models:</span> + <button class="btn btn-secondary btn-sm" onclick="fetchModels()" id="refreshModelsBtn" aria-label="Refresh available models">🔄 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> + <label for="configCleanModel">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> + <label for="configTranslateModel">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> + <label for="configValidateModel">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> + <label for="configTargetLang">Target Language:</label> <select id="configTargetLang"> <option value="English">English</option> <option value="Spanish">Spanish</option> @@ -430,13 +467,13 @@ </div> <div class="form-row"> - <label>Chunk Size:</label> + <label for="configChunkSize">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> + <label for="configWorkflow">Default Workflow:</label> <select id="configWorkflow"> <option value="clean_translate_validate">Clean → Translate → Validate</option> <option value="translate_validate">Translate → Validate</option> @@ -447,8 +484,8 @@ </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> + <button class="btn btn-primary" onclick="saveConfig()" aria-label="Save configuration">Save Config</button> + <button class="btn btn-secondary" onclick="resetConfig()" aria-label="Reset configuration to defaults">Reset to Defaults</button> </div> <div id="configMsg" style="margin-top:8px;"></div> </div> @@ -457,6 +494,7 @@ <script> const CONFIG_KEY = 'pdf2txt_config'; + const API_KEY_KEY = 'pdf2txt_api_key'; const DEFAULT_CONFIG = { apiSource: 'proxied', customUrls: [], @@ -478,6 +516,35 @@ let isPaused = false; let stopRequested = false; let totalTokens = 0; + let pdfDoc = null; + let workflowRunning = false; + + async function readJson(response) { + const body = await response.text(); + try { + return JSON.parse(body); + } catch (e) { + throw new Error('Server did not return JSON (HTTP ' + response.status + ')'); + } + } + + // Split on word boundaries when possible so translations do not start mid-word, + // and always make progress (no infinite loop) even if the size is odd. + function chunkText(text, size) { + const chunks = []; + let i = 0; + while (i < text.length) { + let end = Math.min(i + size, text.length); + if (end < text.length) { + const space = text.lastIndexOf(' ', end); + if (space > i + Math.floor(size / 2)) end = space; + } + const part = text.slice(i, end).trim(); + if (part) chunks.push(part); + i = end; + } + return chunks; + } function switchTab(tab) { document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); @@ -495,7 +562,15 @@ try { const saved = localStorage.getItem(CONFIG_KEY); if (saved) { - return { ...DEFAULT_CONFIG, ...JSON.parse(saved) }; + const parsed = JSON.parse(saved); + // Older versions stored the API key in localStorage; move it to sessionStorage + // so the secret is not left at rest on a shared origin. + if (parsed.ollamaApiKey) { + try { sessionStorage.setItem(API_KEY_KEY, parsed.ollamaApiKey); } catch (e) {} + delete parsed.ollamaApiKey; + localStorage.setItem(CONFIG_KEY, JSON.stringify(parsed)); + } + return { ...DEFAULT_CONFIG, ...parsed, ollamaApiKey: sessionStorage.getItem(API_KEY_KEY) || '' }; } } catch (e) {} return { ...DEFAULT_CONFIG }; @@ -549,7 +624,7 @@ const source = config.apiSource || 'proxied'; if (source === 'local') return 'http://localhost:11434'; - if (source === 'custom') return config.ollamaUrl || '/ollama'; + if (source === 'custom') return (config.ollamaUrl || '/ollama').replace(/\/+$/, ''); return '/ollama'; } @@ -578,7 +653,6 @@ 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, @@ -586,7 +660,10 @@ chunkSize: parseInt(document.getElementById('configChunkSize').value) || 1500, workflow: document.getElementById('configWorkflow').value }; + const apiKey = document.getElementById('ollamaApiKey').value.trim(); try { + if (apiKey) sessionStorage.setItem(API_KEY_KEY, apiKey); + else sessionStorage.removeItem(API_KEY_KEY); localStorage.setItem(CONFIG_KEY, JSON.stringify(config)); applyConfig(config); showConfigMsg('Settings saved!', 'success'); @@ -667,7 +744,6 @@ } }; - const config = loadConfig(); populate(cleanSel, config.cleanModel); populate(transSel, config.translateModel); populate(validSel, config.validateModel); @@ -702,12 +778,12 @@ fetchModels(); } + // Document text is deliberately not persisted; it stays in this tab. Any copy left + // behind by an older version is removed. function saveState() { try { - localStorage.setItem('pdf2txt_text', cleanedText || editor.value); - } catch (e) { - console.warn('saveState failed:', e); - } + localStorage.removeItem('pdf2txt_text'); + } catch (e) {} } initConfig(); @@ -738,10 +814,29 @@ logCollapsed = !logCollapsed; logContent.classList.toggle('collapsed', logCollapsed); document.getElementById('logToggle').textContent = logCollapsed ? '▶' : '▼'; + const header = document.querySelector('.log-header'); + if (header) header.setAttribute('aria-expanded', String(!logCollapsed)); } dropZone.addEventListener('click', () => fileInput.click()); + // Keyboard parity for the drop zone: Enter/Space open the file picker. + dropZone.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') { + e.preventDefault(); + fileInput.click(); + } + }); dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('dragover'); }); + // The log header is a div that toggles on click; make it operable from the keyboard too. + const logHeaderEl = document.querySelector('.log-header'); + if (logHeaderEl) { + logHeaderEl.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') { + e.preventDefault(); + toggleLog(); + } + }); + } dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover')); dropZone.addEventListener('drop', (e) => { e.preventDefault(); @@ -758,14 +853,18 @@ document.getElementById('fileSize').textContent = formatSize(file.size); document.getElementById('fileInfo').style.display = 'flex'; - if (file.type === 'application/pdf') { + if (file.type === 'application/pdf' || ext === 'pdf') { log('Loading PDF: ' + file.name, 'info'); pdfData = file; - document.getElementById('pageCount').textContent = 'PDF'; + pdfDoc = null; + document.getElementById('pageCount').textContent = '…'; document.getElementById('extractBtn').disabled = false; + loadPdfDocument(file); log('PDF ready for extraction', 'success'); } else if (textExts.includes(ext) || file.type === 'text/plain') { log('Loading text file: ' + file.name, 'info'); + pdfData = null; + pdfDoc = null; const reader = new FileReader(); reader.onload = (e) => { extractedText = e.target.result; @@ -778,7 +877,26 @@ reader.onerror = () => log('Failed to read file', 'error'); reader.readAsText(file); } else { - log('Unsupported file type: ' + file.type, 'error'); + pdfData = null; + pdfDoc = null; + document.getElementById('extractBtn').disabled = true; + log('Unsupported file type: ' + (file.type || ext), 'error'); + } + } + + async function loadPdfDocument(file) { + try { + const bytes = new Uint8Array(await file.arrayBuffer()); + const doc = await pdfjsLib.getDocument({ data: bytes }).promise; + if (pdfData !== file) return; // another file was chosen while this one parsed + pdfDoc = doc; + document.getElementById('pageCount').textContent = doc.numPages; + } catch (err) { + if (pdfData !== file) return; + pdfDoc = null; + document.getElementById('pageCount').textContent = 'unreadable'; + document.getElementById('extractBtn').disabled = true; + log('Could not open PDF: ' + err.message, 'error'); } } @@ -806,16 +924,8 @@ } 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'); + if (!pdfData) { + log('No PDF loaded — text files are already in the editor', 'info'); return; } @@ -824,21 +934,36 @@ 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 pdf = pdfDoc; + if (!pdf) { + const bytes = new Uint8Array(await pdfData.arrayBuffer()); + pdf = await pdfjsLib.getDocument({ data: bytes }).promise; + pdfDoc = pdf; + } let fullText = ''; const total = pdf.numPages; + document.getElementById('pageCount').textContent = total; 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'; + // Keep the line breaks pdf.js reports instead of flattening a whole page + // into one paragraph. + let pageText = ''; + for (const item of textContent.items) { + pageText += item.str + (item.hasEOL ? '\n' : ' '); + } + fullText += pageText.trim() + '\n\n'; + page.cleanup(); showProgress((i / total) * 100, `Page ${i}/${total}`); } + if (!fullText.trim()) { + log('No text layer found — this looks like a scanned PDF; OCR is not supported.', 'warn'); + hideProgress(); + return; + } + extractedText = fullText; cleanedText = fullText; editor.value = extractedText; @@ -850,12 +975,13 @@ document.getElementById('translateBtn').disabled = false; hideProgress(); - log('Extraction complete!', 'success'); + log('Extraction complete! ' + total + ' page(s), ' + fullText.length + ' characters.', 'success'); } catch (err) { log('Extraction failed: ' + err.message, 'error'); hideProgress(); + } finally { + document.getElementById('extractBtn').disabled = false; } - document.getElementById('extractBtn').disabled = false; } async function cleanText() { @@ -892,8 +1018,11 @@ }) }); - if (!response.ok) throw new Error('API error'); - const data = await response.json(); + if (!response.ok) throw new Error('HTTP ' + response.status + ' ' + response.statusText); + const data = await readJson(response); + if (typeof data.response !== 'string' || !data.response.trim()) { + throw new Error('Model returned no text' + (data.error ? ': ' + data.error : ' — is "' + model + '" installed?')); + } logTokens(data.prompt_eval_count || 0, data.eval_count || 0); @@ -928,25 +1057,25 @@ log('Translating to ' + targetLang + ' with ' + translateModel + '...', 'info'); document.getElementById('translateBtn').disabled = true; document.getElementById('translating').style.display = 'block'; + if (!workflowRunning) { + stopRequested = false; + isPaused = false; + } + isProcessing = true; 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)); - } + const CHUNK_SIZE = Math.min(5000, Math.max(500, parseInt(config.chunkSize, 10) || 1500)); + const chunks = chunkText(textToTranslate, CHUNK_SIZE); let translated = ''; try { for (let i = 0; i < chunks.length; i++) { - if (stopRequested) { - log('Translation stopped', 'warn'); - break; - } + if (stopRequested) break; while (isPaused) { await new Promise(r => setTimeout(r, 100)); if (stopRequested) break; } + if (stopRequested) break; const pct = Math.round(((i + 1) / chunks.length) * 100); showProgress(pct, `Translating chunk ${i + 1}/${chunks.length}`); @@ -954,7 +1083,6 @@ 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}`; @@ -972,17 +1100,25 @@ }) }); - if (!response.ok) throw new Error('Translation API error'); - const data = await response.json(); + if (!response.ok) throw new Error('HTTP ' + response.status + ' ' + response.statusText); + const data = await readJson(response); + if (typeof data.response !== 'string') { + throw new Error('Model returned no text' + (data.error ? ': ' + data.error : ' — is "' + translateModel + '" installed?')); + } logTokens(data.prompt_eval_count || 0, data.eval_count || 0); translated += data.response + '\n\n'; } - if (!stopRequested) { - editor.value = translated; - cleanedText = translated; - saveState(); + if (stopRequested) { + hideProgress(); + log('Translation stopped — the editor was left unchanged.', 'warn'); + return false; } + if (!translated.trim()) throw new Error('Model returned no text'); + + editor.value = translated; + cleanedText = translated; + saveState(); hideProgress(); log('Translation complete! ' + totalTokens + ' tokens used.', 'success'); @@ -992,6 +1128,10 @@ log('Translation error: ' + err.message, 'error'); hideProgress(); return false; + } finally { + document.getElementById('translating').style.display = 'none'; + document.getElementById('translateBtn').disabled = false; + isProcessing = false; } } @@ -1030,11 +1170,12 @@ }) }); - if (!response.ok) throw new Error('Validation API error'); - const data = await response.json(); + if (!response.ok) throw new Error('HTTP ' + response.status + ' ' + response.statusText); + const data = await readJson(response); + if (typeof data.response !== 'string') throw new Error('Model returned no text' + (data.error ? ': ' + data.error : '')); logTokens(data.prompt_eval_count || 0, data.eval_count || 0); - const result = data.response; + const result = data.response.trim(); if (result.startsWith('NEEDS FIX:')) { const corrected = result.replace('NEEDS FIX:', '').trim(); editor.value = corrected; @@ -1062,7 +1203,10 @@ const targetLang = document.getElementById('targetLang').value; log('Starting workflow: ' + workflow, 'info'); + stopRequested = false; + isPaused = false; isProcessing = true; + workflowRunning = true; const steps = { 'clean_translate_validate': ['clean', 'translate', 'validate'], @@ -1072,7 +1216,7 @@ 'clean_only': ['clean'] }; - for (const step of steps[workflow]) { + workflow: for (const step of steps[workflow]) { if (stopRequested) break; while (isPaused && !stopRequested) { await new Promise(r => setTimeout(r, 100)); @@ -1080,20 +1224,23 @@ switch (step) { case 'clean': - const cleaned = await cleanText(); - if (!cleaned) { log('Workflow stopped: clean failed', 'error'); break; } + if (!(await cleanText())) { log('Workflow stopped: clean failed', 'error'); break workflow; } break; case 'translate': - const translated = await translateText(); - if (!translated) { log('Workflow stopped: translate failed', 'error'); break; } + if (!(await translateText())) { log('Workflow stopped: translate failed', 'error'); break workflow; } break; case 'validate': - await validateText(); + if (!(await validateText())) { log('Workflow stopped: validate failed', 'error'); break workflow; } break; } } + workflowRunning = false; isProcessing = false; + if (stopRequested) { + log('Workflow stopped.', 'warn'); + return; + } log('Workflow complete!', 'success'); saveState(); } @@ -1102,7 +1249,9 @@ const area = document.getElementById('previewArea'); const btn = area.querySelector('.fullscreen-btn'); area.classList.toggle('fullscreen'); - btn.textContent = area.classList.contains('fullscreen') ? '⛶ Exit' : '⛶ Fullscreen'; + const on = area.classList.contains('fullscreen'); + btn.textContent = on ? '⛶ Exit' : '⛶ Fullscreen'; + btn.setAttribute('aria-label', on ? 'Exit fullscreen editor' : 'Toggle fullscreen editor'); } function selectAll() { @@ -1140,9 +1289,13 @@ function clearAll() { pdfData = null; + pdfDoc = null; extractedText = ''; cleanedText = ''; + stopRequested = false; + isPaused = false; editor.value = ''; + document.getElementById('translating').style.display = 'none'; document.getElementById('fileInfo').style.display = 'none'; document.getElementById('fileInput').value = ''; document.getElementById('editCard').style.display = 'none'; @@ -1173,7 +1326,9 @@ function togglePause() { isPaused = !isPaused; - document.getElementById('pauseBtn').textContent = isPaused ? '▶️' : '⏸️'; + const btn = document.getElementById('pauseBtn'); + btn.textContent = isPaused ? '▶️' : '⏸️'; + btn.setAttribute('aria-label', isPaused ? 'Resume translation' : 'Pause translation'); log(isPaused ? 'Paused' : 'Resumed', 'info'); } |