diff --git a/app/renderer/dialogs.js b/app/renderer/dialogs.js index 999235a..b2fcf13 100644 --- a/app/renderer/dialogs.js +++ b/app/renderer/dialogs.js @@ -640,14 +640,19 @@ function showPlaceholdersDialog({ title = 'Placeholders', hint = '', values = {} } /** - * Optional document metadata (author, co-authors, organization) shown at the - * top of the guide, below the title, and surfaced on the PDF cover page. + * Optional document metadata (author, co-authors, organization, description) + * shown at the top of the guide, below the title, and surfaced on the PDF + * cover page and the top of other export formats. */ function showGuideInfoDialog({ values = {}, onSave } = {}) { return new Promise((resolve) => { const authorInput = makeInput(values.author || '', 'text', { placeholder: 'e.g. Jane Doe' }); const coAuthorsInput = makeInput(values.coAuthors || '', 'text', { placeholder: 'e.g. Alex Lee, Sam Patel' }); const organizationInput = makeInput(values.organization || '', 'text', { placeholder: 'e.g. Acme Corp' }); + const descriptionInput = el('textarea', { + rows: 4, + placeholder: 'A short summary of this guide.', + }, values.description || ''); const { close } = openModal({ title: 'Guide information', @@ -657,6 +662,9 @@ function showGuideInfoDialog({ values = {}, onSave } = {}) { labeledRow('Author', authorInput), labeledRow('Co-authors', coAuthorsInput), labeledRow('Organization', organizationInput), + labeledRow('Description', descriptionInput, { stacked: true }), + el('div.muted', { style: { marginTop: '-4px' } }, + 'Shown on the first page of the PDF and at the top of other export formats.'), ), footer: [ el('button', { onClick: () => { close(); resolve(false); } }, 'Cancel'), @@ -666,6 +674,7 @@ function showGuideInfoDialog({ values = {}, onSave } = {}) { author: authorInput.value.trim(), coAuthors: coAuthorsInput.value.trim(), organization: organizationInput.value.trim(), + description: descriptionInput.value.trim(), }); close(); resolve(true); diff --git a/app/renderer/editor.js b/app/renderer/editor.js index 8d875b4..ee8d14f 100644 --- a/app/renderer/editor.js +++ b/app/renderer/editor.js @@ -1574,9 +1574,13 @@ class GuideEditor { async openGuideInfo() { if (!this.guide) return; await dialogs.showGuideInfoDialog({ - values: this.guide.metadata || {}, - onSave: async (metadata) => { + values: { + ...this.guide.metadata, + description: htmlToPlainText(this.guide.descriptionHtml || ''), + }, + onSave: async ({ description, ...metadata }) => { this.guide.metadata = metadata; + this.guide.descriptionHtml = textToHtml(description); await api.guide.save({ guide: this.guide }); this.onToast('Guide information saved.'); }, diff --git a/app/renderer/util.js b/app/renderer/util.js index c521fe7..47915cb 100644 --- a/app/renderer/util.js +++ b/app/renderer/util.js @@ -187,3 +187,21 @@ function fmtDate(iso) { const escapeHtml = (s) => String(s ?? '') .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + +/** Inverse of textToHtml, for loading sanitized description HTML into a plain textarea. */ +function htmlToPlainText(html) { + return String(html || '') + .replace(/<(br|\/p|\/div|\/li|\/h[1-6])\s*\/?>/gi, '\n') + .replace(/<[^>]*>/g, '') + .replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '\'').replace(/&/g, '&') + .replace(/[ \t]+/g, ' ').replace(/\s*\n\s*/g, '\n').trim(); +} + +/** Plain textarea text -> sanitizer-allowed paragraph HTML (blank line = new paragraph). */ +function textToHtml(text) { + const trimmed = String(text || '').trim(); + if (!trimmed) return ''; + return trimmed.split(/\n{2,}/) + .map((para) => `
${escapeHtml(para).replace(/\n/g, '
')}