From b97ebf8b829f1c3cf5ebb53d9e4dd4529cb2eb0e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 14:07:58 +0000 Subject: [PATCH 01/12] Add birthdate field on profile (#167); fix project link in compact print (#168) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `birthdate` column to `profile` via automatic migration; new `` in the admin profile modal. Renders as a localized contact badge in the admin view and as a "Born: …" line on the ATS PDF and static-site exports, formatted via Intl.DateTimeFormat in the active locale. When empty, nothing renders anywhere. - Treat birthdate as sensitive PII: stripped from the public /api/profile endpoint and from the public dataset endpoints (serveDatasetData / serveDatasetDataById) so it never reaches the public read-only site even when a dataset is marked public. - Add `form.birthdate`, `form.birthdate_hint`, `contact.born` translation keys to all 8 locale files. - Bug fix: drop `.project-link` from the print-compact hide rule in styles.css so the link icon appears next to the project title in compact print mode (matching ATS export behavior). - Regression tests in tests/backend.test.js: round-trip, validation, empty-clears-value, public endpoint never exposes birthdate, PDF byte-length differs when birthdate is present vs absent and across locales. --- CHANGELOG.md | 8 +++++ package-lock.json | 4 +-- package.json | 2 +- public/shared/admin.js | 6 ++++ public/shared/i18n/de.json | 3 ++ public/shared/i18n/en.json | 3 ++ public/shared/i18n/es.json | 3 ++ public/shared/i18n/fr.json | 3 ++ public/shared/i18n/it.json | 3 ++ public/shared/i18n/nl.json | 3 ++ public/shared/i18n/pt.json | 3 ++ public/shared/i18n/zh.json | 3 ++ public/shared/scripts.js | 23 ++++++++++++ public/shared/styles.css | 1 - src/server.js | 48 +++++++++++++++++++++---- tests/backend.test.js | 72 ++++++++++++++++++++++++++++++++++++++ version.json | 2 +- 17 files changed, 179 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f371d05f..84c26914 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 ``. 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 44705609..5f33b2b9 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 57d92591..639f539f 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.js b/public/shared/admin.js index 3713d9b1..f86b6729 100644 --- a/public/shared/admin.js +++ b/public/shared/admin.js @@ -1478,6 +1478,11 @@ function profileForm(d) { +
+ + +
${t('form.birthdate_hint')}
+
`; } @@ -1757,6 +1762,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 fa547317..413495c1 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 34fbd1db..5997d273 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 e52a8f80..eb309d47 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 3b801329..90d5ce3b 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 6dba768d..989addbf 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 09007e6a..906dd7ae 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 fa4a9c44..33e2d65b 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 e53cc537..998abfd8 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 7568d571..8773ca1d 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 98429734..389f373c 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 { diff --git a/src/server.js b/src/server.js index f2f80dfc..96429a80 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 cf4b04c5..49173a59 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 f9350dc6..5d650137 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" } From 6c7a55111c1b8b0fb4f6f99e5a568387bcc9164a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 15:25:56 +0000 Subject: [PATCH 02/12] Make birthdate input half-width and override grey date-input background - Wrap the birthdate field in a form-row so it occupies one grid cell (1fr of 2) instead of stretching across the full modal, matching the email/phone visual width. - Add explicit white background and a softened calendar-picker indicator on input[type="date"].form-input so the native browser styling no longer paints a grey fill that differed from the surrounding text inputs. --- CHANGELOG.md | 5 +++++ package-lock.json | 4 ++-- package.json | 2 +- public/shared/admin.css | 4 ++++ public/shared/admin.js | 11 +++++++---- version.json | 2 +- 6 files changed, 20 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84c26914..20c0d236 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ 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.1] - 2026-05-27 + +### Changed +- **Birthdate input in the profile modal is now half-width** (matching email/phone) instead of stretching across the full row, and the native browser grey background on `` is overridden with an explicit white fill so it visually matches the other text inputs. + ## [1.50.0] - 2026-05-27 ### Added diff --git a/package-lock.json b/package-lock.json index 5f33b2b9..aeed3a18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cv-manager", - "version": "1.50.0", + "version": "1.50.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cv-manager", - "version": "1.50.0", + "version": "1.50.1", "dependencies": { "archiver": "^7.0.1", "better-sqlite3": "^9.4.3", diff --git a/package.json b/package.json index 639f539f..e7eaa2b0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cv-manager", - "version": "1.50.0", + "version": "1.50.1", "description": "Professional CV Management System", "main": "src/server.js", "scripts": { diff --git a/public/shared/admin.css b/public/shared/admin.css index 1012683f..62a4ac09 100644 --- a/public/shared/admin.css +++ b/public/shared/admin.css @@ -404,9 +404,13 @@ border-radius: var(--radius-sm); font-family: inherit; font-size: 14px; + background: #fff; transition: all 0.15s ease; } +input[type="date"].form-input { background: #fff; color: var(--gray-700); } +input[type="date"].form-input::-webkit-calendar-picker-indicator { opacity: 0.6; cursor: pointer; } + .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 f86b6729..b8368761 100644 --- a/public/shared/admin.js +++ b/public/shared/admin.js @@ -1478,10 +1478,13 @@ function profileForm(d) { -
- - -
${t('form.birthdate_hint')}
+
+
+ + +
${t('form.birthdate_hint')}
+
+
`; } diff --git a/version.json b/version.json index 5d650137..3b8cea5d 100644 --- a/version.json +++ b/version.json @@ -1,4 +1,4 @@ { - "version": "1.50.0", + "version": "1.50.1", "changelog": "https://github.com/vincentmakes/cv-manager/blob/main/CHANGELOG.md" } From 10de3476251a88add90f7c79791b85c3063fd5f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 15:30:16 +0000 Subject: [PATCH 03/12] Stick to 1.50.0 for the birthdate feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold the half-width / white-background tweak into the 1.50.0 changelog entry instead of cutting a separate 1.50.1 patch — the UI polish is part of the same feature ship. --- CHANGELOG.md | 7 +------ package-lock.json | 4 ++-- package.json | 2 +- version.json | 2 +- 4 files changed, 5 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20c0d236..061c077d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,15 +4,10 @@ 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.1] - 2026-05-27 - -### Changed -- **Birthdate input in the profile modal is now half-width** (matching email/phone) instead of stretching across the full row, and the native browser grey background on `` is overridden with an explicit white fill so it visually matches the other text inputs. - ## [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 ``. 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. +- **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. diff --git a/package-lock.json b/package-lock.json index aeed3a18..5f33b2b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cv-manager", - "version": "1.50.1", + "version": "1.50.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cv-manager", - "version": "1.50.1", + "version": "1.50.0", "dependencies": { "archiver": "^7.0.1", "better-sqlite3": "^9.4.3", diff --git a/package.json b/package.json index e7eaa2b0..639f539f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cv-manager", - "version": "1.50.1", + "version": "1.50.0", "description": "Professional CV Management System", "main": "src/server.js", "scripts": { diff --git a/version.json b/version.json index 3b8cea5d..5d650137 100644 --- a/version.json +++ b/version.json @@ -1,4 +1,4 @@ { - "version": "1.50.1", + "version": "1.50.0", "changelog": "https://github.com/vincentmakes/cv-manager/blob/main/CHANGELOG.md" } From 23d8fbdbcc95bd41ce57ba9561daf801f91f8c5f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 15:35:11 +0000 Subject: [PATCH 04/12] Use a text input for birthdate so it matches the email field exactly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native renders through Chromium's ::-webkit-datetime-edit* shadow DOM with its own line metrics, intrinsic min-width, and placeholder color — even with a heavy CSS override the field looked taller, wider, and lighter than the surrounding text inputs, and the fix was browser-specific. Switch to type="text" with pattern="\d{4}-\d{2}-\d{2}", maxlength=10, inputmode="numeric", autocomplete="bday", and a "YYYY-MM-DD" placeholder. The field now inherits the exact same .form-input styling as email/phone/etc. Server-side validation already enforces the same regex so the round-trip is unchanged. --- public/shared/admin.css | 3 --- public/shared/admin.js | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/public/shared/admin.css b/public/shared/admin.css index 62a4ac09..2124a60e 100644 --- a/public/shared/admin.css +++ b/public/shared/admin.css @@ -404,12 +404,9 @@ border-radius: var(--radius-sm); font-family: inherit; font-size: 14px; - background: #fff; transition: all 0.15s ease; } -input[type="date"].form-input { background: #fff; color: var(--gray-700); } -input[type="date"].form-input::-webkit-calendar-picker-indicator { opacity: 0.6; cursor: pointer; } .form-input:focus, .form-textarea:focus, .form-select:focus { outline: none; diff --git a/public/shared/admin.js b/public/shared/admin.js index b8368761..9ba3768a 100644 --- a/public/shared/admin.js +++ b/public/shared/admin.js @@ -1481,7 +1481,7 @@ function profileForm(d) {
- +
${t('form.birthdate_hint')}
From af362fbe1c9d06281e441403401724dbbafe494c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 15:38:00 +0000 Subject: [PATCH 05/12] Restore native date picker on birthdate with normalized CSS Reverts the type="text" workaround. Keep so the native calendar picker is still available, but normalize the Chromium ::-webkit-datetime-edit* shadow-DOM tree so the field visually matches the other .form-input text fields: - appearance: none + min-width: 0 so the date input uses the box model from .form-input instead of its intrinsic dimensions. - Pseudo-element color inherits from .form-input (no more light grey "yyyy-mm-dd" placeholder) and line-height/padding are zeroed so the box collapses to the same height as text inputs. - .form-input now declares explicit color, background and line-height (which the date input pseudo-elements inherit). - Calendar picker indicator dimmed to 0.55 opacity so the icon feels in-place rather than the visual centerpiece. --- public/shared/admin.css | 32 ++++++++++++++++++++++++++++++++ public/shared/admin.js | 2 +- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/public/shared/admin.css b/public/shared/admin.css index 2124a60e..9ea4f954 100644 --- a/public/shared/admin.css +++ b/public/shared/admin.css @@ -404,9 +404,41 @@ 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; diff --git a/public/shared/admin.js b/public/shared/admin.js index 9ba3768a..f30c8c42 100644 --- a/public/shared/admin.js +++ b/public/shared/admin.js @@ -1481,7 +1481,7 @@ function profileForm(d) {
- +
${t('form.birthdate_hint')}
From a807cf457fbb886aa018f4412caf202a564a4d00 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 15:57:30 +0000 Subject: [PATCH 06/12] Inline + shrink project link icon in compact print mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this override .project-link's default display:flex, 28×28 box, and grey button background made the link wrap to its own line and dominate the row in compact print mode. The print-compact rule now strips the box (display:inline, no background, no margin) and shrinks the icon to 9px so the link sits right after the project title as a small inline glyph. --- public/shared/styles.css | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/public/shared/styles.css b/public/shared/styles.css index 389f373c..c9662eb9 100644 --- a/public/shared/styles.css +++ b/public/shared/styles.css @@ -2041,6 +2041,27 @@ body.has-preview-banner .container { font-weight: 700; margin: 0; } + /* Compact print: project link sits inline right after the title as a small, + borderless icon. Without this override .project-link's display:flex + + 28px box + grey button background bumps it onto its own line. */ + #section-projects.print-compact .project-link { + display: inline; + width: auto; + height: auto; + margin: 0 0 0 4px; + padding: 0; + background: none; + border-radius: 0; + vertical-align: baseline; + color: var(--primary); + } + #section-projects.print-compact .project-link svg, + #section-projects.print-compact .project-link .material-symbols-outlined { + width: 9px; + height: 9px; + font-size: 9px; + vertical-align: middle; + } #section-projects.print-compact .project-description { display: inline; font-size: 11px; From 91baa984cbe3b0207aee2b5e42610f7267236d91 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 16:06:07 +0000 Subject: [PATCH 07/12] Center compact-print project link icon and equalize margins - Force the icon to 10px with !important so it overrides the inline style="font-size:14px" emitted by materialIcon(), which was leaving the icon larger than the surrounding 11px text. - Switch the anchor to inline-block + vertical-align: middle so the glyph centers on the text's vertical midline instead of sitting on the baseline. - Margin 0 4px so the icon has equal breathing room on both sides of the em-dash that separates title from description. --- public/shared/styles.css | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/public/shared/styles.css b/public/shared/styles.css index c9662eb9..e5159b69 100644 --- a/public/shared/styles.css +++ b/public/shared/styles.css @@ -2043,24 +2043,28 @@ body.has-preview-banner .container { } /* Compact print: project link sits inline right after the title as a small, borderless icon. Without this override .project-link's display:flex + - 28px box + grey button background bumps it onto its own line. */ + 28px box + grey button background bumps it onto its own line. + The Material icon span has an inline style="font-size:14px" from + materialIcon(), so the !important is required to scale it down. */ #section-projects.print-compact .project-link { - display: inline; + display: inline-block; width: auto; height: auto; - margin: 0 0 0 4px; + margin: 0 4px; padding: 0; background: none; border-radius: 0; - vertical-align: baseline; + vertical-align: middle; color: var(--primary); + line-height: 1; } #section-projects.print-compact .project-link svg, #section-projects.print-compact .project-link .material-symbols-outlined { - width: 9px; - height: 9px; - font-size: 9px; + width: 10px !important; + height: 10px !important; + font-size: 10px !important; vertical-align: middle; + line-height: 1; } #section-projects.print-compact .project-description { display: inline; From 432d501f3a1a42763ee42bdcf361c2c02d7f24f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 16:10:50 +0000 Subject: [PATCH 08/12] Expand compact-print project link hit area without growing the icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PDF generators use the anchor box as the hyperlink hit region. With width/height auto and no padding the box collapsed to the 10×10 glyph — a tiny click target in saved PDFs. Add invisible 2px/4px padding around the icon so the clickable region is wide enough to hit reliably while the visible glyph stays the same size. Reduce horizontal margin to 2px to compensate. --- public/shared/styles.css | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/public/shared/styles.css b/public/shared/styles.css index e5159b69..11c0ed5b 100644 --- a/public/shared/styles.css +++ b/public/shared/styles.css @@ -2050,8 +2050,11 @@ body.has-preview-banner .container { display: inline-block; width: auto; height: auto; - margin: 0 4px; - padding: 0; + margin: 0 2px; + /* Expand the clickable region without changing the visual size of the + glyph — the PDF generator uses the anchor's box as the hyperlink hit + area, and a 10×10 region is too small to click comfortably. */ + padding: 2px 4px; background: none; border-radius: 0; vertical-align: middle; From 4a7acac2d163bbbd453e08cfd8cdd78d4f94c070 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 17:41:03 +0000 Subject: [PATCH 09/12] Give compact-print project link explicit box so PDF link annotation survives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Safari's "Save as PDF" + iOS Preview drop the hyperlink annotation on anchors that lay out as display:inline or display:inline-block with width:auto — the PDF generator attaches the annotation to the anchor's box, and an effectively empty rectangle means the link is unclickable in the resulting PDF. Switch to display:inline-flex with explicit 14×14 dimensions and align-items/justify-content centering. The icon stays the same visible size but the anchor now has a concrete rectangle that iOS Preview (and other PDF viewers) honour as a clickable region. --- public/shared/styles.css | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/public/shared/styles.css b/public/shared/styles.css index 11c0ed5b..1322c30e 100644 --- a/public/shared/styles.css +++ b/public/shared/styles.css @@ -2042,19 +2042,24 @@ body.has-preview-banner .container { margin: 0; } /* Compact print: project link sits inline right after the title as a small, - borderless icon. Without this override .project-link's display:flex + - 28px box + grey button background bumps it onto its own line. - The Material icon span has an inline style="font-size:14px" from - materialIcon(), so the !important is required to scale it down. */ + borderless icon. Without this override .project-link's default display:flex + + 28px box + grey button background bumps it onto its own line. + Notes on PDF clickability (Safari "Save as PDF" + iOS Preview): + — The PDF generator attaches the hyperlink annotation to the anchor's + layout box. With display:inline or width:auto inline-block the + annotation can collapse and the link becomes unclickable. + display:inline-flex + explicit width/height gives the generator a + concrete rectangle, which is what iOS Preview needs. + — The Material icon span has an inline style="font-size:14px" from + materialIcon(), so !important is required to scale it down. */ #section-projects.print-compact .project-link { - display: inline-block; - width: auto; - height: auto; - margin: 0 2px; - /* Expand the clickable region without changing the visual size of the - glyph — the PDF generator uses the anchor's box as the hyperlink hit - area, and a 10×10 region is too small to click comfortably. */ - padding: 2px 4px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + margin: 0 3px; + padding: 0; background: none; border-radius: 0; vertical-align: middle; From b1eb48922c6a0163f66dfddd9ff97485d7313031 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 18:23:31 +0000 Subject: [PATCH 10/12] Print project URL as visible text in compact print mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The icon-only approach kept dropping out as a non-clickable glyph in some PDF viewers (notably iOS Preview) because the hyperlink annotation attached to an effectively empty/oddly positioned anchor box. Hide the Material icon in compact print and emit the URL as text via .project-link::after { content: attr(href) }. The visible text is the anchor's content, so the PDF generator attaches the link annotation to actual rendered glyphs — the most reliably clickable form across PDF viewers. Style is small/grey/inline so it doesn't dominate the title row. --- public/shared/styles.css | 45 ++++++++++++++++++---------------------- 1 file changed, 20 insertions(+), 25 deletions(-) diff --git a/public/shared/styles.css b/public/shared/styles.css index 1322c30e..5329c3bd 100644 --- a/public/shared/styles.css +++ b/public/shared/styles.css @@ -2041,38 +2041,33 @@ body.has-preview-banner .container { font-weight: 700; margin: 0; } - /* Compact print: project link sits inline right after the title as a small, - borderless icon. Without this override .project-link's default display:flex - + 28px box + grey button background bumps it onto its own line. - Notes on PDF clickability (Safari "Save as PDF" + iOS Preview): - — The PDF generator attaches the hyperlink annotation to the anchor's - layout box. With display:inline or width:auto inline-block the - annotation can collapse and the link becomes unclickable. - display:inline-flex + explicit width/height gives the generator a - concrete rectangle, which is what iOS Preview needs. - — The Material icon span has an inline style="font-size:14px" from - materialIcon(), so !important is required to scale it down. */ + /* 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-flex; - align-items: center; - justify-content: center; - width: 14px; - height: 14px; - margin: 0 3px; + display: inline; + margin: 0 0 0 4px; padding: 0; background: none; border-radius: 0; - vertical-align: middle; - color: var(--primary); - line-height: 1; + 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 { - width: 10px !important; - height: 10px !important; - font-size: 10px !important; - vertical-align: middle; - line-height: 1; + display: none !important; + } + #section-projects.print-compact .project-link::after { + content: attr(href); } #section-projects.print-compact .project-description { display: inline; From eecde31d59c09b0a249ff7bdb4347d28a61fef31 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 18:31:30 +0000 Subject: [PATCH 11/12] Wrap compact-print project URL in braces --- public/shared/styles.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/shared/styles.css b/public/shared/styles.css index 5329c3bd..236c0c38 100644 --- a/public/shared/styles.css +++ b/public/shared/styles.css @@ -2067,7 +2067,7 @@ body.has-preview-banner .container { display: none !important; } #section-projects.print-compact .project-link::after { - content: attr(href); + content: "{" attr(href) "}"; } #section-projects.print-compact .project-description { display: inline; From 414e9e8b4362e3dc8577726d3d1fca2b7c8a3b2d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 18:31:43 +0000 Subject: [PATCH 12/12] Use parentheses instead of braces around compact-print URL --- public/shared/styles.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/shared/styles.css b/public/shared/styles.css index 236c0c38..3ee7bb48 100644 --- a/public/shared/styles.css +++ b/public/shared/styles.css @@ -2067,7 +2067,7 @@ body.has-preview-banner .container { display: none !important; } #section-projects.print-compact .project-link::after { - content: "{" attr(href) "}"; + content: "(" attr(href) ")"; } #section-projects.print-compact .project-description { display: inline;