From 2b3d6a540cacf42e0cfc5765039eac52738234ac Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 23:36:25 +0000 Subject: [PATCH] Add DOCX export endpoint and Export button - GET /api/export generates a Word document with all non-archived prayers - Accepts ?status=active|answered|all and ?tag= query params to match the current filter/tag selection in the UI - Document sections: Active Prayers + Answered Prayers with page break, each prayer showing person, request, answer, dates, and tags - Styled with Palatino Linotype, page numbers in footer - Export button in the toolbar triggers download of dated .docx file Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01DReRqHxSYuVfCzTzQGD9hZ --- package-lock.json | 154 ++++++++++++++++++++++++++++++++++ package.json | 1 + server.js | 204 +++++++++++++++++++++++++++++++++++++++++++++- src/App.css | 16 ++++ src/App.tsx | 11 +++ 5 files changed, 383 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index ccd4f25..d81a70c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "cors": "^2.8.6", + "docx": "^9.7.1", "express": "^5.2.1", "react": "^19.2.7", "react-dom": "^19.2.7", @@ -6673,6 +6674,50 @@ "node": ">=6.0.0" } }, + "node_modules/docx": { + "version": "9.7.1", + "resolved": "https://registry.npmjs.org/docx/-/docx-9.7.1.tgz", + "integrity": "sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==", + "license": "MIT", + "dependencies": { + "@types/node": "^25.2.3", + "hash.js": "^1.1.7", + "jszip": "^3.10.1", + "nanoid": "^5.1.3", + "xml": "^1.0.1", + "xml-js": "^1.6.8" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/docx/node_modules/@types/node": { + "version": "25.9.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz", + "integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/docx/node_modules/nanoid": { + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -8852,6 +8897,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -9193,6 +9248,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/immer": { "version": "9.0.21", "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz", @@ -11063,6 +11124,54 @@ "node": ">=4.0" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -11149,6 +11258,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lilconfig": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", @@ -12058,6 +12176,12 @@ "node": ">=6" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/param-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", @@ -14938,6 +15062,12 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -16458,6 +16588,12 @@ "integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==", "license": "MIT" }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "license": "MIT" + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", @@ -17753,6 +17889,24 @@ } } }, + "node_modules/xml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", + "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", + "license": "MIT" + }, + "node_modules/xml-js": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "license": "MIT", + "dependencies": { + "sax": "^1.2.4" + }, + "bin": { + "xml-js": "bin/cli.js" + } + }, "node_modules/xml-name-validator": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", diff --git a/package.json b/package.json index 7e402b9..9f4bbaa 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "cors": "^2.8.6", + "docx": "^9.7.1", "express": "^5.2.1", "react": "^19.2.7", "react-dom": "^19.2.7", diff --git a/server.js b/server.js index 88bbaac..b4da116 100644 --- a/server.js +++ b/server.js @@ -3,20 +3,24 @@ const cors = require('cors'); const fs = require('fs'); const path = require('path'); const { randomUUID } = require('crypto'); +const { + Document, Packer, Paragraph, TextRun, HeadingLevel, + AlignmentType, BorderStyle, ShadingType, TableRow, TableCell, + Table, WidthType, PageBreak, Header, Footer, PageNumber, + NumberFormat, +} = require('docx'); const app = express(); const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'); const DATA_FILE = path.join(DATA_DIR, 'prayers.json'); const PORT = process.env.PORT || 4000; -// Ensure data directory exists and is writable on startup try { fs.mkdirSync(DATA_DIR, { recursive: true }); fs.accessSync(DATA_DIR, fs.constants.W_OK); console.log(`Data directory ready: ${DATA_DIR}`); } catch (err) { console.error(`WARNING: Data directory not writable: ${DATA_DIR} โ€” ${err.message}`); - // Attempt chmod in case it's a simple permission gap try { fs.chmodSync(DATA_DIR, 0o755); } catch {} } @@ -38,6 +42,201 @@ function writePrayers(prayers) { fs.writeFileSync(DATA_FILE, JSON.stringify(prayers, null, 2)); } +function fmtDate(iso) { + return iso ? new Date(iso).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }) : ''; +} + +function prayerToSection(prayer, index) { + const paras = []; + + // Prayer number + person + const titleRuns = [ + new TextRun({ text: `${index + 1}. `, bold: true, size: 24, color: '4B6FA8' }), + ]; + if (prayer.person) { + titleRuns.push(new TextRun({ text: prayer.person, bold: true, size: 24, color: '4B6FA8' })); + } + paras.push(new Paragraph({ children: titleRuns, spacing: { before: 240, after: 60 } })); + + // Meta: date + tags + const metaParts = [`Added ${fmtDate(prayer.createdAt)}`]; + if (prayer.tags && prayer.tags.length) metaParts.push(`Tags: ${prayer.tags.join(', ')}`); + paras.push(new Paragraph({ + children: [new TextRun({ text: metaParts.join(' ยท '), size: 18, color: '888888', italics: true })], + spacing: { after: 120 }, + })); + + // Request + paras.push(new Paragraph({ + children: [new TextRun({ text: 'Request', bold: true, size: 20, color: '333333' })], + spacing: { after: 60 }, + })); + paras.push(new Paragraph({ + children: [new TextRun({ text: prayer.request, size: 22 })], + spacing: { after: 160 }, + })); + + // Answer (if present) + if (prayer.answer) { + paras.push(new Paragraph({ + children: [new TextRun({ text: 'Answer', bold: true, size: 20, color: '4A8C6A' })], + spacing: { after: 60 }, + })); + paras.push(new Paragraph({ + children: [new TextRun({ text: prayer.answer, size: 22, color: '2D5C40' })], + spacing: { after: 80 }, + })); + if (prayer.answeredAt) { + paras.push(new Paragraph({ + children: [new TextRun({ text: `Answered ${fmtDate(prayer.answeredAt)}`, size: 18, italics: true, color: '888888' })], + spacing: { after: 160 }, + })); + } + } + + // Divider paragraph with bottom border + paras.push(new Paragraph({ + children: [], + spacing: { after: 200 }, + border: { bottom: { style: BorderStyle.SINGLE, size: 1, color: 'E2DAC8' } }, + })); + + return paras; +} + +app.get('/api/export', (req, res) => { + try { + const all = readPrayers(); + const status = req.query.status || 'all'; + const tag = req.query.tag || ''; + + const prayers = all.filter(p => { + const statusMatch = status === 'all' ? p.status !== 'archived' : p.status === status; + const tagMatch = tag ? (p.tags || []).includes(tag) : true; + return statusMatch && tagMatch; + }); + + const active = prayers.filter(p => p.status === 'active'); + const answered = prayers.filter(p => p.status === 'answered'); + + const titleLabel = status === 'active' ? 'Active Prayers' + : status === 'answered' ? 'Answered Prayers' + : 'Prayer Journal'; + + const children = [ + new Paragraph({ + text: 'Prayer Journal', + heading: HeadingLevel.TITLE, + alignment: AlignmentType.CENTER, + spacing: { after: 120 }, + }), + new Paragraph({ + children: [new TextRun({ text: `Exported ${fmtDate(new Date().toISOString())}`, size: 20, italics: true, color: '888888' })], + alignment: AlignmentType.CENTER, + spacing: { after: tag ? 80 : 480 }, + }), + ]; + + if (tag) { + children.push(new Paragraph({ + children: [new TextRun({ text: `Tag: ${tag}`, size: 20, italics: true, color: '4B6FA8' })], + alignment: AlignmentType.CENTER, + spacing: { after: 480 }, + })); + } + + if (status === 'all' || status === 'active') { + if (active.length) { + children.push(new Paragraph({ + text: `Active Prayers (${active.length})`, + heading: HeadingLevel.HEADING_1, + spacing: { before: 0, after: 240 }, + })); + active.forEach((p, i) => children.push(...prayerToSection(p, i))); + } + } + + if (status === 'all' && answered.length && active.length) { + children.push(new Paragraph({ children: [new PageBreak()] })); + } + + if (status === 'all' || status === 'answered') { + if (answered.length) { + children.push(new Paragraph({ + text: `Answered Prayers (${answered.length})`, + heading: HeadingLevel.HEADING_1, + spacing: { before: 0, after: 240 }, + })); + answered.forEach((p, i) => children.push(...prayerToSection(p, i))); + } + } + + if (prayers.length === 0) { + children.push(new Paragraph({ + children: [new TextRun({ text: 'No prayers found.', italics: true, color: '888888' })], + })); + } + + const doc = new Document({ + styles: { + default: { + document: { + run: { font: 'Palatino Linotype', size: 22 }, + }, + }, + paragraphStyles: [ + { + id: 'Title', + name: 'Title', + basedOn: 'Normal', + run: { size: 56, bold: false, color: '252018', font: 'Palatino Linotype' }, + paragraph: { spacing: { before: 0, after: 120 } }, + }, + { + id: 'Heading1', + name: 'Heading 1', + basedOn: 'Normal', + run: { size: 32, bold: true, color: '4B6FA8', font: 'Palatino Linotype' }, + paragraph: { + spacing: { before: 480, after: 240 }, + border: { bottom: { style: BorderStyle.SINGLE, size: 2, color: 'C4A882' } }, + }, + }, + ], + }, + sections: [{ + properties: { + page: { size: { width: 12240, height: 15840 } }, + margin: { top: 1440, bottom: 1440, left: 1440, right: 1440 }, + }, + footers: { + default: new Footer({ + children: [new Paragraph({ + alignment: AlignmentType.CENTER, + children: [ + new TextRun({ children: [PageNumber.CURRENT], size: 18, color: '888888' }), + new TextRun({ text: ' / ', size: 18, color: '888888' }), + new TextRun({ children: [PageNumber.TOTAL_PAGES], size: 18, color: '888888' }), + ], + })], + }), + }, + children, + }], + }); + + Packer.toBuffer(doc).then(buffer => { + const filename = `prayer-journal-${new Date().toISOString().slice(0, 10)}.docx`; + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'); + res.send(buffer); + }); + } catch (err) { + console.error('Export failed:', err.message); + res.status(500).json({ error: 'Export failed: ' + err.message }); + } +}); + app.get('/api/health', (req, res) => { let writable = false; try { fs.accessSync(DATA_DIR, fs.constants.W_OK); writable = true; } catch {} @@ -87,7 +286,6 @@ app.delete('/api/prayers/:id', (req, res) => { } }); -// Serve React for all other routes app.get('*splat', (req, res) => { res.sendFile(path.join(__dirname, 'build', 'index.html')); }); diff --git a/src/App.css b/src/App.css index 0094714..e2dca6a 100644 --- a/src/App.css +++ b/src/App.css @@ -214,6 +214,22 @@ body { gap: .5rem; } +.btn-export { + background: var(--surface); + border-color: var(--border); + color: var(--text-muted); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-size: .82rem; + padding: .38rem .8rem; + white-space: nowrap; + flex-shrink: 0; +} + +.btn-export:hover { + color: var(--text); + border-color: var(--primary); +} + /* Search */ .search-wrap { position: relative; diff --git a/src/App.tsx b/src/App.tsx index b52f25c..93a1111 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -103,6 +103,13 @@ export default function App() { setActiveTag(prev => prev === tag ? null : tag); } + function handleExport() { + const params = new URLSearchParams(); + params.set('status', filter); + if (activeTag) params.set('tag', activeTag); + window.location.href = `/api/export?${params.toString()}`; + } + const emptyMessage = search ? `No results for "${search}".` : activeTag @@ -167,6 +174,10 @@ export default function App() { + +
โŒ•