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:
2026-07-31 12:04:44 +02:00
parent e70b432fe6
commit 96d2f10346
2 changed files with 290 additions and 0 deletions

View File

@@ -444,6 +444,21 @@
</div>
<div id="glossary-msg-en-de"></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 &mdash; 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>
@@ -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 <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();
// ---------------------------------------------------------------------