commit 92f44a291652a72ebe15805e9a649c7d8e140241
Author: christinatrs <64808180+christinatrs@users.noreply.github.com>
Date: Fri Jul 31 11:25:43 2026 +0200
first commit
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..39ad3a2
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,6 @@
+.env
+.env.example
+domains.json
+node_modules
+.git
+*.md
diff --git a/.env b/.env
new file mode 100644
index 0000000..efc0351
--- /dev/null
+++ b/.env
@@ -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
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..a5e56a7
--- /dev/null
+++ b/.env.example
@@ -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
diff --git a/.env.testcopy b/.env.testcopy
new file mode 100644
index 0000000..f90bf89
--- /dev/null
+++ b/.env.testcopy
@@ -0,0 +1,2 @@
+# Not used by the app — leftover from testing. Safe to delete.
+# Configure your key in ".env" instead (see .env.example).
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..1a15a42
--- /dev/null
+++ b/Dockerfile
@@ -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"]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b64f255
--- /dev/null
+++ b/README.md
@@ -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)
diff --git a/cleanup-glossaries.js b/cleanup-glossaries.js
new file mode 100644
index 0000000..37cee05
--- /dev/null
+++ b/cleanup-glossaries.js
@@ -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);
+});
diff --git a/domains.json b/domains.json
new file mode 100644
index 0000000..2c49e12
--- /dev/null
+++ b/domains.json
@@ -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
+ }
+ }
+}
\ No newline at end of file
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..f162f58
--- /dev/null
+++ b/index.html
@@ -0,0 +1,948 @@
+
+
+
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.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Only user-facing string values are sent to DeepL. Keys, numbers, booleans, nulls, URLs, HTML/code snippets,
+ template tags like {{var}}, and key-like identifiers (e.g. status_code_404) are
+ skipped and reconstructed verbatim — this is a heuristic filter, so review the output before shipping it.
+
+
+
+
+
+
+
+
+
+
+
Domain settings
+
+
+
+
+
+
Terms: German → English
+
No terms configured for this domain yet.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Terms: English → German
+
No terms configured for this domain yet.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..4d9cfdf
--- /dev/null
+++ b/package.json
@@ -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": {}
+}
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..4cbd9e8
--- /dev/null
+++ b/server.js
@@ -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}`);
+});