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 @@ + + + + + +Scheer Enterprise Translation Tool + + + + +
+
+

Scheer Group: Translation Tool

+
Powered by DeepL API · Growth Plan
+
+
+
Usage: loading…
+ +
+
+ +
+ + +
+ Domain +
+ + + + +
+ +
+ +
+
Text Translation
+
Document (.docx)
+
PDF Translation
+
JSON Translation
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + + 0 characters +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
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. +
+
+ + +
+ + + + + + + + + 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}`); +});