import { apiGet, apiPost, apiPut, apiDelete, apiDownloadFetch } 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('../api/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;
}
});
}