import { apiGet, apiPost, apiPut, apiDelete, apiDownloadFetch, apiUrl } from '../api.js'; import { canEdit, pageHeaderHTML, showToast } from '../app.js'; import { confirmAction } from '../confirm-modal.js'; import { SOURCE_LANG, normalizeEntry, translationFor, targetLanguages, isLanguageGloballyEnabled, sourceLanguageLabel, translationListHeaderHTML, buildTranslationGroups, renderCollapsibleGroupedTranslationsHTML, flattenAllQuestionnaires, countMissing, sectionEntries, escHtml as esc, } from '../translations-helpers.js'; const STORAGE_LANG = 'qdb_trans_lang'; const STORAGE_SCOPE = 'qdb_trans_scope'; const STORAGE_SHOW = 'qdb_trans_show_mode'; let transData = null; let allEntries = []; let selectedLang = ''; let selectedScope = ''; /** @type {'missing' | 'all' | 'complete'} */ let showMode = 'missing'; let saveTimers = {}; let savesInFlight = 0; export async function translationsPage() { const app = document.getElementById('app'); app.innerHTML = ` ${pageHeaderHTML( 'Translations', '', { subtitle: 'Manage languages, availability, and translated text for the coach app.' }, )}
`; if (canEdit()) { document.getElementById('transHeaderActions').innerHTML = ` `; bindTranslationBundleActions(); } try { renderShell(); await loadTranslations(); } catch (e) { document.getElementById('transPageRoot').innerHTML = `

${esc(e.message)}

`; } } function renderShell() { document.getElementById('transPageRoot').innerHTML = `
`; } function pickDefaultTargetLang(languages) { const targets = targetLanguages(languages); if (!targets.length) return ''; const stored = localStorage.getItem(STORAGE_LANG); if (stored && targets.some(l => l.languageCode === stored)) return stored; if (selectedLang && targets.some(l => l.languageCode === selectedLang)) return selectedLang; return targets[0].languageCode; } function pickDefaultShowMode() { const stored = localStorage.getItem(STORAGE_SHOW); if (stored === 'missing' || stored === 'all' || stored === 'complete') return stored; return 'missing'; } function pickDefaultScope(sections, entries, lang) { const stored = localStorage.getItem(STORAGE_SCOPE); if (stored === '__all__' || sections.some(s => s.questionnaireID === stored)) return stored; for (const sec of sections) { if (countMissing(sectionEntries(entries, sec), lang) > 0) return sec.questionnaireID; } return sections[0]?.questionnaireID || '__app__'; } function scopeLabel(sec, entries, lang) { const missing = countMissing(sectionEntries(entries, sec), lang); return { name: sec.name, missing, complete: missing === 0 }; } function scopedSections() { const sections = transData?.sections || []; if (!selectedScope || selectedScope === '__all__') return sections; return sections.filter(s => s.questionnaireID === selectedScope); } function entriesInScope() { const sections = scopedSections(); const out = []; for (const sec of sections) out.push(...sectionEntries(allEntries, sec)); return out; } function currentScopeMeta() { const sections = transData?.sections || []; if (selectedScope === '__all__') return { id: '__all__', name: 'All content', isApp: false, isQuestionnaire: false }; const sec = sections.find(s => s.questionnaireID === selectedScope); if (!sec) return { id: selectedScope, name: 'Content', isApp: false, isQuestionnaire: false }; return { id: sec.questionnaireID, name: sec.name, isApp: !!sec.isApp, isQuestionnaire: !sec.isApp && sec.questionnaireID !== '__app__', }; } function currentLangMeta() { const languages = transData?.languages || []; const row = languages.find(l => l.languageCode === selectedLang); return { code: selectedLang, name: row?.name || selectedLang.toUpperCase(), enabled: row ? isLanguageGloballyEnabled(row) : true, }; } function setSaveStatus(state) { const el = document.getElementById('transSaveStatus'); if (!el) return; el.dataset.state = state; el.textContent = state === 'saving' ? 'Saving…' : state === 'error' ? 'Save failed' : 'All changes saved'; } async function ensureGermanLanguage() { try { await apiPut('translations.php', { type: 'language', languageCode: SOURCE_LANG, name: 'German' }); } catch (_) { /* already exists */ } } async function loadTranslations() { const listArea = document.getElementById('transListArea'); if (listArea) listArea.innerHTML = '
'; saveTimers = {}; try { await ensureGermanLanguage(); const data = await apiGet('translations.php?all=1'); transData = data; const languages = transData.languages || []; const questionnaires = transData.questionnaires || []; const flat = flattenAllQuestionnaires(questionnaires, data.appStrings || []); allEntries = flat.allEntries; transData.sections = flat.sections; selectedLang = pickDefaultTargetLang(languages); showMode = pickDefaultShowMode(); selectedScope = pickDefaultScope(flat.sections, allEntries, selectedLang); renderLanguageSidebar(languages); renderContentNav(); renderDeleteForm(); renderWorkspaceHeader(); renderQnAvailabilityStrip(); renderEntryList(); bindFilters(); setSaveStatus('saved'); } catch (e) { if (listArea) listArea.innerHTML = `

${esc(e.message)}

`; showToast(e.message, 'error'); } } function renderLanguageSidebar(languages) { const list = document.getElementById('transLangSidebar'); const addBox = document.getElementById('transAddLang'); if (!list || !addBox) return; const others = targetLanguages(languages); const source = languages.find(l => l.languageCode === SOURCE_LANG); list.innerHTML = `
DE ${esc(source?.name || 'German')} Source language Source
${others.length ? others.map(lang => { const selected = lang.languageCode === selectedLang; const enabled = isLanguageGloballyEnabled(lang); const missing = countMissing(allEntries, lang.languageCode); return ` ` : ''} `; }).join('') : `

No target languages yet. Add one below to start translating.

`} `; addBox.innerHTML = canEdit() ? `
` : ''; list.querySelectorAll('.trans-lang-item[data-lc]').forEach(btn => { btn.addEventListener('click', e => { if (e.target.closest('[data-stop-select]')) return; selectedLang = btn.dataset.lc; localStorage.setItem(STORAGE_LANG, selectedLang); renderLanguageSidebar(languages); renderContentNav(); renderWorkspaceHeader(); renderQnAvailabilityStrip(); renderEntryList(); }); }); list.querySelectorAll('.trans-global-lang-toggle').forEach(input => { input.addEventListener('change', async e => { e.stopPropagation(); const lc = input.dataset.lc; const enabled = input.checked; input.disabled = true; try { await apiPut('translations.php', { type: 'language_enabled', languageCode: lc, enabled }); const lang = languages.find(l => l.languageCode === lc); if (lang) lang.enabled = enabled ? 1 : 0; showToast(enabled ? `${lc} enabled in app` : `${lc} hidden from app`, 'success'); renderLanguageSidebar(languages); renderQnAvailabilityStrip(); } catch (err) { input.checked = !enabled; showToast(err.message, 'error'); } finally { input.disabled = false; } }); }); list.querySelectorAll('.trans-lang-remove').forEach(btn => { btn.addEventListener('click', async e => { e.stopPropagation(); const lc = btn.dataset.lc; if (!(await confirmAction({ title: 'Remove language', message: `Remove "${lc}" and delete all of its translations?`, confirmLabel: 'Remove', variant: 'danger', }))) return; try { await apiDelete('translations.php', { type: 'language', languageCode: lc }); showToast(`Language "${lc}" removed`, 'success'); if (selectedLang === lc) selectedLang = ''; await loadTranslations(); } catch (err) { showToast(err.message, 'error'); } }); }); document.getElementById('addLangBtn')?.addEventListener('click', async () => { const code = document.getElementById('newLangCode').value.trim().toLowerCase(); const name = document.getElementById('newLangName').value.trim(); if (!code) { showToast('Language code is required', 'error'); return; } if (code === SOURCE_LANG) { showToast('German (de) is always the source language', 'error'); return; } if (languages.some(l => l.languageCode === code)) { showToast('Language already exists', 'error'); return; } try { await apiPut('translations.php', { type: 'language', languageCode: code, name }); showToast(`Language "${code}" added`, 'success'); selectedLang = code; await loadTranslations(); } catch (err) { showToast(err.message, 'error'); } }); } function renderContentNav() { const nav = document.getElementById('transContentNav'); if (!nav || !transData) return; const sections = transData.sections || []; const items = sections.map(sec => { const { name, missing, complete } = scopeLabel(sec, allEntries, selectedLang); const active = sec.questionnaireID === selectedScope; const badge = missing ? `${missing}` : ``; const icon = sec.isApp ? '◇' : '▢'; return ` `; }); items.push(` `); nav.innerHTML = items.join(''); nav.querySelectorAll('.trans-content-item').forEach(btn => { btn.addEventListener('click', () => { selectedScope = btn.dataset.scope; localStorage.setItem(STORAGE_SCOPE, selectedScope); renderContentNav(); renderWorkspaceHeader(); renderQnAvailabilityStrip(); renderDeleteForm(); renderEntryList(); }); }); } function questionnaireLanguageDisabled(questionnaireID, languageCode) { return (transData?.questionnaireLanguageDisables || []).some( row => row.questionnaireID === questionnaireID && row.languageCode === languageCode ); } function setQuestionnaireLanguageDisabled(questionnaireID, languageCode, disabled) { if (!transData) return; const rows = transData.questionnaireLanguageDisables || []; const idx = rows.findIndex( row => row.questionnaireID === questionnaireID && row.languageCode === languageCode ); if (disabled) { if (idx < 0) rows.push({ questionnaireID, languageCode }); } else if (idx >= 0) { rows.splice(idx, 1); } transData.questionnaireLanguageDisables = rows; } function renderQnAvailabilityStrip() { const strip = document.getElementById('transQnAvailability'); if (!strip || !transData) return; const scope = currentScopeMeta(); const languages = targetLanguages(transData.languages || []).filter(isLanguageGloballyEnabled); if (!scope.isQuestionnaire || !languages.length) { strip.hidden = true; strip.innerHTML = ''; return; } strip.hidden = false; strip.innerHTML = `
Available in app for this questionnaire
${languages.map(lang => { const on = !questionnaireLanguageDisabled(scope.id, lang.languageCode); return ` `; }).join('')}
`; strip.querySelectorAll('.trans-qn-lang-toggle').forEach(input => { input.addEventListener('change', async () => { const qid = input.dataset.qid; const lc = input.dataset.lc; const enabled = input.checked; input.disabled = true; try { await apiPut('translations.php', { type: 'questionnaire_language', questionnaireID: qid, languageCode: lc, enabled, }); setQuestionnaireLanguageDisabled(qid, lc, !enabled); renderQnAvailabilityStrip(); showToast( enabled ? `${lc} enabled for this questionnaire` : `${lc} hidden for this questionnaire`, 'success' ); } catch (err) { input.checked = !enabled; showToast(err.message, 'error'); } finally { input.disabled = false; } }); }); } function renderDeleteForm() { const form = document.getElementById('transDeleteForm'); if (!form || !transData || !canEdit()) { if (form) form.innerHTML = ''; return; } const languages = targetLanguages(transData.languages || []); const sections = (transData.sections || []).filter(sec => sec.questionnaireID !== '__app__'); const defaultScope = selectedScope && selectedScope !== '__all__' ? selectedScope : '__app__'; form.innerHTML = ` `; document.getElementById('transDeleteBtn')?.addEventListener('click', onDeleteTranslations); } async function onDeleteTranslations() { const scopeVal = document.getElementById('transDeleteScope')?.value || '__app__'; const lang = document.getElementById('transDeleteLang')?.value || ''; const scope = scopeVal === '__app__' ? 'app' : (scopeVal === '__all__' ? 'all' : 'questionnaire'); const sections = (transData?.sections || []).filter(sec => sec.questionnaireID !== '__app__'); const scopeLabel = scopeVal === '__app__' ? 'App UI strings' : (scopeVal === '__all__' ? 'all content' : (sections.find(s => s.questionnaireID === scopeVal)?.name || 'this questionnaire')); const langLabel = lang ? lang.toUpperCase() : 'all target languages'; if (!(await confirmAction({ title: 'Delete translations', message: `Delete ${langLabel} translations for ${scopeLabel}? This cannot be undone.`, confirmLabel: 'Delete', variant: 'danger', }))) return; const btn = document.getElementById('transDeleteBtn'); btn.disabled = true; try { const body = { action: 'deleteTranslationsScope', scope }; if (scope === 'questionnaire') body.questionnaireID = scopeVal; if (lang) body.languageCode = lang; const result = await apiPost('translations.php', body); showToast(`Deleted ${result.deleted ?? 0} translation row(s)`, 'success'); await loadTranslations(); } catch (e) { showToast(e.message, 'error'); } finally { btn.disabled = false; } } function renderWorkspaceHeader() { const header = document.getElementById('transWorkspaceHeader'); if (!header || !transData) return; const scope = currentScopeMeta(); const lang = currentLangMeta(); const scopeEntries = entriesInScope(); const missingCount = selectedLang ? countMissing(scopeEntries, selectedLang) : 0; const pct = scopeEntries.length && selectedLang ? Math.round(((scopeEntries.length - missingCount) / scopeEntries.length) * 100) : 0; header.innerHTML = `

${esc(scope.name)}

${selectedLang ? `Translating into ${esc(lang.name)} (${esc(lang.code)})` : 'Add a target language to begin'} ${!lang.enabled && selectedLang ? ' · Hidden from app' : ''}

All changes saved
${missingCount ? `${missingCount} missing · ` : ''}${scopeEntries.length} strings ${pct}% complete
`; header.querySelectorAll('.trans-pill').forEach(btn => { btn.addEventListener('click', () => { showMode = btn.dataset.show; localStorage.setItem(STORAGE_SHOW, showMode); header.querySelectorAll('.trans-pill').forEach(p => p.classList.toggle('is-active', p.dataset.show === showMode)); applyFilters(); }); }); document.getElementById('transJumpMissingBtn')?.addEventListener('click', jumpToNextMissing); filtersBound = false; bindFilters(); } function renderEntryList() { const listArea = document.getElementById('transListArea'); if (!listArea || !transData) return; const languages = transData.languages || []; const targets = targetLanguages(languages); const sections = scopedSections(); if (!targets.length) { listArea.innerHTML = `

Add a language to get started

Use the form in the sidebar to add English, French, or any other target language.

`; return; } if (!selectedLang) { listArea.innerHTML = `

Select a target language

Click a language in the sidebar to begin translating.

`; return; } if (!transData.sections?.length) { listArea.innerHTML = `

No translatable content

Create questionnaires in the editor first. German source text is edited there.

`; return; } const sourceLabel = sourceLanguageLabel(languages); const langLabel = targets.find(l => l.languageCode === selectedLang)?.name || selectedLang.toUpperCase(); const useQnDetails = selectedScope === '__all__'; const sectionsHtml = sections.map(sec => { const entries = sectionEntries(allEntries, sec); const groups = buildTranslationGroups(entries, selectedLang, sec.startIdx); const secMissing = countMissing(entries, selectedLang); const inner = renderCollapsibleGroupedTranslationsHTML(groups, selectedLang, sourceLabel, true, { openGroupsWithMissing: selectedScope !== '__all__', }); if (useQnDetails) { const open = secMissing > 0 && sections.filter(s => countMissing(sectionEntries(allEntries, s), selectedLang) > 0 )[0]?.questionnaireID === sec.questionnaireID; const summary = secMissing ? `${sec.name} · ${secMissing} missing` : `${sec.name} · complete`; return `
${esc(summary)} ${inner}
`; } return `
${inner}
`; }).join(''); listArea.innerHTML = `
${translationListHeaderHTML(langLabel, sourceLabel)}
${sectionsHtml}
`; bindEntryEditing(allEntries); applyFilters(); } function openAncestors(el) { let node = el; while (node) { if (node.tagName === 'DETAILS') node.open = true; node = node.parentElement; } } function jumpToNextMissing() { const rows = [...document.querySelectorAll('#transGroupedRoot .trans-list-item')] .filter(r => !r.classList.contains('trans-row-hidden') && r.dataset.missing === '1'); if (!rows.length) { showToast(showMode === 'missing' ? 'All translations in this scope are complete' : 'No missing translations in view', 'info'); return; } const activeRow = document.activeElement?.closest?.('.trans-list-item'); let start = 0; if (activeRow) { const idx = rows.indexOf(activeRow); if (idx >= 0) start = idx + 1; } const target = rows[start] || rows[0]; openAncestors(target); target.querySelector('[data-field="translation"]')?.focus({ preventScroll: false }); target.scrollIntoView({ behavior: 'smooth', block: 'center' }); } function bindEntryEditing(entries) { document.querySelectorAll('#transGroupedRoot .trans-cell-input').forEach(inp => { inp.addEventListener('input', () => { const idx = parseInt(inp.dataset.idx, 10); const entry = entries[idx]; if (!entry) return; const isGerman = inp.dataset.field === 'german'; if (isGerman) { entry.germanText = inp.value; if (!entry.translations || typeof entry.translations !== 'object') entry.translations = {}; entry.translations[SOURCE_LANG] = inp.value; } else { if (!entry.translations || typeof entry.translations !== 'object') entry.translations = {}; entry.translations[selectedLang] = inp.value; } if (!isGerman) { const missing = !inp.value.trim(); inp.classList.toggle('trans-input-missing', missing); const row = inp.closest('.trans-list-item'); if (row) { row.classList.toggle('trans-list-item-missing', missing); row.dataset.missing = missing ? '1' : '0'; } } const timerKey = isGerman ? `${idx}_de` : `${idx}_${selectedLang}`; clearTimeout(saveTimers[timerKey]); saveTimers[timerKey] = setTimeout(() => saveTranslation(entry, inp, isGerman), 500); setSaveStatus('saving'); }); }); } async function saveTranslation(entry, inp, isGerman = false) { savesInFlight++; setSaveStatus('saving'); try { await apiPut('translations.php', { type: entry.type, id: entry.entityId, languageCode: isGerman ? SOURCE_LANG : selectedLang, text: inp.value, }); inp.classList.add('save-flash'); setTimeout(() => inp.classList.remove('save-flash'), 400); updateProgress(); } catch (e) { setSaveStatus('error'); showToast(e.message, 'error'); } finally { savesInFlight = Math.max(0, savesInFlight - 1); if (savesInFlight === 0) { setSaveStatus('saved'); renderContentNav(); renderLanguageSidebar(transData?.languages || []); } } } function updateProgress() { const scopeEntries = entriesInScope(); const missing = countMissing(scopeEntries, selectedLang); const pct = scopeEntries.length ? Math.round(((scopeEntries.length - missing) / scopeEntries.length) * 100) : 0; const fill = document.querySelector('.trans-progress-fill'); if (fill) fill.style.width = `${pct}%`; const track = document.querySelector('.trans-progress-track'); if (track) track.setAttribute('aria-valuenow', String(pct)); const pctEl = document.querySelector('.trans-progress-pct'); if (pctEl) pctEl.textContent = `${pct}% complete`; if (!document.getElementById('transFilter')?.value && !document.getElementById('transTypeFilter')?.value) { updateVisibleCountSummary(); } } function updateVisibleCountSummary(visible = null, visibleMissing = null) { const countEl = document.getElementById('transVisibleCount'); if (!countEl) return; const scopeEntries = entriesInScope(); const text = document.getElementById('transFilter')?.value || ''; const type = document.getElementById('transTypeFilter')?.value || ''; const missing = countMissing(scopeEntries, selectedLang); const pct = scopeEntries.length ? Math.round(((scopeEntries.length - missing) / scopeEntries.length) * 100) : 0; if (text || type || showMode !== 'missing') { if (visible != null) { countEl.innerHTML = `Showing ${visible}${visibleMissing ? ` (${visibleMissing} missing)` : ''} ${pct}% complete`; } return; } countEl.innerHTML = `${missing ? `${missing} missing · ` : ''}${scopeEntries.length} strings ${pct}% complete`; } let filtersBound = false; function bindFilters() { if (filtersBound) { applyFilters(); return; } filtersBound = true; document.getElementById('transFilter')?.addEventListener('input', applyFilters); document.getElementById('transTypeFilter')?.addEventListener('change', applyFilters); } function applyFilters() { const text = (document.getElementById('transFilter')?.value || '').toLowerCase(); const type = document.getElementById('transTypeFilter')?.value || ''; const items = document.querySelectorAll('#transGroupedRoot .trans-list-item'); let visible = 0; let visibleMissing = 0; items.forEach(row => { const key = row.dataset.key?.toLowerCase() || ''; const label = row.dataset.label?.toLowerCase() || row.querySelector('.trans-key-text')?.textContent?.toLowerCase() || ''; const rowType = row.dataset.type || ''; const german = row.querySelector('.trans-source-text')?.textContent?.toLowerCase() || row.querySelector('[data-field="german"]')?.value?.toLowerCase() || ''; const val = row.querySelector('[data-field="translation"]')?.value?.toLowerCase() || ''; const isMissing = row.dataset.missing === '1'; let show = (!type || rowType === type) && (!text || key.includes(text) || label.includes(text) || german.includes(text) || val.includes(text)); if (showMode === 'missing' && !isMissing) show = false; if (showMode === 'complete' && isMissing) show = false; row.classList.toggle('trans-row-hidden', !show); if (show) { visible++; if (isMissing) visibleMissing++; } }); document.querySelectorAll('#transGroupedRoot .trans-group-details').forEach(group => { const anyVisible = [...group.querySelectorAll('.trans-list-item')].some(r => !r.classList.contains('trans-row-hidden')); group.classList.toggle('trans-group-hidden', !anyVisible); }); document.querySelectorAll('#transGroupedRoot .trans-scope-block, #transGroupedRoot .trans-qn-section').forEach(block => { const anyVisible = [...block.querySelectorAll('.trans-list-item')].some(r => !r.classList.contains('trans-row-hidden')); block.classList.toggle('trans-qn-hidden', !anyVisible); }); const emptyEl = document.getElementById('transEmptyHint'); if (emptyEl) emptyEl.remove(); if (visible === 0 && items.length > 0) { const root = document.getElementById('transGroupedRoot'); if (root) { const hint = document.createElement('p'); hint.id = 'transEmptyHint'; hint.className = 'text-muted trans-hint trans-empty-hint'; hint.textContent = showMode === 'missing' ? 'All translations in this scope are complete for the selected language.' : 'No rows match the current filters.'; root.insertAdjacentElement('afterend', hint); } } updateVisibleCountSummary(visible, visibleMissing); } function bindTranslationBundleActions() { const fileInput = document.getElementById('importTransBundleFile'); const importBtn = document.getElementById('importTransBundleBtn'); const exportBtn = document.getElementById('exportTransBundleBtn'); if (!fileInput || !importBtn || !exportBtn) return; exportBtn.addEventListener('click', async () => { exportBtn.disabled = true; const prev = exportBtn.textContent; exportBtn.textContent = 'Preparing…'; try { const res = await apiDownloadFetch(apiUrl('translations?exportBundle=1')); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error?.message || err.error || 'Export failed'); } const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `translations_bundle_${new Date().toISOString().slice(0, 10)}.json`; a.click(); URL.revokeObjectURL(url); showToast('Translations bundle downloaded', 'success'); } catch (e) { showToast(e.message, 'error'); } finally { exportBtn.disabled = false; exportBtn.textContent = prev; } }); importBtn.addEventListener('click', () => fileInput.click()); fileInput.addEventListener('change', async () => { const file = fileInput.files?.[0]; fileInput.value = ''; if (!file) return; let bundle; try { bundle = JSON.parse(await file.text()); } catch { showToast('Invalid JSON file', 'error'); return; } if (!bundle?.entries || !Array.isArray(bundle.entries)) { showToast('Not a translations bundle (missing entries array)', 'error'); return; } if (!(await confirmAction({ title: 'Import translations', message: `Import translations for ${bundle.entries.length} entries from this file?`, confirmLabel: 'Import', }))) return; importBtn.disabled = true; const prev = importBtn.textContent; importBtn.textContent = 'Importing…'; try { const result = await apiPost('translations.php', { action: 'importBundle', bundle }); const imported = result.imported ?? 0; const skipped = result.skipped ?? 0; let msg = `Imported ${imported} translation(s)`; if (skipped) msg += ` (${skipped} skipped)`; showToast(msg, 'success'); await loadTranslations(); } catch (e) { showToast(e.message, 'error'); } finally { importBtn.disabled = false; importBtn.textContent = prev; } }); }