Files
barometer-server/website/js/translations-helpers.js
tom.hempel f1caa9e681
Some checks failed
PHPUnit / test (push) Has been cancelled
initial prototype based on nat-as-server
2026-06-29 12:39:55 +02:00

286 lines
10 KiB
JavaScript

/** 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);
}
/** 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
? `<h2 class="trans-qn-title">${escHtml(questionnaireTitle)}</h2>`
: '';
const body = groups.map(g => `
<section class="trans-group" data-group="${escHtml(g.id)}">
<h3 class="trans-group-title">${escHtml(g.title)}</h3>
<div class="trans-list trans-list-in-group">
${g.items.map(({ entry, idx }) =>
translationRowHTML(entry, idx, { targetLang, editable })
).join('')}
</div>
</section>
`).join('');
const wrapClass = questionnaireTitle ? 'trans-qn-block' : 'trans-groups-wrap';
return `<div class="${wrapClass}">${qnHeader}${header}${body}</div>`;
}
/** 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 `<span class="trans-type-badge ${cls[type] || ''}">${map[type] || type}</span>`;
}
export function escHtml(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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 `
<div class="trans-list-header trans-list-header-sticky">
<span class="trans-col-key">Label</span>
<span class="trans-col-source">${escHtml(sourceLabel)}</span>
<span class="trans-col-target">${escHtml(targetLangLabel)}</span>
</div>`;
}
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
? `<input type="text" class="trans-cell-input" data-idx="${idx}" data-field="german"
value="${escHtml(german)}" placeholder="German text…" autocomplete="off">`
: `<span class="trans-source-text" title="${escHtml(german)}">${escHtml(german)}</span>`;
const germanPreview = german.length > 72 ? `${german.slice(0, 72)}` : german;
return `
<div class="trans-list-item ${missing ? 'trans-list-item-missing' : ''}"
data-idx="${idx}" data-type="${entry.type}" data-key="${escHtml(lookupKey)}" data-label="${escHtml(label)}" data-missing="${missing ? '1' : '0'}">
<div class="trans-list-key">
${typeBadge(entry.type)}
<div class="trans-key-labels">
<span class="trans-key-primary" title="${escHtml(german)}">${escHtml(germanPreview || label)}</span>
<span class="trans-key-secondary" title="${escHtml(lookupKey)}">${escHtml(lookupKey)}</span>
</div>
</div>
<div class="trans-list-source">${sourceCell}</div>
<input type="text" class="trans-cell-input ${missing ? 'trans-input-missing' : ''}"
data-idx="${idx}" data-field="translation" value="${escHtml(val)}"
placeholder="Translation…" ${disabled} autocomplete="off">
</div>`;
}
/**
* 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 `
<details class="trans-group-details"${open ? ' open' : ''}>
<summary class="trans-group-summary">${escHtml(summary)}</summary>
<div class="trans-group-rows">
${g.items.map(({ entry, idx }) =>
translationRowHTML(entry, idx, { targetLang, editable })
).join('')}
</div>
</details>`;
}).join('');
return `<div class="trans-groups-flat">${body}</div>`;
}