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(``); 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" }