Until now the UI could only reach glossaries recorded in domains.json. A
glossary can also sit on the DeepL account with no domain pointing at it —
created before this app, left over from testing, or because domains.json was
reset, which is the normal state of a fresh deployment volume. Such a
glossary is invisible and unreachable here while still consuming the
account's glossary allowance, so on a small plan one forgotten glossary
blocks every save with no way to clear it from the UI.
Adds three endpoints backed by a new section in the domain settings modal:
GET /api/glossaries what is really on the account, each
annotated with the domain using it
DELETE /api/glossaries/:id remove one, freeing a slot, and drop
any local reference to it
POST /api/glossaries/:id/adopt claim an existing glossary for a
domain, fetching its entries so they
become editable
Adopting is a move, not a copy: if another domain already referenced the
glossary, that slot is released. Two domains pointing at one glossary would
mean editing through either silently replaces what the other still lists.
This does not raise the plan's glossary limit; it makes the limit visible
and the single slot reusable without hand-editing domains.json.
Co-Authored-By: Claude <noreply@anthropic.com>
1079 lines
43 KiB
HTML
1079 lines
43 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>
|
|
|
|
<!-- Every glossary that exists on the DeepL account -->
|
|
<div class="modal-section">
|
|
<h3>Glossaries on the DeepL account</h3>
|
|
<div class="current-config">
|
|
All glossaries that exist on the DeepL account — including any that no domain
|
|
above is using. Every one of them counts against your DeepL plan's glossary limit,
|
|
so deleting an unused glossary frees a slot.
|
|
</div>
|
|
<div id="accountGlossaryList"></div>
|
|
<div class="actions">
|
|
<button class="secondary small-btn" id="accountGlossaryRefreshBtn">Refresh list</button>
|
|
</div>
|
|
<div id="accountGlossary-msg"></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');
|
|
loadAccountGlossaries(); // not awaited: it hits DeepL, so don't delay opening
|
|
});
|
|
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');
|
|
}
|
|
});
|
|
});
|
|
|
|
// --- Glossaries that exist on the DeepL account -------------------------
|
|
// The two sections above can only reach glossaries this app has recorded.
|
|
// Glossaries can also exist on the account without any domain pointing at
|
|
// them, and they still use up the plan's glossary allowance - on a small
|
|
// plan one forgotten glossary is enough to block every save. This section
|
|
// lists what is actually on the account so it can be freed or claimed.
|
|
|
|
const accountGlossaryMsgEl = () => document.getElementById('accountGlossary-msg');
|
|
|
|
function accountGlossaryRow(g) {
|
|
const row = document.createElement('div');
|
|
row.className = 'entry-row';
|
|
|
|
const info = document.createElement('div');
|
|
info.style.flex = '1';
|
|
info.style.minWidth = '0';
|
|
|
|
const title = document.createElement('div');
|
|
title.textContent = g.name;
|
|
title.style.fontWeight = '600';
|
|
title.style.fontSize = '13px';
|
|
|
|
const meta = document.createElement('div');
|
|
meta.className = 'hint';
|
|
meta.style.marginTop = '2px';
|
|
const usedBy = g.tracked
|
|
? `used by ${g.tracked.domainLabel} (${g.tracked.direction === 'de-en' ? 'DE → EN' : 'EN → DE'})`
|
|
: 'not used by any domain — free to delete';
|
|
meta.textContent =
|
|
`${String(g.source_lang).toUpperCase()} → ${String(g.target_lang).toUpperCase()} · ` +
|
|
`${g.entry_count} term${g.entry_count === 1 ? '' : 's'} · ${usedBy}`;
|
|
|
|
info.appendChild(title);
|
|
info.appendChild(meta);
|
|
row.appendChild(info);
|
|
|
|
// "Use for <domain>" claims the glossary for the domain currently open in
|
|
// this modal, which also makes its terms editable in the sections above.
|
|
const alreadyHere = g.tracked && g.tracked.domain === activeDomain;
|
|
if (g.direction && !alreadyHere) {
|
|
const useBtn = document.createElement('button');
|
|
useBtn.className = 'secondary small-btn';
|
|
useBtn.textContent = `Use for ${DOMAIN_LABELS[activeDomain] || activeDomain}`;
|
|
useBtn.addEventListener('click', async () => {
|
|
const msgEl = accountGlossaryMsgEl();
|
|
clearMsg(msgEl);
|
|
useBtn.disabled = true;
|
|
try {
|
|
const data = await apiPost(`/api/glossaries/${g.glossary_id}/adopt`, { domain: activeDomain });
|
|
const movedNote = data.movedFrom ? ` (moved away from ${data.movedFrom.domainLabel})` : '';
|
|
showMsg(msgEl, `"${g.name}" is now used by ${DOMAIN_LABELS[activeDomain]}${movedNote}.`, 'ok');
|
|
await loadDomains();
|
|
populateGlossarySection();
|
|
await loadAccountGlossaries();
|
|
} catch (e) {
|
|
showMsg(msgEl, e.message, 'error');
|
|
useBtn.disabled = false;
|
|
}
|
|
});
|
|
row.appendChild(useBtn);
|
|
}
|
|
|
|
const delBtn = document.createElement('button');
|
|
delBtn.className = 'secondary small-btn';
|
|
delBtn.textContent = 'Delete';
|
|
delBtn.addEventListener('click', async () => {
|
|
const msgEl = accountGlossaryMsgEl();
|
|
clearMsg(msgEl);
|
|
const warning = g.tracked
|
|
? `Delete "${g.name}" from DeepL?\n\nIt is currently used by ${g.tracked.domainLabel}, which will be left with no terms for that direction.`
|
|
: `Delete "${g.name}" from DeepL?`;
|
|
if (!confirm(`${warning}\n\nThis cannot be undone.`)) return;
|
|
delBtn.disabled = true;
|
|
try {
|
|
const resp = await fetch(`/api/glossaries/${g.glossary_id}`, { method: 'DELETE' });
|
|
if (!resp.ok) throw new Error(((await resp.json()) || {}).error || `Request failed (${resp.status})`);
|
|
showMsg(msgEl, `"${g.name}" deleted. A glossary slot is now free.`, 'ok');
|
|
await loadDomains();
|
|
populateGlossarySection();
|
|
await loadAccountGlossaries();
|
|
} catch (e) {
|
|
showMsg(msgEl, e.message, 'error');
|
|
delBtn.disabled = false;
|
|
}
|
|
});
|
|
row.appendChild(delBtn);
|
|
|
|
return row;
|
|
}
|
|
|
|
async function loadAccountGlossaries() {
|
|
const listEl = document.getElementById('accountGlossaryList');
|
|
listEl.textContent = 'Loading…';
|
|
try {
|
|
const resp = await fetch('/api/glossaries');
|
|
const data = await resp.json();
|
|
if (!resp.ok) throw new Error(data.error || `Request failed (${resp.status})`);
|
|
listEl.innerHTML = '';
|
|
if (!data.glossaries.length) {
|
|
listEl.innerHTML = '<div class="hint">No glossaries exist on this DeepL account yet.</div>';
|
|
return;
|
|
}
|
|
data.glossaries.forEach((g) => listEl.appendChild(accountGlossaryRow(g)));
|
|
} catch (e) {
|
|
listEl.innerHTML = '';
|
|
showMsg(accountGlossaryMsgEl(), `Could not list glossaries: ${e.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
document.getElementById('accountGlossaryRefreshBtn').addEventListener('click', () => {
|
|
clearMsg(accountGlossaryMsgEl());
|
|
loadAccountGlossaries();
|
|
});
|
|
|
|
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>
|