import { apiGet, apiPost, apiPut, apiDelete } from '../api.js'; import { canEdit, pageHeaderHTML, showToast } from '../app.js'; import { SOURCE_LANG, normalizeEntry, translationFor, targetLanguages, sourceLanguageLabel, translationListHeaderHTML, buildTranslationGroups, renderGroupedTranslationsHTML, flattenAllQuestionnaires, escHtml as esc, } from '../translations-helpers.js'; let transData = null; let allEntries = []; let selectedLang = ''; let saveTimers = {}; 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 ''; if (selectedLang && targets.some(l => l.languageCode === selectedLang)) { return selectedLang; } return targets[0].languageCode; } 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); 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; renderEntryList(); }; } if (filter) filter.disabled = false; if (typeFilter) typeFilter.disabled = false; renderLanguagePanel(languages); renderEntryList(); bindFilters(); } 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 (!confirm(`Remove "${lc}" and all its translations?`)) 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 renderEntryList() { const listArea = document.getElementById('transListArea'); if (!listArea || !transData) return; const languages = transData.languages || []; const targets = targetLanguages(languages); const sections = transData.sections || []; 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 (!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 missingCount = allEntries.filter(e => !translationFor(e, selectedLang).trim()).length; const pct = allEntries.length ? Math.round(((allEntries.length - missingCount) / allEntries.length) * 100) : 0; const headerOnce = translationListHeaderHTML(langLabel, sourceLabel); const sectionsHtml = sections.map(sec => { let entries = allEntries.slice(sec.startIdx, sec.startIdx + sec.entryCount); if (sec.isApp) { entries = entries.filter(e => e.type === 'app_string'); } const groups = buildTranslationGroups(entries, selectedLang, sec.startIdx); return `

${esc(sec.name)}

${renderGroupedTranslationsHTML(groups, selectedLang, sourceLabel, true, { showColumnHeader: false, })}
`; }).join(''); listArea.innerHTML = ` ${headerOnce}
${sectionsHtml}
${missingCount ? `${missingCount} missing · ` : ''}${allEntries.length} keys ${pct}% in ${esc(selectedLang.toUpperCase())}
`; bindEntryEditing(allEntries); applyFilters(); } 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); }); }); } async function saveTranslation(entry, inp, isGerman = false) { 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) { showToast(e.message, 'error'); } } function updateProgress() { const missing = allEntries.filter(e => !translationFor(e, selectedLang).trim()).length; const pct = allEntries.length ? Math.round(((allEntries.length - missing) / allEntries.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()}`; const countEl = document.getElementById('transVisibleCount'); if (countEl && !document.getElementById('transFilter')?.value && !document.getElementById('transTypeFilter')?.value) { countEl.textContent = `${missing ? `${missing} missing · ` : ''}${allEntries.length} keys`; } } 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 show = (!type || rowType === type) && (!text || key.includes(text) || label.includes(text) || german.includes(text) || val.includes(text)); row.classList.toggle('trans-row-hidden', !show); if (show) { visible++; if (row.dataset.missing === '1') visibleMissing++; } }); document.querySelectorAll('#transGroupedRoot .trans-group').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-qn-block').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 countEl = document.getElementById('transVisibleCount'); if (countEl) { if (text || type) { countEl.textContent = `Showing ${visible} (${visibleMissing} missing)`; } else { const totalMissing = [...items].filter(r => r.dataset.missing === '1').length; countEl.textContent = `${totalMissing ? `${totalMissing} missing · ` : ''}${items.length} keys`; } } } 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 token = localStorage.getItem('qdb_token'); const res = await fetch('../api/translations?exportBundle=1', { headers: { Authorization: `Bearer ${token}` }, }); 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 (!confirm(`Import translations for ${count} entries from this file?`)) 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; } }); }