first commit

This commit is contained in:
christinatrs
2026-07-31 11:25:43 +02:00
commit 92f44a2916
11 changed files with 1812 additions and 0 deletions

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
.env
.env.example
domains.json
node_modules
.git
*.md

9
.env Normal file
View File

@@ -0,0 +1,9 @@
# Fill in your DeepL Growth API key below, then run: node server.js
# This file stays on your machine and is read only by server.js.
DEEPL_API_KEY=02a9299f-9f72-4c21-aa7c-788a19270409:fx
#fd74146f-001c-44f9-ae9f-96d45d7f1922:fx
# Optional: change the local port (default 3000)
# PORT=3000

7
.env.example Normal file
View File

@@ -0,0 +1,7 @@
# Copy this file to ".env" (same folder as server.js) and fill in your key.
# The key stays on the server only — it is never sent to the browser.
DEEPL_API_KEY=your-deepl-growth-api-key-here
# Optional: change the local port (default 3000)
# PORT=3000

2
.env.testcopy Normal file
View File

@@ -0,0 +1,2 @@
# Not used by the app — leftover from testing. Safe to delete.
# Configure your key in ".env" instead (see .env.example).

16
Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
# Scheer Enterprise Translation Tool — container image.
# No dependencies to install (server.js only uses Node's built-ins), so the
# image just needs a Node runtime plus the two app files.
FROM node:18-alpine
WORKDIR /app
COPY server.js index.html package.json ./
# DEEPL_API_KEY is passed at "docker run" time (via -e or --env-file), not
# baked into the image. domains.json should be bind-mounted so glossary
# data survives container restarts/recreation (see README).
ENV PORT=3000
EXPOSE 3000
CMD ["node", "server.js"]

134
README.md Normal file
View File

