added translation tab
This commit is contained in:
185
website/js/translations-helpers.js
Normal file
185
website/js/translations-helpers.js
Normal file
@ -0,0 +1,185 @@
|
||||
/** 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, '&')
|
||||
.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 `
|
||||
<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>`;
|
||||
}
|
||||
Reference in New Issue
Block a user