Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ All notable changes to CV Manager will be documented in this file.

Format follows [Keep a Changelog](https://keepachangelog.com/), versioning follows [Semantic Versioning](https://semver.org/).

## [1.26.7] - 2026-04-15

### Fixed
- ATS PDF export is now fully localized to the selected UI language. Section headings (Work Experience, Education, Skills, etc.), the "Present" label for current roles, the "Technologies:" prefix in projects, the PDF document language tag, and PDF metadata (Title, Subject) now follow the active locale instead of always appearing in English.

### Added
- Server-side i18n loader that reads the existing `public/shared/i18n/*.json` files so server-generated output can be localized.
- New `ats.technologies_label`, `ats.pdf_title_suffix`, `ats.pdf_subject`, and `ats.name_fallback` i18n keys in all 8 locale files (`en`, `de`, `fr`, `nl`, `es`, `it`, `pt`, `zh`).

## [1.26.6] - 2026-04-15

### Fixed
Expand Down
30 changes: 26 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,17 +187,39 @@ Parameter interpolation uses `{{variable}}` syntax.

Every PR that adds or modifies user-visible strings **must** follow this checklist:

**Before you start:** enumerate every string the feature emits to the user — UI label, toast, confirmation dialog, exported file contents, PDF heading, filename fragment, email subject, document metadata. For each one, decide whether it's rendered client-side or server-side, then pick the matching mechanism below. Missing this step is the most common way localization regressions ship.

1. **No hardcoded English in JS** — Use `t('key')` for every user-visible string in `admin.js` and `scripts.js`. This includes button labels, titles, placeholders, empty-state messages, toasts, and confirmation dialogs.

2. **No hardcoded English in HTML** — Use `data-i18n="key"` (text), `data-i18n-title="key"` (title attribute), or `data-i18n-placeholder="key"` (placeholder) on every element with static English text.

3. **Add the key to `en.json` first** — Add the new key to `public/shared/i18n/en.json` under the appropriate namespace. This is the source of truth.
3. **No hardcoded English in server-generated user output** — Anything `src/server.js` writes into a user-facing response must go through the server-side translation helper, not be a string literal. This covers:
- PDF / document exports (e.g. `/api/export/ats-pdf`)
- Downloadable file contents (ZIP manifests, CSV headers, etc.)
- Document metadata written into generated files (PDF Title, Subject, `/Lang` tag)
- Any API response field shown to the user verbatim (error messages, labels)

Use the `serverT(key, locale)` helper and `resolveLocale(requested)` — both live near the top of `src/server.js`. The client passes `locale: I18n.locale` in the request body; the server falls back to the stored `language` setting, then English. For PDFs, set the document `lang` metadata to the resolved locale for accessibility.

```js
// In src/server.js — WRONG:
doc.text('Work Experience', ...);

// In src/server.js — RIGHT:
const locale = resolveLocale(req.body.locale);
const t = (key) => serverT(key, locale);
doc.text(t('section.experience'), ...);
```

4. **Add the key to `en.json` first** — Add the new key to `public/shared/i18n/en.json` under the appropriate namespace. This is the source of truth — used by both client `t()` and server `serverT()`.

5. **Add the key to every other locale file** — All translation files must have the exact same set of keys. Add the translated value to each of: `de.json`, `fr.json`, `nl.json`, `es.json`, `it.json`, `pt.json`, `zh.json`. If you don't know the correct translation, use the English value as a placeholder — it will still be functional since English is the fallback.

4. **Add the key to every other locale file** — All translation files must have the exact same set of keys. Add the translated value to each of: `de.json`, `fr.json`, `nl.json`, `es.json`, `it.json`, `pt.json`, `zh.json`. If you don't know the correct translation, use the English value as a placeholder — it will still be functional since English is the fallback.
6. **Key parity is enforced by tests** — `npm run test:frontend` validates that every locale file has the same keys as `en.json` and no extra keys. The CI will fail if keys are missing or mismatched.

5. **Key parity is enforced by tests** — `npm run test:frontend` validates that every locale file has the same keys as `en.json` and no extra keys. The CI will fail if keys are missing or mismatched.
7. **Server-side output localization is enforced by tests** — When adding a new server-rendered artifact (PDF/file export, email, etc.), add a regression test in `tests/backend.test.js` that requests the artifact in two different locales and asserts the outputs differ. See the `ATS PDF export localization` describe block for the pattern. A test that only asserts status 200 won't catch a missing `serverT()` call.

6. **Namespace your keys** — Follow the existing conventions (`toolbar.*`, `section.*`, `form.*`, `toast.*`, etc.). For new feature areas, create a new namespace.
8. **Namespace your keys** — Follow the existing conventions (`toolbar.*`, `section.*`, `form.*`, `toast.*`, etc.). For new feature areas, create a new namespace.

**Quick example — adding a new button:**
```js
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "cv-manager",
"version": "1.26.6",
"version": "1.26.7",
"description": "Professional CV Management System",
"main": "src/server.js",
"scripts": {
Expand Down
4 changes: 2 additions & 2 deletions public/shared/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -3965,7 +3965,7 @@ async function fetchAtsPdfPreview() {
const res = await fetch('/api/export/ats-pdf', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ scale, paperSize })
body: JSON.stringify({ scale, paperSize, locale: I18n.locale })
});

if (!res.ok) throw new Error('Failed to generate PDF');
Expand Down Expand Up @@ -3993,7 +3993,7 @@ async function downloadAtsPdf() {
const res = await fetch('/api/export/ats-pdf', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ scale, paperSize })
body: JSON.stringify({ scale, paperSize, locale: I18n.locale })
});

if (!res.ok) throw new Error('Failed to generate PDF');
Expand Down
4 changes: 4 additions & 0 deletions public/shared/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,10 @@
"ats.generate_failed": "ATS-Dokument konnte nicht erstellt werden",
"ats.downloaded": "ATS-Dokument heruntergeladen",
"ats.what_is": "Was ist ATS-Export?",
"ats.technologies_label": "Technologien",
"ats.pdf_title_suffix": "Lebenslauf",
"ats.pdf_subject": "Lebenslauf / CV",
"ats.name_fallback": "Name",

"present": "Aktuell",
"loading": "Laden...",
Expand Down
4 changes: 4 additions & 0 deletions public/shared/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,10 @@
"ats.generate_failed": "Failed to generate ATS document",
"ats.downloaded": "ATS document downloaded",
"ats.what_is": "What is ATS Export?",
"ats.technologies_label": "Technologies",
"ats.pdf_title_suffix": "Resume",
"ats.pdf_subject": "Resume / CV",
"ats.name_fallback": "Name",

"present": "Present",
"loading": "Loading...",
Expand Down
4 changes: 4 additions & 0 deletions public/shared/i18n/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,10 @@
"ats.generate_failed": "Error al generar el documento ATS",
"ats.downloaded": "Documento ATS descargado",
"ats.what_is": "¿Qué es la exportación ATS?",
"ats.technologies_label": "Tecnologías",
"ats.pdf_title_suffix": "Currículum",
"ats.pdf_subject": "Currículum / CV",
"ats.name_fallback": "Nombre",

"present": "Actualidad",
"loading": "Cargando...",
Expand Down
4 changes: 4 additions & 0 deletions public/shared/i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,10 @@
"ats.generate_failed": "Échec de la génération du document ATS",
"ats.downloaded": "Document ATS téléchargé",
"ats.what_is": "Qu'est-ce que l'export ATS ?",
"ats.technologies_label": "Technologies",
"ats.pdf_title_suffix": "CV",
"ats.pdf_subject": "CV / Résumé",
"ats.name_fallback": "Nom",

"present": "En poste",
"loading": "Chargement...",
Expand Down
4 changes: 4 additions & 0 deletions public/shared/i18n/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,10 @@
"ats.generate_failed": "Impossibile generare il documento ATS",
"ats.downloaded": "Documento ATS scaricato",
"ats.what_is": "Cos'è l'esportazione ATS?",
"ats.technologies_label": "Tecnologie",
"ats.pdf_title_suffix": "Curriculum",
"ats.pdf_subject": "Curriculum / CV",
"ats.name_fallback": "Nome",

"present": "Presente",
"loading": "Caricamento...",
Expand Down
4 changes: 4 additions & 0 deletions public/shared/i18n/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,10 @@
"ats.generate_failed": "ATS-document kon niet worden gegenereerd",
"ats.downloaded": "ATS-document gedownload",
"ats.what_is": "Wat is ATS-export?",
"ats.technologies_label": "Technologieën",
"ats.pdf_title_suffix": "CV",
"ats.pdf_subject": "CV / Curriculum vitae",
"ats.name_fallback": "Naam",

"present": "Heden",
"loading": "Laden...",
Expand Down
4 changes: 4 additions & 0 deletions public/shared/i18n/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,10 @@
"ats.generate_failed": "Falha ao gerar documento ATS",
"ats.downloaded": "Documento ATS baixado",
"ats.what_is": "O que é a exportação ATS?",
"ats.technologies_label": "Tecnologias",
"ats.pdf_title_suffix": "Currículo",
"ats.pdf_subject": "Currículo / CV",
"ats.name_fallback": "Nome",

"present": "Presente",
"loading": "A carregar...",
Expand Down
4 changes: 4 additions & 0 deletions public/shared/i18n/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,10 @@
"ats.generate_failed": "生成 ATS 文档失败",
"ats.downloaded": "ATS 文档已下载",
"ats.what_is": "什么是 ATS 导出?",
"ats.technologies_label": "技术",
"ats.pdf_title_suffix": "简历",
"ats.pdf_subject": "简历 / CV",
"ats.name_fallback": "姓名",

"present": "至今",
"loading": "加载中...",
Expand Down
59 changes: 49 additions & 10 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,40 @@ const SECTION_DISPLAY_NAMES = {

const DEFAULT_SECTION_ORDER = ['about', 'timeline', 'experience', 'certifications', 'education', 'skills', 'projects'];

// Server-side i18n: load all translations once at startup so server-generated
// content (e.g., ATS PDF export) can be localized to the user's active language.
const I18N_DIR = path.join(__dirname, '../public/shared/i18n');
const serverTranslations = {};
try {
if (fs.existsSync(I18N_DIR)) {
for (const file of fs.readdirSync(I18N_DIR)) {
if (file.endsWith('.json')) {
const code = path.basename(file, '.json');
try {
serverTranslations[code] = JSON.parse(fs.readFileSync(path.join(I18N_DIR, file), 'utf8'));
} catch (e) {
console.warn(`Failed to parse translation file ${file}: ${e.message}`);
}
}
}
}
} catch (e) { console.warn(`Failed to load server translations: ${e.message}`); }

function serverT(key, locale) {
const loc = serverTranslations[locale] || {};
const en = serverTranslations['en'] || {};
return loc[key] || en[key] || key;
}

function resolveLocale(requested) {
if (requested && serverTranslations[requested]) return requested;
try {
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get('language');
if (row && row.value && serverTranslations[row.value]) return row.value;
} catch (e) { /* ignore — db may not be ready or settings table may lack the key */ }
return 'en';
}

function checkFilesystemAccess(dir) {
const testFile = path.join(dir, '.write-test-' + process.pid);
try {
Expand Down Expand Up @@ -1894,27 +1928,32 @@ if (PUBLIC_ONLY) {
// Uses pdfkit directly for StructTreeRoot / tagged PDF support
app.post('/api/export/ats-pdf', async (req, res) => {
try {
const { scale = 1, paperSize = 'A4' } = req.body || {};
const { scale = 1, paperSize = 'A4', locale: reqLocale } = req.body || {};
const s = Math.max(0.5, Math.min(1.5, parseFloat(scale) || 1));
const locale = resolveLocale(reqLocale);
const t = (key) => serverT(key, locale);

const cvData = gatherCvData();
const p = cvData.profile || {};
const sectionOrder = cvData.sectionOrder || [];

const MONTH_KEYS = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'];
function fmtDate(dateStr) {
if (!dateStr) return '';
if (/^\d{4}$/.test(dateStr)) return dateStr;
if (/^\d{4}-\d{2}$/.test(dateStr)) {
const [y, m] = dateStr.split('-');
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
return `${months[parseInt(m) - 1]} ${y}`;
return `${t('month.short.' + MONTH_KEYS[parseInt(m) - 1])} ${y}`;
}
return dateStr;
}

function getSectionName(key) {
const orderEntry = sectionOrder.find(s => s.key === key);
if (orderEntry && orderEntry.display_name) return orderEntry.display_name;
const translationKey = 'section.' + key;
const translated = t(translationKey);
if (translated !== translationKey) return translated;
if (SECTION_DISPLAY_NAMES[key]) return SECTION_DISPLAY_NAMES[key];
if (orderEntry && orderEntry.name) return orderEntry.name;
const cs = (cvData.customSections || []).find(s => s.section_key === key);
Expand All @@ -1935,12 +1974,12 @@ if (PUBLIC_ONLY) {
size: [pageW, pageH],
margins: { top: margin, bottom: margin, left: margin, right: margin },
info: {
Title: `${p.name || 'CV'} - Resume`,
Title: `${p.name || 'CV'} - ${t('ats.pdf_title_suffix')}`,
Author: p.name || '',
Subject: 'Resume / CV',
Subject: t('ats.pdf_subject'),
Keywords: (cvData.skills || []).flatMap(c => c.skills || []).join(', ')
},
lang: 'en',
lang: locale,
font: 'Helvetica'
});

Expand Down Expand Up @@ -2027,7 +2066,7 @@ if (PUBLIC_ONLY) {
}

// --- Header ---
addHeading('H1', p.name || 'Name', sz(22));
addHeading('H1', p.name || t('ats.name_fallback'), sz(22));
if (p.title) addParagraph(p.title, sz(12), { color: '#444' });
if (p.subtitle) addParagraph(p.subtitle, sz(10), { color: '#666' });

Expand Down Expand Up @@ -2067,7 +2106,7 @@ if (PUBLIC_ONLY) {
advanceY(6);
}
const title = exp.job_title || '';
const dateStr = `${fmtDate(exp.start_date)} – ${exp.end_date ? fmtDate(exp.end_date) : 'Present'}`;
const dateStr = `${fmtDate(exp.start_date)} – ${exp.end_date ? fmtDate(exp.end_date) : t('present')}`;

// Job title on its own line as H3
ensureSpace(sz(10) * 1.3 + 4);
Expand Down Expand Up @@ -2113,7 +2152,7 @@ if (PUBLIC_ONLY) {
advanceY(6);
}
const title = edu.degree_title || '';
const dateStr = `${fmtDate(edu.start_date)} – ${edu.end_date ? fmtDate(edu.end_date) : 'Present'}`;
const dateStr = `${fmtDate(edu.start_date)} – ${edu.end_date ? fmtDate(edu.end_date) : t('present')}`;

// Degree on its own line as H3
ensureSpace(sz(10) * 1.3 + 4);
Expand Down Expand Up @@ -2197,7 +2236,7 @@ if (PUBLIC_ONLY) {

if (proj.description) addParagraph(proj.description, sz(9));
if (proj.technologies && proj.technologies.length > 0) {
addParagraph(`Technologies: ${proj.technologies.join(', ')}`, sz(8.5), { color: '#666', font: 'Helvetica-Oblique' });
addParagraph(`${t('ats.technologies_label')}: ${proj.technologies.join(', ')}`, sz(8.5), { color: '#666', font: 'Helvetica-Oblique' });
}
if (proj.link) addParagraph(proj.link, sz(8), { color: accentColor });
});
Expand Down
Loading
Loading