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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DReRqHxSYuVfCzTzQGD9hZ
This commit is contained in:
Claude committed 2026-07-18 23:36:25 +00:00
1 parent af836d4ec9
commit 2b3d6a540c
5 files changed
+383 -3

No files matched your search

+154
View File
@@ -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",
+1
View File
@@ -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",
+201 -3
View File
@@ -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'));
});
+16
View File
@@ -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;
+11
View File
@@ -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() {
</button>
</div>
<button className="btn btn-export" onClick={handleExport} title="Export to Word">
↓ Export
</button>
<div className="search-wrap">
<span className="search-icon">⌕</span>
<input