diff --git a/index.html b/index.html index f162f58..373e4b5 100644 --- a/index.html +++ b/index.html @@ -444,6 +444,21 @@
+ + + @@ -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) => {