Scheer Enterprise Translation Tool
Node HTTP server that proxies the DeepL API and serves a single-page UI, plus per-domain glossary management for the four Scheer entities. Runs on Node's built-ins only; no dependencies. Containerized for Dokploy: the domain -> glossary_id map is written to DOMAINS_FILE (default /app/data/domains.json) so it can live on a mounted volume and survive redeploys. See DEPLOY.md. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
.env
|
||||||
|
.env.example
|
||||||
|
.env.testcopy
|
||||||
|
domains.json
|
||||||
|
data
|
||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
*.md
|
||||||
7
.env.example
Normal file
7
.env.example
Normal 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
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.env
|
||||||
|
.env.testcopy
|
||||||
|
node_modules
|
||||||
|
data/
|
||||||
94
DEPLOY.md
Normal file
94
DEPLOY.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# Deploying to Dokploy
|
||||||
|
|
||||||
|
The app is a single Node process with no dependencies. Dokploy builds it from
|
||||||
|
the `Dockerfile` in this repo and puts Traefik in front of it.
|
||||||
|
|
||||||
|
## 1. Push the repo to a Git host
|
||||||
|
|
||||||
|
Dokploy pulls from GitHub/GitLab/Gitea (or a raw Git URL over SSH).
|
||||||
|
|
||||||
|
**Before the first push:** `.env` and `.env.testcopy` contain a real DeepL API
|
||||||
|
key. They are untracked from now on via `.gitignore`, but if they were already
|
||||||
|
committed they still exist in history — remove them from tracking and rotate
|
||||||
|
the key rather than relying on the file being gone from the working tree.
|
||||||
|
|
||||||
|
## 2. Create the application in Dokploy
|
||||||
|
|
||||||
|
Project → **Create Service** → **Application**.
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
| ------------- | ------------------------------ |
|
||||||
|
| Source | Git provider + this repo/branch |
|
||||||
|
| Build Type | **Dockerfile** |
|
||||||
|
| Dockerfile | `Dockerfile` |
|
||||||
|
|
||||||
|
## 3. Environment variables
|
||||||
|
|
||||||
|
**Environment** tab:
|
||||||
|
|
||||||
|
```
|
||||||
|
DEEPL_API_KEY=<your DeepL Growth API key>
|
||||||
|
```
|
||||||
|
|
||||||
|
`PORT` and `DOMAINS_FILE` are already set in the Dockerfile — do not override
|
||||||
|
them unless you also change the volume mount path to match.
|
||||||
|
|
||||||
|
The key is server-side only; it is never sent to the browser.
|
||||||
|
|
||||||
|
## 4. Volume (required)
|
||||||
|
|
||||||
|
Without this, every redeploy resets all glossary mappings.
|
||||||
|
|
||||||
|
**Advanced → Volumes → Add** :
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
| ----------- | -------------- |
|
||||||
|
| Type | Volume Mount |
|
||||||
|
| Volume Name | `deepl-data` |
|
||||||
|
| Mount Path | `/app/data` |
|
||||||
|
|
||||||
|
`DOMAINS_FILE=/app/data/domains.json` writes into that volume. The path is a
|
||||||
|
subdirectory of `/app` on purpose — mounting `/app` itself would hide
|
||||||
|
`server.js` and `index.html`.
|
||||||
|
|
||||||
|
## 5. Domain
|
||||||
|
|
||||||
|
**Domains** tab → Add:
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
| ------------- | ------------------------ |
|
||||||
|
| Host | your hostname |
|
||||||
|
| Container Port| `3000` |
|
||||||
|
| HTTPS | on, Let's Encrypt |
|
||||||
|
|
||||||
|
## 6. Deploy
|
||||||
|
|
||||||
|
Hit **Deploy**. Verify with:
|
||||||
|
|
||||||
|
```
|
||||||
|
curl https://<your-host>/health
|
||||||
|
# {"status":"ok","apiKeyConfigured":true}
|
||||||
|
```
|
||||||
|
|
||||||
|
`apiKeyConfigured: false` means `DEEPL_API_KEY` did not reach the container.
|
||||||
|
|
||||||
|
## Seeding existing glossary mappings
|
||||||
|
|
||||||
|
A fresh volume starts empty and the app writes a default `domains.json` with
|
||||||
|
all four domains and no glossaries. The `domains.json` in this repo is **not**
|
||||||
|
copied into the image (it is in `.dockerignore`), so any glossary IDs recorded
|
||||||
|
there are not carried over. Either re-save the glossaries through the UI after
|
||||||
|
the first deploy, or copy the file in once:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker cp domains.json <container>:/app/data/domains.json
|
||||||
|
docker restart <container>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The app has **no authentication**. Anything on the public domain can use your
|
||||||
|
DeepL quota and edit the shared glossaries. Put Traefik basic auth or an
|
||||||
|
IP allowlist in front of it if it is internet-facing.
|
||||||
|
- Docker `HEALTHCHECK` hits `/health` every 30s. It does not call DeepL, so a
|
||||||
|
DeepL outage will not restart the container.
|
||||||
27
Dockerfile
Normal file
27
Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# 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 runtime (docker run -e / --env-file, or the
|
||||||
|
# Dokploy "Environment" tab), never baked into the image.
|
||||||
|
#
|
||||||
|
# domains.json holds which DeepL glossary_id belongs to which domain and
|
||||||
|
# direction. It must live on a mounted volume, otherwise every redeploy
|
||||||
|
# builds a fresh filesystem and the mappings are lost. /app/data is a
|
||||||
|
# subdirectory so mounting it does not shadow the app files in /app.
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV DOMAINS_FILE=/app/data/domains.json
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
VOLUME ["/app/data"]
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||||
|
CMD wget -qO- http://127.0.0.1:3000/health || exit 1
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
134
README.md
Normal file
134
README.md
Normal 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
103
cleanup-glossaries.js
Normal 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
58
domains.json
Normal 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
948
index.html
Normal 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 · Growth Plan</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<div id="usagePill" class="status-pill">Usage: loading…</div>
|
||||||
|
<button class="header-icon-btn" id="usageRefreshBtn" title="Refresh usage">↻</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
|
||||||
|
<!-- Domain selector: always visible, independent of tab navigation -->
|
||||||
|
<div class="domain-bar">
|
||||||
|
<span class="domain-label">Domain</span>
|
||||||
|
<div class="domain-btns" id="domainBtns">
|
||||||
|
<button type="button" class="domain-btn" data-domain="scheer-group">Scheer Group</button>
|
||||||
|
<button type="button" class="domain-btn" data-domain="scheer-ids">Scheer IDS</button>
|
||||||
|
<button type="button" class="domain-btn" data-domain="scheer-pas">Scheer PAS</button>
|
||||||
|
<button type="button" class="domain-btn" data-domain="scheer-imc">Scheer IMC</button>
|
||||||
|
</div>
|
||||||
|
<button class="secondary" id="domainSettingsBtn" style="margin-left:auto;">⚙ Domain settings</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<div class="tab active" data-tab="text">Text Translation</div>
|
||||||
|
<div class="tab" data-tab="doc">Document (.docx)</div>
|
||||||
|
<div class="tab" data-tab="pdf">PDF Translation</div>
|
||||||
|
<div class="tab" data-tab="json">JSON Translation</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TEXT TRANSLATION -->
|
||||||
|
<div class="panel active" id="panel-text">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<label>Source language</label>
|
||||||
|
<select id="text-source-lang">
|
||||||
|
<option value="">Auto-detect</option>
|
||||||
|
<option value="DE">German</option>
|
||||||
|
<option value="EN">English</option>
|
||||||
|
<option value="FR">French</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label>Target language</label>
|
||||||
|
<select id="text-target-lang">
|
||||||
|
<option value="DE">German</option>
|
||||||
|
<option value="DE-CH">German (Swiss)</option>
|
||||||
|
<option value="EN-GB">English (British)</option>
|
||||||
|
<option value="EN-US">English (American)</option>
|
||||||
|
<option value="FR">French</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col" style="max-width:220px;">
|
||||||
|
<label> </label>
|
||||||
|
<label style="font-weight:400; font-size:13px; display:flex; align-items:center; gap:6px;">
|
||||||
|
<input type="checkbox" id="text-protect-placeholders" checked style="width:auto;" />
|
||||||
|
Protect placeholders ({{var}}, %s, {id})
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="col">
|
||||||
|
<label>Source text</label>
|
||||||
|
<textarea id="text-input" placeholder="Paste or type text to translate..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label>Translation</label>
|
||||||
|
<textarea id="text-output" readonly placeholder="Translation will appear here..."></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="text-translate-btn">Translate</button>
|
||||||
|
<button class="secondary" id="text-copy-btn">Copy output</button>
|
||||||
|
<span class="hint" id="text-charcount">0 characters</span>
|
||||||
|
</div>
|
||||||
|
<div id="text-msg"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DOCUMENT TRANSLATION -->
|
||||||
|
<div class="panel" id="panel-doc">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<label>Source language</label>
|
||||||
|
<select id="doc-source-lang">
|
||||||
|
<option value="">Auto-detect</option>
|
||||||
|
<option value="DE">German</option>
|
||||||
|
<option value="EN">English</option>
|
||||||
|
<option value="FR">French</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label>Target language</label>
|
||||||
|
<select id="doc-target-lang">
|
||||||
|
<option value="DE">German</option>
|
||||||
|
<option value="DE-CH">German (Swiss)</option>
|
||||||
|
<option value="EN-GB">English (British)</option>
|
||||||
|
<option value="EN-US">English (American)</option>
|
||||||
|
<option value="FR">French</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label>File (.docx)</label>
|
||||||
|
<input type="file" id="doc-file" accept=".docx,.pptx,.xlsx,.pdf,.txt,.html" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="doc-translate-btn">Upload & translate</button>
|
||||||
|
<span id="doc-status" class="hint"></span>
|
||||||
|
</div>
|
||||||
|
<div id="doc-msg"></div>
|
||||||
|
<div id="doc-download" style="margin-top:12px;"></div>
|
||||||
|
<div class="hint">Supports the file formats DeepL's document API accepts (.docx, .pptx, .xlsx, .pdf, .txt, .html). Formatting, layout and placeholders are preserved by DeepL automatically for these formats.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PDF TRANSLATION -->
|
||||||
|
<div class="panel" id="panel-pdf">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<label>Source language</label>
|
||||||
|
<select id="pdf-source-lang">
|
||||||
|
<option value="">Auto-detect</option>
|
||||||
|
<option value="DE">German</option>
|
||||||
|
<option value="EN">English</option>
|
||||||
|
<option value="FR">French</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label>Target language</label>
|
||||||
|
<select id="pdf-target-lang">
|
||||||
|
<option value="DE">German</option>
|
||||||
|
<option value="DE-CH">German (Swiss)</option>
|
||||||
|
<option value="EN-GB">English (British)</option>
|
||||||
|
<option value="EN-US">English (American)</option>
|
||||||
|
<option value="FR">French</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label>PDF file</label>
|
||||||
|
<input type="file" id="pdf-file" accept=".pdf" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="pdf-translate-btn">Translate PDF</button>
|
||||||
|
<span id="pdf-status" class="hint"></span>
|
||||||
|
</div>
|
||||||
|
<div id="pdf-msg"></div>
|
||||||
|
<div id="pdf-download" style="margin-top:12px;"></div>
|
||||||
|
<div class="hint">
|
||||||
|
Works best on PDFs with a real text layer; scanned/image-only PDFs aren't OCR'd by DeepL
|
||||||
|
and may translate poorly or come back unchanged.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- JSON TRANSLATION -->
|
||||||
|
<div class="panel" id="panel-json">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<label>Target language</label>
|
||||||
|
<select id="json-target-lang">
|
||||||
|
<option value="DE">German</option>
|
||||||
|
<option value="DE-CH">German (Swiss)</option>
|
||||||
|
<option value="EN-GB">English (British)</option>
|
||||||
|
<option value="EN-US">English (American)</option>
|
||||||
|
<option value="FR">French</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label>Source language</label>
|
||||||
|
<select id="json-source-lang">
|
||||||
|
<option value="">Auto-detect</option>
|
||||||
|
<option value="DE">German</option>
|
||||||
|
<option value="EN">English</option>
|
||||||
|
<option value="FR">French</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label>Or upload a .json file</label>
|
||||||
|
<input type="file" id="json-file" accept=".json" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col" style="margin-bottom:12px;">
|
||||||
|
<label>Input JSON</label>
|
||||||
|
<textarea id="json-input" style="min-height:220px; font-family: ui-monospace, Consolas, monospace; font-size:13px;" placeholder='{"button_label": "Submit", "status_code_404": "not_found", "welcome_msg": "Hello {{name}}, welcome back!"}'></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="json-translate-btn">Translate JSON values</button>
|
||||||
|
<button class="secondary" id="json-download-btn" disabled>Download translated JSON</button>
|
||||||
|
</div>
|
||||||
|
<div id="json-msg"></div>
|
||||||
|
<div class="stat-grid" id="json-stats" style="display:none;"></div>
|
||||||
|
<div class="col">
|
||||||
|
<label>Output JSON</label>
|
||||||
|
<textarea id="json-output" readonly style="min-height:220px; font-family: ui-monospace, Consolas, monospace; font-size:13px;"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="hint">
|
||||||
|
Only user-facing string values are sent to DeepL. Keys, numbers, booleans, nulls, URLs, HTML/code snippets,
|
||||||
|
template tags like <code>{{var}}</code>, and key-like identifiers (e.g. <code>status_code_404</code>) are
|
||||||
|
skipped and reconstructed verbatim — this is a heuristic filter, so review the output before shipping it.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DOMAIN SETTINGS MODAL -->
|
||||||
|
<div class="modal-overlay" id="domainModalOverlay">
|
||||||
|
<div class="modal-box">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="domainModalTitle">Domain settings</h2>
|
||||||
|
<button class="modal-close" id="domainModalCloseBtn" title="Close">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Glossary: German -> English -->
|
||||||
|
<div class="modal-section">
|
||||||
|
<h3>Terms: German → English</h3>
|
||||||
|
<div class="current-config" id="glossaryCurrent-de-en">No terms configured for this domain yet.</div>
|
||||||
|
<label>Terms (German → English)</label>
|
||||||
|
<div id="glossaryEntriesContainer-de-en"></div>
|
||||||
|
<div class="actions" style="margin-top:8px;">
|
||||||
|
<button class="secondary small-btn" data-add-row="de-en">+ Add term</button>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button data-save="de-en">Save German → English terms</button>
|
||||||
|
<button class="secondary" data-delete="de-en">Delete list</button>
|
||||||
|
</div>
|
||||||
|
<div id="glossary-msg-de-en"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Glossary: English -> German -->
|
||||||
|
<div class="modal-section">
|
||||||
|
<h3>Terms: English → German</h3>
|
||||||
|
<div class="current-config" id="glossaryCurrent-en-de">No terms configured for this domain yet.</div>
|
||||||
|
<label>Terms (English → German)</label>
|
||||||
|
<div id="glossaryEntriesContainer-en-de"></div>
|
||||||
|
<div class="actions" style="margin-top:8px;">
|
||||||
|
<button class="secondary small-btn" data-add-row="en-de">+ Add term</button>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button data-save="en-de">Save English → German terms</button>
|
||||||
|
<button class="secondary" data-delete="en-de">Delete list</button>
|
||||||
|
</div>
|
||||||
|
<div id="glossary-msg-en-de"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>Scheer Enterprise Translation Tool — internal use — DeepL API (Growth plan)</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Shared helpers
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
function showMsg(el, text, type) {
|
||||||
|
el.innerHTML = `<div class="msg ${type}">${text}</div>`;
|
||||||
|
}
|
||||||
|
function clearMsg(el) { el.innerHTML = ''; }
|
||||||
|
|
||||||
|
async function apiPost(path, body) {
|
||||||
|
const resp = await fetch(path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
if (!resp.ok) throw new Error(data.error || `Request failed (${resp.status})`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function translateTexts(texts, targetLang, sourceLang, domain) {
|
||||||
|
// DeepL accepts up to 50 text items per request — chunk larger batches.
|
||||||
|
const CHUNK = 50;
|
||||||
|
const out = [];
|
||||||
|
let applied = { glossary: false };
|
||||||
|
for (let i = 0; i < texts.length; i += CHUNK) {
|
||||||
|
const chunk = texts.slice(i, i + CHUNK);
|
||||||
|
const data = await apiPost('/api/translate', {
|
||||||
|
texts: chunk, target_lang: targetLang, source_lang: sourceLang || undefined, domain: domain || undefined,
|
||||||
|
});
|
||||||
|
out.push(...data.translations.map(t => t.text));
|
||||||
|
if (data.applied) applied = data.applied;
|
||||||
|
}
|
||||||
|
return { texts: out, applied };
|
||||||
|
}
|
||||||
|
|
||||||
|
function appliedNote(applied) {
|
||||||
|
return applied && applied.glossary ? ' (applied domain glossary)' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Domain customization: persistent domain bar + settings modal
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
const DOMAIN_STORAGE_KEY = 'scheer_active_domain';
|
||||||
|
const DOMAIN_LABELS = {
|
||||||
|
'scheer-group': 'Scheer Group',
|
||||||
|
'scheer-ids': 'Scheer IDS',
|
||||||
|
'scheer-pas': 'Scheer PAS',
|
||||||
|
'scheer-imc': 'Scheer IMC',
|
||||||
|
};
|
||||||
|
let activeDomain = localStorage.getItem(DOMAIN_STORAGE_KEY) || 'scheer-group';
|
||||||
|
let domainsCache = {};
|
||||||
|
|
||||||
|
function renderDomainBar() {
|
||||||
|
document.querySelectorAll('.domain-btn').forEach((btn) => {
|
||||||
|
const id = btn.dataset.domain;
|
||||||
|
btn.classList.toggle('active', id === activeDomain);
|
||||||
|
const cfg = domainsCache[id];
|
||||||
|
const hasCustomization = !!(cfg && cfg.glossaries && (cfg.glossaries['de-en'] || cfg.glossaries['en-de']));
|
||||||
|
const existingDot = btn.querySelector('.dot');
|
||||||
|
if (hasCustomization && !existingDot) {
|
||||||
|
const dot = document.createElement('span');
|
||||||
|
dot.className = 'dot';
|
||||||
|
btn.appendChild(dot);
|
||||||
|
} else if (!hasCustomization && existingDot) {
|
||||||
|
existingDot.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDomains() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/domains');
|
||||||
|
domainsCache = await resp.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Could not load domain settings:', e.message);
|
||||||
|
}
|
||||||
|
renderDomainBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.domain-btn').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
activeDomain = btn.dataset.domain;
|
||||||
|
localStorage.setItem(DOMAIN_STORAGE_KEY, activeDomain);
|
||||||
|
renderDomainBar();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Modal open/close ---
|
||||||
|
const domainModalOverlay = document.getElementById('domainModalOverlay');
|
||||||
|
document.getElementById('domainSettingsBtn').addEventListener('click', async () => {
|
||||||
|
document.getElementById('domainModalTitle').textContent = `${DOMAIN_LABELS[activeDomain] || activeDomain} — domain settings`;
|
||||||
|
await loadDomains(); // pull the latest shared state before showing (other users may have edited it)
|
||||||
|
populateGlossarySection();
|
||||||
|
domainModalOverlay.classList.add('open');
|
||||||
|
});
|
||||||
|
document.getElementById('domainModalCloseBtn').addEventListener('click', () => domainModalOverlay.classList.remove('open'));
|
||||||
|
domainModalOverlay.addEventListener('click', (e) => { if (e.target === domainModalOverlay) domainModalOverlay.classList.remove('open'); });
|
||||||
|
|
||||||
|
// --- Glossary section: two fixed-direction term lists ---
|
||||||
|
const GLOSSARY_DIRECTIONS = ['de-en', 'en-de'];
|
||||||
|
|
||||||
|
function glossaryEntryRow(source, target) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'entry-row';
|
||||||
|
row.innerHTML = `
|
||||||
|
<input type="text" class="glossary-source" placeholder="Source term" value="${(source || '').replace(/"/g, '"')}" />
|
||||||
|
<input type="text" class="glossary-target" placeholder="Target term" value="${(target || '').replace(/"/g, '"')}" />
|
||||||
|
<button type="button" class="secondary small-btn remove-row-btn">Remove</button>
|
||||||
|
`;
|
||||||
|
row.querySelector('.remove-row-btn').addEventListener('click', () => row.remove());
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateGlossarySection() {
|
||||||
|
const cfg = domainsCache[activeDomain];
|
||||||
|
GLOSSARY_DIRECTIONS.forEach((dir) => {
|
||||||
|
const g = cfg && cfg.glossaries && cfg.glossaries[dir];
|
||||||
|
const currentEl = document.getElementById(`glossaryCurrent-${dir}`);
|
||||||
|
const container = document.getElementById(`glossaryEntriesContainer-${dir}`);
|
||||||
|
container.innerHTML = '';
|
||||||
|
if (g) {
|
||||||
|
currentEl.textContent = `Current: ${g.entry_count} term${g.entry_count === 1 ? '' : 's'}`;
|
||||||
|
(g.entries || []).forEach((e) => container.appendChild(glossaryEntryRow(e.source, e.target)));
|
||||||
|
} else {
|
||||||
|
currentEl.textContent = 'No terms configured for this domain yet.';
|
||||||
|
}
|
||||||
|
if (container.children.length === 0) container.appendChild(glossaryEntryRow('', ''));
|
||||||
|
clearMsg(document.getElementById(`glossary-msg-${dir}`));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
GLOSSARY_DIRECTIONS.forEach((dir) => {
|
||||||
|
document.querySelector(`[data-add-row="${dir}"]`).addEventListener('click', () => {
|
||||||
|
document.getElementById(`glossaryEntriesContainer-${dir}`).appendChild(glossaryEntryRow('', ''));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector(`[data-save="${dir}"]`).addEventListener('click', async () => {
|
||||||
|
const msgEl = document.getElementById(`glossary-msg-${dir}`);
|
||||||
|
clearMsg(msgEl);
|
||||||
|
const entries = [...document.querySelectorAll(`#glossaryEntriesContainer-${dir} .entry-row`)].map((row) => ({
|
||||||
|
source: row.querySelector('.glossary-source').value.trim(),
|
||||||
|
target: row.querySelector('.glossary-target').value.trim(),
|
||||||
|
})).filter((e) => e.source && e.target);
|
||||||
|
if (entries.length === 0) { showMsg(msgEl, 'Add at least one term pair.', 'error'); return; }
|
||||||
|
const btn = document.querySelector(`[data-save="${dir}"]`);
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
await apiPost(`/api/domains/${activeDomain}/glossary/${dir}`, { entries });
|
||||||
|
showMsg(msgEl, 'Terms saved.', 'ok');
|
||||||
|
await loadDomains();
|
||||||
|
populateGlossarySection();
|
||||||
|
} catch (e) {
|
||||||
|
showMsg(msgEl, e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector(`[data-delete="${dir}"]`).addEventListener('click', async () => {
|
||||||
|
const msgEl = document.getElementById(`glossary-msg-${dir}`);
|
||||||
|
clearMsg(msgEl);
|
||||||
|
try {
|
||||||
|
await fetch(`/api/domains/${activeDomain}/glossary/${dir}`, { method: 'DELETE' });
|
||||||
|
showMsg(msgEl, 'List deleted.', 'ok');
|
||||||
|
await loadDomains();
|
||||||
|
populateGlossarySection();
|
||||||
|
} catch (e) {
|
||||||
|
showMsg(msgEl, e.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
loadDomains();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Usage indicator (always visible in the header, auto-loads + refreshable)
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
async function refreshUsage() {
|
||||||
|
const pill = document.getElementById('usagePill');
|
||||||
|
pill.textContent = 'Usage: checking...';
|
||||||
|
try {
|
||||||
|
const data = await apiPost('/api/usage', {});
|
||||||
|
pill.textContent = `Usage: ${data.character_count.toLocaleString()} / ${data.character_limit ? data.character_limit.toLocaleString() : '∞'} chars`;
|
||||||
|
} catch (e) {
|
||||||
|
pill.textContent = 'Usage: error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.getElementById('usageRefreshBtn').addEventListener('click', refreshUsage);
|
||||||
|
refreshUsage();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Tabs
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
document.querySelectorAll('.tab').forEach(tab => {
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
|
||||||
|
tab.classList.add('active');
|
||||||
|
document.getElementById(`panel-${tab.dataset.tab}`).classList.add('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Module 1: Text translation (with placeholder protection)
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
const PLACEHOLDER_REGEX = /(\{\{[^{}]+\}\}|%[sd]|\{[a-zA-Z0-9_.]+\})/g;
|
||||||
|
|
||||||
|
function protectPlaceholders(text) {
|
||||||
|
const tokens = [];
|
||||||
|
const protectedText = text.replace(PLACEHOLDER_REGEX, (match) => {
|
||||||
|
const token = `§PH${tokens.length}§`;
|
||||||
|
tokens.push(match);
|
||||||
|
return token;
|
||||||
|
});
|
||||||
|
return { protectedText, tokens };
|
||||||
|
}
|
||||||
|
function restorePlaceholders(text, tokens) {
|
||||||
|
return text.replace(/§PH(\d+)§/g, (_, i) => tokens[Number(i)] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const textInputEl = document.getElementById('text-input');
|
||||||
|
textInputEl.addEventListener('input', () => {
|
||||||
|
document.getElementById('text-charcount').textContent = `${textInputEl.value.length} characters`;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('text-translate-btn').addEventListener('click', async () => {
|
||||||
|
const msgEl = document.getElementById('text-msg');
|
||||||
|
clearMsg(msgEl);
|
||||||
|
const input = textInputEl.value;
|
||||||
|
if (!input.trim()) { showMsg(msgEl, 'Enter some text to translate.', 'error'); return; }
|
||||||
|
const target = document.getElementById('text-target-lang').value;
|
||||||
|
const source = document.getElementById('text-source-lang').value;
|
||||||
|
const protect = document.getElementById('text-protect-placeholders').checked;
|
||||||
|
const btn = document.getElementById('text-translate-btn');
|
||||||
|
btn.disabled = true; btn.textContent = 'Translating...';
|
||||||
|
try {
|
||||||
|
let payload = input, tokens = [];
|
||||||
|
if (protect) ({ protectedText: payload, tokens } = protectPlaceholders(input));
|
||||||
|
const { texts: translatedArr, applied } = await translateTexts([payload], target, source, activeDomain);
|
||||||
|
const translated = translatedArr[0];
|
||||||
|
document.getElementById('text-output').value = protect ? restorePlaceholders(translated, tokens) : translated;
|
||||||
|
showMsg(msgEl, `Translation complete.${appliedNote(applied)}`, 'ok');
|
||||||
|
} catch (e) {
|
||||||
|
showMsg(msgEl, e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false; btn.textContent = 'Translate';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('text-copy-btn').addEventListener('click', () => {
|
||||||
|
const out = document.getElementById('text-output');
|
||||||
|
out.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Module 1b: Document translation (.docx and other DeepL doc formats)
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
function fileToBase64(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(reader.result.split(',')[1]);
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||||
|
|
||||||
|
document.getElementById('doc-translate-btn').addEventListener('click', async () => {
|
||||||
|
const msgEl = document.getElementById('doc-msg');
|
||||||
|
const statusEl = document.getElementById('doc-status');
|
||||||
|
const downloadEl = document.getElementById('doc-download');
|
||||||
|
clearMsg(msgEl); downloadEl.innerHTML = ''; statusEl.textContent = '';
|
||||||
|
const fileInput = document.getElementById('doc-file');
|
||||||
|
const file = fileInput.files[0];
|
||||||
|
if (!file) { showMsg(msgEl, 'Choose a file first.', 'error'); return; }
|
||||||
|
const target = document.getElementById('doc-target-lang').value;
|
||||||
|
const source = document.getElementById('doc-source-lang').value;
|
||||||
|
const btn = document.getElementById('doc-translate-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
statusEl.textContent = 'Uploading...';
|
||||||
|
const fileBase64 = await fileToBase64(file);
|
||||||
|
const uploadResp = await apiPost('/api/document/upload', {
|
||||||
|
filename: file.name, fileBase64, target_lang: target, source_lang: source || undefined, domain: activeDomain,
|
||||||
|
});
|
||||||
|
const { document_id, document_key, applied } = uploadResp;
|
||||||
|
|
||||||
|
let status = 'queued';
|
||||||
|
while (status === 'queued' || status === 'translating') {
|
||||||
|
await sleep(2000);
|
||||||
|
const s = await apiPost('/api/document/status', { document_id, document_key });
|
||||||
|
status = s.status;
|
||||||
|
statusEl.textContent = `Status: ${status}${s.seconds_remaining ? ` (~${s.seconds_remaining}s remaining)` : ''}`;
|
||||||
|
if (status === 'error') throw new Error(s.error_message || 'Translation failed on DeepL side.');
|
||||||
|
}
|
||||||
|
|
||||||
|
statusEl.textContent = 'Downloading result...';
|
||||||
|
const result = await apiPost('/api/document/result', { document_id, document_key });
|
||||||
|
const blob = base64ToBlob(result.fileBase64);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const outName = file.name.replace(/(\.[a-zA-Z0-9]+)$/, `_${target}$1`);
|
||||||
|
downloadEl.innerHTML = `<a href="${url}" download="${outName}"><button>Download ${outName}</button></a>`;
|
||||||
|
statusEl.textContent = 'Done.';
|
||||||
|
showMsg(msgEl, `Document translated successfully.${appliedNote(applied)}`, 'ok');
|
||||||
|
} catch (e) {
|
||||||
|
showMsg(msgEl, e.message, 'error');
|
||||||
|
statusEl.textContent = '';
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function base64ToBlob(base64) {
|
||||||
|
const byteChars = atob(base64);
|
||||||
|
const byteNumbers = new Array(byteChars.length);
|
||||||
|
for (let i = 0; i < byteChars.length; i++) byteNumbers[i] = byteChars.charCodeAt(i);
|
||||||
|
return new Blob([new Uint8Array(byteNumbers)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Module 1c: PDF translation
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
document.getElementById('pdf-translate-btn').addEventListener('click', async () => {
|
||||||
|
const msgEl = document.getElementById('pdf-msg');
|
||||||
|
const statusEl = document.getElementById('pdf-status');
|
||||||
|
const downloadEl = document.getElementById('pdf-download');
|
||||||
|
clearMsg(msgEl); downloadEl.innerHTML = ''; statusEl.textContent = '';
|
||||||
|
const fileInput = document.getElementById('pdf-file');
|
||||||
|
const file = fileInput.files[0];
|
||||||
|
if (!file) { showMsg(msgEl, 'Choose a PDF file first.', 'error'); return; }
|
||||||
|
if (!/\.pdf$/i.test(file.name)) { showMsg(msgEl, 'Please choose a .pdf file.', 'error'); return; }
|
||||||
|
const source = document.getElementById('pdf-source-lang').value;
|
||||||
|
const target = document.getElementById('pdf-target-lang').value;
|
||||||
|
const btn = document.getElementById('pdf-translate-btn');
|
||||||
|
btn.disabled = true; btn.textContent = 'Translating...';
|
||||||
|
try {
|
||||||
|
statusEl.textContent = 'Uploading...';
|
||||||
|
const fileBase64 = await fileToBase64(file);
|
||||||
|
const uploadResp = await apiPost('/api/document/upload', {
|
||||||
|
filename: file.name, fileBase64, target_lang: target, source_lang: source || undefined, domain: activeDomain,
|
||||||
|
});
|
||||||
|
const { document_id, document_key, applied } = uploadResp;
|
||||||
|
|
||||||
|
let status = 'queued';
|
||||||
|
while (status === 'queued' || status === 'translating') {
|
||||||
|
await sleep(2000);
|
||||||
|
const s = await apiPost('/api/document/status', { document_id, document_key });
|
||||||
|
status = s.status;
|
||||||
|
statusEl.textContent = `Status: ${status}${s.seconds_remaining ? ` (~${s.seconds_remaining}s remaining)` : ''}`;
|
||||||
|
if (status === 'error') throw new Error(s.error_message || 'Translation failed on DeepL side.');
|
||||||
|
}
|
||||||
|
|
||||||
|
statusEl.textContent = 'Downloading result...';
|
||||||
|
const result = await apiPost('/api/document/result', { document_id, document_key });
|
||||||
|
const blob = base64ToBlob(result.fileBase64);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const outName = file.name.replace(/\.pdf$/i, `_${target}.pdf`);
|
||||||
|
downloadEl.innerHTML = `<a href="${url}" download="${outName}"><button>Download ${outName}</button></a>`;
|
||||||
|
statusEl.textContent = 'Done.';
|
||||||
|
showMsg(msgEl, `PDF translated successfully.${appliedNote(applied)}`, 'ok');
|
||||||
|
} catch (e) {
|
||||||
|
showMsg(msgEl, e.message, 'error');
|
||||||
|
statusEl.textContent = '';
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false; btn.textContent = 'Translate PDF';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Module 4: Token-optimized JSON translation
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
const URL_REGEX = /^(https?:\/\/|www\.)/i;
|
||||||
|
const KEY_LIKE_REGEX = /^[a-z0-9]+(_[a-z0-9]+)+$/i; // e.g. status_code_404, welcome_msg
|
||||||
|
const HTML_ONLY_REGEX = /^\s*<[^>]+>[\s\S]*<\/[^>]+>\s*$/;
|
||||||
|
const TEMPLATE_ONLY_REGEX = /^\s*(\{\{[^{}]+\}\}|%[sd]|\{[a-zA-Z0-9_.]+\})\s*$/;
|
||||||
|
|
||||||
|
function shouldSkipValue(str) {
|
||||||
|
if (typeof str !== 'string' || str.trim() === '') return true;
|
||||||
|
if (URL_REGEX.test(str.trim())) return true;
|
||||||
|
if (KEY_LIKE_REGEX.test(str.trim()) && !str.includes(' ')) return true;
|
||||||
|
if (HTML_ONLY_REGEX.test(str.trim())) return true;
|
||||||
|
if (TEMPLATE_ONLY_REGEX.test(str.trim())) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectTranslatables(node, bucket) {
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
node.forEach((item) => collectTranslatables(item, bucket));
|
||||||
|
} else if (node && typeof node === 'object') {
|
||||||
|
for (const k of Object.keys(node)) collectTranslatables(node[k], bucket);
|
||||||
|
} else if (typeof node === 'string') {
|
||||||
|
bucket.total++;
|
||||||
|
if (shouldSkipValue(node)) {
|
||||||
|
bucket.skipped++;
|
||||||
|
} else {
|
||||||
|
bucket.items.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildTree(node, translationsQueue) {
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
return node.map(item => rebuildTree(item, translationsQueue));
|
||||||
|
} else if (node && typeof node === 'object') {
|
||||||
|
const out = {};
|
||||||
|
for (const k of Object.keys(node)) out[k] = rebuildTree(node[k], translationsQueue);
|
||||||
|
return out;
|
||||||
|
} else if (typeof node === 'string') {
|
||||||
|
if (shouldSkipValue(node)) return node;
|
||||||
|
return translationsQueue.shift();
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('json-file').addEventListener('change', async (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
document.getElementById('json-input').value = await file.text();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('json-translate-btn').addEventListener('click', async () => {
|
||||||
|
const msgEl = document.getElementById('json-msg');
|
||||||
|
const statsEl = document.getElementById('json-stats');
|
||||||
|
clearMsg(msgEl); statsEl.style.display = 'none';
|
||||||
|
const raw = document.getElementById('json-input').value;
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch (e) {
|
||||||
|
showMsg(msgEl, `Invalid JSON: ${e.message}`, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = document.getElementById('json-target-lang').value;
|
||||||
|
const source = document.getElementById('json-source-lang').value;
|
||||||
|
const btn = document.getElementById('json-translate-btn');
|
||||||
|
btn.disabled = true; btn.textContent = 'Translating...';
|
||||||
|
try {
|
||||||
|
const bucket = { total: 0, skipped: 0, items: [] };
|
||||||
|
collectTranslatables(parsed, bucket);
|
||||||
|
|
||||||
|
// protect embedded placeholders within translatable strings
|
||||||
|
const protectedItems = [];
|
||||||
|
const tokenMap = [];
|
||||||
|
for (const s of bucket.items) {
|
||||||
|
const { protectedText, tokens } = protectPlaceholders(s);
|
||||||
|
protectedItems.push(protectedText);
|
||||||
|
tokenMap.push(tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
let translated = [];
|
||||||
|
let applied = { glossary: false, style: false, translation_memory: false };
|
||||||
|
if (protectedItems.length > 0) {
|
||||||
|
const result = await translateTexts(protectedItems, target, source, activeDomain);
|
||||||
|
translated = result.texts.map((t, i) => restorePlaceholders(t, tokenMap[i]));
|
||||||
|
applied = result.applied;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queue = [...translated];
|
||||||
|
const rebuilt = rebuildTree(parsed, queue);
|
||||||
|
const outStr = JSON.stringify(rebuilt, null, 2);
|
||||||
|
document.getElementById('json-output').value = outStr;
|
||||||
|
document.getElementById('json-download-btn').disabled = false;
|
||||||
|
|
||||||
|
const originalChars = JSON.stringify(parsed).length;
|
||||||
|
const sentChars = protectedItems.join('').length;
|
||||||
|
const savingsPct = originalChars > 0 ? Math.round((1 - sentChars / originalChars) * 100) : 0;
|
||||||
|
|
||||||
|
statsEl.style.display = 'grid';
|
||||||
|
statsEl.innerHTML = `
|
||||||
|
<div class="stat-box"><div class="num">${bucket.total}</div><div class="lbl">String values scanned</div></div>
|
||||||
|
<div class="stat-box"><div class="num">${bucket.items.length}</div><div class="lbl">Sent to DeepL</div></div>
|
||||||
|
<div class="stat-box"><div class="num">${bucket.skipped}</div><div class="lbl">Skipped (keys/urls/code)</div></div>
|
||||||
|
<div class="stat-box"><div class="num">${savingsPct}%</div><div class="lbl">Character savings vs. full file</div></div>
|
||||||
|
`;
|
||||||
|
showMsg(msgEl, `JSON translated.${appliedNote(applied)} Review skipped/translated split above before using in production.`, 'ok');
|
||||||
|
} catch (e) {
|
||||||
|
showMsg(msgEl, e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false; btn.textContent = 'Translate JSON values';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('json-download-btn').addEventListener('click', () => {
|
||||||
|
const outStr = document.getElementById('json-output').value;
|
||||||
|
const blob = new Blob([outStr], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url; a.download = 'translated.json';
|
||||||
|
a.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
13
package.json
Normal file
13
package.json
Normal 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": {}
|
||||||
|
}
|
||||||
528
server.js
Normal file
528
server.js
Normal file
@@ -0,0 +1,528 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
|
||||||
|
// The path is overridable via DOMAINS_FILE so a container deployment can point
|
||||||
|
// it at a mounted volume (e.g. /app/data/domains.json) instead of the image's
|
||||||
|
// own filesystem, which is recreated on every redeploy.
|
||||||
|
const DOMAINS_FILE = process.env.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.mkdirSync(path.dirname(DOMAINS_FILE), { recursive: true });
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liveness probe for container platforms (Docker HEALTHCHECK, Dokploy).
|
||||||
|
// Deliberately does not call DeepL: it answers "is this process serving?",
|
||||||
|
// not "is the upstream API up", so a DeepL outage doesn't restart the app.
|
||||||
|
function handleHealth(req, res) {
|
||||||
|
sendJson(res, 200, { status: 'ok', apiKeyConfigured: Boolean(DEEPL_API_KEY) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const routes = {
|
||||||
|
'GET /health': handleHealth,
|
||||||
|
'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}`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user