Manage the DeepL account's glossaries from the domain settings
Until now the UI could only reach glossaries recorded in domains.json. A
glossary can also sit on the DeepL account with no domain pointing at it —
created before this app, left over from testing, or because domains.json was
reset, which is the normal state of a fresh deployment volume. Such a
glossary is invisible and unreachable here while still consuming the
account's glossary allowance, so on a small plan one forgotten glossary
blocks every save with no way to clear it from the UI.
Adds three endpoints backed by a new section in the domain settings modal:
GET /api/glossaries what is really on the account, each
annotated with the domain using it
DELETE /api/glossaries/:id remove one, freeing a slot, and drop
any local reference to it
POST /api/glossaries/:id/adopt claim an existing glossary for a
domain, fetching its entries so they
become editable
Adopting is a move, not a copy: if another domain already referenced the
glossary, that slot is released. Two domains pointing at one glossary would
mean editing through either silently replaces what the other still lists.
This does not raise the plan's glossary limit; it makes the limit visible
and the single slot reusable without hand-editing domains.json.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
130
index.html
130
index.html
@@ -444,6 +444,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="glossary-msg-en-de"></div>
|
<div id="glossary-msg-en-de"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Every glossary that exists on the DeepL account -->
|
||||||
|
<div class="modal-section">
|
||||||
|
<h3>Glossaries on the DeepL account</h3>
|
||||||
|
<div class="current-config">
|
||||||
|
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.
|
||||||
|
</div>
|
||||||
|
<div id="accountGlossaryList"></div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="secondary small-btn" id="accountGlossaryRefreshBtn">Refresh list</button>
|
||||||
|
</div>
|
||||||
|
<div id="accountGlossary-msg"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -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)
|
await loadDomains(); // pull the latest shared state before showing (other users may have edited it)
|
||||||
populateGlossarySection();
|
populateGlossarySection();
|
||||||
domainModalOverlay.classList.add('open');
|
domainModalOverlay.classList.add('open');
|
||||||
|
loadAccountGlossaries(); // not awaited: it hits DeepL, so don't delay opening
|
||||||
});
|
});
|
||||||
document.getElementById('domainModalCloseBtn').addEventListener('click', () => domainModalOverlay.classList.remove('open'));
|
document.getElementById('domainModalCloseBtn').addEventListener('click', () => domainModalOverlay.classList.remove('open'));
|
||||||
domainModalOverlay.addEventListener('click', (e) => { if (e.target === domainModalOverlay) 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 <domain>" 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 = '<div class="hint">No glossaries exist on this DeepL account yet.</div>';
|
||||||
|
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();
|
loadDomains();
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|||||||
160
server.js
160
server.js
@@ -268,6 +268,162 @@ async function handleDeleteGlossary(req, res, domainId, direction) {
|
|||||||
sendJson(res, 200, { ok: true });
|
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) {
|
function sendJson(res, status, obj) {
|
||||||
const body = JSON.stringify(obj);
|
const body = JSON.stringify(obj);
|
||||||
res.writeHead(status, {
|
res.writeHead(status, {
|
||||||
@@ -505,6 +661,7 @@ const routes = {
|
|||||||
'POST /api/document/status': handleDocumentStatus,
|
'POST /api/document/status': handleDocumentStatus,
|
||||||
'POST /api/document/result': handleDocumentResult,
|
'POST /api/document/result': handleDocumentResult,
|
||||||
'GET /api/domains': handleGetDomains,
|
'GET /api/domains': handleGetDomains,
|
||||||
|
'GET /api/glossaries': handleListAccountGlossaries,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Routes with :domain and :direction path segments
|
// Routes with :domain and :direction path segments
|
||||||
@@ -512,6 +669,9 @@ const routes = {
|
|||||||
const DYNAMIC_ROUTES = [
|
const DYNAMIC_ROUTES = [
|
||||||
{ method: 'POST', regex: /^\/api\/domains\/([a-z-]+)\/glossary\/(de-en|en-de)$/, handler: handleSaveGlossary },
|
{ 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 },
|
{ 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) => {
|
const server = http.createServer(async (req, res) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user