Node HTTP server that proxies the DeepL API and serves a single-page UI, plus per-domain glossary management for the four Scheer entities. Runs on Node's built-ins only; no dependencies. Containerized for Dokploy: the domain -> glossary_id map is written to DOMAINS_FILE (default /app/data/domains.json) so it can live on a mounted volume and survive redeploys. See DEPLOY.md. Co-Authored-By: Claude <noreply@anthropic.com>
949 lines
38 KiB
HTML
949 lines
38 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>Scheer Enterprise Translation Tool</title>
|
|
<style>
|
|
:root {
|
|
--scheer-black: #222222;
|
|
--scheer-black-2: #3a3a3a;
|
|
--scheer-red: #f1573e;
|
|
--scheer-red-dark: #d6362e;
|
|
--scheer-grey: #cccccc;
|
|
--scheer-light-grey: #f1f1f1;
|
|
--accent: var(--scheer-red);
|
|
--bg: #f4f4f4;
|
|
--panel: #ffffff;
|
|
--border: #e0e0e0;
|
|
--text: #222222;
|
|
--muted: #74787c;
|
|
--danger: #c0392b;
|
|
--ok: #1e8e5a;
|
|
--warn: #b8860b;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
}
|
|
header {
|
|
background: linear-gradient(90deg, var(--scheer-black) 0%, var(--scheer-black-2) 100%);
|
|
color: #fff;
|
|
padding: 18px 28px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
flex-wrap: wrap;
|
|
gap: 12px;
|
|
border-bottom: 3px solid var(--scheer-red);
|
|
}
|
|
header h1 { font-size: 18px; margin: 0; font-weight: 600; }
|
|
header .sub { font-size: 12px; opacity: 0.75; margin-top: 2px; }
|
|
.container { max-width: 1100px; margin: 0 auto; padding: 24px; }
|
|
.settings {
|
|
background: var(--panel);
|
|
border: 1px solid var(--border);
|
|
border-radius: 10px;
|
|
padding: 16px 20px;
|
|
margin-bottom: 20px;
|
|
display: flex;
|
|
gap: 16px;
|
|
align-items: flex-end;
|
|
flex-wrap: wrap;
|
|
}
|
|
.field { display: flex; flex-direction: column; gap: 4px; }
|
|
.field label { font-size: 12px; font-weight: 600; color: var(--muted); }
|
|
input, select, textarea {
|
|
font-family: inherit;
|
|
font-size: 14px;
|
|
padding: 8px 10px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
background: #fff;
|
|
color: var(--text);
|
|
}
|
|
input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
button {
|
|
font-family: inherit;
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
padding: 9px 16px;
|
|
border-radius: 6px;
|
|
border: none;
|
|
background: var(--scheer-red);
|
|
color: #fff;
|
|
cursor: pointer;
|
|
}
|
|
button:hover { background: var(--scheer-red-dark); }
|
|
button.secondary { background: #fff; color: var(--scheer-black); border: 1px solid var(--scheer-black); }
|
|
button.secondary:hover { background: var(--scheer-light-grey); }
|
|
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
.status-pill {
|
|
font-size: 12px;
|
|
padding: 3px 10px;
|
|
border-radius: 999px;
|
|
background: rgba(255,255,255,0.12);
|
|
color: #fff;
|
|
font-weight: 600;
|
|
}
|
|
.header-right { display: flex; align-items: center; gap: 8px; }
|
|
.header-icon-btn {
|
|
background: rgba(255,255,255,0.12);
|
|
color: #fff;
|
|
border: 1px solid rgba(255,255,255,0.3);
|
|
border-radius: 6px;
|
|
padding: 4px 9px;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
}
|
|
.header-icon-btn:hover { background: rgba(255,255,255,0.24); }
|
|
.tabs { display: flex; gap: 4px; border-bottom: 2px solid var(--border); margin-bottom: 20px; }
|
|
.tab {
|
|
padding: 10px 18px;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
color: var(--muted);
|
|
border-bottom: 3px solid transparent;
|
|
margin-bottom: -2px;
|
|
}
|
|
.tab.active { color: var(--scheer-black); border-bottom-color: var(--scheer-red); }
|
|
.panel { display: none; background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 24px; }
|
|
.panel.active { display: block; }
|
|
.row { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; }
|
|
.col { flex: 1; min-width: 260px; display: flex; flex-direction: column; gap: 6px; }
|
|
textarea { resize: vertical; min-height: 180px; line-height: 1.5; }
|
|
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
@media (max-width: 720px) { .grid-2 { grid-template-columns: 1fr; } }
|
|
.actions { display: flex; gap: 10px; align-items: center; margin: 14px 0; flex-wrap: wrap; }
|
|
.msg { font-size: 13px; padding: 10px 12px; border-radius: 6px; margin-top: 10px; }
|
|
.msg.error { background: #fdecea; color: var(--danger); border: 1px solid #f5c6c2; }
|
|
.msg.ok { background: #e9f9f0; color: var(--ok); border: 1px solid #b7ecd0; }
|
|
.msg.info { background: var(--scheer-light-grey); color: var(--scheer-black); border: 1px solid var(--border); }
|
|
.hint { font-size: 12px; color: var(--muted); margin-top: 4px; }
|
|
.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin: 14px 0; }
|
|
.stat-box { background: var(--scheer-light-grey); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; }
|
|
.stat-box .num { font-size: 20px; font-weight: 700; color: var(--scheer-red-dark); }
|
|
.stat-box .lbl { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.03em; }
|
|
footer { text-align: center; font-size: 12px; color: var(--muted); padding: 30px 0 10px; }
|
|
code { background: #f0f2f5; padding: 1px 5px; border-radius: 4px; font-size: 12.5px; }
|
|
|
|
/* Domain bar (always visible, independent of tab navigation) */
|
|
.domain-bar {
|
|
background: var(--panel);
|
|
border: 1px solid var(--border);
|
|
border-radius: 10px;
|
|
padding: 14px 20px;
|
|
margin-bottom: 20px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.domain-bar .domain-label { font-size: 12px; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: 0.03em; }
|
|
.domain-btns { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
.domain-btn {
|
|
padding: 8px 14px;
|
|
border-radius: 999px;
|
|
border: 1px solid var(--border);
|
|
background: #fff;
|
|
color: var(--text);
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
.domain-btn:hover { border-color: var(--scheer-red); background: var(--scheer-light-grey); }
|
|
.domain-btn.active { background: var(--scheer-red); color: #fff; border-color: var(--scheer-red); }
|
|
|
|
/* Domain settings modal */
|
|
.modal-overlay {
|
|
display: none;
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0,0,0,0.55);
|
|
z-index: 1000;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 20px;
|
|
}
|
|
.modal-overlay.open { display: flex; }
|
|
.modal-box {
|
|
background: #fff;
|
|
border-radius: 10px;
|
|
max-width: 760px;
|
|
width: 100%;
|
|
max-height: 88vh;
|
|
overflow-y: auto;
|
|
padding: 24px;
|
|
}
|
|
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; border-bottom: 3px solid var(--scheer-red); padding-bottom: 12px; }
|
|
.modal-header h2 { margin: 0; font-size: 18px; }
|
|
.modal-close { background: none; border: none; color: var(--muted); font-size: 22px; cursor: pointer; padding: 0 6px; line-height: 1; }
|
|
.modal-close:hover { color: var(--scheer-red); }
|
|
.modal-section { border-top: 1px solid var(--border); padding-top: 16px; margin-top: 16px; }
|
|
.modal-section:first-of-type { border-top: none; margin-top: 0; padding-top: 0; }
|
|
.modal-section h3 { font-size: 14px; margin: 0 0 10px; color: var(--scheer-black); }
|
|
.entry-row { display: flex; gap: 8px; margin-bottom: 6px; align-items: center; }
|
|
.entry-row input, .entry-row select { flex: 1; }
|
|
.entry-row button { padding: 6px 10px; font-size: 12px; flex: 0 0 auto; }
|
|
.small-btn { padding: 6px 10px !important; font-size: 12px !important; }
|
|
.current-config { font-size: 12px; color: var(--muted); background: var(--scheer-light-grey); border-radius: 6px; padding: 8px 10px; margin-bottom: 10px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<header>
|
|
<div>
|
|
<h1>Scheer Group: Translation Tool</h1>
|
|
<div class="sub">Powered by DeepL API · Growth Plan</div>
|
|
</div>
|
|
<div class="header-right">
|
|
<div id="usagePill" class="status-pill">Usage: loading…</div>
|
|
<button class="header-icon-btn" id="usageRefreshBtn" title="Refresh usage">↻</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="container">
|
|
|
|
<!-- Domain selector: always visible, independent of tab navigation -->
|
|
<div class="domain-bar">
|
|
<span class="domain-label">Domain</span>
|
|
<div class="domain-btns" id="domainBtns">
|
|
<button type="button" class="domain-btn" data-domain="scheer-group">Scheer Group</button>
|
|
<button type="button" class="domain-btn" data-domain="scheer-ids">Scheer IDS</button>
|
|
<button type="button" class="domain-btn" data-domain="scheer-pas">Scheer PAS</button>
|
|
<button type="button" class="domain-btn" data-domain="scheer-imc">Scheer IMC</button>
|
|
</div>
|
|
<button class="secondary" id="domainSettingsBtn" style="margin-left:auto;">⚙ Domain settings</button>
|
|
</div>
|
|
|
|
<div class="tabs">
|
|
<div class="tab active" data-tab="text">Text Translation</div>
|
|
<div class="tab" data-tab="doc">Document (.docx)</div>
|
|
<div class="tab" data-tab="pdf">PDF Translation</div>
|
|
<div class="tab" data-tab="json">JSON Translation</div>
|
|
</div>
|
|
|
|
<!-- TEXT TRANSLATION -->
|
|
<div class="panel active" id="panel-text">
|
|
<div class="row">
|
|
<div class="col">
|
|
<label>Source language</label>
|
|
<select id="text-source-lang">
|
|
<option value="">Auto-detect</option>
|
|
<option value="DE">German</option>
|
|
<option value="EN">English</option>
|
|
<option value="FR">French</option>
|
|
</select>
|
|
</div>
|
|
<div class="col">
|
|
<label>Target language</label>
|
|
<select id="text-target-lang">
|
|
<option value="DE">German</option>
|
|
<option value="DE-CH">German (Swiss)</option>
|
|
<option value="EN-GB">English (British)</option>
|
|
<option value="EN-US">English (American)</option>
|
|
<option value="FR">French</option>
|
|
</select>
|
|
</div>
|
|
<div class="col" style="max-width:220px;">
|
|
<label> </label>
|
|
<label style="font-weight:400; font-size:13px; display:flex; align-items:center; gap:6px;">
|
|
<input type="checkbox" id="text-protect-placeholders" checked style="width:auto;" />
|
|
Protect placeholders ({{var}}, %s, {id})
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div class="grid-2">
|
|
<div class="col">
|
|
<label>Source text</label>
|
|
<textarea id="text-input" placeholder="Paste or type text to translate..."></textarea>
|
|
</div>
|
|
<div class="col">
|
|
<label>Translation</label>
|
|
<textarea id="text-output" readonly placeholder="Translation will appear here..."></textarea>
|
|
</div>
|
|
</div>
|
|
<div class="actions">
|
|
<button id="text-translate-btn">Translate</button>
|
|
<button class="secondary" id="text-copy-btn">Copy output</button>
|
|
<span class="hint" id="text-charcount">0 characters</span>
|
|
</div>
|
|
<div id="text-msg"></div>
|
|
</div>
|
|
|
|
<!-- DOCUMENT TRANSLATION -->
|
|
<div class="panel" id="panel-doc">
|
|
<div class="row">
|
|
<div class="col">
|
|
<label>Source language</label>
|
|
<select id="doc-source-lang">
|
|
<option value="">Auto-detect</option>
|
|
<option value="DE">German</option>
|
|
<option value="EN">English</option>
|
|
<option value="FR">French</option>
|
|
</select>
|
|
</div>
|
|
<div class="col">
|
|
<label>Target language</label>
|
|
<select id="doc-target-lang">
|
|
<option value="DE">German</option>
|
|
<option value="DE-CH">German (Swiss)</option>
|
|
<option value="EN-GB">English (British)</option>
|
|
<option value="EN-US">English (American)</option>
|
|
<option value="FR">French</option>
|
|
</select>
|
|
</div>
|
|
<div class="col">
|
|
<label>File (.docx)</label>
|
|
<input type="file" id="doc-file" accept=".docx,.pptx,.xlsx,.pdf,.txt,.html" />
|
|
</div>
|
|
</div>
|
|
<div class="actions">
|
|
<button id="doc-translate-btn">Upload & translate</button>
|
|
<span id="doc-status" class="hint"></span>
|
|
</div>
|
|
<div id="doc-msg"></div>
|
|
<div id="doc-download" style="margin-top:12px;"></div>
|
|
<div class="hint">Supports the file formats DeepL's document API accepts (.docx, .pptx, .xlsx, .pdf, .txt, .html). Formatting, layout and placeholders are preserved by DeepL automatically for these formats.</div>
|
|
</div>
|
|
|
|
<!-- PDF TRANSLATION -->
|
|
<div class="panel" id="panel-pdf">
|
|
<div class="row">
|
|
<div class="col">
|
|
<label>Source language</label>
|
|
<select id="pdf-source-lang">
|
|
<option value="">Auto-detect</option>
|
|
<option value="DE">German</option>
|
|
<option value="EN">English</option>
|
|
<option value="FR">French</option>
|
|
</select>
|
|
</div>
|
|
<div class="col">
|
|
<label>Target language</label>
|
|
<select id="pdf-target-lang">
|
|
<option value="DE">German</option>
|
|
<option value="DE-CH">German (Swiss)</option>
|
|
<option value="EN-GB">English (British)</option>
|
|
<option value="EN-US">English (American)</option>
|
|
<option value="FR">French</option>
|
|
</select>
|
|
</div>
|
|
<div class="col">
|
|
<label>PDF file</label>
|
|
<input type="file" id="pdf-file" accept=".pdf" />
|
|
</div>
|
|
</div>
|
|
<div class="actions">
|
|
<button id="pdf-translate-btn">Translate PDF</button>
|
|
<span id="pdf-status" class="hint"></span>
|
|
</div>
|
|
<div id="pdf-msg"></div>
|
|
<div id="pdf-download" style="margin-top:12px;"></div>
|
|
<div class="hint">
|
|
Works best on PDFs with a real text layer; scanned/image-only PDFs aren't OCR'd by DeepL
|
|
and may translate poorly or come back unchanged.
|
|
</div>
|
|
</div>
|
|
|
|
<!-- JSON TRANSLATION -->
|
|
<div class="panel" id="panel-json">
|
|
<div class="row">
|
|
<div class="col">
|
|
<label>Target language</label>
|
|
<select id="json-target-lang">
|
|
<option value="DE">German</option>
|
|
<option value="DE-CH">German (Swiss)</option>
|
|
<option value="EN-GB">English (British)</option>
|
|
<option value="EN-US">English (American)</option>
|
|
<option value="FR">French</option>
|
|
</select>
|
|
</div>
|
|
<div class="col">
|
|
<label>Source language</label>
|
|
<select id="json-source-lang">
|
|
<option value="">Auto-detect</option>
|
|
<option value="DE">German</option>
|
|
<option value="EN">English</option>
|
|
<option value="FR">French</option>
|
|
</select>
|
|
</div>
|
|
<div class="col">
|
|
<label>Or upload a .json file</label>
|
|
<input type="file" id="json-file" accept=".json" />
|
|
</div>
|
|
</div>
|
|
<div class="col" style="margin-bottom:12px;">
|
|
<label>Input JSON</label>
|
|
<textarea id="json-input" style="min-height:220px; font-family: ui-monospace, Consolas, monospace; font-size:13px;" placeholder='{"button_label": "Submit", "status_code_404": "not_found", "welcome_msg": "Hello {{name}}, welcome back!"}'></textarea>
|
|
</div>
|
|
<div class="actions">
|
|
<button id="json-translate-btn">Translate JSON values</button>
|
|
<button class="secondary" id="json-download-btn" disabled>Download translated JSON</button>
|
|
</div>
|
|
<div id="json-msg"></div>
|
|
<div class="stat-grid" id="json-stats" style="display:none;"></div>
|
|
<div class="col">
|
|
<label>Output JSON</label>
|
|
<textarea id="json-output" readonly style="min-height:220px; font-family: ui-monospace, Consolas, monospace; font-size:13px;"></textarea>
|
|
</div>
|
|
<div class="hint">
|
|
Only user-facing string values are sent to DeepL. Keys, numbers, booleans, nulls, URLs, HTML/code snippets,
|
|
template tags like <code>{{var}}</code>, and key-like identifiers (e.g. <code>status_code_404</code>) are
|
|
skipped and reconstructed verbatim — this is a heuristic filter, so review the output before shipping it.
|
|
</div>
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
<!-- DOMAIN SETTINGS MODAL -->
|
|
<div class="modal-overlay" id="domainModalOverlay">
|
|
<div class="modal-box">
|
|
<div class="modal-header">
|
|
<h2 id="domainModalTitle">Domain settings</h2>
|
|
<button class="modal-close" id="domainModalCloseBtn" title="Close">×</button>
|
|
</div>
|
|
|
|
<!-- Glossary: German -> English -->
|
|
<div class="modal-section">
|
|
<h3>Terms: German → English</h3>
|
|
<div class="current-config" id="glossaryCurrent-de-en">No terms configured for this domain yet.</div>
|
|
<label>Terms (German → English)</label>
|
|
<div id="glossaryEntriesContainer-de-en"></div>
|
|
<div class="actions" style="margin-top:8px;">
|
|
<button class="secondary small-btn" data-add-row="de-en">+ Add term</button>
|
|
</div>
|
|
<div class="actions">
|
|
<button data-save="de-en">Save German → English terms</button>
|
|
<button class="secondary" data-delete="de-en">Delete list</button>
|
|
</div>
|
|
<div id="glossary-msg-de-en"></div>
|
|
</div>
|
|
|
|
<!-- Glossary: English -> German -->
|
|
<div class="modal-section">
|
|
<h3>Terms: English → German</h3>
|
|
<div class="current-config" id="glossaryCurrent-en-de">No terms configured for this domain yet.</div>
|
|
<label>Terms (English → German)</label>
|
|
<div id="glossaryEntriesContainer-en-de"></div>
|
|
<div class="actions" style="margin-top:8px;">
|
|
<button class="secondary small-btn" data-add-row="en-de">+ Add term</button>
|
|
</div>
|
|
<div class="actions">
|
|
<button data-save="en-de">Save English → German terms</button>
|
|
<button class="secondary" data-delete="en-de">Delete list</button>
|
|
</div>
|
|
<div id="glossary-msg-en-de"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<footer>Scheer Enterprise Translation Tool — internal use — DeepL API (Growth plan)</footer>
|
|
|
|
<script>
|
|
// ---------------------------------------------------------------------
|
|
// Shared helpers
|
|
// ---------------------------------------------------------------------
|
|
function showMsg(el, text, type) {
|
|
el.innerHTML = `<div class="msg ${type}">${text}</div>`;
|
|
}
|
|
function clearMsg(el) { el.innerHTML = ''; }
|
|
|
|
async function apiPost(path, body) {
|
|
const resp = await fetch(path, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
const data = await resp.json().catch(() => ({}));
|
|
if (!resp.ok) throw new Error(data.error || `Request failed (${resp.status})`);
|
|
return data;
|
|
}
|
|
|
|
async function translateTexts(texts, targetLang, sourceLang, domain) {
|
|
// DeepL accepts up to 50 text items per request — chunk larger batches.
|
|
const CHUNK = 50;
|
|
const out = [];
|
|
let applied = { glossary: false };
|
|
for (let i = 0; i < texts.length; i += CHUNK) {
|
|
const chunk = texts.slice(i, i + CHUNK);
|
|
const data = await apiPost('/api/translate', {
|
|
texts: chunk, target_lang: targetLang, source_lang: sourceLang || undefined, domain: domain || undefined,
|
|
});
|
|
out.push(...data.translations.map(t => t.text));
|
|
if (data.applied) applied = data.applied;
|
|
}
|
|
return { texts: out, applied };
|
|
}
|
|
|
|
function appliedNote(applied) {
|
|
return applied && applied.glossary ? ' (applied domain glossary)' : '';
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Domain customization: persistent domain bar + settings modal
|
|
// ---------------------------------------------------------------------
|
|
const DOMAIN_STORAGE_KEY = 'scheer_active_domain';
|
|
const DOMAIN_LABELS = {
|
|
'scheer-group': 'Scheer Group',
|
|
'scheer-ids': 'Scheer IDS',
|
|
'scheer-pas': 'Scheer PAS',
|
|
'scheer-imc': 'Scheer IMC',
|
|
};
|
|
let activeDomain = localStorage.getItem(DOMAIN_STORAGE_KEY) || 'scheer-group';
|
|
let domainsCache = {};
|
|
|
|
function renderDomainBar() {
|
|
document.querySelectorAll('.domain-btn').forEach((btn) => {
|
|
const id = btn.dataset.domain;
|
|
btn.classList.toggle('active', id === activeDomain);
|
|
const cfg = domainsCache[id];
|
|
const hasCustomization = !!(cfg && cfg.glossaries && (cfg.glossaries['de-en'] || cfg.glossaries['en-de']));
|
|
const existingDot = btn.querySelector('.dot');
|
|
if (hasCustomization && !existingDot) {
|
|
const dot = document.createElement('span');
|
|
dot.className = 'dot';
|
|
btn.appendChild(dot);
|
|
} else if (!hasCustomization && existingDot) {
|
|
existingDot.remove();
|
|
}
|
|
});
|
|
}
|
|
|
|
async function loadDomains() {
|
|
try {
|
|
const resp = await fetch('/api/domains');
|
|
domainsCache = await resp.json();
|
|
} catch (e) {
|
|
console.warn('Could not load domain settings:', e.message);
|
|
}
|
|
renderDomainBar();
|
|
}
|
|
|
|
document.querySelectorAll('.domain-btn').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
activeDomain = btn.dataset.domain;
|
|
localStorage.setItem(DOMAIN_STORAGE_KEY, activeDomain);
|
|
renderDomainBar();
|
|
});
|
|
});
|
|
|
|
// --- Modal open/close ---
|
|
const domainModalOverlay = document.getElementById('domainModalOverlay');
|
|
document.getElementById('domainSettingsBtn').addEventListener('click', async () => {
|
|
document.getElementById('domainModalTitle').textContent = `${DOMAIN_LABELS[activeDomain] || activeDomain} — domain settings`;
|
|
await loadDomains(); // pull the latest shared state before showing (other users may have edited it)
|
|
populateGlossarySection();
|
|
domainModalOverlay.classList.add('open');
|
|
});
|
|
document.getElementById('domainModalCloseBtn').addEventListener('click', () => domainModalOverlay.classList.remove('open'));
|
|
domainModalOverlay.addEventListener('click', (e) => { if (e.target === domainModalOverlay) domainModalOverlay.classList.remove('open'); });
|
|
|
|
// --- Glossary section: two fixed-direction term lists ---
|
|
const GLOSSARY_DIRECTIONS = ['de-en', 'en-de'];
|
|
|
|
function glossaryEntryRow(source, target) {
|
|
const row = document.createElement('div');
|
|
row.className = 'entry-row';
|
|
row.innerHTML = `
|
|
<input type="text" class="glossary-source" placeholder="Source term" value="${(source || '').replace(/"/g, '"')}" />
|
|
<input type="text" class="glossary-target" placeholder="Target term" value="${(target || '').replace(/"/g, '"')}" />
|
|
<button type="button" class="secondary small-btn remove-row-btn">Remove</button>
|
|
`;
|
|
row.querySelector('.remove-row-btn').addEventListener('click', () => row.remove());
|
|
return row;
|
|
}
|
|
|
|
function populateGlossarySection() {
|
|
const cfg = domainsCache[activeDomain];
|
|
GLOSSARY_DIRECTIONS.forEach((dir) => {
|
|
const g = cfg && cfg.glossaries && cfg.glossaries[dir];
|
|
const currentEl = document.getElementById(`glossaryCurrent-${dir}`);
|
|
const container = document.getElementById(`glossaryEntriesContainer-${dir}`);
|
|
container.innerHTML = '';
|
|
if (g) {
|
|
currentEl.textContent = `Current: ${g.entry_count} term${g.entry_count === 1 ? '' : 's'}`;
|
|
(g.entries || []).forEach((e) => container.appendChild(glossaryEntryRow(e.source, e.target)));
|
|
} else {
|
|
currentEl.textContent = 'No terms configured for this domain yet.';
|
|
}
|
|
if (container.children.length === 0) container.appendChild(glossaryEntryRow('', ''));
|
|
clearMsg(document.getElementById(`glossary-msg-${dir}`));
|
|
});
|
|
}
|
|
|
|
GLOSSARY_DIRECTIONS.forEach((dir) => {
|
|
document.querySelector(`[data-add-row="${dir}"]`).addEventListener('click', () => {
|
|
document.getElementById(`glossaryEntriesContainer-${dir}`).appendChild(glossaryEntryRow('', ''));
|
|
});
|
|
|
|
document.querySelector(`[data-save="${dir}"]`).addEventListener('click', async () => {
|
|
const msgEl = document.getElementById(`glossary-msg-${dir}`);
|
|
clearMsg(msgEl);
|
|
const entries = [...document.querySelectorAll(`#glossaryEntriesContainer-${dir} .entry-row`)].map((row) => ({
|
|
source: row.querySelector('.glossary-source').value.trim(),
|
|
target: row.querySelector('.glossary-target').value.trim(),
|
|
})).filter((e) => e.source && e.target);
|
|
if (entries.length === 0) { showMsg(msgEl, 'Add at least one term pair.', 'error'); return; }
|
|
const btn = document.querySelector(`[data-save="${dir}"]`);
|
|
btn.disabled = true;
|
|
try {
|
|
await apiPost(`/api/domains/${activeDomain}/glossary/${dir}`, { entries });
|
|
showMsg(msgEl, 'Terms saved.', 'ok');
|
|
await loadDomains();
|
|
populateGlossarySection();
|
|
} catch (e) {
|
|
showMsg(msgEl, e.message, 'error');
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
|
|
document.querySelector(`[data-delete="${dir}"]`).addEventListener('click', async () => {
|
|
const msgEl = document.getElementById(`glossary-msg-${dir}`);
|
|
clearMsg(msgEl);
|
|
try {
|
|
await fetch(`/api/domains/${activeDomain}/glossary/${dir}`, { method: 'DELETE' });
|
|
showMsg(msgEl, 'List deleted.', 'ok');
|
|
await loadDomains();
|
|
populateGlossarySection();
|
|
} catch (e) {
|
|
showMsg(msgEl, e.message, 'error');
|
|
}
|
|
});
|
|
});
|
|
|
|
loadDomains();
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Usage indicator (always visible in the header, auto-loads + refreshable)
|
|
// ---------------------------------------------------------------------
|
|
async function refreshUsage() {
|
|
const pill = document.getElementById('usagePill');
|
|
pill.textContent = 'Usage: checking...';
|
|
try {
|
|
const data = await apiPost('/api/usage', {});
|
|
pill.textContent = `Usage: ${data.character_count.toLocaleString()} / ${data.character_limit ? data.character_limit.toLocaleString() : '∞'} chars`;
|
|
} catch (e) {
|
|
pill.textContent = 'Usage: error';
|
|
}
|
|
}
|
|
document.getElementById('usageRefreshBtn').addEventListener('click', refreshUsage);
|
|
refreshUsage();
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Tabs
|
|
// ---------------------------------------------------------------------
|
|
document.querySelectorAll('.tab').forEach(tab => {
|
|
tab.addEventListener('click', () => {
|
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
|
|
tab.classList.add('active');
|
|
document.getElementById(`panel-${tab.dataset.tab}`).classList.add('active');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Module 1: Text translation (with placeholder protection)
|
|
// ---------------------------------------------------------------------
|
|
const PLACEHOLDER_REGEX = /(\{\{[^{}]+\}\}|%[sd]|\{[a-zA-Z0-9_.]+\})/g;
|
|
|
|
function protectPlaceholders(text) {
|
|
const tokens = [];
|
|
const protectedText = text.replace(PLACEHOLDER_REGEX, (match) => {
|
|
const token = `§PH${tokens.length}§`;
|
|
tokens.push(match);
|
|
return token;
|
|
});
|
|
return { protectedText, tokens };
|
|
}
|
|
function restorePlaceholders(text, tokens) {
|
|
return text.replace(/§PH(\d+)§/g, (_, i) => tokens[Number(i)] ?? '');
|
|
}
|
|
|
|
const textInputEl = document.getElementById('text-input');
|
|
textInputEl.addEventListener('input', () => {
|
|
document.getElementById('text-charcount').textContent = `${textInputEl.value.length} characters`;
|
|
});
|
|
|
|
document.getElementById('text-translate-btn').addEventListener('click', async () => {
|
|
const msgEl = document.getElementById('text-msg');
|
|
clearMsg(msgEl);
|
|
const input = textInputEl.value;
|
|
if (!input.trim()) { showMsg(msgEl, 'Enter some text to translate.', 'error'); return; }
|
|
const target = document.getElementById('text-target-lang').value;
|
|
const source = document.getElementById('text-source-lang').value;
|
|
const protect = document.getElementById('text-protect-placeholders').checked;
|
|
const btn = document.getElementById('text-translate-btn');
|
|
btn.disabled = true; btn.textContent = 'Translating...';
|
|
try {
|
|
let payload = input, tokens = [];
|
|
if (protect) ({ protectedText: payload, tokens } = protectPlaceholders(input));
|
|
const { texts: translatedArr, applied } = await translateTexts([payload], target, source, activeDomain);
|
|
const translated = translatedArr[0];
|
|
document.getElementById('text-output').value = protect ? restorePlaceholders(translated, tokens) : translated;
|
|
showMsg(msgEl, `Translation complete.${appliedNote(applied)}`, 'ok');
|
|
} catch (e) {
|
|
showMsg(msgEl, e.message, 'error');
|
|
} finally {
|
|
btn.disabled = false; btn.textContent = 'Translate';
|
|
}
|
|
});
|
|
|
|
document.getElementById('text-copy-btn').addEventListener('click', () => {
|
|
const out = document.getElementById('text-output');
|
|
out.select();
|
|
document.execCommand('copy');
|
|
});
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Module 1b: Document translation (.docx and other DeepL doc formats)
|
|
// ---------------------------------------------------------------------
|
|
function fileToBase64(file) {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => resolve(reader.result.split(',')[1]);
|
|
reader.onerror = reject;
|
|
reader.readAsDataURL(file);
|
|
});
|
|
}
|
|
|
|
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
|
|
document.getElementById('doc-translate-btn').addEventListener('click', async () => {
|
|
const msgEl = document.getElementById('doc-msg');
|
|
const statusEl = document.getElementById('doc-status');
|
|
const downloadEl = document.getElementById('doc-download');
|
|
clearMsg(msgEl); downloadEl.innerHTML = ''; statusEl.textContent = '';
|
|
const fileInput = document.getElementById('doc-file');
|
|
const file = fileInput.files[0];
|
|
if (!file) { showMsg(msgEl, 'Choose a file first.', 'error'); return; }
|
|
const target = document.getElementById('doc-target-lang').value;
|
|
const source = document.getElementById('doc-source-lang').value;
|
|
const btn = document.getElementById('doc-translate-btn');
|
|
btn.disabled = true;
|
|
try {
|
|
statusEl.textContent = 'Uploading...';
|
|
const fileBase64 = await fileToBase64(file);
|
|
const uploadResp = await apiPost('/api/document/upload', {
|
|
filename: file.name, fileBase64, target_lang: target, source_lang: source || undefined, domain: activeDomain,
|
|
});
|
|
const { document_id, document_key, applied } = uploadResp;
|
|
|
|
let status = 'queued';
|
|
while (status === 'queued' || status === 'translating') {
|
|
await sleep(2000);
|
|
const s = await apiPost('/api/document/status', { document_id, document_key });
|
|
status = s.status;
|
|
statusEl.textContent = `Status: ${status}${s.seconds_remaining ? ` (~${s.seconds_remaining}s remaining)` : ''}`;
|
|
if (status === 'error') throw new Error(s.error_message || 'Translation failed on DeepL side.');
|
|
}
|
|
|
|
statusEl.textContent = 'Downloading result...';
|
|
const result = await apiPost('/api/document/result', { document_id, document_key });
|
|
const blob = base64ToBlob(result.fileBase64);
|
|
const url = URL.createObjectURL(blob);
|
|
const outName = file.name.replace(/(\.[a-zA-Z0-9]+)$/, `_${target}$1`);
|
|
downloadEl.innerHTML = `<a href="${url}" download="${outName}"><button>Download ${outName}</button></a>`;
|
|
statusEl.textContent = 'Done.';
|
|
showMsg(msgEl, `Document translated successfully.${appliedNote(applied)}`, 'ok');
|
|
} catch (e) {
|
|
showMsg(msgEl, e.message, 'error');
|
|
statusEl.textContent = '';
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
|
|
function base64ToBlob(base64) {
|
|
const byteChars = atob(base64);
|
|
const byteNumbers = new Array(byteChars.length);
|
|
for (let i = 0; i < byteChars.length; i++) byteNumbers[i] = byteChars.charCodeAt(i);
|
|
return new Blob([new Uint8Array(byteNumbers)]);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Module 1c: PDF translation
|
|
// ---------------------------------------------------------------------
|
|
document.getElementById('pdf-translate-btn').addEventListener('click', async () => {
|
|
const msgEl = document.getElementById('pdf-msg');
|
|
const statusEl = document.getElementById('pdf-status');
|
|
const downloadEl = document.getElementById('pdf-download');
|
|
clearMsg(msgEl); downloadEl.innerHTML = ''; statusEl.textContent = '';
|
|
const fileInput = document.getElementById('pdf-file');
|
|
const file = fileInput.files[0];
|
|
if (!file) { showMsg(msgEl, 'Choose a PDF file first.', 'error'); return; }
|
|
if (!/\.pdf$/i.test(file.name)) { showMsg(msgEl, 'Please choose a .pdf file.', 'error'); return; }
|
|
const source = document.getElementById('pdf-source-lang').value;
|
|
const target = document.getElementById('pdf-target-lang').value;
|
|
const btn = document.getElementById('pdf-translate-btn');
|
|
btn.disabled = true; btn.textContent = 'Translating...';
|
|
try {
|
|
statusEl.textContent = 'Uploading...';
|
|
const fileBase64 = await fileToBase64(file);
|
|
const uploadResp = await apiPost('/api/document/upload', {
|
|
filename: file.name, fileBase64, target_lang: target, source_lang: source || undefined, domain: activeDomain,
|
|
});
|
|
const { document_id, document_key, applied } = uploadResp;
|
|
|
|
let status = 'queued';
|
|
while (status === 'queued' || status === 'translating') {
|
|
await sleep(2000);
|
|
const s = await apiPost('/api/document/status', { document_id, document_key });
|
|
status = s.status;
|
|
statusEl.textContent = `Status: ${status}${s.seconds_remaining ? ` (~${s.seconds_remaining}s remaining)` : ''}`;
|
|
if (status === 'error') throw new Error(s.error_message || 'Translation failed on DeepL side.');
|
|
}
|
|
|
|
statusEl.textContent = 'Downloading result...';
|
|
const result = await apiPost('/api/document/result', { document_id, document_key });
|
|
const blob = base64ToBlob(result.fileBase64);
|
|
const url = URL.createObjectURL(blob);
|
|
const outName = file.name.replace(/\.pdf$/i, `_${target}.pdf`);
|
|
downloadEl.innerHTML = `<a href="${url}" download="${outName}"><button>Download ${outName}</button></a>`;
|
|
statusEl.textContent = 'Done.';
|
|
showMsg(msgEl, `PDF translated successfully.${appliedNote(applied)}`, 'ok');
|
|
} catch (e) {
|
|
showMsg(msgEl, e.message, 'error');
|
|
statusEl.textContent = '';
|
|
} finally {
|
|
btn.disabled = false; btn.textContent = 'Translate PDF';
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Module 4: Token-optimized JSON translation
|
|
// ---------------------------------------------------------------------
|
|
const URL_REGEX = /^(https?:\/\/|www\.)/i;
|
|
const KEY_LIKE_REGEX = /^[a-z0-9]+(_[a-z0-9]+)+$/i; // e.g. status_code_404, welcome_msg
|
|
const HTML_ONLY_REGEX = /^\s*<[^>]+>[\s\S]*<\/[^>]+>\s*$/;
|
|
const TEMPLATE_ONLY_REGEX = /^\s*(\{\{[^{}]+\}\}|%[sd]|\{[a-zA-Z0-9_.]+\})\s*$/;
|
|
|
|
function shouldSkipValue(str) {
|
|
if (typeof str !== 'string' || str.trim() === '') return true;
|
|
if (URL_REGEX.test(str.trim())) return true;
|
|
if (KEY_LIKE_REGEX.test(str.trim()) && !str.includes(' ')) return true;
|
|
if (HTML_ONLY_REGEX.test(str.trim())) return true;
|
|
if (TEMPLATE_ONLY_REGEX.test(str.trim())) return true;
|
|
return false;
|
|
}
|
|
|
|
function collectTranslatables(node, bucket) {
|
|
if (Array.isArray(node)) {
|
|
node.forEach((item) => collectTranslatables(item, bucket));
|
|
} else if (node && typeof node === 'object') {
|
|
for (const k of Object.keys(node)) collectTranslatables(node[k], bucket);
|
|
} else if (typeof node === 'string') {
|
|
bucket.total++;
|
|
if (shouldSkipValue(node)) {
|
|
bucket.skipped++;
|
|
} else {
|
|
bucket.items.push(node);
|
|
}
|
|
}
|
|
}
|
|
|
|
function rebuildTree(node, translationsQueue) {
|
|
if (Array.isArray(node)) {
|
|
return node.map(item => rebuildTree(item, translationsQueue));
|
|
} else if (node && typeof node === 'object') {
|
|
const out = {};
|
|
for (const k of Object.keys(node)) out[k] = rebuildTree(node[k], translationsQueue);
|
|
return out;
|
|
} else if (typeof node === 'string') {
|
|
if (shouldSkipValue(node)) return node;
|
|
return translationsQueue.shift();
|
|
}
|
|
return node;
|
|
}
|
|
|
|
document.getElementById('json-file').addEventListener('change', async (e) => {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
document.getElementById('json-input').value = await file.text();
|
|
});
|
|
|
|
document.getElementById('json-translate-btn').addEventListener('click', async () => {
|
|
const msgEl = document.getElementById('json-msg');
|
|
const statsEl = document.getElementById('json-stats');
|
|
clearMsg(msgEl); statsEl.style.display = 'none';
|
|
const raw = document.getElementById('json-input').value;
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch (e) {
|
|
showMsg(msgEl, `Invalid JSON: ${e.message}`, 'error');
|
|
return;
|
|
}
|
|
const target = document.getElementById('json-target-lang').value;
|
|
const source = document.getElementById('json-source-lang').value;
|
|
const btn = document.getElementById('json-translate-btn');
|
|
btn.disabled = true; btn.textContent = 'Translating...';
|
|
try {
|
|
const bucket = { total: 0, skipped: 0, items: [] };
|
|
collectTranslatables(parsed, bucket);
|
|
|
|
// protect embedded placeholders within translatable strings
|
|
const protectedItems = [];
|
|
const tokenMap = [];
|
|
for (const s of bucket.items) {
|
|
const { protectedText, tokens } = protectPlaceholders(s);
|
|
protectedItems.push(protectedText);
|
|
tokenMap.push(tokens);
|
|
}
|
|
|
|
let translated = [];
|
|
let applied = { glossary: false, style: false, translation_memory: false };
|
|
if (protectedItems.length > 0) {
|
|
const result = await translateTexts(protectedItems, target, source, activeDomain);
|
|
translated = result.texts.map((t, i) => restorePlaceholders(t, tokenMap[i]));
|
|
applied = result.applied;
|
|
}
|
|
|
|
const queue = [...translated];
|
|
const rebuilt = rebuildTree(parsed, queue);
|
|
const outStr = JSON.stringify(rebuilt, null, 2);
|
|
document.getElementById('json-output').value = outStr;
|
|
document.getElementById('json-download-btn').disabled = false;
|
|
|
|
const originalChars = JSON.stringify(parsed).length;
|
|
const sentChars = protectedItems.join('').length;
|
|
const savingsPct = originalChars > 0 ? Math.round((1 - sentChars / originalChars) * 100) : 0;
|
|
|
|
statsEl.style.display = 'grid';
|
|
statsEl.innerHTML = `
|
|
<div class="stat-box"><div class="num">${bucket.total}</div><div class="lbl">String values scanned</div></div>
|
|
<div class="stat-box"><div class="num">${bucket.items.length}</div><div class="lbl">Sent to DeepL</div></div>
|
|
<div class="stat-box"><div class="num">${bucket.skipped}</div><div class="lbl">Skipped (keys/urls/code)</div></div>
|
|
<div class="stat-box"><div class="num">${savingsPct}%</div><div class="lbl">Character savings vs. full file</div></div>
|
|
`;
|
|
showMsg(msgEl, `JSON translated.${appliedNote(applied)} Review skipped/translated split above before using in production.`, 'ok');
|
|
} catch (e) {
|
|
showMsg(msgEl, e.message, 'error');
|
|
} finally {
|
|
btn.disabled = false; btn.textContent = 'Translate JSON values';
|
|
}
|
|
});
|
|
|
|
document.getElementById('json-download-btn').addEventListener('click', () => {
|
|
const outStr = document.getElementById('json-output').value;
|
|
const blob = new Blob([outStr], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url; a.download = 'translated.json';
|
|
a.click();
|
|
});
|
|
|
|
</script>
|
|
</body>
|
|
</html>
|