- React + TipTap editor with formatting toolbar (bold, italic, underline, strikethrough, alignment, highlight, scene breaks) - Custom image node view with resize and alignment controls; server-side WebP conversion via sharp - Express + SQLite backend with JWT auth and admin user management - Export to PDF, EPUB, and ODT - Five themes (Midnight, Gothic Night, Enchanted Forest, Aged Manuscript, Neon Noir); Lora body font for readability - Writing streak, daily word goal, milestones, and Ollama writing prompts - Docker Compose setup for self-hosted deployment behind NPMplus Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
139 lines
6.4 KiB
JavaScript
139 lines
6.4 KiB
JavaScript
import JSZip from 'jszip'
|
|
|
|
function esc(str) {
|
|
return String(str)
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
.replace(/"/g, '"').replace(/'/g, ''')
|
|
}
|
|
|
|
function nodeToOdt(node) {
|
|
if (!node) return ''
|
|
const ch = () => (node.content || []).map(nodeToOdt).join('')
|
|
|
|
switch (node.type) {
|
|
case 'doc': return ch()
|
|
case 'paragraph': return `<text:p text:style-name="Text_20_Body">${ch()}</text:p>\n`
|
|
case 'heading': {
|
|
const l = node.attrs?.level || 1
|
|
return `<text:h text:style-name="Heading_20_${l}" text:outline-level="${l}">${ch()}</text:h>\n`
|
|
}
|
|
case 'text': {
|
|
let t = esc(node.text || '')
|
|
const marks = node.marks || []
|
|
const bold = marks.some(m => m.type === 'bold')
|
|
const italic = marks.some(m => m.type === 'italic')
|
|
if (bold && italic) return `<text:span text:style-name="BoldItalic">${t}</text:span>`
|
|
if (bold) return `<text:span text:style-name="Strong_20_Emphasis">${t}</text:span>`
|
|
if (italic) return `<text:span text:style-name="Emphasis">${t}</text:span>`
|
|
return t
|
|
}
|
|
case 'bulletList': return (node.content || []).map(item =>
|
|
(item.content || []).map(p =>
|
|
`<text:p text:style-name="List_20_Bullet">${(p.content || []).map(nodeToOdt).join('')}</text:p>\n`
|
|
).join('')
|
|
).join('')
|
|
case 'orderedList': return (node.content || []).map(item =>
|
|
(item.content || []).map(p =>
|
|
`<text:p text:style-name="List_20_Number">${(p.content || []).map(nodeToOdt).join('')}</text:p>\n`
|
|
).join('')
|
|
).join('')
|
|
case 'blockquote': return (node.content || []).map(n =>
|
|
n.type === 'paragraph'
|
|
? `<text:p text:style-name="Quotations">${(n.content || []).map(nodeToOdt).join('')}</text:p>\n`
|
|
: nodeToOdt(n)
|
|
).join('')
|
|
case 'hardBreak': return '<text:line-break/>'
|
|
case 'image': return '' // ODT image embedding is complex — skipped
|
|
default: return ch()
|
|
}
|
|
}
|
|
|
|
const STYLES = `
|
|
<style:style style:name="Text_20_Body" style:display-name="Text Body" style:family="paragraph">
|
|
<style:paragraph-properties fo:margin-bottom="0.2cm" fo:line-height="150%"/>
|
|
<style:text-properties fo:font-size="12pt"/>
|
|
</style:style>
|
|
<style:style style:name="Heading_20_1" style:display-name="Heading 1" style:family="paragraph">
|
|
<style:paragraph-properties fo:margin-top="1.5cm" fo:margin-bottom="0.5cm" fo:break-before="page"/>
|
|
<style:text-properties fo:font-size="18pt" fo:font-weight="bold"/>
|
|
</style:style>
|
|
<style:style style:name="Heading_20_2" style:display-name="Heading 2" style:family="paragraph">
|
|
<style:paragraph-properties fo:margin-top="1cm" fo:margin-bottom="0.3cm"/>
|
|
<style:text-properties fo:font-size="14pt" fo:font-weight="bold"/>
|
|
</style:style>
|
|
<style:style style:name="Heading_20_3" style:display-name="Heading 3" style:family="paragraph">
|
|
<style:paragraph-properties fo:margin-top="0.8cm" fo:margin-bottom="0.2cm"/>
|
|
<style:text-properties fo:font-size="12pt" fo:font-weight="bold" fo:font-style="italic"/>
|
|
</style:style>
|
|
<style:style style:name="Quotations" style:display-name="Quotations" style:family="paragraph">
|
|
<style:paragraph-properties fo:margin-left="1cm" fo:margin-right="1cm"/>
|
|
<style:text-properties fo:font-style="italic"/>
|
|
</style:style>
|
|
<style:style style:name="List_20_Bullet" style:display-name="List Bullet" style:family="paragraph">
|
|
<style:paragraph-properties fo:margin-left="1cm"/>
|
|
</style:style>
|
|
<style:style style:name="List_20_Number" style:display-name="List Number" style:family="paragraph">
|
|
<style:paragraph-properties fo:margin-left="1cm"/>
|
|
</style:style>
|
|
<style:style style:name="Strong_20_Emphasis" style:display-name="Strong Emphasis" style:family="text">
|
|
<style:text-properties fo:font-weight="bold"/>
|
|
</style:style>
|
|
<style:style style:name="Emphasis" style:display-name="Emphasis" style:family="text">
|
|
<style:text-properties fo:font-style="italic"/>
|
|
</style:style>
|
|
<style:style style:name="BoldItalic" style:display-name="Bold Italic" style:family="text">
|
|
<style:text-properties fo:font-weight="bold" fo:font-style="italic"/>
|
|
</style:style>
|
|
`
|
|
|
|
export async function generateOdt(story) {
|
|
const zip = new JSZip()
|
|
const doc = typeof story.content === 'string' ? JSON.parse(story.content) : (story.content || {})
|
|
const title = story.title || 'Untitled'
|
|
const body = nodeToOdt(doc)
|
|
|
|
zip.file('mimetype', 'application/vnd.oasis.opendocument.text', { compression: 'STORE' })
|
|
|
|
zip.file('META-INF/manifest.xml', `<?xml version="1.0" encoding="UTF-8"?>
|
|
<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.3">
|
|
<manifest:file-entry manifest:full-path="/" manifest:media-type="application/vnd.oasis.opendocument.text"/>
|
|
<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>
|
|
<manifest:file-entry manifest:full-path="styles.xml" manifest:media-type="text/xml"/>
|
|
<manifest:file-entry manifest:full-path="meta.xml" manifest:media-type="text/xml"/>
|
|
</manifest:manifest>`)
|
|
|
|
zip.file('meta.xml', `<?xml version="1.0" encoding="UTF-8"?>
|
|
<office:document-meta xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
|
|
xmlns:dc="http://purl.org/dc/elements/1.1/" office:version="1.3">
|
|
<office:meta>
|
|
<dc:title>${esc(title)}</dc:title>
|
|
</office:meta>
|
|
</office:document-meta>`)
|
|
|
|
zip.file('styles.xml', `<?xml version="1.0" encoding="UTF-8"?>
|
|
<office:document-styles
|
|
xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
|
|
xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"
|
|
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
|
|
xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"
|
|
office:version="1.3">
|
|
<office:styles>${STYLES}</office:styles>
|
|
</office:document-styles>`)
|
|
|
|
zip.file('content.xml', `<?xml version="1.0" encoding="UTF-8"?>
|
|
<office:document-content
|
|
xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
|
|
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
|
|
xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"
|
|
xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"
|
|
office:version="1.3">
|
|
<office:body>
|
|
<office:text>
|
|
<text:h text:style-name="Heading_20_1" text:outline-level="1">${esc(title)}</text:h>
|
|
${body} </office:text>
|
|
</office:body>
|
|
</office:document-content>`)
|
|
|
|
return zip.generateAsync({ type: 'nodebuffer' })
|
|
}
|