/** German (de) is the canonical source language for questionnaire content. */ export const SOURCE_LANG = 'de'; export function normalizeTranslations(raw) { if (!raw) return {}; if (Array.isArray(raw)) { const map = {}; raw.forEach(row => { if (row && row.languageCode) map[row.languageCode] = row.text ?? ''; }); return map; } return typeof raw === 'object' ? { ...raw } : {}; } export function normalizeEntry(entry) { const translations = normalizeTranslations(entry.translations); const germanText = (entry.germanText || translations[SOURCE_LANG] || entry.key || '').trim() || entry.key || ''; return { ...entry, translations, germanText }; } export function germanFor(entry) { return entry.germanText || normalizeTranslations(entry.translations)[SOURCE_LANG] || entry.key || ''; } export function translationFor(entry, lang) { return normalizeTranslations(entry.translations)[lang] || ''; } export function targetLanguages(languages) { return (languages || []).filter(l => l.languageCode !== SOURCE_LANG); } /** Sort rows within a group: missing translations first, then questionnaire order */ export function sortGroupItems(items, targetLang) { return [...items].sort((a, b) => { const aMissing = !translationFor(a.entry, targetLang).trim(); const bMissing = !translationFor(b.entry, targetLang).trim(); if (aMissing !== bMissing) return aMissing ? -1 : 1; const orderA = a.entry.sortOrder ?? 0; const orderB = b.entry.sortOrder ?? 0; if (orderA !== orderB) return orderA - orderB; const typeCmp = (a.entry.type === 'question' ? 0 : 1) - (b.entry.type === 'question' ? 0 : 1); if (typeCmp !== 0) return typeCmp; return germanFor(a.entry).localeCompare(germanFor(b.entry), 'de'); }); } /** * Groups for one questionnaire: UI strings, then questions + options (interleaved). * @param {number} indexOffset - global index for save handlers */ export function buildTranslationGroups(entries, targetLang, indexOffset = 0) { const items = entries.map((entry, i) => ({ entry, idx: indexOffset + i })); const appStrings = items.filter(({ entry }) => entry.type === 'app_string'); const qStrings = items.filter(({ entry }) => entry.type === 'string'); const content = items.filter(({ entry }) => entry.type !== 'string' && entry.type !== 'app_string' ); const groups = []; if (appStrings.length) { groups.push({ id: 'app_strings', title: 'App strings', items: sortGroupItems(appStrings, targetLang), }); } if (qStrings.length) { groups.push({ id: 'strings', title: 'UI strings', items: sortGroupItems(qStrings, targetLang), }); } if (content.length) { groups.push({ id: 'content', title: 'Questions', items: sortGroupItems(content, targetLang), }); } return groups; } /** Render grouped sections (optional questionnaire wrapper title). */ export function renderGroupedTranslationsHTML(groups, targetLang, sourceLabel, editable, options = {}) { const { showColumnHeader = true, questionnaireTitle = null } = options; if (!groups.length) return ''; const header = showColumnHeader ? translationListHeaderHTML( targetLang, sourceLabel ) : ''; const qnHeader = questionnaireTitle ? `

${escHtml(questionnaireTitle)}

` : ''; const body = groups.map(g => `

${escHtml(g.title)}

${g.items.map(({ entry, idx }) => translationRowHTML(entry, idx, { targetLang, editable }) ).join('')}
`).join(''); const wrapClass = questionnaireTitle ? 'trans-qn-block' : 'trans-groups-wrap'; return `
${qnHeader}${header}${body}
`; } /** Only global app UI rows (never questionnaire questions/options). */ export function filterGlobalAppEntries(entries) { return (entries || []).filter(e => e && e.type === 'app_string'); } /** Flatten app strings + questionnaires from ?all=1 */ export function flattenAllQuestionnaires(questionnaires, appStrings = []) { const allEntries = []; const sections = []; const appNorm = filterGlobalAppEntries((appStrings || []).map(normalizeEntry)); if (appNorm.length) { const startIdx = 0; appNorm.forEach(e => allEntries.push(e)); sections.push({ questionnaireID: '__app__', name: 'App UI', startIdx, entryCount: appNorm.length, isApp: true, }); } for (const qn of questionnaires) { const normalized = (qn.entries || []).map(normalizeEntry); if (!normalized.length) continue; const startIdx = allEntries.length; normalized.forEach(e => allEntries.push(e)); sections.push({ questionnaireID: qn.questionnaireID, name: qn.name || 'Questionnaire', startIdx, entryCount: normalized.length, isApp: false, }); } return { allEntries, sections }; } export function typeBadge(type) { const map = { question: 'Q', answer_option: 'Opt', string: 'Str', app_string: 'App' }; const cls = { question: 'badge-q', answer_option: 'badge-opt', string: 'badge-str', app_string: 'badge-app' }; return `${map[type] || type}`; } export function escHtml(s) { if (s == null) return ''; return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } export function sourceLanguageLabel(languages) { const row = (languages || []).find(l => l.languageCode === SOURCE_LANG); return row?.name ? `${row.name} (${SOURCE_LANG})` : 'German'; } /** Column headers: Key | main language (German) | target language */ export function translationListHeaderHTML(targetLangLabel, sourceLabel = 'German') { return `
Key ${escHtml(sourceLabel)} ${escHtml(targetLangLabel)}
`; } export function translationRowHTML(entry, idx, { targetLang, editable = true }) { const key = entry.key || ''; const german = germanFor(entry); const val = translationFor(entry, targetLang); const missing = !val.trim(); const disabled = editable ? '' : 'disabled'; const sourceCell = editable ? `` : `${escHtml(german)}`; return `
${typeBadge(entry.type)} ${escHtml(key)}
${sourceCell}
`; }