@@ -0,0 +1,134 @@
# Scheer Enterprise Translation Tool
A web tool connecting to the **DeepL API (Growth plan)** for text, document
and PDF translation, plus token-optimized JSON translation, with per-domain
glossaries for Scheer Group, Scheer IDS, Scheer PAS, and Scheer IMC.
Supported languages everywhere in the tool: **German, German (Swiss), English
(British), English (American), and French.**
## Why there's a small server involved
DeepL's API blocks direct calls from browser JavaScript (CORS) and your key
should never be embedded in public front-end code. `server.js` is a tiny
zero-dependency Node proxy: it serves the UI and relays requests to DeepL
using an API key configured **only on the server**. The key never touches
the browser, localStorage, or any client-side code.
## Local setup (single user, on your own machine)
1. Requires Node.js 18+ (uses built-in `fetch`/`FormData`/`Blob` — no `npm install` needed).
2. Get your DeepL Growth API key from your DeepL account (developer/API settings).
3. Copy `.env.example` to `.env` in this folder and set `DEEPL_API_KEY=...`.
(Alternatively, export it as a real environment variable: `DEEPL_API_KEY=xxx node server.js`.)
4. Run:
```
node server.js
```
5. Open **http://localhost:3000**. The usage indicator in the top-right corner of the header
loads automatically (click the ↻ next to it to refresh anytime) and confirms the
server is connected to DeepL.
## Deploying for shared, multi-user access on a real domain
To make this available to everyone in the org at a URL like `https://translate.yourcompany.com`
instead of just `localhost`, you need three things: a server to run it on, a
domain pointed at that server, and a way to keep it running. None of this
requires code changes — `server.js` already listens on all network
interfaces, not just `localhost`.
1. **Get a server.** Any internal VM, cloud instance, or on-prem box that
can stay running and is reachable from wherever your users are (office
network, VPN, or the public internet, depending on your needs).
2. **Point your domain at it.** Create a DNS A/AAAA record for
`translate.yourcompany.com` pointing at that server's IP.
3. **Put a reverse proxy in front of it** (e.g. Nginx or Caddy) to handle
HTTPS and forward requests to the Node app on port 3000. Caddy is the
simplest option — a `Caddyfile` with just:
```
translate.yourcompany.com {
reverse_proxy localhost:3000
}
```
gets you automatic HTTPS via Let's Encrypt with no extra config.
4. **Keep the Node process running** with a process manager or container
instead of a terminal session — see the Docker option below, or use
something like `pm2` or a `systemd` service if you'd rather run it bare.
5. **Configure `.env` and `domains.json` once, on that server** — every user
hitting the domain shares the same DeepL key and the same four domain
glossaries. That's the intended design: one shared quota, one shared set
of glossaries per Scheer entity, managed centrally.
### Running it in Docker
```bash
docker build -t scheer-translation-tool .
# domains.json needs to exist before the bind mount below, otherwise Docker
# will create it as a directory instead of a file:
touch domains.json
docker run -d --name scheer-translation \
-p 3000:3000 \
--env-file .env \
-v "$(pwd)/domains.json:/app/domains.json" \
--restart unless-stopped \
scheer-translation-tool
```
Then point your reverse proxy at `localhost:3000` on that host as described
above. `--restart unless-stopped` makes it survive reboots.
### Important: this tool has no built-in login
Anyone who can reach the domain can translate documents (consuming your
shared DeepL quota) and edit any domain's glossary — there's no user
authentication built in. Depending on your needs:
- Restrict it to your internal network or VPN (don't expose it on the
public internet), and/or
- Add HTTP Basic Auth at the reverse proxy layer (a couple of lines in
Nginx or Caddy), and/or
- Put it behind whatever SSO/gateway your org already uses for internal tools.
## Features
- **Text Translation** — free text in/out, placeholder protection (`{{var}}`, `%s`, `{id}` preserved verbatim), auto-detect or manual source language.
- **Document Translation** — upload `.docx`/`.pptx`/`.xlsx`/`.pdf`/`.txt`/`.html`, tracks DeepL's upload → poll → download flow, gives you a translated file to download.
- **PDF Translation** — same upload → poll → download flow, dedicated to PDFs, with the full language selection.
- **JSON Translation** — paste or upload JSON, only extracts and translates user-facing string values (skips keys, numbers, booleans, nulls, URLs, HTML/code, template tags, and key-like identifiers such as `status_code_404`), batches everything into as few DeepL requests as possible, and reconstructs the exact tree. Shows a stats panel (values scanned/sent/skipped, character savings).
- **Domain glossaries** — a domain bar at the top of every page (always visible, independent of which tab you're on) lets you pick one of the four Scheer entities: Scheer Group, Scheer IDS, Scheer PAS, Scheer IMC. Each domain stores **two fixed-direction glossaries**: German → English and English → German, each with its own list of exact term pairs, created via DeepL's glossary API. Click **⚙ Domain settings** to manage the currently selected domain's two term lists. Every translate action (text, document, PDF, JSON) automatically applies the matching direction's glossary and shows a note confirming it was applied.
## How glossaries are stored (and why everyone sees the same ones)
Glossary terms are **not** stored in the browser (no localStorage, no per-user
data). Saving a term list calls DeepL's glossary API to create the actual
glossary on DeepL's servers, and the server writes the resulting glossary ID
plus a local copy of the terms to **`domains.json`** — a JSON file that lives
next to `server.js` on whichever machine is running the server. Every
request from every browser goes through that one server, which reads the
same `domains.json`, so anyone visiting the site sees and edits the same
shared German→English and English→German lists per domain. The settings
modal also re-fetches from the server each time it's opened, so you'll see
another teammate's most recent edits rather than a stale local copy.
For a small internal tool with a handful of admins editing glossaries
occasionally, a flat JSON file is a reasonable, low-maintenance choice —
there's no database to run or back up. If this ever needs to support many
people editing glossaries concurrently, or an audit trail of who changed
what, that would be the point to move `domains.json` into a real database.
## Known limitations
- **JSON skip rules are heuristic** (regex-based). Always review the output panel before shipping translated JSON to production.
- **Glossaries are recreated (not edited in place) on every save.** DeepL's v2 glossaries are immutable, so saving a domain's German→English or English→German list deletes the old one and creates a new one with the full term list.
- Only these two directions are supported for glossaries. A glossary only applies to a translation when the request is going exactly German→English or English→German with an explicit (non-auto-detect) source language — the glossary API requires the source language to be set.
## Files
- `server.js` — proxy server (no external dependencies), reads `DEEPL_API_KEY` from `.env`/environment
- `index.html` — the single-page UI (styled with the Scheer brand palette: red, black, greys, whitespace)
- `domains.json` — auto-created on first run; stores each domain's two glossary IDs/metadata (see storage section above)
- `.env.example` — copy to `.env` and fill in your key
- `package.json` — for `npm start` convenience (nothing to install)
- `Dockerfile`, `.dockerignore` — for containerized deployment (see above)

103
cleanup-glossaries.js Normal file
View File

@@ -0,0 +1,103 @@
/**
* One-off maintenance script: lists every glossary that currently exists on
* your DeepL account and compares it against the glossaries this app
* actually tracks in domains.json. Run this on the machine where server.js
* runs (it needs the same DEEPL_API_KEY and network access to DeepL).
*
* Why you might need this: DeepL accounts have a cap on total glossary
* count. If you ever hit a "Too many glossaries" (456) error when saving
* terms, it means glossaries exist on your DeepL account that this app
* isn't tracking anymore (e.g. left over from earlier testing/debugging, or
* from a save that got interrupted). This script finds and removes those
* orphans so you have room to save again.
*
* Usage:
* node cleanup-glossaries.js # dry run - lists orphans only
* node cleanup-glossaries.js --delete # actually deletes the orphans
*/
const fs = require('fs');
const path = require('path');
function loadEnv() {
const envPath = path.join(__dirname, '.env');
if (!fs.existsSync(envPath)) return;
for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) {
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/i);
if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
}
}
loadEnv();
const DEEPL_API_KEY = process.env.DEEPL_API_KEY;
if (!DEEPL_API_KEY) {
console.error('DEEPL_API_KEY not found (checked .env and environment).');
process.exit(1);
}
const base = DEEPL_API_KEY.endsWith(':fx') ? 'https://api-free.deepl.com' : 'https://api.deepl.com';
function loadDomainsJson() {
const p = path.join(__dirname, 'domains.json');
if (!fs.existsSync(p)) return {};
try {
return JSON.parse(fs.readFileSync(p, 'utf8'));
} catch {
return {};
}
}
async function main() {
const domains = loadDomainsJson();
const trackedIds = new Set();
for (const d of Object.values(domains)) {
if (!d || !d.glossaries) continue;
for (const g of Object.values(d.glossaries)) {
if (g && g.id) trackedIds.add(g.id);
}
}
const resp = await fetch(`${base}/v2/glossaries`, {
headers: { Authorization: `DeepL-Auth-Key ${DEEPL_API_KEY}` },
});
if (!resp.ok) {
console.error(`Failed to list glossaries: ${resp.status} ${await resp.text()}`);
process.exit(1);
}
const { glossaries } = await resp.json();
console.log(`Found ${glossaries.length} glossary/glossaries on the DeepL account. This app tracks ${trackedIds.size} of them in domains.json.\n`);
const orphans = glossaries.filter((g) => !trackedIds.has(g.glossary_id));
if (orphans.length === 0) {
console.log('No orphaned glossaries found. Nothing to clean up.');
console.log('(If you are still getting "Too many glossaries", the account limit may just need to be raised with DeepL, or every glossary on the account is legitimately in use.)');
return;
}
console.log(`Orphaned glossaries (exist on DeepL, not referenced by domains.json):\n`);
for (const g of orphans) {
console.log(` - ${g.glossary_id} "${g.name}" ${g.source_lang}->${g.target_lang} (${g.entry_count} entries, created ${g.creation_time})`);
}
const shouldDelete = process.argv.includes('--delete');
if (!shouldDelete) {
console.log(`\nDry run only - nothing was deleted. Re-run with --delete to remove the ${orphans.length} orphan(s) above.`);
return;
}
console.log('\nDeleting orphans...');
for (const g of orphans) {
const delResp = await fetch(`${base}/v2/glossaries/${g.glossary_id}`, {
method: 'DELETE',
headers: { Authorization: `DeepL-Auth-Key ${DEEPL_API_KEY}` },
});
console.log(` - ${g.glossary_id}: ${delResp.ok ? 'deleted' : `failed (${delResp.status})`}`);
}
console.log('Done.');
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

58
domains.json Normal file
View File

@@ -0,0 +1,58 @@
{
"scheer-group": {
"label": "Scheer Group",
"glossaries": {
"de-en": null,
"en-de": null
}
},
"scheer-ids": {
"label": "Scheer IDS",
"glossaries": {
"de-en": null,
"en-de": {
"id": "47125d5e-5705-4182-9802-e48fd9cc58e1",
"name": "Scheer IDS English → German Glossary",
"source_lang": "en",
"target_lang": "de",
"entry_count": 5,
"entries": [
{
"source": "Agentic AI",
"target": "Agentic AI"
},
{
"source": "staff",
"target": "Mitarbeitende"
},
{
"source": "employees",
"target": "Mitarbeitende"
},
{
"source": "learners",
"target": "Lernende"
},
{
"source": "employee",
"target": "Mitarbeitende"
}
]
}
}
},
"scheer-pas": {
"label": "Scheer PAS",
"glossaries": {
"de-en": null,
"en-de": null
}
},
"scheer-imc": {
"label": "Scheer IMC",
"glossaries": {
"de-en": null,
"en-de": null
}
}
}

948
index.html Normal file
View File

@@ -0,0 +1,948 @@
<!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 &middot; Growth Plan</div>
</div>
<div class="header-right">
<div id="usagePill" class="status-pill">Usage: loading&hellip;</div>
<button class="header-icon-btn" id="usageRefreshBtn" title="Refresh usage">&#8635;</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;">&#9881;&nbsp; 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>&nbsp;</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 &amp; 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 &mdash; 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">&times;</button>
</div>
<!-- Glossary: German -> English -->
<div class="modal-section">
<h3>Terms: German &rarr; English</h3>
<div class="current-config" id="glossaryCurrent-de-en">No terms configured for this domain yet.</div>
<label>Terms (German &rarr; 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 &rarr; 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 &rarr; German</h3>
<div class="current-config" id="glossaryCurrent-en-de">No terms configured for this domain yet.</div>
<label>Terms (English &rarr; 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 &rarr; 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 &mdash; internal use &mdash; 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, '&quot;')}" />
<input type="text" class="glossary-target" placeholder="Target term" value="${(target || '').replace(/"/g, '&quot;')}" />
<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>

