diff --git a/CHANGELOG.md b/CHANGELOG.md
index f371d05..061c077 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,14 @@ 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.50.0] - 2026-05-27
+
+### Added
+- **Date of birth field on profile** (#167). Common on CVs in many European countries, especially Germany. A new `birthdate` column on the `profile` table (added via automatic migration), edited from the profile modal with a native `` sized to half the modal width (matching the email and phone fields) and forced to a white background so it visually matches the other text inputs. When set, it renders as a contact badge in the admin view and as a localized "Born / Geb. / Né(e) le / …" line on the ATS PDF and static-site exports, formatted via `Intl.DateTimeFormat` in the active locale (e.g. "14. März 1990" in DE, "March 14, 1990" in EN). When empty, nothing renders anywhere. Treated as sensitive PII: the public read-only server strips `birthdate` from `/api/profile`, `/api/datasets/slug/:slug`, and `/api/datasets/id/:id`, and the static-site export also excludes it — so it is never exposed on the public site even when a dataset is explicitly marked public. `form.birthdate`, `form.birthdate_hint`, and `contact.born` translation keys added to all 8 locale files.
+
+### Fixed
+- **Project link disappeared in compact print mode** (#168). When the Projects section had the "print compact" toggle on, `#section-projects.print-compact .project-link { display: none !important }` in `public/shared/styles.css` hid the link icon during printing, even though the same link was always rendered by the ATS PDF export and by the non-compact print view. Removed `.project-link` from that selector list so the link icon now appears next to the project title in compact print mode — consistent with the ATS export.
+
## [1.49.5] - 2026-05-06
### Fixed
diff --git a/package-lock.json b/package-lock.json
index 4470560..5f33b2b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "cv-manager",
- "version": "1.49.5",
+ "version": "1.50.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cv-manager",
- "version": "1.49.5",
+ "version": "1.50.0",
"dependencies": {
"archiver": "^7.0.1",
"better-sqlite3": "^9.4.3",
diff --git a/package.json b/package.json
index 57d9259..639f539 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "cv-manager",
- "version": "1.49.5",
+ "version": "1.50.0",
"description": "Professional CV Management System",
"main": "src/server.js",
"scripts": {
diff --git a/public/shared/admin.css b/public/shared/admin.css
index 1012683..9ea4f95 100644
--- a/public/shared/admin.css
+++ b/public/shared/admin.css
@@ -404,9 +404,42 @@
border-radius: var(--radius-sm);
font-family: inherit;
font-size: 14px;
+ line-height: 1.2;
+ color: var(--gray-700);
+ background: #fff;
transition: all 0.15s ease;
}
+/* Normalize native so it visually matches the other
+ .form-input text fields. Chromium renders the value through a shadow-DOM
+ ::-webkit-datetime-edit tree with its own line metrics, intrinsic
+ min-width, padding, and placeholder color — without these overrides the
+ field looks taller, wider and lighter than the surrounding inputs. */
+.form-input-date {
+ -webkit-appearance: none;
+ appearance: none;
+ min-width: 0;
+}
+.form-input-date::-webkit-datetime-edit,
+.form-input-date::-webkit-datetime-edit-fields-wrapper,
+.form-input-date::-webkit-datetime-edit-text,
+.form-input-date::-webkit-datetime-edit-year-field,
+.form-input-date::-webkit-datetime-edit-month-field,
+.form-input-date::-webkit-datetime-edit-day-field {
+ color: inherit;
+ padding: 0;
+ line-height: 1.2;
+ font-family: inherit;
+ font-size: inherit;
+}
+.form-input-date::-webkit-calendar-picker-indicator {
+ opacity: 0.55;
+ cursor: pointer;
+ padding: 0;
+ margin-left: 4px;
+}
+
+
.form-input:focus, .form-textarea:focus, .form-select:focus {
outline: none;
border-color: var(--primary);
diff --git a/public/shared/admin.js b/public/shared/admin.js
index 3713d9b..f30c8c4 100644
--- a/public/shared/admin.js
+++ b/public/shared/admin.js
@@ -1478,6 +1478,14 @@ function profileForm(d) {
+
+
+
+
+
${t('form.birthdate_hint')}
+
+
+
`;
}
@@ -1757,6 +1765,7 @@ async function saveItem() {
linkedin: val('f-linkedin'),
email: val('f-email'),
phone: val('f-phone'),
+ birthdate: val('f-birthdate'),
visible: true,
profile_picture_enabled: checked('f-profilePictureEnabled'),
picture_propagate: propagate,
diff --git a/public/shared/i18n/de.json b/public/shared/i18n/de.json
index fa54731..413495c 100644
--- a/public/shared/i18n/de.json
+++ b/public/shared/i18n/de.json
@@ -172,6 +172,9 @@
"form.open_to_work_hint": "Zeigt ein Abzeichen auf Ihrem öffentlichen Lebenslauf",
"form.email": "E-Mail",
"form.phone": "Telefon",
+ "form.birthdate": "Geburtsdatum",
+ "form.birthdate_hint": "Nur in der Admin-Ansicht, im Druck und in ATS-Exporten sichtbar. Auf der öffentlichen Seite niemals.",
+ "contact.born": "Geb.",
"form.job_title": "Berufsbezeichnung",
"form.company": "Unternehmen",
"form.country_code": "Ländercode",
diff --git a/public/shared/i18n/en.json b/public/shared/i18n/en.json
index 34fbd1d..5997d27 100644
--- a/public/shared/i18n/en.json
+++ b/public/shared/i18n/en.json
@@ -171,6 +171,9 @@
"form.open_to_work_hint": "Display a badge on your public CV",
"form.email": "Email",
"form.phone": "Phone",
+ "form.birthdate": "Date of Birth",
+ "form.birthdate_hint": "Shown on admin view, print, and ATS exports only. Never shown on the public site.",
+ "contact.born": "Born",
"form.job_title": "Job Title",
"form.company": "Company",
"form.country_code": "Country Code",
diff --git a/public/shared/i18n/es.json b/public/shared/i18n/es.json
index e52a8f8..eb309d4 100644
--- a/public/shared/i18n/es.json
+++ b/public/shared/i18n/es.json
@@ -172,6 +172,9 @@
"form.open_to_work_hint": "Mostrar una insignia en tu CV público",
"form.email": "Correo electrónico",
"form.phone": "Teléfono",
+ "form.birthdate": "Fecha de nacimiento",
+ "form.birthdate_hint": "Solo se muestra en la vista de admin, impresión y exportaciones ATS. Nunca en el sitio público.",
+ "contact.born": "Nacido el",
"form.job_title": "Cargo",
"form.company": "Empresa",
"form.country_code": "Código de país",
diff --git a/public/shared/i18n/fr.json b/public/shared/i18n/fr.json
index 3b80132..90d5ce3 100644
--- a/public/shared/i18n/fr.json
+++ b/public/shared/i18n/fr.json
@@ -172,6 +172,9 @@
"form.open_to_work_hint": "Afficher un badge sur votre CV public",
"form.email": "E-mail",
"form.phone": "Téléphone",
+ "form.birthdate": "Date de naissance",
+ "form.birthdate_hint": "Affichée uniquement dans l'interface d'admin, à l'impression et dans les exports ATS. Jamais sur le site public.",
+ "contact.born": "Né(e) le",
"form.job_title": "Intitulé du poste",
"form.company": "Entreprise",
"form.country_code": "Code pays",
diff --git a/public/shared/i18n/it.json b/public/shared/i18n/it.json
index 6dba768..989addb 100644
--- a/public/shared/i18n/it.json
+++ b/public/shared/i18n/it.json
@@ -172,6 +172,9 @@
"form.open_to_work_hint": "Mostra un badge sul tuo CV pubblico",
"form.email": "Email",
"form.phone": "Telefono",
+ "form.birthdate": "Data di nascita",
+ "form.birthdate_hint": "Mostrata solo nella vista admin, in stampa e nelle esportazioni ATS. Mai sul sito pubblico.",
+ "contact.born": "Nato il",
"form.job_title": "Qualifica professionale",
"form.company": "Azienda",
"form.country_code": "Codice paese",
diff --git a/public/shared/i18n/nl.json b/public/shared/i18n/nl.json
index 09007e6..906dd7a 100644
--- a/public/shared/i18n/nl.json
+++ b/public/shared/i18n/nl.json
@@ -172,6 +172,9 @@
"form.open_to_work_hint": "Toon een badge op uw openbare CV",
"form.email": "E-mail",
"form.phone": "Telefoon",
+ "form.birthdate": "Geboortedatum",
+ "form.birthdate_hint": "Alleen zichtbaar in admin-weergave, afdruk en ATS-export. Nooit op de openbare site.",
+ "contact.born": "Geboren",
"form.job_title": "Functietitel",
"form.company": "Bedrijf",
"form.country_code": "Landcode",
diff --git a/public/shared/i18n/pt.json b/public/shared/i18n/pt.json
index fa4a9c4..33e2d65 100644
--- a/public/shared/i18n/pt.json
+++ b/public/shared/i18n/pt.json
@@ -172,6 +172,9 @@
"form.open_to_work_hint": "Exibir um emblema no seu CV público",
"form.email": "Email",
"form.phone": "Telefone",
+ "form.birthdate": "Data de nascimento",
+ "form.birthdate_hint": "Exibida apenas na visualização admin, impressão e exportações ATS. Nunca no site público.",
+ "contact.born": "Nascido em",
"form.job_title": "Cargo",
"form.company": "Empresa",
"form.country_code": "Código do País",
diff --git a/public/shared/i18n/zh.json b/public/shared/i18n/zh.json
index e53cc53..998abfd 100644
--- a/public/shared/i18n/zh.json
+++ b/public/shared/i18n/zh.json
@@ -172,6 +172,9 @@
"form.open_to_work_hint": "在您的公开简历上显示徽章",
"form.email": "电子邮箱",
"form.phone": "电话",
+ "form.birthdate": "出生日期",
+ "form.birthdate_hint": "仅在管理视图、打印和 ATS 导出中显示。公开网站上从不显示。",
+ "contact.born": "出生于",
"form.job_title": "职位名称",
"form.company": "公司",
"form.country_code": "国家代码",
diff --git a/public/shared/scripts.js b/public/shared/scripts.js
index 7568d57..8773ca1 100644
--- a/public/shared/scripts.js
+++ b/public/shared/scripts.js
@@ -26,6 +26,7 @@ const icons = {
email: materialIcon('email', 14),
phone: materialIcon('phone', 14),
location: materialIcon('location_on', 14),
+ birthday: materialIcon('cake', 14),
linkedin: '',
languages: materialIcon('language', 14),
link: materialIcon('open_in_new', 14),
@@ -781,6 +782,24 @@ function formatTimelinePeriod(item) {
return item.period || '';
}
+// Format an ISO YYYY-MM-DD birthdate for display using the active locale.
+// Returns '' for empty/invalid input so callers can skip rendering entirely.
+function formatBirthdate(value, locale) {
+ if (!value || typeof value !== 'string') return '';
+ const m = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
+ if (!m) return '';
+ const year = Number(m[1]), month = Number(m[2]), day = Number(m[3]);
+ if (month < 1 || month > 12 || day < 1 || day > 31) return '';
+ // Construct date in UTC to avoid timezone shifting the day.
+ const date = new Date(Date.UTC(year, month - 1, day));
+ const loc = locale || (typeof I18n !== 'undefined' && I18n.locale) || 'en';
+ try {
+ return new Intl.DateTimeFormat(loc, { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' }).format(date);
+ } catch (_e) {
+ return new Intl.DateTimeFormat('en', { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' }).format(date);
+ }
+}
+
// Load Profile (shared between admin and public)
async function loadProfile(includePrivate = false) {
const p = await api('/api/profile');
@@ -829,6 +848,10 @@ async function loadProfile(includePrivate = false) {
const badges = [];
if (includePrivate && p.email) badges.push(`${icons.email} ${escapeHtml(p.email)}`);
if (includePrivate && p.phone) badges.push(`${icons.phone} ${escapeHtml(p.phone)}`);
+ if (includePrivate && p.birthdate) {
+ const formatted = formatBirthdate(p.birthdate);
+ if (formatted) badges.push(`${icons.birthday} ${escapeHtml(formatted)}`);
+ }
if (p.location) badges.push(`${icons.location} ${escapeHtml(p.location)}`);
if (p.linkedin) badges.push(`${icons.linkedin} LinkedIn`);
if (p.languages) badges.push(`${icons.languages} ${escapeHtml(p.languages)}`);
diff --git a/public/shared/styles.css b/public/shared/styles.css
index 9842973..3ee7bb4 100644
--- a/public/shared/styles.css
+++ b/public/shared/styles.css
@@ -2029,7 +2029,6 @@ body.has-preview-banner .container {
break-inside: avoid;
}
#section-projects.print-compact .tech-tags,
- #section-projects.print-compact .project-link,
#section-projects.print-compact .drag-handle,
#section-projects.print-compact .item-actions { display: none !important; }
#section-projects.print-compact .project-header {
@@ -2042,6 +2041,34 @@ body.has-preview-banner .container {
font-weight: 700;
margin: 0;
}
+ /* Compact print: print the project URL as visible text after the title
+ instead of an icon. The Material icon span is hidden and the anchor's
+ ::after pseudo-element emits the href as text — the resulting PDF link
+ annotation attaches to actual rendered glyphs, which is the most
+ reliably clickable form across PDF viewers (incl. iOS Preview).
+ The anchor itself stays inline so it doesn't force a line break. */
+ #section-projects.print-compact .project-link {
+ display: inline;
+ margin: 0 0 0 4px;
+ padding: 0;
+ background: none;
+ border-radius: 0;
+ color: var(--gray-600);
+ font-size: 9px;
+ font-weight: 400;
+ text-decoration: none;
+ vertical-align: baseline;
+ line-height: inherit;
+ width: auto;
+ height: auto;
+ }
+ #section-projects.print-compact .project-link svg,
+ #section-projects.print-compact .project-link .material-symbols-outlined {
+ display: none !important;
+ }
+ #section-projects.print-compact .project-link::after {
+ content: "(" attr(href) ")";
+ }
#section-projects.print-compact .project-description {
display: inline;
font-size: 11px;
diff --git a/src/server.js b/src/server.js
index f2f80df..96429a8 100644
--- a/src/server.js
+++ b/src/server.js
@@ -707,12 +707,22 @@ function refreshDatasetSectionNames(data, language) {
return data;
}
+// Birthdate is sensitive PII — never leaks to the public site, even when a
+// dataset is explicitly published. Stricter than the email/phone treatment,
+// because the owner usually controls whether to expose email/phone but
+// birthdate exposure is a one-way data leak that should not happen by accident.
+function stripPublicSensitiveProfile(data) {
+ if (!data || !data.profile) return data;
+ const { birthdate, ...safeProfile } = data.profile;
+ return { ...data, profile: safeProfile };
+}
+
function serveDatasetData(req, res) {
try {
const lang = req.params.lang || req.query.lang;
const dataset = resolveDatasetBySlug(req.params.slug, lang, true);
if (!dataset) return res.status(404).json({ error: 'Not found' });
- const data = refreshDatasetSectionNames(JSON.parse(dataset.data), dataset.language);
+ const data = stripPublicSensitiveProfile(refreshDatasetSectionNames(JSON.parse(dataset.data), dataset.language));
const siblings = getDatasetSiblings(dataset);
res.json({ name: dataset.name, slug: dataset.slug, language: dataset.language, language_group: dataset.language_group, version_group: dataset.version_group, version: dataset.version || 1, siblings, ...data });
} catch (err) {
@@ -735,7 +745,7 @@ function serveDatasetDataById(req, res) {
}
}
if (!dataset) return res.status(404).json({ error: 'Not found' });
- const data = refreshDatasetSectionNames(JSON.parse(dataset.data), dataset.language);
+ const data = stripPublicSensitiveProfile(refreshDatasetSectionNames(JSON.parse(dataset.data), dataset.language));
const siblings = getDatasetSiblings(dataset);
res.json({ name: dataset.name, slug: dataset.slug, language: dataset.language, language_group: dataset.language_group, version_group: dataset.version_group, version: dataset.version || 1, siblings, ...data });
} catch (err) {
@@ -1027,6 +1037,15 @@ if (!PUBLIC_ONLY) {
}
} catch (err) { console.log('Migration check (picture_crop):', err.message); }
+ // Step 2f6: Migration - add birthdate column to profile if missing
+ try {
+ const profileBirthdateInfo = db.prepare("PRAGMA table_info(profile)").all();
+ if (!profileBirthdateInfo.some(col => col.name === 'birthdate')) {
+ console.log('Migrating profile table: adding birthdate');
+ db.exec("ALTER TABLE profile ADD COLUMN birthdate TEXT DEFAULT ''");
+ }
+ } catch (err) { console.log('Migration check (birthdate):', err.message); }
+
// Step 2g: Migration - add is_default column to saved_datasets if missing
try {
@@ -2116,8 +2135,14 @@ if (PUBLIC_ONLY) {
// ADMIN Mode
app.get('/api/profile', (req, res) => { res.json(db.prepare('SELECT * FROM profile WHERE id = 1').get()); });
app.put('/api/profile', (req, res) => {
- const { name, initials, title, subtitle, bio, location, linkedin, email, phone, languages, visible, profile_picture_enabled, picture_propagate, open_to_work } = req.body;
- db.prepare(`UPDATE profile SET name = ?, initials = ?, title = ?, subtitle = ?, bio = ?, location = ?, linkedin = ?, email = ?, phone = ?, languages = ?, visible = ?, profile_picture_enabled = ?, picture_propagate = ?, open_to_work = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1`).run(name, initials, title, subtitle, bio, location, linkedin, email, phone, languages, visible ? 1 : 0, profile_picture_enabled ? 1 : 0, picture_propagate === undefined ? 1 : (picture_propagate ? 1 : 0), open_to_work ? 1 : 0);
+ const { name, initials, title, subtitle, bio, location, linkedin, email, phone, languages, visible, profile_picture_enabled, picture_propagate, open_to_work, birthdate } = req.body;
+ // birthdate must be ISO YYYY-MM-DD or empty/null. Reject anything else so we
+ // never store junk that the locale-aware formatter would silently drop.
+ const normalizedBirthdate = (birthdate == null || birthdate === '') ? '' : String(birthdate).trim();
+ if (normalizedBirthdate && !/^\d{4}-\d{2}-\d{2}$/.test(normalizedBirthdate)) {
+ return res.status(400).json({ error: 'Invalid birthdate. Expected YYYY-MM-DD or empty.' });
+ }
+ db.prepare(`UPDATE profile SET name = ?, initials = ?, title = ?, subtitle = ?, bio = ?, location = ?, linkedin = ?, email = ?, phone = ?, languages = ?, birthdate = ?, visible = ?, profile_picture_enabled = ?, picture_propagate = ?, open_to_work = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1`).run(name, initials, title, subtitle, bio, location, linkedin, email, phone, languages, normalizedBirthdate, visible ? 1 : 0, profile_picture_enabled ? 1 : 0, picture_propagate === undefined ? 1 : (picture_propagate ? 1 : 0), open_to_work ? 1 : 0);
res.json({ success: true });
});
@@ -3488,7 +3513,7 @@ if (PUBLIC_ONLY) {
res.status(500).json({ error: err.message });
}
});
- app.post('/api/datasets/:id/load', (req, res) => { const dataset = db.prepare('SELECT * FROM saved_datasets WHERE id = ?').get(req.params.id); if (!dataset) return res.status(404).json({ error: 'Dataset not found' }); try { const data = JSON.parse(dataset.data); const importData = db.transaction(() => { if (data.theme && typeof data.theme === 'object') { const t = data.theme; const upsert = db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)'); if (t.primary && /^#[0-9a-fA-F]{6}$/.test(t.primary)) upsert.run('themeColor', t.primary); if (t.fontFamily) upsert.run('themeFontFamily', t.fontFamily); if (t.bulletStyle && ALLOWED_BULLET_STYLES.has(t.bulletStyle)) upsert.run('themeBulletStyle', t.bulletStyle); if (t.gradientStart && /^#[0-9a-fA-F]{6}$/.test(t.gradientStart)) upsert.run('themeGradientStart', t.gradientStart); else db.prepare('DELETE FROM settings WHERE key = ?').run('themeGradientStart'); if (t.gradientEnd && /^#[0-9a-fA-F]{6}$/.test(t.gradientEnd)) upsert.run('themeGradientEnd', t.gradientEnd); else db.prepare('DELETE FROM settings WHERE key = ?').run('themeGradientEnd'); if (t.sectionTitleColor && /^#[0-9a-fA-F]{6}$/.test(t.sectionTitleColor)) upsert.run('themeSectionTitleColor', t.sectionTitleColor); else db.prepare('DELETE FROM settings WHERE key = ?').run('themeSectionTitleColor'); if (Number.isInteger(t.sectionRadius) && t.sectionRadius >= SECTION_RADIUS_MIN && t.sectionRadius <= SECTION_RADIUS_MAX) upsert.run('themeSectionRadius', String(t.sectionRadius)); else db.prepare('DELETE FROM settings WHERE key = ?').run('themeSectionRadius'); } if (data.profile) { const p = data.profile; const cropValue = p.picture_crop ? (typeof p.picture_crop === 'string' ? p.picture_crop : JSON.stringify(p.picture_crop)) : null; db.prepare(`UPDATE profile SET name = ?, initials = ?, title = ?, subtitle = ?, bio = ?, location = ?, linkedin = ?, email = ?, phone = ?, languages = ?, profile_picture_enabled = ?, picture_filename = ?, picture_propagate = ?, picture_crop = ? WHERE id = 1`).run(p.name, p.initials, p.title, p.subtitle, p.bio, p.location, p.linkedin, p.email, p.phone, p.languages, p.profile_picture_enabled == null ? 1 : (p.profile_picture_enabled ? 1 : 0), p.picture_filename || null, p.picture_propagate == null ? 1 : (p.picture_propagate ? 1 : 0), cropValue); } if (data.experiences) { db.prepare('DELETE FROM experiences').run(); const stmt = db.prepare(`INSERT INTO experiences (job_title, company_name, start_date, end_date, location, country_code, highlights, summary, sort_order, visible, logo_filename, logo_propagate) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); data.experiences.forEach((e, idx) => { stmt.run(e.job_title, e.company_name, e.start_date, e.end_date, e.location, e.country_code || '', JSON.stringify(e.highlights || []), e.summary || null, idx, e.visible != false ? 1 : 0, e.logo_filename || null, e.logo_propagate ? 1 : 0); }); } if (data.certifications) { db.prepare('DELETE FROM certifications').run(); const stmt = db.prepare(`INSERT INTO certifications (name, provider, issue_date, expiry_date, credential_id, sort_order, visible, logo_filename, logo_propagate) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`); data.certifications.forEach((c, idx) => { stmt.run(c.name, c.provider, c.issue_date, c.expiry_date, c.credential_id, idx, c.visible != false ? 1 : 0, c.logo_filename || null, c.logo_propagate ? 1 : 0); }); } if (data.education) { db.prepare('DELETE FROM education').run(); const stmt = db.prepare(`INSERT INTO education (degree_title, institution_name, start_date, end_date, description, sort_order, visible, logo_filename, logo_propagate) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`); data.education.forEach((e, idx) => { stmt.run(e.degree_title, e.institution_name, e.start_date, e.end_date, e.description, idx, e.visible != false ? 1 : 0, e.logo_filename || null, e.logo_propagate ? 1 : 0); }); } if (data.skills) { db.prepare('DELETE FROM skills').run(); db.prepare('DELETE FROM skill_categories').run(); const catStmt = db.prepare('INSERT INTO skill_categories (name, icon, sort_order, visible) VALUES (?, ?, ?, ?)'); const skillStmt = db.prepare('INSERT INTO skills (category_id, name, sort_order) VALUES (?, ?, ?)'); data.skills.forEach((cat, catIdx) => { const result = catStmt.run(cat.name, cat.icon || 'default', catIdx, cat.visible != false ? 1 : 0); const categoryId = result.lastInsertRowid; if (cat.skills) { cat.skills.forEach((skill, skillIdx) => { skillStmt.run(categoryId, skill, skillIdx); }); } }); } if (data.projects) { db.prepare('DELETE FROM projects').run(); const stmt = db.prepare(`INSERT INTO projects (title, description, technologies, link, sort_order, visible) VALUES (?, ?, ?, ?, ?, ?)`); data.projects.forEach((p, idx) => { stmt.run(p.title, p.description, JSON.stringify(p.technologies || []), p.link, idx, p.visible != false ? 1 : 0); }); } if (data.customSections && Array.isArray(data.customSections)) { db.prepare('DELETE FROM custom_section_items').run(); db.prepare('DELETE FROM custom_sections').run(); db.prepare("DELETE FROM section_visibility WHERE section_name LIKE 'custom_%'").run(); const sectionStmt = db.prepare(`INSERT INTO custom_sections (name, section_key, layout_type, icon, sort_order, visible, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)`); const itemStmt = db.prepare(`INSERT INTO custom_section_items (section_id, title, subtitle, description, link, icon, image, metadata, sort_order, visible) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); data.customSections.forEach((s, idx) => { const sectionKey = s.section_key || `custom_${Date.now()}_${idx}`; const sectionMetadata = s.metadata ? (typeof s.metadata === 'string' ? s.metadata : JSON.stringify(s.metadata)) : null; const result = sectionStmt.run(s.name, sectionKey, s.layout_type || 'grid-3', s.icon || 'layers', s.sort_order !== undefined ? s.sort_order : idx, s.visible != false ? 1 : 0, sectionMetadata); const sectionId = result.lastInsertRowid; db.prepare('INSERT OR REPLACE INTO section_visibility (section_name, visible, sort_order, display_name) VALUES (?, ?, ?, ?)').run(sectionKey, s.visible != false ? 1 : 0, s.sort_order !== undefined ? s.sort_order : idx, s.display_name || null); if (s.items && Array.isArray(s.items)) { s.items.forEach((item, itemIdx) => { itemStmt.run(sectionId, item.title || null, item.subtitle || null, item.description || null, item.link || null, item.icon || null, item.image || null, item.metadata ? (typeof item.metadata === 'string' ? item.metadata : JSON.stringify(item.metadata)) : null, item.sort_order !== undefined ? item.sort_order : itemIdx, item.visible != false ? 1 : 0); }); } }); } if (data.sectionOrder && Array.isArray(data.sectionOrder)) { data.sectionOrder.forEach(s => { const existing = db.prepare('SELECT print_visible, print_compact FROM section_visibility WHERE section_name = ?').get(s.key); const printVisible = s.print_visible !== undefined ? (s.print_visible !== false ? 1 : 0) : (existing ? (existing.print_visible ? 1 : 0) : 1); const printCompact = s.print_compact !== undefined ? (s.print_compact ? 1 : 0) : (existing ? (existing.print_compact ? 1 : 0) : 0); db.prepare('UPDATE section_visibility SET visible = ?, sort_order = ?, display_name = ?, print_visible = ?, print_compact = ? WHERE section_name = ?').run(s.visible != false ? 1 : 0, s.sort_order || 0, s.display_name || null, printVisible, printCompact, s.key); }); } else if (data.sectionVisibility) { for (const [section, visible] of Object.entries(data.sectionVisibility)) { db.prepare('UPDATE section_visibility SET visible = ? WHERE section_name = ?').run(visible ? 1 : 0, section); } } }); importData(); res.json({ success: true, id: dataset.id, name: dataset.name, language: dataset.language || 'en', language_group: dataset.language_group, version_group: dataset.version_group, version: dataset.version || 1, is_default: !!dataset.is_default, is_public: !!dataset.is_public, theme: data.theme || null }); } catch (err) { res.status(500).json({ error: err.message }); } });
+ app.post('/api/datasets/:id/load', (req, res) => { const dataset = db.prepare('SELECT * FROM saved_datasets WHERE id = ?').get(req.params.id); if (!dataset) return res.status(404).json({ error: 'Dataset not found' }); try { const data = JSON.parse(dataset.data); const importData = db.transaction(() => { if (data.theme && typeof data.theme === 'object') { const t = data.theme; const upsert = db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)'); if (t.primary && /^#[0-9a-fA-F]{6}$/.test(t.primary)) upsert.run('themeColor', t.primary); if (t.fontFamily) upsert.run('themeFontFamily', t.fontFamily); if (t.bulletStyle && ALLOWED_BULLET_STYLES.has(t.bulletStyle)) upsert.run('themeBulletStyle', t.bulletStyle); if (t.gradientStart && /^#[0-9a-fA-F]{6}$/.test(t.gradientStart)) upsert.run('themeGradientStart', t.gradientStart); else db.prepare('DELETE FROM settings WHERE key = ?').run('themeGradientStart'); if (t.gradientEnd && /^#[0-9a-fA-F]{6}$/.test(t.gradientEnd)) upsert.run('themeGradientEnd', t.gradientEnd); else db.prepare('DELETE FROM settings WHERE key = ?').run('themeGradientEnd'); if (t.sectionTitleColor && /^#[0-9a-fA-F]{6}$/.test(t.sectionTitleColor)) upsert.run('themeSectionTitleColor', t.sectionTitleColor); else db.prepare('DELETE FROM settings WHERE key = ?').run('themeSectionTitleColor'); if (Number.isInteger(t.sectionRadius) && t.sectionRadius >= SECTION_RADIUS_MIN && t.sectionRadius <= SECTION_RADIUS_MAX) upsert.run('themeSectionRadius', String(t.sectionRadius)); else db.prepare('DELETE FROM settings WHERE key = ?').run('themeSectionRadius'); } if (data.profile) { const p = data.profile; const cropValue = p.picture_crop ? (typeof p.picture_crop === 'string' ? p.picture_crop : JSON.stringify(p.picture_crop)) : null; const loadBirthdate = (p.birthdate && /^\d{4}-\d{2}-\d{2}$/.test(String(p.birthdate).trim())) ? String(p.birthdate).trim() : ''; db.prepare(`UPDATE profile SET name = ?, initials = ?, title = ?, subtitle = ?, bio = ?, location = ?, linkedin = ?, email = ?, phone = ?, languages = ?, birthdate = ?, profile_picture_enabled = ?, picture_filename = ?, picture_propagate = ?, picture_crop = ? WHERE id = 1`).run(p.name, p.initials, p.title, p.subtitle, p.bio, p.location, p.linkedin, p.email, p.phone, p.languages, loadBirthdate, p.profile_picture_enabled == null ? 1 : (p.profile_picture_enabled ? 1 : 0), p.picture_filename || null, p.picture_propagate == null ? 1 : (p.picture_propagate ? 1 : 0), cropValue); } if (data.experiences) { db.prepare('DELETE FROM experiences').run(); const stmt = db.prepare(`INSERT INTO experiences (job_title, company_name, start_date, end_date, location, country_code, highlights, summary, sort_order, visible, logo_filename, logo_propagate) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); data.experiences.forEach((e, idx) => { stmt.run(e.job_title, e.company_name, e.start_date, e.end_date, e.location, e.country_code || '', JSON.stringify(e.highlights || []), e.summary || null, idx, e.visible != false ? 1 : 0, e.logo_filename || null, e.logo_propagate ? 1 : 0); }); } if (data.certifications) { db.prepare('DELETE FROM certifications').run(); const stmt = db.prepare(`INSERT INTO certifications (name, provider, issue_date, expiry_date, credential_id, sort_order, visible, logo_filename, logo_propagate) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`); data.certifications.forEach((c, idx) => { stmt.run(c.name, c.provider, c.issue_date, c.expiry_date, c.credential_id, idx, c.visible != false ? 1 : 0, c.logo_filename || null, c.logo_propagate ? 1 : 0); }); } if (data.education) { db.prepare('DELETE FROM education').run(); const stmt = db.prepare(`INSERT INTO education (degree_title, institution_name, start_date, end_date, description, sort_order, visible, logo_filename, logo_propagate) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`); data.education.forEach((e, idx) => { stmt.run(e.degree_title, e.institution_name, e.start_date, e.end_date, e.description, idx, e.visible != false ? 1 : 0, e.logo_filename || null, e.logo_propagate ? 1 : 0); }); } if (data.skills) { db.prepare('DELETE FROM skills').run(); db.prepare('DELETE FROM skill_categories').run(); const catStmt = db.prepare('INSERT INTO skill_categories (name, icon, sort_order, visible) VALUES (?, ?, ?, ?)'); const skillStmt = db.prepare('INSERT INTO skills (category_id, name, sort_order) VALUES (?, ?, ?)'); data.skills.forEach((cat, catIdx) => { const result = catStmt.run(cat.name, cat.icon || 'default', catIdx, cat.visible != false ? 1 : 0); const categoryId = result.lastInsertRowid; if (cat.skills) { cat.skills.forEach((skill, skillIdx) => { skillStmt.run(categoryId, skill, skillIdx); }); } }); } if (data.projects) { db.prepare('DELETE FROM projects').run(); const stmt = db.prepare(`INSERT INTO projects (title, description, technologies, link, sort_order, visible) VALUES (?, ?, ?, ?, ?, ?)`); data.projects.forEach((p, idx) => { stmt.run(p.title, p.description, JSON.stringify(p.technologies || []), p.link, idx, p.visible != false ? 1 : 0); }); } if (data.customSections && Array.isArray(data.customSections)) { db.prepare('DELETE FROM custom_section_items').run(); db.prepare('DELETE FROM custom_sections').run(); db.prepare("DELETE FROM section_visibility WHERE section_name LIKE 'custom_%'").run(); const sectionStmt = db.prepare(`INSERT INTO custom_sections (name, section_key, layout_type, icon, sort_order, visible, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)`); const itemStmt = db.prepare(`INSERT INTO custom_section_items (section_id, title, subtitle, description, link, icon, image, metadata, sort_order, visible) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); data.customSections.forEach((s, idx) => { const sectionKey = s.section_key || `custom_${Date.now()}_${idx}`; const sectionMetadata = s.metadata ? (typeof s.metadata === 'string' ? s.metadata : JSON.stringify(s.metadata)) : null; const result = sectionStmt.run(s.name, sectionKey, s.layout_type || 'grid-3', s.icon || 'layers', s.sort_order !== undefined ? s.sort_order : idx, s.visible != false ? 1 : 0, sectionMetadata); const sectionId = result.lastInsertRowid; db.prepare('INSERT OR REPLACE INTO section_visibility (section_name, visible, sort_order, display_name) VALUES (?, ?, ?, ?)').run(sectionKey, s.visible != false ? 1 : 0, s.sort_order !== undefined ? s.sort_order : idx, s.display_name || null); if (s.items && Array.isArray(s.items)) { s.items.forEach((item, itemIdx) => { itemStmt.run(sectionId, item.title || null, item.subtitle || null, item.description || null, item.link || null, item.icon || null, item.image || null, item.metadata ? (typeof item.metadata === 'string' ? item.metadata : JSON.stringify(item.metadata)) : null, item.sort_order !== undefined ? item.sort_order : itemIdx, item.visible != false ? 1 : 0); }); } }); } if (data.sectionOrder && Array.isArray(data.sectionOrder)) { data.sectionOrder.forEach(s => { const existing = db.prepare('SELECT print_visible, print_compact FROM section_visibility WHERE section_name = ?').get(s.key); const printVisible = s.print_visible !== undefined ? (s.print_visible !== false ? 1 : 0) : (existing ? (existing.print_visible ? 1 : 0) : 1); const printCompact = s.print_compact !== undefined ? (s.print_compact ? 1 : 0) : (existing ? (existing.print_compact ? 1 : 0) : 0); db.prepare('UPDATE section_visibility SET visible = ?, sort_order = ?, display_name = ?, print_visible = ?, print_compact = ? WHERE section_name = ?').run(s.visible != false ? 1 : 0, s.sort_order || 0, s.display_name || null, printVisible, printCompact, s.key); }); } else if (data.sectionVisibility) { for (const [section, visible] of Object.entries(data.sectionVisibility)) { db.prepare('UPDATE section_visibility SET visible = ? WHERE section_name = ?').run(visible ? 1 : 0, section); } } }); importData(); res.json({ success: true, id: dataset.id, name: dataset.name, language: dataset.language || 'en', language_group: dataset.language_group, version_group: dataset.version_group, version: dataset.version || 1, is_default: !!dataset.is_default, is_public: !!dataset.is_public, theme: data.theme || null }); } catch (err) { res.status(500).json({ error: err.message }); } });
app.delete('/api/datasets/:id', (req, res) => {
try {
const ds = db.prepare('SELECT * FROM saved_datasets WHERE id = ?').get(req.params.id);
@@ -3799,7 +3824,7 @@ if (PUBLIC_ONLY) {
app.get('/api/cv', (req, res) => { const profile = db.prepare('SELECT * FROM profile WHERE id = 1').get(); const experiences = db.prepare('SELECT * FROM experiences ORDER BY sort_order ASC, start_date DESC').all(); const certifications = db.prepare('SELECT * FROM certifications ORDER BY sort_order ASC, issue_date DESC').all(); const education = db.prepare('SELECT * FROM education ORDER BY sort_order ASC, end_date DESC').all(); const skillCategories = db.prepare('SELECT * FROM skill_categories ORDER BY sort_order ASC').all(); const skills = db.prepare('SELECT * FROM skills ORDER BY sort_order ASC').all(); const projects = db.prepare('SELECT * FROM projects ORDER BY sort_order ASC').all(); const sections = db.prepare('SELECT * FROM section_visibility ORDER BY sort_order ASC').all(); const sectionVisibility = {}; const sectionOrderData = []; sections.forEach(s => { sectionVisibility[s.section_name] = !!s.visible; sectionOrderData.push({ key: s.section_name, sort_order: s.sort_order || 0, visible: !!s.visible, display_name: s.display_name || null }); }); const customSections = db.prepare('SELECT * FROM custom_sections ORDER BY sort_order ASC').all(); const customItems = db.prepare('SELECT * FROM custom_section_items ORDER BY sort_order ASC').all(); const customSectionsData = customSections.map(s => ({ ...s, visible: !!s.visible, metadata: s.metadata ? JSON.parse(s.metadata) : null, items: customItems.filter(i => i.section_id === s.id).map(i => ({ ...i, visible: !!i.visible, metadata: i.metadata ? JSON.parse(i.metadata) : null })) })); res.json({ profile, experiences: experiences.map(e => ({ ...e, highlights: e.highlights ? JSON.parse(e.highlights) : [] })), certifications, education, skills: skillCategories.map(cat => ({ ...cat, skills: skills.filter(s => s.category_id === cat.id).map(s => s.name) })), projects: projects.map(p => ({ ...p, technologies: p.technologies ? JSON.parse(p.technologies) : [] })), sectionVisibility, sectionOrder: sectionOrderData, customSections: customSectionsData }); });
- app.post('/api/import', (req, res) => { const data = req.body; const importData = db.transaction(() => { if (data.profile) { const p = data.profile; db.prepare(`UPDATE profile SET name = ?, initials = ?, title = ?, subtitle = ?, bio = ?, location = ?, linkedin = ?, email = ?, phone = ?, languages = ? WHERE id = 1`).run(p.name, p.initials, p.title, p.subtitle, p.bio, p.location, p.linkedin, p.email, p.phone, p.languages); } if (data.experiences) { db.prepare('DELETE FROM experiences').run(); const stmt = db.prepare(`INSERT INTO experiences (job_title, company_name, start_date, end_date, location, country_code, highlights, summary, sort_order, visible, logo_filename, logo_propagate) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); data.experiences.forEach((e, idx) => { stmt.run(e.job_title, e.company_name, e.start_date, e.end_date, e.location, e.country_code || '', JSON.stringify(e.highlights || []), e.summary || null, idx, e.visible != false ? 1 : 0, e.logo_filename || null, e.logo_propagate ? 1 : 0); }); } if (data.certifications) { db.prepare('DELETE FROM certifications').run(); const stmt = db.prepare(`INSERT INTO certifications (name, provider, issue_date, expiry_date, credential_id, sort_order, visible, logo_filename, logo_propagate) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`); data.certifications.forEach((c, idx) => { stmt.run(c.name, c.provider, c.issue_date, c.expiry_date, c.credential_id, idx, c.visible != false ? 1 : 0, c.logo_filename || null, c.logo_propagate ? 1 : 0); }); } if (data.education) { db.prepare('DELETE FROM education').run(); const stmt = db.prepare(`INSERT INTO education (degree_title, institution_name, start_date, end_date, description, sort_order, visible, logo_filename, logo_propagate) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`); data.education.forEach((e, idx) => { stmt.run(e.degree_title, e.institution_name, e.start_date, e.end_date, e.description, idx, e.visible != false ? 1 : 0, e.logo_filename || null, e.logo_propagate ? 1 : 0); }); } if (data.skills) { db.prepare('DELETE FROM skills').run(); db.prepare('DELETE FROM skill_categories').run(); const catStmt = db.prepare('INSERT INTO skill_categories (name, icon, sort_order, visible) VALUES (?, ?, ?, ?)'); const skillStmt = db.prepare('INSERT INTO skills (category_id, name, sort_order) VALUES (?, ?, ?)'); data.skills.forEach((cat, catIdx) => { const result = catStmt.run(cat.name, cat.icon || 'default', catIdx, cat.visible != false ? 1 : 0); const categoryId = result.lastInsertRowid; if (cat.skills) { cat.skills.forEach((skill, skillIdx) => { skillStmt.run(categoryId, skill, skillIdx); }); } }); } if (data.projects) { db.prepare('DELETE FROM projects').run(); const stmt = db.prepare(`INSERT INTO projects (title, description, technologies, link, sort_order, visible) VALUES (?, ?, ?, ?, ?, ?)`); data.projects.forEach((p, idx) => { stmt.run(p.title, p.description, JSON.stringify(p.technologies || []), p.link, idx, p.visible != false ? 1 : 0); }); } if (data.customSections && Array.isArray(data.customSections)) { db.prepare('DELETE FROM custom_section_items').run(); db.prepare('DELETE FROM custom_sections').run(); db.prepare("DELETE FROM section_visibility WHERE section_name LIKE 'custom_%'").run(); const sectionStmt = db.prepare(`INSERT INTO custom_sections (name, section_key, layout_type, icon, sort_order, visible, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)`); const itemStmt = db.prepare(`INSERT INTO custom_section_items (section_id, title, subtitle, description, link, icon, image, metadata, sort_order, visible) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); data.customSections.forEach((s, idx) => { const sectionKey = s.section_key || `custom_${Date.now()}_${idx}`; const sectionMetadata = s.metadata ? (typeof s.metadata === 'string' ? s.metadata : JSON.stringify(s.metadata)) : null; const result = sectionStmt.run(s.name, sectionKey, s.layout_type || 'grid-3', s.icon || 'layers', s.sort_order !== undefined ? s.sort_order : idx, s.visible != false ? 1 : 0, sectionMetadata); const sectionId = result.lastInsertRowid; db.prepare('INSERT OR REPLACE INTO section_visibility (section_name, visible, sort_order, display_name) VALUES (?, ?, ?, ?)').run(sectionKey, s.visible != false ? 1 : 0, s.sort_order !== undefined ? s.sort_order : idx, s.display_name || null); if (s.items && Array.isArray(s.items)) { s.items.forEach((item, itemIdx) => { itemStmt.run(sectionId, item.title || null, item.subtitle || null, item.description || null, item.link || null, item.icon || null, item.image || null, item.metadata ? (typeof item.metadata === 'string' ? item.metadata : JSON.stringify(item.metadata)) : null, item.sort_order !== undefined ? item.sort_order : itemIdx, item.visible != false ? 1 : 0); }); } }); } if (data.sectionOrder && Array.isArray(data.sectionOrder)) { data.sectionOrder.forEach(s => { const existing = db.prepare('SELECT print_visible, print_compact FROM section_visibility WHERE section_name = ?').get(s.key); const printVisible = s.print_visible !== undefined ? (s.print_visible !== false ? 1 : 0) : (existing ? (existing.print_visible ? 1 : 0) : 1); const printCompact = s.print_compact !== undefined ? (s.print_compact ? 1 : 0) : (existing ? (existing.print_compact ? 1 : 0) : 0); db.prepare('UPDATE section_visibility SET visible = ?, sort_order = ?, display_name = ?, print_visible = ?, print_compact = ? WHERE section_name = ?').run(s.visible != false ? 1 : 0, s.sort_order || 0, s.display_name || null, printVisible, printCompact, s.key); }); } }); try { importData(); res.json({ success: true }); } catch (err) { res.status(500).json({ error: err.message }); } });
+ app.post('/api/import', (req, res) => { const data = req.body; const importData = db.transaction(() => { if (data.profile) { const p = data.profile; const importBirthdate = (p.birthdate && /^\d{4}-\d{2}-\d{2}$/.test(String(p.birthdate).trim())) ? String(p.birthdate).trim() : ''; db.prepare(`UPDATE profile SET name = ?, initials = ?, title = ?, subtitle = ?, bio = ?, location = ?, linkedin = ?, email = ?, phone = ?, languages = ?, birthdate = ? WHERE id = 1`).run(p.name, p.initials, p.title, p.subtitle, p.bio, p.location, p.linkedin, p.email, p.phone, p.languages, importBirthdate); } if (data.experiences) { db.prepare('DELETE FROM experiences').run(); const stmt = db.prepare(`INSERT INTO experiences (job_title, company_name, start_date, end_date, location, country_code, highlights, summary, sort_order, visible, logo_filename, logo_propagate) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); data.experiences.forEach((e, idx) => { stmt.run(e.job_title, e.company_name, e.start_date, e.end_date, e.location, e.country_code || '', JSON.stringify(e.highlights || []), e.summary || null, idx, e.visible != false ? 1 : 0, e.logo_filename || null, e.logo_propagate ? 1 : 0); }); } if (data.certifications) { db.prepare('DELETE FROM certifications').run(); const stmt = db.prepare(`INSERT INTO certifications (name, provider, issue_date, expiry_date, credential_id, sort_order, visible, logo_filename, logo_propagate) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`); data.certifications.forEach((c, idx) => { stmt.run(c.name, c.provider, c.issue_date, c.expiry_date, c.credential_id, idx, c.visible != false ? 1 : 0, c.logo_filename || null, c.logo_propagate ? 1 : 0); }); } if (data.education) { db.prepare('DELETE FROM education').run(); const stmt = db.prepare(`INSERT INTO education (degree_title, institution_name, start_date, end_date, description, sort_order, visible, logo_filename, logo_propagate) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`); data.education.forEach((e, idx) => { stmt.run(e.degree_title, e.institution_name, e.start_date, e.end_date, e.description, idx, e.visible != false ? 1 : 0, e.logo_filename || null, e.logo_propagate ? 1 : 0); }); } if (data.skills) { db.prepare('DELETE FROM skills').run(); db.prepare('DELETE FROM skill_categories').run(); const catStmt = db.prepare('INSERT INTO skill_categories (name, icon, sort_order, visible) VALUES (?, ?, ?, ?)'); const skillStmt = db.prepare('INSERT INTO skills (category_id, name, sort_order) VALUES (?, ?, ?)'); data.skills.forEach((cat, catIdx) => { const result = catStmt.run(cat.name, cat.icon || 'default', catIdx, cat.visible != false ? 1 : 0); const categoryId = result.lastInsertRowid; if (cat.skills) { cat.skills.forEach((skill, skillIdx) => { skillStmt.run(categoryId, skill, skillIdx); }); } }); } if (data.projects) { db.prepare('DELETE FROM projects').run(); const stmt = db.prepare(`INSERT INTO projects (title, description, technologies, link, sort_order, visible) VALUES (?, ?, ?, ?, ?, ?)`); data.projects.forEach((p, idx) => { stmt.run(p.title, p.description, JSON.stringify(p.technologies || []), p.link, idx, p.visible != false ? 1 : 0); }); } if (data.customSections && Array.isArray(data.customSections)) { db.prepare('DELETE FROM custom_section_items').run(); db.prepare('DELETE FROM custom_sections').run(); db.prepare("DELETE FROM section_visibility WHERE section_name LIKE 'custom_%'").run(); const sectionStmt = db.prepare(`INSERT INTO custom_sections (name, section_key, layout_type, icon, sort_order, visible, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)`); const itemStmt = db.prepare(`INSERT INTO custom_section_items (section_id, title, subtitle, description, link, icon, image, metadata, sort_order, visible) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); data.customSections.forEach((s, idx) => { const sectionKey = s.section_key || `custom_${Date.now()}_${idx}`; const sectionMetadata = s.metadata ? (typeof s.metadata === 'string' ? s.metadata : JSON.stringify(s.metadata)) : null; const result = sectionStmt.run(s.name, sectionKey, s.layout_type || 'grid-3', s.icon || 'layers', s.sort_order !== undefined ? s.sort_order : idx, s.visible != false ? 1 : 0, sectionMetadata); const sectionId = result.lastInsertRowid; db.prepare('INSERT OR REPLACE INTO section_visibility (section_name, visible, sort_order, display_name) VALUES (?, ?, ?, ?)').run(sectionKey, s.visible != false ? 1 : 0, s.sort_order !== undefined ? s.sort_order : idx, s.display_name || null); if (s.items && Array.isArray(s.items)) { s.items.forEach((item, itemIdx) => { itemStmt.run(sectionId, item.title || null, item.subtitle || null, item.description || null, item.link || null, item.icon || null, item.image || null, item.metadata ? (typeof item.metadata === 'string' ? item.metadata : JSON.stringify(item.metadata)) : null, item.sort_order !== undefined ? item.sort_order : itemIdx, item.visible != false ? 1 : 0); }); } }); } if (data.sectionOrder && Array.isArray(data.sectionOrder)) { data.sectionOrder.forEach(s => { const existing = db.prepare('SELECT print_visible, print_compact FROM section_visibility WHERE section_name = ?').get(s.key); const printVisible = s.print_visible !== undefined ? (s.print_visible !== false ? 1 : 0) : (existing ? (existing.print_visible ? 1 : 0) : 1); const printCompact = s.print_compact !== undefined ? (s.print_compact ? 1 : 0) : (existing ? (existing.print_compact ? 1 : 0) : 0); db.prepare('UPDATE section_visibility SET visible = ?, sort_order = ?, display_name = ?, print_visible = ?, print_compact = ? WHERE section_name = ?').run(s.visible != false ? 1 : 0, s.sort_order || 0, s.display_name || null, printVisible, printCompact, s.key); }); } }); try { importData(); res.json({ success: true }); } catch (err) { res.status(500).json({ error: err.message }); } });
// ATS-friendly tagged PDF export (admin only)
// Uses pdfkit directly for StructTreeRoot / tagged PDF support
@@ -4020,6 +4045,16 @@ if (PUBLIC_ONLY) {
if (p.location) contactParts.push(p.location);
if (p.linkedin) contactParts.push(p.linkedin);
if (p.languages) contactParts.push(p.languages);
+ if (p.birthdate && /^\d{4}-\d{2}-\d{2}$/.test(String(p.birthdate))) {
+ const [yy, mm, dd] = p.birthdate.split('-').map(Number);
+ let formattedBirth;
+ try {
+ formattedBirth = new Intl.DateTimeFormat(locale, { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' }).format(new Date(Date.UTC(yy, mm - 1, dd)));
+ } catch (_e) {
+ formattedBirth = `${yy}-${String(mm).padStart(2, '0')}-${String(dd).padStart(2, '0')}`;
+ }
+ contactParts.push(`${t('contact.born')}: ${formattedBirth}`);
+ }
if (contactParts.length > 0) {
addParagraph(contactParts.join(' | '), sz(9), { color: '#555' });
}
@@ -4255,6 +4290,7 @@ if (PUBLIC_ONLY) {
const profile = { ...cvData.profile };
delete profile.email;
delete profile.phone;
+ delete profile.birthdate;
delete profile.id;
delete profile.updated_at;
diff --git a/tests/backend.test.js b/tests/backend.test.js
index cf4b04c..49173a5 100644
--- a/tests/backend.test.js
+++ b/tests/backend.test.js
@@ -1448,6 +1448,78 @@ describe('Backend API', () => {
});
});
+ describe('Birthdate on profile', () => {
+ async function putProfile(extra) {
+ const current = await (await fetch(`${BASE_URL}/api/profile`)).json();
+ return fetch(`${BASE_URL}/api/profile`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ ...current, ...extra }),
+ });
+ }
+
+ async function exportAtsPdf(locale) {
+ const res = await fetch(`${BASE_URL}/api/export/ats-pdf`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ scale: 1, paperSize: 'A4', locale }),
+ });
+ return Buffer.from(await res.arrayBuffer());
+ }
+
+ it('round-trips a valid ISO birthdate via PUT then GET', async () => {
+ const res = await putProfile({ birthdate: '1990-03-14' });
+ assert.strictEqual(res.status, 200);
+ const profile = await (await fetch(`${BASE_URL}/api/profile`)).json();
+ assert.strictEqual(profile.birthdate, '1990-03-14');
+ });
+
+ it('rejects an invalid birthdate format', async () => {
+ const res = await putProfile({ birthdate: 'not-a-date' });
+ assert.strictEqual(res.status, 400);
+ });
+
+ it('accepts empty string birthdate and clears any previous value', async () => {
+ await putProfile({ birthdate: '1990-03-14' });
+ const res = await putProfile({ birthdate: '' });
+ assert.strictEqual(res.status, 200);
+ const profile = await (await fetch(`${BASE_URL}/api/profile`)).json();
+ assert.strictEqual(profile.birthdate || '', '');
+ });
+
+ it('public /api/profile never exposes birthdate even when set', async () => {
+ await putProfile({ birthdate: '1990-03-14' });
+ const publicProfile = await (await fetch(`${PUBLIC_URL}/api/profile`)).json();
+ assert.ok(!('birthdate' in publicProfile), 'public profile must not include birthdate field');
+ });
+
+ it('ATS PDF includes a localized birth label when birthdate is set', async () => {
+ await putProfile({ birthdate: '1990-03-14' });
+ const enBuf = await exportAtsPdf('en');
+ const deBuf = await exportAtsPdf('de');
+ // PDF text streams are compressed by default with pdfkit, so byte-equality
+ // would be a brittle test. Compare to the "no birthdate" baseline — the
+ // PDF must differ when a birthdate is added.
+ await putProfile({ birthdate: '' });
+ const enEmpty = await exportAtsPdf('en');
+ assert.notStrictEqual(enBuf.length, enEmpty.length,
+ 'EN PDF with birthdate should differ in size from EN PDF without');
+ assert.notStrictEqual(enBuf.length, deBuf.length,
+ 'EN and DE PDFs with birthdate should differ (localized prefix)');
+ });
+
+ it('ATS PDF without birthdate produces no extra contact bytes for it', async () => {
+ await putProfile({ birthdate: '' });
+ const before = await exportAtsPdf('en');
+ await putProfile({ birthdate: '1990-03-14' });
+ const after = await exportAtsPdf('en');
+ assert.ok(after.length > before.length,
+ 'Adding a birthdate should add bytes to the PDF; if equal, the conditional render is broken');
+ // Restore empty
+ await putProfile({ birthdate: '' });
+ });
+ });
+
describe('Bold markdown (**word**) handling', () => {
it('strips ** markers from og:description / meta description on SSR', async () => {
// The SSR path depends on whether a default dataset exists. Fetch the
diff --git a/version.json b/version.json
index f9350dc..5d65013 100644
--- a/version.json
+++ b/version.json
@@ -1,4 +1,4 @@
{
- "version": "1.49.5",
+ "version": "1.50.0",
"changelog": "https://github.com/vincentmakes/cv-manager/blob/main/CHANGELOG.md"
}