Files
nat-as-server/website/js/translations-helpers.js
2026-05-22 16:20:00 +02:00

186 lines
6.9 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 || '';
}
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 strings = items.filter(({ entry }) => entry.type === 'string');
const content = items.filter(({ entry }) => entry.type !== 'string');
const groups = [];
if (strings.length) {
groups.push({
id: 'strings',
title: 'UI strings',
items: sortGroupItems(strings, 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>`;
}
/** Flatten questionnaires from ?all=1 into one entries array + section metadata */
export function flattenAllQuestionnaires(questionnaires) {
const allEntries = [];
const sections = [];
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,
});
}
return { allEntries, sections };
}
export function typeBadge(type) {
const map = { question: 'Q', answer_option: 'Opt', string: 'Str' };
const cls = { question: 'badge-q', answer_option: 'badge-opt', string: 'badge-str' };
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: Key | main language (German) | target language */
export function translationListHeaderHTML(targetLangLabel, sourceLabel = 'German') {
return `
<div class="trans-list-header">
<span class="trans-col-key">Key</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 key = entry.key || '';
const german = germanFor(entry);
const val = translationFor(entry, targetLang);
const missing = !val.trim();
const disabled = editable ? '' : 'disabled';
const sourceCell = entry.type === 'string' && 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>`;
return `
<div class="trans-list-item ${missing ? 'trans-list-item-missing' : ''}"
data-idx="${idx}" data-type="${entry.type}" data-key="${escHtml(key)}" data-missing="${missing ? '1' : '0'}">
<div class="trans-list-key">
${typeBadge(entry.type)}
<span class="trans-key-text" title="${escHtml(key)}">${escHtml(key)}</span>
</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>`;
}