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, 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', '')}
`; 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 = `
More languages
Loading…
`; } 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) { const slice = sectionEntries(entries, sec); if (countMissing(slice, lang) > 0) { return sec.questionnaireID; } } return sections[0]?.questionnaireID || '__app__'; } function scopeLabel(sec, entries, lang) { const slice = sectionEntries(entries, sec); const missing = countMissing(slice, lang); const suffix = missing ? ` (${missing} missing)` : ' (complete)'; return `${sec.name}${suffix}`; } 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 setSaveStatus(state) { const el = document.getElementById('transSaveStatus'); if (!el) return; el.dataset.state = state; if (state === 'saving') { el.textContent = 'Saving…'; } else if (state === 'error') { el.textContent = 'Save failed'; } else { el.textContent = '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'); const langSelect = document.getElementById('transLangSelect'); const filter = document.getElementById('transFilter'); const typeFilter = document.getElementById('transTypeFilter'); 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); if (langSelect) { const targets = targetLanguages(languages); langSelect.disabled = !targets.length; langSelect.innerHTML = targets.length ? targets.map(l => { const label = l.name ? `${l.name} (${l.languageCode})` : l.languageCode; return ``; }).join('') : ''; langSelect.onchange = () => { selectedLang = langSelect.value; localStorage.setItem(STORAGE_LANG, selectedLang); populateScopeSelect(); renderEntryList(); }; } const scopeSelect = document.getElementById('transScopeSelect'); const showSelect = document.getElementById('transShowMode'); if (scopeSelect) { scopeSelect.disabled = false; populateScopeSelect(); scopeSelect.onchange = () => { selectedScope = scopeSelect.value; localStorage.setItem(STORAGE_SCOPE, selectedScope); renderEntryList(); }; } if (showSelect) { showSelect.disabled = false; showSelect.value = showMode; showSelect.onchange = () => { showMode = showSelect.value; localStorage.setItem(STORAGE_SHOW, showMode); applyFilters(); }; } if (filter) filter.disabled = false; if (typeFilter) typeFilter.disabled = false; renderLanguagePanel(languages); renderEntryList(); bindFilters(); setSaveStatus('saved'); } catch (e) { if (listArea) listArea.innerHTML = `

${esc(e.message)}

`; showToast(e.message, 'error'); } } function renderLanguagePanel(languages) { const panel = document.getElementById('transLangPanel'); if (!panel) return; const others = targetLanguages(languages); panel.innerHTML = `

German source text is edited in the questionnaire editor. Add other languages here.

DE German (source) ${others.map(l => ` ${esc(l.languageCode.toUpperCase())} ${l.name ? `${esc(l.name)}` : ''} `).join('')}
`; 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 available as 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 (e) { showToast(e.message, 'error'); } }); panel.querySelectorAll('.lang-chip-remove').forEach(btn => { btn.addEventListener('click', async () => { const lc = btn.dataset.lc; if (!(await confirmAction({ title: 'Remove language', message: `Remove "${lc}" and all 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 (e) { showToast(e.message, 'error'); } }); }); } function populateScopeSelect() { const scopeSelect = document.getElementById('transScopeSelect'); if (!scopeSelect || !transData) return; const sections = transData.sections || []; const options = sections.map(sec => `` ); options.push(``); scopeSelect.innerHTML = options.join(''); if (!selectedScope || (selectedScope !== '__all__' && !sections.some(s => s.questionnaireID === selectedScope))) { selectedScope = pickDefaultScope(sections, allEntries, selectedLang); scopeSelect.value = selectedScope; localStorage.setItem(STORAGE_SCOPE, selectedScope); } } 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 at least one language under More languages to translate into.

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

Choose a language above.

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

No translatable content yet. Add questionnaires and questions in the editor first.

`; return; } const sourceLabel = sourceLanguageLabel(languages); const langLabel = targets.find(l => l.languageCode === selectedLang)?.name || selectedLang.toUpperCase(); const scopeEntries = entriesInScope(); const missingCount = countMissing(scopeEntries, selectedLang); const pct = scopeEntries.length ? Math.round(((scopeEntries.length - missingCount) / scopeEntries.length) * 100) : 0; const headerOnce = translationListHeaderHTML(langLabel, sourceLabel); 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 = `
${missingCount ? `${missingCount} missing · ` : ''}${scopeEntries.length} in scope ${pct}% in ${esc(selectedLang.toUpperCase())}
All changes saved
${headerOnce}
${sectionsHtml}
`; document.getElementById('transJumpMissingBtn')?.addEventListener('click', jumpToNextMissing); 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); const inp = target.querySelector('[data-field="translation"]'); inp?.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'); populateScopeSelect(); } } } 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 label = document.querySelector('.trans-progress-label'); if (label) label.textContent = `${pct}% in ${selectedLang.toUpperCase()}`; 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 || ''; if (text || type || showMode !== 'missing') { if (visible != null) { countEl.textContent = `Showing ${visible}${visibleMissing ? ` (${visibleMissing} missing)` : ''}`; } return; } const missing = countMissing(scopeEntries, selectedLang); countEl.textContent = `${missing ? `${missing} missing · ` : ''}${scopeEntries.length} in scope`; } 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 (row.dataset.missing === '1') visibleMissing++; } }); document.querySelectorAll('#transGroupedRoot .trans-group-details').forEach(group => { const rows = group.querySelectorAll('.trans-list-item'); const anyVisible = [...rows].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; const stamp = new Date().toISOString().slice(0, 10); a.download = `translations_bundle_${stamp}.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; } const count = bundle.entries.length; if (!(await confirmAction({ title: 'Import translations', message: `Import translations for ${count} 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; } }); }