/** 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 || ''; } const HEX_ID_RE = /^[a-f0-9]{32}$/i; /** Key column label (short id or readable preview — not the app lookup key). */ export function entryDisplayKey(entry) { const dk = entry.displayKey || ''; if (dk && !HEX_ID_RE.test(dk)) return dk; if (entry.type === 'question' || entry.type === 'answer_option') { const key = (entry.key || '').trim(); if (key) { return key.length > 56 ? `${key.slice(0, 56)}…` : key; } } if (entry.type === 'question' || entry.type === 'answer_option') { const id = entry.entityId || ''; const sep = id.lastIndexOf('__'); if (sep >= 0) return id.slice(sep + 2); } return dk || entry.key || ''; } export function translationFor(entry, lang) { return normalizeTranslations(entry.translations)[lang] || ''; } export function isEntryMissing(entry, lang) { return !translationFor(entry, lang).trim(); } export function countMissing(entries, lang) { return entries.filter(e => isEntryMissing(e, lang)).length; } export function sectionEntries(allEntries, section) { let entries = allEntries.slice(section.startIdx, section.startIdx + section.entryCount); if (section.isApp) { entries = entries.filter(e => e.type === 'app_string'); } return entries; } export function targetLanguages(languages) { return (languages || []).filter(l => l.languageCode !== SOURCE_LANG); } export function isLanguageGloballyEnabled(lang) { if (!lang) return false; if (lang.languageCode === SOURCE_LANG) return true; return Number(lang.enabled ?? 1) === 1; } export function globallyEnabledLanguages(languages) { return (languages || []).filter(isLanguageGloballyEnabled); } /** 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: label | main language (German) | target language */ export function translationListHeaderHTML(targetLangLabel, sourceLabel = 'German') { return `
Label ${escHtml(sourceLabel)} ${escHtml(targetLangLabel)}
`; } export function translationRowHTML(entry, idx, { targetLang, editable = true }) { const lookupKey = entry.key || ''; const label = entryDisplayKey(entry); const german = germanFor(entry); const val = translationFor(entry, targetLang); const missing = !val.trim(); const disabled = editable ? '' : 'disabled'; const sourceCell = editable ? `` : `${escHtml(german)}`; const germanPreview = german.length > 72 ? `${german.slice(0, 72)}…` : german; return `
${typeBadge(entry.type)}
${escHtml(germanPreview || label)} ${escHtml(lookupKey)}
${sourceCell}
`; } /** * Grouped rows with collapsible sections and missing counts in summaries. */ export function renderCollapsibleGroupedTranslationsHTML(groups, targetLang, sourceLabel, editable, options = {}) { const { openGroupsWithMissing = false } = options; if (!groups.length) return ''; const body = groups.map(g => { const missing = g.items.filter(({ entry }) => isEntryMissing(entry, targetLang)).length; const total = g.items.length; const open = openGroupsWithMissing && missing > 0; const summary = missing ? `${g.title} · ${missing} missing` : `${g.title} · complete (${total})`; return `
${escHtml(summary)}
${g.items.map(({ entry, idx }) => translationRowHTML(entry, idx, { targetLang, editable }) ).join('')}
`; }).join(''); return `
${body}
`; }