+ All glossaries that exist on the DeepL account — including any that no domain
+ above is using. Every one of them counts against your DeepL plan's glossary limit,
+ so deleting an unused glossary frees a slot.
+
+
+
+
+
+
+
@@ -544,6 +559,7 @@ document.getElementById('domainSettingsBtn').addEventListener('click', async ()
await loadDomains(); // pull the latest shared state before showing (other users may have edited it)
populateGlossarySection();
domainModalOverlay.classList.add('open');
+ loadAccountGlossaries(); // not awaited: it hits DeepL, so don't delay opening
});
document.getElementById('domainModalCloseBtn').addEventListener('click', () => domainModalOverlay.classList.remove('open'));
domainModalOverlay.addEventListener('click', (e) => { if (e.target === domainModalOverlay) domainModalOverlay.classList.remove('open'); });
@@ -622,6 +638,120 @@ GLOSSARY_DIRECTIONS.forEach((dir) => {
});
});
+// --- Glossaries that exist on the DeepL account -------------------------
+// The two sections above can only reach glossaries this app has recorded.
+// Glossaries can also exist on the account without any domain pointing at
+// them, and they still use up the plan's glossary allowance - on a small
+// plan one forgotten glossary is enough to block every save. This section
+// lists what is actually on the account so it can be freed or claimed.
+
+const accountGlossaryMsgEl = () => document.getElementById('accountGlossary-msg');
+
+function accountGlossaryRow(g) {
+ const row = document.createElement('div');
+ row.className = 'entry-row';
+
+ const info = document.createElement('div');
+ info.style.flex = '1';
+ info.style.minWidth = '0';
+
+ const title = document.createElement('div');
+ title.textContent = g.name;
+ title.style.fontWeight = '600';
+ title.style.fontSize = '13px';
+
+ const meta = document.createElement('div');
+ meta.className = 'hint';
+ meta.style.marginTop = '2px';
+ const usedBy = g.tracked
+ ? `used by ${g.tracked.domainLabel} (${g.tracked.direction === 'de-en' ? 'DE → EN' : 'EN → DE'})`
+ : 'not used by any domain — free to delete';
+ meta.textContent =
+ `${String(g.source_lang).toUpperCase()} → ${String(g.target_lang).toUpperCase()} · ` +
+ `${g.entry_count} term${g.entry_count === 1 ? '' : 's'} · ${usedBy}`;
+
+ info.appendChild(title);
+ info.appendChild(meta);
+ row.appendChild(info);
+
+ // "Use for " claims the glossary for the domain currently open in
+ // this modal, which also makes its terms editable in the sections above.
+ const alreadyHere = g.tracked && g.tracked.domain === activeDomain;
+ if (g.direction && !alreadyHere) {
+ const useBtn = document.createElement('button');
+ useBtn.className = 'secondary small-btn';
+ useBtn.textContent = `Use for ${DOMAIN_LABELS[activeDomain] || activeDomain}`;
+ useBtn.addEventListener('click', async () => {
+ const msgEl = accountGlossaryMsgEl();
+ clearMsg(msgEl);
+ useBtn.disabled = true;
+ try {
+ const data = await apiPost(`/api/glossaries/${g.glossary_id}/adopt`, { domain: activeDomain });
+ const movedNote = data.movedFrom ? ` (moved away from ${data.movedFrom.domainLabel})` : '';
+ showMsg(msgEl, `"${g.name}" is now used by ${DOMAIN_LABELS[activeDomain]}${movedNote}.`, 'ok');
+ await loadDomains();
+ populateGlossarySection();
+ await loadAccountGlossaries();
+ } catch (e) {
+ showMsg(msgEl, e.message, 'error');
+ useBtn.disabled = false;
+ }
+ });
+ row.appendChild(useBtn);
+ }
+
+ const delBtn = document.createElement('button');
+ delBtn.className = 'secondary small-btn';
+ delBtn.textContent = 'Delete';
+ delBtn.addEventListener('click', async () => {
+ const msgEl = accountGlossaryMsgEl();
+ clearMsg(msgEl);
+ const warning = g.tracked
+ ? `Delete "${g.name}" from DeepL?\n\nIt is currently used by ${g.tracked.domainLabel}, which will be left with no terms for that direction.`
+ : `Delete "${g.name}" from DeepL?`;
+ if (!confirm(`${warning}\n\nThis cannot be undone.`)) return;
+ delBtn.disabled = true;
+ try {
+ const resp = await fetch(`/api/glossaries/${g.glossary_id}`, { method: 'DELETE' });
+ if (!resp.ok) throw new Error(((await resp.json()) || {}).error || `Request failed (${resp.status})`);
+ showMsg(msgEl, `"${g.name}" deleted. A glossary slot is now free.`, 'ok');
+ await loadDomains();
+ populateGlossarySection();
+ await loadAccountGlossaries();
+ } catch (e) {
+ showMsg(msgEl, e.message, 'error');
+ delBtn.disabled = false;
+ }
+ });
+ row.appendChild(delBtn);
+
+ return row;
+}
+
+async function loadAccountGlossaries() {
+ const listEl = document.getElementById('accountGlossaryList');
+ listEl.textContent = 'Loading…';
+ try {
+ const resp = await fetch('/api/glossaries');
+ const data = await resp.json();
+ if (!resp.ok) throw new Error(data.error || `Request failed (${resp.status})`);
+ listEl.innerHTML = '';
+ if (!data.glossaries.length) {
+ listEl.innerHTML = '
No glossaries exist on this DeepL account yet.
';
+ return;
+ }
+ data.glossaries.forEach((g) => listEl.appendChild(accountGlossaryRow(g)));
+ } catch (e) {
+ listEl.innerHTML = '';
+ showMsg(accountGlossaryMsgEl(), `Could not list glossaries: ${e.message}`, 'error');
+ }
+}
+
+document.getElementById('accountGlossaryRefreshBtn').addEventListener('click', () => {
+ clearMsg(accountGlossaryMsgEl());
+ loadAccountGlossaries();
+});
+
loadDomains();
// ---------------------------------------------------------------------
diff --git a/server.js b/server.js
index d57f870..8ae55e1 100644
--- a/server.js
+++ b/server.js
@@ -268,6 +268,162 @@ async function handleDeleteGlossary(req, res, domainId, direction) {
sendJson(res, 200, { ok: true });
}
+// ---- Account-level glossary management ---------------------------------
+// The endpoints above only reach glossaries that domains.json already knows
+// about. That is not the whole picture: glossaries can exist on the DeepL
+// account without being tracked here (created before this app, left over
+// from testing, or simply because domains.json was reset - a fresh
+// deployment volume starts empty). Those untracked glossaries still occupy
+// the account's glossary allowance, so on a small plan they can block every
+// save while being invisible and unreachable from the UI.
+//
+// These three endpoints work against the DeepL account directly, so an
+// admin can see what is really there, delete what is not needed, and pull an
+// untracked glossary back under a domain.
+
+// Which of the two supported directions a glossary's language pair maps to,
+// or null for a pair this app does not model (e.g. FR->EN).
+function directionForLangs(sourceLang, targetLang) {
+ const s = String(sourceLang || '').toUpperCase();
+ const t = String(targetLang || '').split('-')[0].toUpperCase();
+ if (s === 'DE' && t === 'EN') return 'de-en';
+ if (s === 'EN' && t === 'DE') return 'en-de';
+ return null;
+}
+
+// glossary_id -> which domain/direction currently references it.
+function trackedGlossaryOwners(domains) {
+ const owners = {};
+ for (const [domainId, d] of Object.entries(domains)) {
+ for (const [direction, g] of Object.entries((d && d.glossaries) || {})) {
+ if (g && g.id) {
+ owners[g.id] = { domain: domainId, domainLabel: d.label, direction };
+ }
+ }
+ }
+ return owners;
+}
+
+async function handleListAccountGlossaries(req, res) {
+ if (!DEEPL_API_KEY) return sendJson(res, 500, { error: 'Server is missing DEEPL_API_KEY.' });
+ const base = baseUrlFor(DEEPL_API_KEY);
+ const resp = await fetch(`${base}/v2/glossaries`, {
+ headers: { Authorization: `DeepL-Auth-Key ${DEEPL_API_KEY}` },
+ });
+ if (!resp.ok) return sendJson(res, resp.status, { error: await forwardDeepLError(resp) });
+ const { glossaries } = await resp.json();
+ const owners = trackedGlossaryOwners(loadDomains());
+ sendJson(res, 200, {
+ glossaries: (glossaries || []).map((g) => ({
+ glossary_id: g.glossary_id,
+ name: g.name,
+ source_lang: g.source_lang,
+ target_lang: g.target_lang,
+ entry_count: g.entry_count,
+ creation_time: g.creation_time,
+ ready: g.ready,
+ // null when no domain references it - i.e. it is consuming a slot
+ // without being editable through the normal domain settings.
+ tracked: owners[g.glossary_id] || null,
+ // null when the language pair is outside the de<->en pairs this app
+ // models, in which case it can be deleted but not adopted.
+ direction: directionForLangs(g.source_lang, g.target_lang),
+ })),
+ });
+}
+
+async function handleDeleteAccountGlossary(req, res, glossaryId) {
+ if (!DEEPL_API_KEY) return sendJson(res, 500, { error: 'Server is missing DEEPL_API_KEY.' });
+ const base = baseUrlFor(DEEPL_API_KEY);
+ const resp = await fetch(`${base}/v2/glossaries/${encodeURIComponent(glossaryId)}`, {
+ method: 'DELETE',
+ headers: { Authorization: `DeepL-Auth-Key ${DEEPL_API_KEY}` },
+ });
+ // 404 means it is already gone on DeepL's side; still worth clearing any
+ // local reference to it below, so treat it as success.
+ if (!resp.ok && resp.status !== 404) {
+ return sendJson(res, resp.status, { error: await forwardDeepLError(resp) });
+ }
+ const domains = loadDomains();
+ const owner = trackedGlossaryOwners(domains)[glossaryId];
+ if (owner) {
+ domains[owner.domain].glossaries[owner.direction] = null;
+ saveDomains(domains);
+ }
+ sendJson(res, 200, { ok: true, untracked: owner || null });
+}
+
+// Pulls an existing DeepL glossary (including its entries) into a domain
+// slot, so it becomes editable through the normal domain settings instead of
+// sitting on the account unreachable.
+async function handleAdoptGlossary(req, res, glossaryId) {
+ if (!DEEPL_API_KEY) return sendJson(res, 500, { error: 'Server is missing DEEPL_API_KEY.' });
+ const { domain: domainId } = await parseJsonBody(req);
+ if (!VALID_DOMAINS.includes(domainId)) return sendJson(res, 400, { error: 'Unknown domain.' });
+ const base = baseUrlFor(DEEPL_API_KEY);
+
+ const metaResp = await fetch(`${base}/v2/glossaries/${encodeURIComponent(glossaryId)}`, {
+ headers: { Authorization: `DeepL-Auth-Key ${DEEPL_API_KEY}` },
+ });
+ if (!metaResp.ok) return sendJson(res, metaResp.status, { error: await forwardDeepLError(metaResp) });
+ const meta = await metaResp.json();
+
+ const direction = directionForLangs(meta.source_lang, meta.target_lang);
+ if (!direction) {
+ return sendJson(res, 400, {
+ error: `This glossary translates ${meta.source_lang} → ${meta.target_lang}, which is not one of the two directions this tool manages (German → English, English → German). You can delete it to free a slot, but not load it into a domain.`,
+ });
+ }
+
+ const entriesResp = await fetch(`${base}/v2/glossaries/${encodeURIComponent(glossaryId)}/entries`, {
+ headers: { Authorization: `DeepL-Auth-Key ${DEEPL_API_KEY}`, Accept: 'text/tab-separated-values' },
+ });
+ if (!entriesResp.ok) return sendJson(res, entriesResp.status, { error: await forwardDeepLError(entriesResp) });
+ const entries = (await entriesResp.text())
+ .split('\n')
+ .map((line) => line.split('\t'))
+ .filter((parts) => parts.length >= 2 && parts[0].trim() && parts[1].trim())
+ .map((parts) => ({ source: parts[0].trim(), target: parts[1].trim() }));
+
+ const domains = loadDomains();
+ // Refuse to silently overwrite a different glossary already in this slot -
+ // that would orphan it on the account, which is the exact problem this
+ // whole section exists to clean up.
+ const occupying = domains[domainId].glossaries[direction];
+ if (occupying && occupying.id && occupying.id !== glossaryId) {
+ return sendJson(res, 409, {
+ error: `${DOMAIN_LABELS[domainId]} (${GLOSSARY_DIRECTIONS[direction].label}) already has a glossary. Delete that one first, then load this glossary in.`,
+ });
+ }
+
+ // A glossary must be referenced by at most one domain/direction. If some
+ // other domain already points at it, this is a move: release the old slot
+ // rather than leaving two domains aliasing one glossary, where editing via
+ // one silently replaces what the other still claims to use.
+ const previousOwner = trackedGlossaryOwners(domains)[glossaryId];
+ const moved =
+ previousOwner && !(previousOwner.domain === domainId && previousOwner.direction === direction)
+ ? previousOwner
+ : null;
+ if (moved) domains[moved.domain].glossaries[moved.direction] = null;
+
+ domains[domainId].glossaries[direction] = {
+ id: meta.glossary_id,
+ name: meta.name,
+ source_lang: meta.source_lang,
+ target_lang: meta.target_lang,
+ entry_count: entries.length,
+ entries,
+ };
+ saveDomains(domains);
+ sendJson(res, 200, {
+ domain: domainId,
+ direction,
+ movedFrom: moved ? { domain: moved.domain, domainLabel: moved.domainLabel, direction: moved.direction } : null,
+ glossary: domains[domainId].glossaries[direction],
+ });
+}
+
function sendJson(res, status, obj) {
const body = JSON.stringify(obj);
res.writeHead(status, {
@@ -505,6 +661,7 @@ const routes = {
'POST /api/document/status': handleDocumentStatus,
'POST /api/document/result': handleDocumentResult,
'GET /api/domains': handleGetDomains,
+ 'GET /api/glossaries': handleListAccountGlossaries,
};
// Routes with :domain and :direction path segments
@@ -512,6 +669,9 @@ const routes = {
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 },
+ // Account-level glossary management, keyed by DeepL's glossary_id (a UUID).
+ { method: 'DELETE', regex: /^\/api\/glossaries\/([0-9a-fA-F-]{36})$/, handler: handleDeleteAccountGlossary },
+ { method: 'POST', regex: /^\/api\/glossaries\/([0-9a-fA-F-]{36})\/adopt$/, handler: handleAdoptGlossary },
];
const server = http.createServer(async (req, res) => {