13
package.json Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "scheer-translation-tool",
"version": "1.0.0",
"description": "Scheer Enterprise Translation & Transcription Tool — local proxy + UI for the DeepL API (Growth plan).",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"engines": {
"node": ">=18"
},
"dependencies": {}
}

516
server.js Normal file
View File

@@ -0,0 +1,516 @@
/**
* Scheer Enterprise Translation & Transcription Tool — local proxy server.
*
* Why this file exists: the DeepL API blocks direct calls from browser
* JavaScript (CORS), and your API key must never sit in public client code.
* This tiny Node server (no external dependencies, uses Node 18+ built-in
* fetch/FormData/Blob) does two things:
* 1. Serves the single-page UI (index.html)
* 2. Relays translate/document/usage requests to DeepL using the API key
* configured here on the server (via DEEPL_API_KEY env var or a local
* .env file). The key is never sent to or stored in the browser.
*
* Setup: copy .env.example to .env and fill in DEEPL_API_KEY, or export
* DEEPL_API_KEY as an environment variable before starting.
* Run: node server.js
* Open: http://localhost:3000
*/
const http = require('http');
const fs = require('fs');
const path = require('path');
// ---- Minimal .env loader (no dependency) -------------------------------
// Reads KEY=VALUE lines from a .env file next to this script, without
// overriding variables already set in the real environment.
(function loadDotEnv() {
const envPath = path.join(__dirname, '.env');
if (!fs.existsSync(envPath)) return;
const lines = fs.readFileSync(envPath, 'utf-8').split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const idx = trimmed.indexOf('=');
if (idx === -1) continue;
const key = trimmed.slice(0, idx).trim();
let value = trimmed.slice(idx + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
if (!(key in process.env)) process.env[key] = value;
}
})();
const PORT = process.env.PORT || 3000;
const DEEPL_API_KEY = process.env.DEEPL_API_KEY || '';
if (!DEEPL_API_KEY) {
console.warn(
'\n[warning] DEEPL_API_KEY is not set. Set it in a .env file (see .env.example) ' +
'or as an environment variable before running the server. All translation ' +
'requests will fail until it is configured.\n'
);
}
function baseUrlFor(apiKey) {
return apiKey && apiKey.trim().endsWith(':fx')
? 'https://api-free.deepl.com'
: 'https://api.deepl.com';
}
// ---- Domain customization (glossaries) ---------------------------------
// Each of the four Scheer entities gets two fixed-direction glossaries —
// German -> English and English -> German — persisted locally in
// domains.json (which glossary_id belongs to which domain/direction; the
// actual glossary content lives on DeepL's servers). domains.json lives on
// the server disk, not in any user's browser, so every visitor to the site
// sees and edits the same shared glossaries.
const DOMAINS_FILE = path.join(__dirname, 'domains.json');
const VALID_DOMAINS = ['scheer-group', 'scheer-ids', 'scheer-pas', 'scheer-imc'];
const DOMAIN_LABELS = {
'scheer-group': 'Scheer Group',
'scheer-ids': 'Scheer IDS',
'scheer-pas': 'Scheer PAS',
'scheer-imc': 'Scheer IMC',
};
const GLOSSARY_DIRECTIONS = {
'de-en': { source_lang: 'DE', target_lang: 'EN', label: 'German → English' },
'en-de': { source_lang: 'EN', target_lang: 'DE', label: 'English → German' },
};
function emptyDomain(id) {
return { label: DOMAIN_LABELS[id], glossaries: { 'de-en': null, 'en-de': null } };
}
function loadDomains() {
let data = {};
if (fs.existsSync(DOMAINS_FILE)) {
try {
data = JSON.parse(fs.readFileSync(DOMAINS_FILE, 'utf-8'));
} catch (e) {
data = {};
}
}
let changed = false;
for (const id of VALID_DOMAINS) {
if (!data[id]) {
data[id] = emptyDomain(id);
changed = true;
continue;
}
const d = data[id];
// Drop retired style-rules/translation-memory fields from older versions.
if ('style' in d || 'translationMemory' in d) {
delete d.style;
delete d.translationMemory;
changed = true;
}
// Migrate the old single "glossary" field (one selectable language
// pair) into the new fixed two-direction "glossaries" map.
if ('glossary' in d) {
if (!d.glossaries) d.glossaries = { 'de-en': null, 'en-de': null };
const g = d.glossary;
if (g && g.source_lang && g.target_lang) {
const src = String(g.source_lang).toUpperCase();
const tgt = String(g.target_lang).split('-')[0].toUpperCase();
if (src === 'DE' && tgt === 'EN') d.glossaries['de-en'] = g;
else if (src === 'EN' && tgt === 'DE') d.glossaries['en-de'] = g;
// Any other legacy direction is no longer representable and is dropped.
}
delete d.glossary;
changed = true;
}
if (!d.glossaries) {
d.glossaries = { 'de-en': null, 'en-de': null };
changed = true;
}
for (const dir of Object.keys(GLOSSARY_DIRECTIONS)) {
if (!(dir in d.glossaries)) {
d.glossaries[dir] = null;
changed = true;
}
}
}
if (changed || !fs.existsSync(DOMAINS_FILE)) saveDomains(data);
return data;
}
function saveDomains(data) {
fs.writeFileSync(DOMAINS_FILE, JSON.stringify(data, null, 2));
}
// Decide whether one of a domain's two glossaries applies to a given
// translation request (the request's language pair has to exactly match
// the fixed direction the glossary was created for), and build the extra
// DeepL params.
function computeDomainParams(domains, domainId, sourceLang, targetLang) {
const applied = { glossary: false };
const extra = {};
const d = domainId && domains[domainId];
if (!d || !d.glossaries) return { extra, applied };
const targetBase = String(targetLang || '').split('-')[0].toUpperCase();
const srcUpper = sourceLang ? String(sourceLang).toUpperCase() : '';
for (const [dir, dirCfg] of Object.entries(GLOSSARY_DIRECTIONS)) {
const g = d.glossaries[dir];
if (g && g.id && srcUpper === dirCfg.source_lang && targetBase === dirCfg.target_lang) {
extra.glossary_id = g.id;
applied.glossary = true;
break;
}
}
return { extra, applied };
}
async function handleGetDomains(req, res) {
sendJson(res, 200, loadDomains());
}
async function handleSaveGlossary(req, res, domainId, direction) {
if (!VALID_DOMAINS.includes(domainId)) return sendJson(res, 400, { error: 'Unknown domain.' });
if (!GLOSSARY_DIRECTIONS[direction]) return sendJson(res, 400, { error: 'Unknown glossary direction.' });
if (!DEEPL_API_KEY) return sendJson(res, 500, { error: 'Server is missing DEEPL_API_KEY.' });
const { entries } = await parseJsonBody(req);
if (!Array.isArray(entries) || entries.length === 0) {
return sendJson(res, 400, { error: 'At least one glossary entry is required.' });
}
const cleanEntries = entries
.filter((e) => e && e.source != null && e.target != null)
.map((e) => ({ source: String(e.source).trim(), target: String(e.target).trim() }))
.filter((e) => e.source && e.target);
if (cleanEntries.length === 0) return sendJson(res, 400, { error: 'No valid (source, target) entries provided.' });
const { source_lang, target_lang, label } = GLOSSARY_DIRECTIONS[direction];
const name = `${DOMAIN_LABELS[domainId]} ${label} Glossary`;
const tsv = cleanEntries.map((e) => `${e.source}\t${e.target}`).join('\n');
const base = baseUrlFor(DEEPL_API_KEY);
// Delete the old glossary for this domain/direction FIRST, before creating
// the replacement. DeepL accounts have a cap on total glossary count
// ("Too many glossaries" / 456 error), so freeing this slot up front
// avoids briefly needing N+1 slots for what is logically still N
// glossaries (one per domain per direction).
const domains = loadDomains();
const existing = domains[domainId].glossaries[direction];
if (existing && existing.id) {
const delResp = await fetch(`${base}/v2/glossaries/${existing.id}`, {
method: 'DELETE',
headers: { Authorization: `DeepL-Auth-Key ${DEEPL_API_KEY}` },
}).catch(() => null);
// A 404 here just means it was already gone (e.g. deleted manually on
// DeepL's side) - fine to proceed. Other failures are logged but not
// fatal; we still attempt the create below.
if (delResp && !delResp.ok && delResp.status !== 404) {
console.warn(`Warning: failed to delete old glossary ${existing.id} (status ${delResp.status}) before replacing it.`);
}
}
const createResp = await fetch(`${base}/v2/glossaries`, {
method: 'POST',
headers: { Authorization: `DeepL-Auth-Key ${DEEPL_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ name, source_lang, target_lang, entries: tsv, entries_format: 'tsv' }),
});
if (!createResp.ok) {
// The old glossary is already gone at this point, so make sure that's
// reflected in domains.json even though the new save failed, instead of
// leaving a dangling reference to a now-deleted glossary ID.
if (existing && existing.id) {
domains[domainId].glossaries[direction] = null;
saveDomains(domains);
}
return sendJson(res, createResp.status, { error: await forwardDeepLError(createResp) });
}
const created = await createResp.json();
domains[domainId].glossaries[direction] = {
id: created.glossary_id,
name: created.name,
source_lang: created.source_lang,
target_lang: created.target_lang,
entry_count: created.entry_count,
entries: cleanEntries,
};
saveDomains(domains);
sendJson(res, 200, domains[domainId].glossaries[direction]);
}
async function handleDeleteGlossary(req, res, domainId, direction) {
if (!VALID_DOMAINS.includes(domainId)) return sendJson(res, 400, { error: 'Unknown domain.' });
if (!GLOSSARY_DIRECTIONS[direction]) return sendJson(res, 400, { error: 'Unknown glossary direction.' });
const domains = loadDomains();
const existing = domains[domainId].glossaries[direction];
if (existing && existing.id && DEEPL_API_KEY) {
const base = baseUrlFor(DEEPL_API_KEY);
await fetch(`${base}/v2/glossaries/${existing.id}`, {
method: 'DELETE',
headers: { Authorization: `DeepL-Auth-Key ${DEEPL_API_KEY}` },
}).catch(() => {});
}
domains[domainId].glossaries[direction] = null;
saveDomains(domains);
sendJson(res, 200, { ok: true });
}
function sendJson(res, status, obj) {
const body = JSON.stringify(obj);
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(body),
});
res.end(body);
}
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
req.on('data', (c) => {
size += c.length;
if (size > 60 * 1024 * 1024) {
reject(new Error('Payload too large (max 60MB)'));
req.destroy();
return;
}
chunks.push(c);
});
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
}
async function parseJsonBody(req) {
const raw = await readBody(req);
if (!raw.length) return {};
try {
return JSON.parse(raw.toString('utf-8'));
} catch (e) {
const err = new Error('Invalid JSON body');
err.statusCode = 400;
throw err;
}
}
async function forwardDeepLError(resp) {
let detail = '';
try {
detail = await resp.text();
} catch (_) {
/* ignore */
}
return `DeepL API error (${resp.status} ${resp.statusText}): ${detail || 'no detail'}`;
}
// ---- Route handlers ---------------------------------------------------
async function handleTranslate(req, res) {
const { texts, target_lang, source_lang, domain } = await parseJsonBody(req);
const apiKey = DEEPL_API_KEY;
if (!apiKey) return sendJson(res, 500, { error: 'Server is missing DEEPL_API_KEY. Ask an admin to configure it (see .env.example).' });
if (!Array.isArray(texts) || texts.length === 0) {
return sendJson(res, 400, { error: 'No text provided to translate.' });
}
if (!target_lang) return sendJson(res, 400, { error: 'Missing target_lang.' });
const domains = loadDomains();
const { extra, applied } = computeDomainParams(domains, domain, source_lang, target_lang);
const base = baseUrlFor(apiKey);
const params = new URLSearchParams();
for (const t of texts) params.append('text', t);
params.append('target_lang', target_lang);
if (source_lang) params.append('source_lang', source_lang);
params.append('preserve_formatting', '1');
if (extra.glossary_id) params.append('glossary_id', extra.glossary_id);
const resp = await fetch(`${base}/v2/translate`, {
method: 'POST',
headers: {
Authorization: `DeepL-Auth-Key ${apiKey}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params,
});
if (!resp.ok) return sendJson(res, resp.status, { error: await forwardDeepLError(resp) });
const data = await resp.json();
data.applied = applied;
sendJson(res, 200, data);
}
async function handleUsage(req, res) {
await parseJsonBody(req); // drain any request body (unused — key comes from server env)
const apiKey = DEEPL_API_KEY;
if (!apiKey) return sendJson(res, 500, { error: 'Server is missing DEEPL_API_KEY. Ask an admin to configure it (see .env.example).' });
const base = baseUrlFor(apiKey);
const resp = await fetch(`${base}/v2/usage`, {
headers: { Authorization: `DeepL-Auth-Key ${apiKey}` },
});
if (!resp.ok) return sendJson(res, resp.status, { error: await forwardDeepLError(resp) });
sendJson(res, 200, await resp.json());
}
async function handleDocumentUpload(req, res) {
const { filename, fileBase64, target_lang, source_lang, domain } = await parseJsonBody(req);
const apiKey = DEEPL_API_KEY;
if (!apiKey) return sendJson(res, 500, { error: 'Server is missing DEEPL_API_KEY. Ask an admin to configure it (see .env.example).' });
if (!fileBase64 || !filename) return sendJson(res, 400, { error: 'Missing file data.' });
if (!target_lang) return sendJson(res, 400, { error: 'Missing target_lang.' });
const domains = loadDomains();
const { extra, applied } = computeDomainParams(domains, domain, source_lang, target_lang);
const base = baseUrlFor(apiKey);
const buf = Buffer.from(fileBase64, 'base64');
const blob = new Blob([buf]);
const form = new FormData();
form.append('file', blob, filename);
form.append('target_lang', target_lang);
if (source_lang) form.append('source_lang', source_lang);
if (extra.glossary_id) form.append('glossary_id', extra.glossary_id);
const resp = await fetch(`${base}/v2/document`, {
method: 'POST',
headers: { Authorization: `DeepL-Auth-Key ${apiKey}` },
body: form,
});
if (!resp.ok) return sendJson(res, resp.status, { error: await forwardDeepLError(resp) });
const data = await resp.json();
data.applied = applied;
sendJson(res, 200, data);
}
async function handleDocumentStatus(req, res) {
const { document_id, document_key } = await parseJsonBody(req);
const apiKey = DEEPL_API_KEY;
if (!apiKey) return sendJson(res, 500, { error: 'Server is missing DEEPL_API_KEY. Ask an admin to configure it (see .env.example).' });
if (!document_id || !document_key) {
return sendJson(res, 400, { error: 'Missing document_id or document_key.' });
}
const base = baseUrlFor(apiKey);
const resp = await fetch(`${base}/v2/document/${encodeURIComponent(document_id)}`, {
method: 'POST',
headers: {
Authorization: `DeepL-Auth-Key ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ document_key }),
});
if (!resp.ok) return sendJson(res, resp.status, { error: await forwardDeepLError(resp) });
sendJson(res, 200, await resp.json());
}
async function handleDocumentResult(req, res) {
const { document_id, document_key } = await parseJsonBody(req);
const apiKey = DEEPL_API_KEY;
if (!apiKey) return sendJson(res, 500, { error: 'Server is missing DEEPL_API_KEY. Ask an admin to configure it (see .env.example).' });
if (!document_id || !document_key) {
return sendJson(res, 400, { error: 'Missing document_id or document_key.' });
}
const base = baseUrlFor(apiKey);
const resp = await fetch(`${base}/v2/document/${encodeURIComponent(document_id)}/result`, {
method: 'POST',
headers: {
Authorization: `DeepL-Auth-Key ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ document_key }),
});
if (!resp.ok) return sendJson(res, resp.status, { error: await forwardDeepLError(resp) });
const arrayBuf = await resp.arrayBuffer();
sendJson(res, 200, { fileBase64: Buffer.from(arrayBuf).toString('base64') });
}
// ---- Static file + router ---------------------------------------------
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css' };
function serveStatic(req, res) {
const urlPath = req.url.split('?')[0];
const file = urlPath === '/' ? 'index.html' : urlPath.replace(/^\//, '');
const filePath = path.join(__dirname, file);
if (!filePath.startsWith(__dirname)) return res.writeHead(403).end('Forbidden');
fs.readFile(filePath, (err, data) => {
if (err) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
return res.end('Not found');
}
const ext = path.extname(filePath);
res.writeHead(200, {
'Content-Type': MIME[ext] || 'application/octet-stream',
// Prevent browsers/proxies from caching a stale copy of the app shell —
// an outdated cached index.html calling routes that no longer exist on
// a newer server.js is a common source of confusing 404s after updates.
'Cache-Control': 'no-cache, no-store, must-revalidate',
});
res.end(data);
});
}
const routes = {
'POST /api/translate': handleTranslate,
'POST /api/usage': handleUsage,
'POST /api/document/upload': handleDocumentUpload,
'POST /api/document/status': handleDocumentStatus,
'POST /api/document/result': handleDocumentResult,
'GET /api/domains': handleGetDomains,
};
// Routes with :domain and :direction path segments
// (e.g. /api/domains/scheer-group/glossary/de-en).
const DYNAMIC_ROUTES = [
{ method: 'POST', regex: /^\/api\/domains\/([a-z-]+)\/glossary\/(de-en|en-de)$/, handler: handleSaveGlossary },
{ method: 'DELETE', regex: /^\/api\/domains\/([a-z-]+)\/glossary\/(de-en|en-de)$/, handler: handleDeleteGlossary },
];
const server = http.createServer(async (req, res) => {
const urlPath = req.url.split('?')[0];
for (const route of DYNAMIC_ROUTES) {
if (route.method !== req.method) continue;
const m = urlPath.match(route.regex);
if (m) {
try {
await route.handler(req, res, ...m.slice(1));
} catch (e) {
sendJson(res, e.statusCode || 500, { error: e.message || 'Internal server error' });
}
return;
}
}
const key = `${req.method} ${urlPath}`;
const handler = routes[key];
if (handler) {
try {
await handler(req, res);
} catch (e) {
sendJson(res, e.statusCode || 500, { error: e.message || 'Internal server error' });
}
return;
}
if (req.method === 'GET') return serveStatic(req, res);
// Old (pre-direction) glossary route shape, e.g. POST/DELETE
// /api/domains/scheer-group/glossary (no /:direction suffix). This can't
// be handled — we don't know which of the two directional lists was
// intended — but a bare 404 here looks like a server bug rather than what
// it usually is: a browser tab with an old cached copy of index.html
// still calling the old API shape. Point the user at the actual fix.
const legacyGlossaryMatch = urlPath.match(/^\/api\/domains\/([a-z-]+)\/glossary$/);
if (legacyGlossaryMatch && (req.method === 'POST' || req.method === 'DELETE')) {
return sendJson(res, 400, {
error:
'This endpoint is outdated (missing the glossary direction, e.g. /de-en or /en-de). ' +
'This usually means your browser is running a cached, older version of the page. ' +
'Please hard-refresh (Ctrl+F5 or Cmd+Shift+R) and try saving the terms again.',
});
}
sendJson(res, 404, { error: 'Not found' });
});
server.listen(PORT, () => {
console.log(`Scheer Translation Tool running at http://localhost:${PORT}`);
});