added app translations for better maintainability

This commit is contained in:
2026-05-22 16:48:47 +02:00
parent 0291b8be7a
commit 2ded5b6aaf
11 changed files with 594 additions and 122 deletions

View File

@ -1085,6 +1085,7 @@ th.sort-desc::after { content: ' \2193'; opacity: 1; }
}
.badge-q { background: #dbeafe; color: #1e40af; }
.badge-opt { background: #e2e8f0; color: #475569; }
.badge-app { background: #d1fae5; color: #065f46; }
.badge-str { background: #ede9fe; color: #5b21b6; }
.cell-trans { position: relative; }

View File

@ -961,10 +961,10 @@ function renderTranslationsTab() {
if (!container || !transData) return;
const editable = canEdit();
const languages = transData.languages || [];
const entries = transData.entries || [];
const qnEntries = transData.entries || [];
const targets = targetLanguages(languages);
if (!entries.length) {
if (!qnEntries.length) {
container.innerHTML = `
${editable ? languageManagerHTML(languages) : ''}
<p style="color:var(--text-secondary);margin-top:12px">No translatable content yet. Add questions with German text first.</p>
@ -992,7 +992,7 @@ function renderTranslationsTab() {
container.innerHTML = `
${editable ? languageManagerHTML(languages) : ''}
${langSelectHtml}
${targets.length && editorTransLang ? renderEditorTransList(entries, editable) : ''}
${targets.length && editorTransLang ? renderEditorTransList(qnEntries, editable) : ''}
`;
if (editable) bindLanguageManager(languages);
@ -1003,18 +1003,25 @@ function renderTranslationsTab() {
});
if (targets.length && editorTransLang) {
bindEditorTransEditing(entries, editable);
bindEditorTransFiltering(entries);
bindEditorTransEditing(qnEntries, editable);
bindEditorTransFiltering(qnEntries);
}
}
function renderEditorTransList(entries, editable) {
function renderEditorTransList(qnEntries, editable) {
const targets = targetLanguages(transData.languages || []);
const langLabel = targets.find(l => l.languageCode === editorTransLang)?.name || editorTransLang.toUpperCase();
const sourceLabel = sourceLanguageLabel(transData.languages || []);
const groups = buildTranslationGroups(entries, editorTransLang, 0);
const missingCount = entries.filter(e => !translationFor(e, editorTransLang).trim()).length;
const pct = entries.length ? Math.round(((entries.length - missingCount) / entries.length) * 100) : 0;
const missingCount = qnEntries.filter(e => !translationFor(e, editorTransLang).trim()).length;
const pct = qnEntries.length ? Math.round(((qnEntries.length - missingCount) / qnEntries.length) * 100) : 0;
const qnGroups = buildTranslationGroups(qnEntries, editorTransLang, 0);
const qnName = transData.questionnaire?.name || questionnaire?.name || 'Questionnaire';
const bodyHtml = `
<div class="trans-qn-block" data-qn="${escHtml(questionnaire.questionnaireID)}">
<h2 class="trans-qn-title">${escHtml(qnName)}</h2>
${renderGroupedTranslationsHTML(qnGroups, editorTransLang, sourceLabel, editable, { showColumnHeader: false })}
</div>`;
return `
<div class="trans-toolbar">
@ -1027,11 +1034,9 @@ function renderEditorTransList(entries, editable) {
</select>
</div>
${translationListHeaderHTML(langLabel, sourceLabel)}
<div id="transGroupedRoot">
${renderGroupedTranslationsHTML(groups, editorTransLang, sourceLabel, editable, { showColumnHeader: false })}
</div>
<div id="transGroupedRoot">${bodyHtml}</div>
<div class="trans-stats">
<span id="transStats">${missingCount ? `${missingCount} missing · ` : ''}${entries.length} keys</span>
<span id="transStats">${missingCount ? `${missingCount} missing · ` : ''}${qnEntries.length} keys</span>
<span class="trans-stats-progress">
<span class="trans-progress-bar"><span class="trans-progress-fill" style="width:${pct}%"></span></span>
<span class="trans-progress-label">${pct}% in ${escHtml(editorTransLang.toUpperCase())}</span>
@ -1114,6 +1119,10 @@ function bindEditorTransFiltering(entries) {
const anyVisible = [...group.querySelectorAll('.trans-list-item')].some(r => !r.classList.contains('trans-row-hidden'));
group.classList.toggle('trans-group-hidden', !anyVisible);
});
root.querySelectorAll('.trans-qn-block').forEach(block => {
const anyVisible = [...block.querySelectorAll('.trans-list-item')].some(r => !r.classList.contains('trans-row-hidden'));
block.classList.toggle('trans-qn-hidden', !anyVisible);
});
const stats = document.getElementById('transStats');
if (stats) {
stats.textContent = text || type

View File

@ -57,7 +57,8 @@ function renderShell() {
<input type="search" id="transFilter" class="trans-search-input" placeholder="Search…" disabled>
<select id="transTypeFilter" class="trans-select trans-type-select" disabled>
<option value="">All types</option>
<option value="string">UI strings</option>
<option value="app_string">App strings</option>
<option value="string">Questionnaire UI strings</option>
<option value="question">Questions</option>
<option value="answer_option">Answer options</option>
</select>
@ -99,7 +100,7 @@ async function loadTranslations() {
const languages = transData.languages || [];
const questionnaires = transData.questionnaires || [];
const flat = flattenAllQuestionnaires(questionnaires);
const flat = flattenAllQuestionnaires(questionnaires, data.appStrings || []);
allEntries = flat.allEntries;
transData.sections = flat.sections;
@ -234,7 +235,10 @@ function renderEntryList() {
const headerOnce = translationListHeaderHTML(langLabel, sourceLabel);
const sectionsHtml = sections.map(sec => {
const entries = allEntries.slice(sec.startIdx, sec.startIdx + sec.entryCount);
let entries = allEntries.slice(sec.startIdx, sec.startIdx + sec.entryCount);
if (sec.isApp) {
entries = entries.filter(e => e.type === 'app_string');
}
const groups = buildTranslationGroups(entries, selectedLang, sec.startIdx);
return `
<div class="trans-qn-block" data-qn="${esc(sec.questionnaireID)}">

View File

@ -57,15 +57,25 @@ export function sortGroupItems(items, targetLang) {
*/
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 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 (strings.length) {
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(strings, targetLang),
items: sortGroupItems(qStrings, targetLang),
});
}
if (content.length) {
@ -107,11 +117,29 @@ export function renderGroupedTranslationsHTML(groups, targetLang, sourceLabel, e
return `<div class="${wrapClass}">${qnHeader}${header}${body}</div>`;
}
/** Flatten questionnaires from ?all=1 into one entries array + section metadata */
export function flattenAllQuestionnaires(questionnaires) {
/** 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;
@ -122,6 +150,7 @@ export function flattenAllQuestionnaires(questionnaires) {
name: qn.name || 'Questionnaire',
startIdx,
entryCount: normalized.length,
isApp: false,
});
}
@ -129,8 +158,8 @@ export function flattenAllQuestionnaires(questionnaires) {
}
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' };
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>`;
}
@ -165,7 +194,7 @@ export function translationRowHTML(entry, idx, { targetLang, editable = true })
const missing = !val.trim();
const disabled = editable ? '' : 'disabled';
const sourceCell = entry.type === 'string' && editable
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>`;