summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-11-09 15:36:11 +0100
committerAgando <roduccomm@gmail.com>2026-11-09 15:36:11 +0100
commitfa527774c2dfec90920d8b1befbefc3ac8241f22 (patch)
tree3fe1030aa75bd43abeea4541eff85f289371a77b
parent896ae37a4297f703bc4fa3fe155c8a35d49a326d (diff)
fix(products): ship the reviewed 1.0.1 builds — the 1.0.0 pdf2txt never ranHEADv1.0.1main
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.
-rw-r--r--CHANGELOG.md27
-rw-r--r--README.md12
-rw-r--r--VERSION2
-rw-r--r--docs/USAGE.md2
-rw-r--r--src/index.html359
5 files changed, 293 insertions, 109 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0684b9b..72d7731 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,30 @@
+## 1.0.1 — 2026-11-09
+
+A review that drove the page in Chromium found that the 1.0.0 file **did not run at all**. Every fix
+below was reproduced before it was made.
+
+- **P0: the console showed `SyntaxError: Identifier 'config' has already been declared`** — a
+ duplicated `const config = loadConfig()` inside `fetchModels`, so no handler was ever bound: no
+ extraction, no translation, no config. Both inline scripts are now syntax-checked at packaging time.
+- **The API key was written to `localStorage`**, on an origin shared with the rest of the site, and the
+ **whole extracted document was stored** on every action. The key now lives in `sessionStorage`
+ (per tab, gone when the tab closes), old copies are scrubbed on load, and the document is never
+ persisted.
+- Extraction joins pages with `hasEOL` instead of spaces, so paragraphs survive.
+- Chunks split on word boundaries (they used to cut mid-word); the size is clamped to 500–5000.
+- A 200 response that is not JSON, or a reply without a `response` field, now reports the real error
+ instead of writing the literal text `undefined` into the editor and logging success.
+- Translate re-enables its button and hides the banner in a `finally`; `stopRequested` resets, so a
+ second run after Stop actually runs; a failed workflow step stops the workflow (the `break` used to
+ break only the `switch`).
+- A PDF with no text layer says so, disables Download/Translate and keeps the previous text.
+- `.PDF` (uppercase) is recognised as a PDF; the page count is shown after parsing.
+- Mobile: the `@media (max-width: 600px)` block was unterminated and held no rules — fixed and filled
+ in; the Fullscreen button no longer overlaps the first line of the editor.
+- Accessibility: the drop zone is a real `role="button"` with `tabindex="0"` and Enter/Space opens the
+ file picker; the file input stays in the accessibility tree; every visible label is associated and
+ the icon-only buttons carry `aria-label`s (39 controls, 0 unnamed).
+
## [1.0.0] - 2026-11-09
First packaged release: single-file browser app plus local PDF.js vendor assets.
diff --git a/README.md b/README.md
index 566b335..a55d2ed 100644
--- a/README.md
+++ b/README.md
@@ -18,8 +18,9 @@ server, no account, no sign-up and no tracking code in the app.
`Clean → Validate`, `Translate Only`, `Clean Only`.
- **Translate into 16 target languages** offered in the page: English, Spanish, French,
German, Italian, Portuguese, Chinese, Japanese, Korean, Russian, Arabic, Hindi,
- Turkish, Dutch, Polish, Swedish. Text is split into 1,500-character chunks and sent
- one chunk at a time, with pause and stop buttons and a token counter.
+ Turkish, Dutch, Polish, Swedish. The text is split at word boundaries and sent one
+ chunk at a time (1,500 characters by default, 2,500 when a `:cloud` model is chosen),
+ with pause and stop buttons and a token counter.
- **Settings tab** — API source (Proxied `/ollama`, Local `localhost:11434`, or a Custom
URL), optional API key, *Refresh Models* (reads `/api/tags`), and chunk size.
@@ -42,9 +43,10 @@ pdf2txt/
## How to run it
-The page loads PDF.js as a Web Worker, and browsers refuse to start a worker from a
-`file://` URL. So double-clicking `src/index.html` will load but extraction will fail.
-Serve the folder over plain HTTP instead — no dependencies, Python 3 is enough:
+The page loads PDF.js as a Web Worker. Some browsers refuse to start a worker from a
+`file://` URL (Chrome blocks it; a headless Chromium on this host happened to allow it),
+so double-clicking `src/index.html` may load but extract nothing. If that happens, serve
+the folder over plain HTTP — no dependencies, Python 3 is enough:
```bash
cd products/pdf2txt
diff --git a/VERSION b/VERSION
index 3eefcb9..7dea76e 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.0.0
+1.0.1
diff --git a/docs/USAGE.md b/docs/USAGE.md
index df194b6..35c77be 100644
--- a/docs/USAGE.md
+++ b/docs/USAGE.md
@@ -90,7 +90,7 @@ Things worth knowing before you translate:
- The package ships with **API Source** set to `Proxied (/ollama)` and **Translation
Model** set to `glm-5.3-flash:cloud`. Both are cloud settings. For an offline run you
must change them as in steps 3 and 4.
-- Long documents are sent in 1,500-character pieces, one after another, so a big book
+- Long documents are sent in pieces (1,500 characters, or 2,500 with a cloud model), one after another, so a big book
takes a while. Use the pause/stop buttons if needed.
- The **Clean** and **Validate** steps also use a model (`llama3.2:latest` by default). If
you only want a translation, choose the workflow `Translate Only`.
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 &amp; 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');
}