initial prototype based on nat-as-server
Some checks failed
PHPUnit / test (push) Has been cancelled
Some checks failed
PHPUnit / test (push) Has been cancelled
This commit is contained in:
285
website/js/translations-helpers.js
Normal file
285
website/js/translations-helpers.js
Normal file
@ -0,0 +1,285 @@
|
||||
/** 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, '&')
|
||||
.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 `
|
||||
<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>`;
|
||||
}
|
||||
Reference in New Issue
Block a user