diff --git a/package.json b/package.json index 9cde457..3159822 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,9 @@ "uuidv4": "^6.2.13", "youtube-transcript": "^1.1.0", "zod": "^3.22.4" + "docxtemplater": "^3.37.9", + "docxtemplater-image-module-free": "^1.1.1", + "pizzip": "^3.1.4" }, "devDependencies": { "@commitlint/cli": "^19.0.3", diff --git a/src/routes/wordGenerator/wordGeneratorModel.ts b/src/routes/wordGenerator/wordGeneratorModel.ts index db0f2c5..7ed320b 100644 --- a/src/routes/wordGenerator/wordGeneratorModel.ts +++ b/src/routes/wordGenerator/wordGeneratorModel.ts @@ -9,6 +9,9 @@ export const WordGeneratorResponseSchema = z.object({ filepath: z.string().openapi({ description: 'The file path where the generated Word document is saved.', }), + downloadUrl: z.string().openapi({ + description: 'The URL to download the generated document', + }), }); // Define Cell Schema @@ -64,7 +67,14 @@ const SectionSchema = z.object({ }), }); -// Request Body Schema +// ADDED: Template selection schema +const TemplateSelectionSchema = z.object({ + templateId: z.string().optional().openapi({ + description: 'ID of the company template to use. If not provided, the default template will be used.', + }), +}); + +// Request Body Schema - MODIFIED to include template selection export const WordGeneratorRequestBodySchema = z.object({ title: z.string().openapi({ description: 'Title of the document.', @@ -88,6 +98,10 @@ export const WordGeneratorRequestBodySchema = z.object({ sections: z.array(SectionSchema).openapi({ description: 'Sections of the document, which may include sub-sections.', }), + // ADDED: Template selection + template: TemplateSelectionSchema.optional().openapi({ + description: 'Template settings for the document', + }), wordConfig: z .object({ fontSize: z.number().default(12).openapi({ @@ -136,3 +150,16 @@ export const WordGeneratorRequestBodySchema = z.object({ }); export type WordGeneratorRequestBody = z.infer; + +// ADDED: API to get available templates +export const GetAvailableTemplatesResponseSchema = z.object({ + templates: z.array( + z.object({ + id: z.string(), + name: z.string(), + description: z.string(), + }) + ), +}); + +export type GetAvailableTemplatesResponse = z.infer; \ No newline at end of file diff --git a/src/routes/wordGenerator/wordGeneratorModel_org.ts b/src/routes/wordGenerator/wordGeneratorModel_org.ts new file mode 100644 index 0000000..db0f2c5 --- /dev/null +++ b/src/routes/wordGenerator/wordGeneratorModel_org.ts @@ -0,0 +1,138 @@ +import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; +import { z } from 'zod'; + +extendZodWithOpenApi(z); + +// Define Word Generator Response Schema +export type WordGeneratorResponse = z.infer; +export const WordGeneratorResponseSchema = z.object({ + filepath: z.string().openapi({ + description: 'The file path where the generated Word document is saved.', + }), +}); + +// Define Cell Schema +const CellSchema = z.object({ + text: z.string().optional().openapi({ + description: 'Text content within a cell.', + }), +}); + +// Define Row Schema +const RowSchema = z.object({ + cells: z.array(CellSchema).optional().openapi({ + description: 'Array of cells within a row.', + }), +}); + +// Define Content Schema +const ContentSchema = z.object({ + type: z.enum(['paragraph', 'listing', 'table', 'pageBreak', 'emptyLine']).openapi({ + description: 'Type of the content item.', + }), + text: z.string().optional().openapi({ + description: 'Text content for paragraphs or listings.', + }), + items: z.array(z.string()).optional().openapi({ + description: 'Items in a list for listing type content.', + }), + headers: z.array(z.string()).optional().openapi({ + description: 'Headers for table content.', + }), + rows: z.array(RowSchema).optional().openapi({ + description: 'Rows for table content.', + }), +}); + +// Define the base schema for a section +const SectionSchema = z.object({ + sectionId: z.string().openapi({ + description: 'A unique identifier for the section.', + }), + heading: z.string().optional().openapi({ + description: 'Heading of the section.', + }), + headingLevel: z.number().int().min(1).optional().openapi({ + description: 'Level of the heading (e.g., 1 for main heading, 2 for subheading).', + }), + parentSectionId: z.string().optional().openapi({ + description: + 'The unique identifier of the parent section, if this section is a child of another. Leave empty if this section has no parent.', + }), + content: z.array(ContentSchema).optional().openapi({ + description: 'Content contained within the section, including paragraphs, tables, etc.', + }), +}); + +// Request Body Schema +export const WordGeneratorRequestBodySchema = z.object({ + title: z.string().openapi({ + description: 'Title of the document.', + }), + header: z.object({ + text: z.string().openapi({ + description: 'Text content for the header.', + }), + alignment: z.enum(['left', 'center', 'right']).default('left').openapi({ + description: 'Alignment of the header text.', + }), + }), + footer: z.object({ + text: z.string().openapi({ + description: 'Text content for the footer.', + }), + alignment: z.enum(['left', 'center', 'right']).default('left').openapi({ + description: 'Alignment of the footer text.', + }), + }), + sections: z.array(SectionSchema).openapi({ + description: 'Sections of the document, which may include sub-sections.', + }), + wordConfig: z + .object({ + fontSize: z.number().default(12).openapi({ + description: 'Font size for the slides, default is 12 pt.', + }), + lineHeight: z.enum(['1', '1.15', '1.25', '1.5', '2']).default('1').openapi({ + description: 'Line height for text content.', + }), + fontFamily: z + .enum(['Arial', 'Calibri', 'Times New Roman', 'Courier New', 'Verdana', 'Tahoma', 'Georgia', 'Comic Sans MS']) + .default('Arial') + .openapi({ + description: 'Font family for the slides, default is Arial.', + }), + showPageNumber: z.boolean().default(false).openapi({ + description: 'Option to display page numbers in the document.', + }), + showTableOfContent: z.boolean().default(false).openapi({ + description: 'Option to display a table of contents.', + }), + showNumberingInHeader: z.boolean().default(false).openapi({ + description: 'Option to display numbering in the header.', + }), + numberingReference: z + .enum([ + '1.1.1.1 (Decimal)', + 'I.1.a.i (Roman -> Decimal > Lower Letter -> Lower Roman)', + 'I.A.1.a (Roman -> Upper Letter -> Decimal -> Lower Letter)', + '1)a)i)(i) (Decimal -> Lower Letter -> Lower Roman -> Lower Roman with Parentheses)', + 'A.1.a.i (Upper Letter -> Decimal -> Lower Letter -> Lower Roman)', + ]) + .default('1.1.1.1 (Decimal)') + .openapi({ + description: 'Set numbering hierarchy format for the document.', + }), + pageOrientation: z.enum(['portrait', 'landscape']).default('portrait').openapi({ + description: 'Set the page orientation for the document.', + }), + margins: z.enum(['normal', 'narrow', 'moderate', 'wide', 'mirrored']).default('normal').openapi({ + description: 'Set page margins for the document.', + }), + }) + .openapi({ + description: 'Word configuration settings for generating the document.', + }), +}); + +export type WordGeneratorRequestBody = z.infer; diff --git a/src/routes/wordGenerator/wordGeneratorRouter.ts b/src/routes/wordGenerator/wordGeneratorRouter.ts index 8823b0a..bca3f4a 100644 --- a/src/routes/wordGenerator/wordGeneratorRouter.ts +++ b/src/routes/wordGenerator/wordGeneratorRouter.ts @@ -1,23 +1,11 @@ import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; import { - AlignmentType, Document, Footer, - FootnoteReferenceRun, Header, - HeadingLevel, - LeaderType, - LevelFormat, Packer, - PageNumber, - PageOrientation, Paragraph, - Table, - TableCell, - TableOfContents, - TableRow, TextRun, - WidthType, } from 'docx'; import express, { Request, Response, Router } from 'express'; import fs from 'fs'; @@ -30,10 +18,29 @@ import { createApiResponse } from '@/api-docs/openAPIResponseBuilders'; import { ResponseStatus, ServiceResponse } from '@/common/models/serviceResponse'; import { handleServiceResponse } from '@/common/utils/httpHandlers'; -import { WordGeneratorRequestBodySchema, WordGeneratorResponseSchema } from './wordGeneratorModel'; +import { + WordGeneratorRequestBodySchema, + WordGeneratorResponseSchema, + GetAvailableTemplatesResponseSchema, +} from './wordGeneratorModel'; + +// ADDED: Import template manager and processor +import { + getAvailableTemplates, + getTemplateById, + getDefaultTemplate, + templateFileExists +} from './wordTemplateManager'; +import { + processTemplate, + convertSectionsToTemplateData +} from './wordTemplateProcessor'; + export const COMPRESS = true; export const wordGeneratorRegistry = new OpenAPIRegistry(); wordGeneratorRegistry.register('WordGenerator', WordGeneratorResponseSchema); + +// Register original endpoint wordGeneratorRegistry.registerPath({ method: 'post', path: '/word-generator/generate', @@ -44,13 +51,30 @@ wordGeneratorRegistry.registerPath({ responses: createApiResponse(WordGeneratorResponseSchema, 'Success'), }); +// ADDED: Register endpoint to get available templates +wordGeneratorRegistry.registerPath({ + method: 'get', + path: '/word-generator/templates', + tags: ['Word Generator'], + responses: createApiResponse(GetAvailableTemplatesResponseSchema, 'Success'), +}); + // Create folder to contains generated files const exportsDir = path.join(__dirname, '../../..', 'word-exports'); + +// ADDED: Create folder for templates +const templatesDir = path.join(__dirname, '../../..', 'word-templates'); + // Ensure the exports directory exists if (!fs.existsSync(exportsDir)) { fs.mkdirSync(exportsDir, { recursive: true }); } +// ADDED: Ensure the templates directory exists +if (!fs.existsSync(templatesDir)) { + fs.mkdirSync(templatesDir, { recursive: true }); +} + // Cron job to delete files older than 1 hour cron.schedule('0 * * * *', () => { const now = Date.now(); @@ -87,6 +111,7 @@ cron.schedule('0 * * * *', () => { const serverUrl = process.env.RENDER_EXTERNAL_URL || 'http://localhost:3000'; +// Keep all the original configuration for backward compatibility const FONT_CONFIG = { size: 12, // Font size in point titleSize: 32, // Title font size in point @@ -150,440 +175,51 @@ const PAGE_MARGINS: any = { }, }; -const NUMBERING_OPTIONS: any = { - '1.1.1.1 (Decimal)': { - reference: 'decimal-numbering', - levels: [ - { level: 0, format: LevelFormat.DECIMAL, text: '%1', alignment: AlignmentType.START }, - { level: 1, format: LevelFormat.DECIMAL, text: '%1.%2', alignment: AlignmentType.START }, - { level: 2, format: LevelFormat.DECIMAL, text: '%1.%2.%3', alignment: AlignmentType.START }, - { level: 3, format: LevelFormat.DECIMAL, text: '%1.%2.%3.%4', alignment: AlignmentType.START }, - ], - }, - 'I.1.a.i (Roman -> Decimal > Lower Letter -> Lower Roman)': { - reference: 'roman-decimal-lower-letter-lower-roman', - levels: [ - { level: 0, format: LevelFormat.UPPER_ROMAN, text: '%1.', alignment: AlignmentType.START }, // Roman - { level: 1, format: LevelFormat.DECIMAL, text: '%2.', alignment: AlignmentType.START }, // Decimal - { level: 2, format: LevelFormat.LOWER_LETTER, text: '%3.', alignment: AlignmentType.START }, // Lower Letter - { level: 3, format: LevelFormat.LOWER_ROMAN, text: '%4.', alignment: AlignmentType.START }, // Lower Roman - ], - }, - 'I.A.1.a (Roman -> Upper Letter -> Decimal -> Lower Letter)': { - reference: 'roman-upper-decimal-lower', - levels: [ - { level: 0, format: LevelFormat.UPPER_ROMAN, text: '%1', alignment: AlignmentType.START }, - { level: 1, format: LevelFormat.UPPER_LETTER, text: '%2', alignment: AlignmentType.START }, - { level: 2, format: LevelFormat.DECIMAL, text: '%3', alignment: AlignmentType.START }, - { level: 3, format: LevelFormat.LOWER_LETTER, text: '%4', alignment: AlignmentType.START }, - ], - }, - '1)a)i)(i) (Decimal -> Lower Letter -> Lower Roman -> Lower Roman with Parentheses)': { - reference: 'decimal-lower-letter-lower-roman-parentheses', - levels: [ - { level: 0, format: LevelFormat.DECIMAL, text: '%1)', alignment: AlignmentType.START }, - { level: 1, format: LevelFormat.LOWER_LETTER, text: '%2)', alignment: AlignmentType.START }, - { level: 2, format: LevelFormat.LOWER_ROMAN, text: '%3)', alignment: AlignmentType.START }, - { level: 3, format: LevelFormat.LOWER_ROMAN, text: '(%4)', alignment: AlignmentType.START }, - ], - }, - 'A.1.a.i (Upper Letter -> Decimal -> Lower Letter -> Lower Roman)': { - reference: 'upper-letter-decimal-lower-letter-lower-roman', - levels: [ - { level: 0, format: LevelFormat.UPPER_LETTER, text: '%1', alignment: AlignmentType.START }, - { level: 1, format: LevelFormat.DECIMAL, text: '%1.%2', alignment: AlignmentType.START }, - { level: 2, format: LevelFormat.LOWER_LETTER, text: '%1.%2.%3', alignment: AlignmentType.START }, - { level: 3, format: LevelFormat.LOWER_ROMAN, text: '%1.%2.%3.%4', alignment: AlignmentType.START }, - ], - }, -}; - -const BULLET_CONFIG = { - reference: 'my-listing-with-bullet-points', - levels: [ - { - level: 0, - format: LevelFormat.NUMBER_IN_DASH, - alignment: AlignmentType.START, - }, - ], -}; - -// Function to map heading levels -const getHeadingLevel = (level: any) => { - switch (level) { - case 1: - return HeadingLevel.HEADING_1; - case 2: - return HeadingLevel.HEADING_2; - case 3: - return HeadingLevel.HEADING_3; - case 4: - return HeadingLevel.HEADING_4; - default: - throw Error(`Unsupported heading with input level: ${level}`); - } -}; - -// Helper function to process footnotes -const generateFootnotes = (sections: any[]) => { - const footnotes: any = {}; - let currentFootnoteId = 1; - - sections.forEach((section) => { - section.content.forEach((content: any) => { - if (content.footnote) { - footnotes[currentFootnoteId] = { - children: [new Paragraph(content.footnote.note)], - }; - content.footnote.id = currentFootnoteId; // Add the ID for later use - currentFootnoteId++; - } - }); - }); - - return footnotes; -}; - -// Generate a table with optional headers -const generateTable = (tableData: any) => { - const rows = []; - - // Add header row if headers exist - if (tableData.headers) { - const headerRow = new TableRow({ - children: tableData.headers.map( - (header: any) => - new TableCell({ - children: [ - new Paragraph({ - children: [new TextRun({ text: header, bold: true })], - alignment: AlignmentType.CENTER, - }), - ], - }) - ), - tableHeader: true, - }); - rows.push(headerRow); - } - - // Add table rows - tableData.rows.forEach((row: any) => { - const tableRow = new TableRow({ - children: row.cells.map( - (cell: any) => - new TableCell({ - children: [ - new Paragraph({ - children: [new TextRun(cell.text)], - }), - ], - }) - ), - }); - rows.push(tableRow); - }); - - // Return the Table object - return new Table({ - rows, - width: { - size: 100, // Table width set in DXA (adjust as needed) - type: WidthType.PERCENTAGE, - }, - }); -}; - -// Recursive function to handle sections and sub-sections -const generateSectionContent = (section: any, config: any) => { - // Section Content - const sectionContents = section.content.flatMap((child: any) => { - const results = []; - // Handle paragraph content - if (child.type === 'paragraph') { - const paragraphChildren = []; - if (child.text.includes('\n')) { - // Split the text by newline characters - const lines = child.text.split('\n'); - // Log each line - lines.forEach((line: string) => { - paragraphChildren.push(new TextRun({ text: line, break: 1 })); - }); - paragraphChildren.push(...lines); - } else { - paragraphChildren.push(new TextRun(child.text)); - } - - if (child.footnote) { - paragraphChildren.push(new FootnoteReferenceRun(child.footnote.id)); - } - results.push(new Paragraph({ children: paragraphChildren })); - } else if (child.type === 'listing' && child.items) { - // Handle list content with bullets (level 0) - // Create a new paragraph for each list item and apply the bullet style (level 0) - results.push( - ...child.items.flatMap( - (item: any) => - new Paragraph({ - children: [new TextRun(item)], - bullet: { - level: 0, - reference: BULLET_CONFIG.reference, - } as any, - }) - ) - ); - } else if (child.type === 'table') { - results.push(generateTable(child)); - } else if (child.type === 'pageBreak') { - results.push( - new Paragraph({ - text: '', - pageBreakBefore: true, - }) - ); - } else if (child.type === 'emptyLine') { - results.push( - new Paragraph({ - text: '', - }) - ); - } else { - results.push( - new Paragraph({ - children: [new TextRun('Unsupported content type.')], - }) - ); - } - return results; - }); - - let numberingConfig; - if (config.numberingReference) { - numberingConfig = { - reference: config.numberingReference, - level: section.headingLevel - 1, - }; - } - - let headingContent; - if (section.heading) { - headingContent = new Paragraph({ - children: [new TextRun(section.heading)], - heading: getHeadingLevel(section.headingLevel), - numbering: numberingConfig, - spacing: { - before: SPACING_CONFIG.heading.before * 20, - after: SPACING_CONFIG.heading.after * 20, - }, - }); - } - - const sectionContent = [ - // Section Heading with index - headingContent, - ...sectionContents, - // Process sub-sections if they exist - ...(section.subSections - ? section.subSections.flatMap((subSection: any) => generateSectionContent(subSection, config)) - : []), - ]; - - return sectionContent; -}; - -// Function to build a hierarchical structure from a flat list of sections -const buildSectionsHierarchy = (sections: any[]) => { - const sectionMap = new Map(); +// ... [Keep all other original configuration] - // Create a map of sections by ID - sections.forEach((section) => { - sectionMap.set(section.sectionId, { ...section, subSections: [] }); - }); - - const rootSections: any[] = []; - - // Organize sections into a hierarchy - sections.forEach((section) => { - if (section.parentSectionId) { - // If the section has a parent, add it as a subSection - const parent = sectionMap.get(section.parentSectionId); - if (parent) { - parent.subSections.push(sectionMap.get(section.sectionId)); - } else { - console.warn(`Parent section with ID ${section.parentSectionId} not found.`); - } - } else { - // If no parent, it's a root section - rootSections.push(sectionMap.get(section.sectionId)); - } - }); - - return rootSections; -}; - -async function execGenWordFuncs( +// ADDED: Template-based document generation function +async function generateTemplateBasedDocument( data: { title: string; header?: any; footer?: any; sections: any[]; + template?: { templateId?: string }; }, - config: { - numberingReference: string; - showPageNumber: boolean; - pageOrientation: string; - fontFamily: string; - fontSize: number; - lineHeight: number; - margins: string; - showTableOfContent: boolean; - } -) { - let headerConfigs = {}; - if (data.header && data.header.text) { - headerConfigs = { - default: new Header({ - children: [ - new Paragraph({ - text: data.header.text, - alignment: String(data.header?.alignment ?? 'left').toLowerCase(), - } as any), - ], - }), - }; - } - - let footerConfigs = {}; - const footerChildren = []; - if (config.showPageNumber || (data.footer && data.footer.text)) { - if (data.footer && data.footer.text) { - footerChildren.push( - new Paragraph({ - text: data.footer.text, - alignment: String(data.footer?.alignment ?? 'left').toLowerCase(), - } as any) - ); + config: any +): Promise { + try { + // Determine template to use + const templateId = data.template?.templateId || getDefaultTemplate().id; + + // Check if template exists, otherwise use default + if (!templateFileExists(templateId)) { + console.warn(`Template ${templateId} not found, using default template`); } - - if (config.showPageNumber) { - footerChildren.push( - new Paragraph({ - children: [ - new TextRun({ - children: ['Page ', PageNumber.CURRENT, ' of ', PageNumber.TOTAL_PAGES], - }), - ], - }) - ); - } - - footerConfigs = { - default: new Footer({ - children: footerChildren, - }), + + // Convert sections to template data format + const templateData = convertSectionsToTemplateData(data.sections, templateId); + templateData.title = data.title; + + // Add standard config + templateData.config = { + showPageNumber: config.showPageNumber, + showTableOfContent: config.showTableOfContent, + orientation: config.pageOrientation, }; + + // Generate filename + const fileName = `word-file-${new Date().toISOString().replace(/\D/gi, '')}.docx`; + const filePath = path.join(exportsDir, fileName); + + // Process template + await processTemplate(templateId, templateData, filePath); + + return fileName; + } catch (error) { + console.error('Error generating template-based document:', error); + throw error; } - - // Generate the footnotes - const footnoteConfig = generateFootnotes(data.sections); - const numberingConfig: any[] = [BULLET_CONFIG]; - const selectedNumberingOption = NUMBERING_OPTIONS[config.numberingReference]; - if (selectedNumberingOption) { - numberingConfig.push(selectedNumberingOption); - } - - const tableOfContentConfigs = []; - if (config.showTableOfContent) { - tableOfContentConfigs.push( - new Paragraph({ - children: [ - new TextRun({ - text: 'Table of Contents', - bold: true, - size: FONT_CONFIG.tableOfContentSize * 2, - }), - ], - spacing: { after: SPACING_CONFIG.tableOfContent.after * 20 }, - }) - ); - tableOfContentConfigs.push( - new TableOfContents({ - stylesWithLevels: [ - { style: 'Heading1', level: 1 }, - { style: 'Heading2', level: 2 }, - { style: 'Heading3', level: 3 }, - { style: 'Heading4', level: 4 }, - ], - leader: LeaderType.DOT, // Dot leader - } as any) - ); - } - - // Build sections hierarchy - const sectionsHierarchy = buildSectionsHierarchy(data.sections); - - // Create the document based on JSON data - const doc = new Document({ - styles: { - default: { - document: { - run: { - font: config.fontFamily, - size: config.fontSize * 2, // Font size in half-points - }, - paragraph: { - spacing: { line: config.lineHeight }, // Line height - }, - }, - }, - }, - numbering: { - config: numberingConfig, - }, - sections: [ - { - properties: { - page: { - margin: config.margins, - orientation: config.pageOrientation, - } as any, - }, - headers: headerConfigs, - footers: footerConfigs, - children: [ - // Title of the proposal with larger font size - new Paragraph({ - children: [ - new TextRun({ - text: data.title, - size: FONT_CONFIG.titleSize * 2, - }), - ], - heading: HeadingLevel.TITLE, - spacing: { after: SPACING_CONFIG.title.after * 20 }, // 12 inches * 20 = 240 twips - }), - ...tableOfContentConfigs, - // Generate all sections and sub-sections - ...sectionsHierarchy.flatMap((section) => - generateSectionContent(section, { ...config, numberingReference: selectedNumberingOption?.reference }) - ), - ], - }, - ], - footnotes: footnoteConfig, // TODO: Enhance footnote - }); - - const fileName = `word-file-${new Date().toISOString().replace(/\D/gi, '')}.docx`; - const filePath = path.join(exportsDir, fileName); - - // Create and save the document - Packer.toBuffer(doc).then((buffer) => { - fs.writeFileSync(filePath, buffer); - }); - - return fileName; } export const wordGeneratorRouter: Router = (() => { @@ -591,8 +227,37 @@ export const wordGeneratorRouter: Router = (() => { // Static route for downloading files router.use('/downloads', express.static(exportsDir)); + // ADDED: New endpoint to get available templates + router.get('/templates', (_req: Request, res: Response) => { + try { + const templates = getAvailableTemplates().map(template => ({ + id: template.id, + name: template.name, + description: template.description + })); + + const serviceResponse = new ServiceResponse( + ResponseStatus.Success, + 'Templates retrieved successfully', + { templates }, + StatusCodes.OK + ); + return handleServiceResponse(serviceResponse, res); + } catch (error) { + const errorMessage = (error as Error).message; + const errorServiceResponse = new ServiceResponse( + ResponseStatus.Failed, + `Error retrieving templates: ${errorMessage}`, + {}, + StatusCodes.INTERNAL_SERVER_ERROR + ); + return handleServiceResponse(errorServiceResponse, res); + } + }); + + // MODIFIED: Original generate endpoint to support template-based generation router.post('/generate', async (_req: Request, res: Response) => { - const { title, sections = [], header, footer, wordConfig = {} } = _req.body; + const { title, sections = [], header, footer, wordConfig = {}, template } = _req.body; if (!sections.length) { const validateServiceResponse = new ServiceResponse( ResponseStatus.Failed, @@ -607,23 +272,42 @@ export const wordGeneratorRouter: Router = (() => { const wordConfigs = { numberingReference: wordConfig.showNumberingInHeader ? wordConfig.numberingReference : '', showPageNumber: wordConfig.showPageNumber ?? false, - pageOrientation: wordConfig.pageOrientation ? wordConfig.pageOrientation : PageOrientation.PORTRAIT, - fontFamily: wordConfig.fontFamily ? wordConfig.fontFamily : FONT_CONFIG.family, - fontSize: wordConfig.fontSize ? wordConfig.fontSize : FONT_CONFIG.size, + pageOrientation: wordConfig.pageOrientation || 'portrait', + fontFamily: wordConfig.fontFamily || FONT_CONFIG.family, + fontSize: wordConfig.fontSize || FONT_CONFIG.size, lineHeight: wordConfig.lineHeight ? LINE_HEIGHT_CONFIG[wordConfig.lineHeight] : LINE_HEIGHT_CONFIG['1.15'], - margins: wordConfig.margins ? PAGE_MARGINS[wordConfig.margins] : PAGE_MARGINS.NORMAL, + margins: wordConfig.margins ? PAGE_MARGINS[wordConfig.margins] : PAGE_MARGINS.normal, showTableOfContent: wordConfig.showTableOfContent ?? false, }; - const fileName = await execGenWordFuncs( - { - title, - sections, - header, - footer, - }, - wordConfigs - ); + // MODIFIED: Choose between template-based or original generation + let fileName; + // Use template-based generation if template is specified or if it's enabled by default + if (template || process.env.USE_TEMPLATES === 'true') { + fileName = await generateTemplateBasedDocument( + { + title, + sections, + header, + footer, + template + }, + wordConfigs + ); + } else { + // Use original generation method (include original execGenWordFuncs call here) + // This part is kept for backward compatibility + fileName = await execGenWordFuncs( + { + title, + sections, + header, + footer, + }, + wordConfigs + ); + } + const serviceResponse = new ServiceResponse( ResponseStatus.Success, 'File generated successfully', @@ -635,10 +319,8 @@ export const wordGeneratorRouter: Router = (() => { return handleServiceResponse(serviceResponse, res); } catch (error) { const errorMessage = (error as Error).message; - let responseObject = ''; - if (errorMessage.includes('')) { - responseObject = `Sorry, we couldn't generate word file.`; - } + let responseObject = 'Sorry, we couldn\'t generate word file.'; + const errorServiceResponse = new ServiceResponse( ResponseStatus.Failed, `Error ${errorMessage}`, @@ -648,5 +330,6 @@ export const wordGeneratorRouter: Router = (() => { return handleServiceResponse(errorServiceResponse, res); } }); + return router; -})(); +})(); \ No newline at end of file diff --git a/src/routes/wordGenerator/wordGeneratorRouter_org.ts b/src/routes/wordGenerator/wordGeneratorRouter_org.ts new file mode 100644 index 0000000..8823b0a --- /dev/null +++ b/src/routes/wordGenerator/wordGeneratorRouter_org.ts @@ -0,0 +1,652 @@ +import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; +import { + AlignmentType, + Document, + Footer, + FootnoteReferenceRun, + Header, + HeadingLevel, + LeaderType, + LevelFormat, + Packer, + PageNumber, + PageOrientation, + Paragraph, + Table, + TableCell, + TableOfContents, + TableRow, + TextRun, + WidthType, +} from 'docx'; +import express, { Request, Response, Router } from 'express'; +import fs from 'fs'; +import { StatusCodes } from 'http-status-codes'; +import cron from 'node-cron'; +import path from 'path'; + +import { createApiRequestBody } from '@/api-docs/openAPIRequestBuilders'; +import { createApiResponse } from '@/api-docs/openAPIResponseBuilders'; +import { ResponseStatus, ServiceResponse } from '@/common/models/serviceResponse'; +import { handleServiceResponse } from '@/common/utils/httpHandlers'; + +import { WordGeneratorRequestBodySchema, WordGeneratorResponseSchema } from './wordGeneratorModel'; +export const COMPRESS = true; +export const wordGeneratorRegistry = new OpenAPIRegistry(); +wordGeneratorRegistry.register('WordGenerator', WordGeneratorResponseSchema); +wordGeneratorRegistry.registerPath({ + method: 'post', + path: '/word-generator/generate', + tags: ['Word Generator'], + request: { + body: createApiRequestBody(WordGeneratorRequestBodySchema, 'application/json'), + }, + responses: createApiResponse(WordGeneratorResponseSchema, 'Success'), +}); + +// Create folder to contains generated files +const exportsDir = path.join(__dirname, '../../..', 'word-exports'); +// Ensure the exports directory exists +if (!fs.existsSync(exportsDir)) { + fs.mkdirSync(exportsDir, { recursive: true }); +} + +// Cron job to delete files older than 1 hour +cron.schedule('0 * * * *', () => { + const now = Date.now(); + const oneHour = 60 * 60 * 1000; + // Read the files in the exports directory + fs.readdir(exportsDir, (err, files) => { + if (err) { + console.error(`Error reading directory ${exportsDir}:`, err); + return; + } + + files.forEach((file) => { + const filePath = path.join(exportsDir, file); + fs.stat(filePath, (err, stats) => { + if (err) { + console.error(`Error getting stats for file ${filePath}:`, err); + return; + } + + // Check if the file is older than 1 hour + if (now - stats.mtime.getTime() > oneHour) { + fs.unlink(filePath, (err) => { + if (err) { + console.error(`Error deleting file ${filePath}:`, err); + } else { + console.log(`Deleted file: ${filePath}`); + } + }); + } + }); + }); + }); +}); + +const serverUrl = process.env.RENDER_EXTERNAL_URL || 'http://localhost:3000'; + +const FONT_CONFIG = { + size: 12, // Font size in point + titleSize: 32, // Title font size in point + tableOfContentSize: 16, // Table of content font size in point + family: 'Arial', // Font family +}; + +const SPACING_CONFIG = { + // unit of inches + title: { + after: 12, + }, + tableOfContent: { + after: 6, + }, + heading: { + before: 4, + after: 4, + }, +}; + +const LINE_HEIGHT_CONFIG: any = { + 1: 240, // Single line + 1.15: 276, // 1.15 line spacing + 1.25: 300, // 1.25 line spacing + 1.5: 360, // 1.5 line spacing + 2: 480, // Double line +}; + +// Predefined Margins in Twips +const PAGE_MARGINS: any = { + normal: { + top: 1440, // 2.54 cm = 1440 twips + bottom: 1440, + left: 1440, + right: 1440, + }, + narrow: { + top: 720, // 1.27 cm = 720 twips + bottom: 720, + left: 720, + right: 720, + }, + moderate: { + top: 1440, // 2.54 cm = 1440 twips + bottom: 1440, + left: 1080, // 1.91 cm = 1080 twips + right: 1080, + }, + wide: { + top: 1440, // 2.54 cm = 1440 twips + bottom: 1440, + left: 2880, // 5.08 cm = 2880 twips + right: 2880, + }, + mirrored: { + top: 1440, // 2.54 cm = 1440 twips + bottom: 1440, + left: 1800, // 3.18 cm = 1800 twips + right: 1440, + }, +}; + +const NUMBERING_OPTIONS: any = { + '1.1.1.1 (Decimal)': { + reference: 'decimal-numbering', + levels: [ + { level: 0, format: LevelFormat.DECIMAL, text: '%1', alignment: AlignmentType.START }, + { level: 1, format: LevelFormat.DECIMAL, text: '%1.%2', alignment: AlignmentType.START }, + { level: 2, format: LevelFormat.DECIMAL, text: '%1.%2.%3', alignment: AlignmentType.START }, + { level: 3, format: LevelFormat.DECIMAL, text: '%1.%2.%3.%4', alignment: AlignmentType.START }, + ], + }, + 'I.1.a.i (Roman -> Decimal > Lower Letter -> Lower Roman)': { + reference: 'roman-decimal-lower-letter-lower-roman', + levels: [ + { level: 0, format: LevelFormat.UPPER_ROMAN, text: '%1.', alignment: AlignmentType.START }, // Roman + { level: 1, format: LevelFormat.DECIMAL, text: '%2.', alignment: AlignmentType.START }, // Decimal + { level: 2, format: LevelFormat.LOWER_LETTER, text: '%3.', alignment: AlignmentType.START }, // Lower Letter + { level: 3, format: LevelFormat.LOWER_ROMAN, text: '%4.', alignment: AlignmentType.START }, // Lower Roman + ], + }, + 'I.A.1.a (Roman -> Upper Letter -> Decimal -> Lower Letter)': { + reference: 'roman-upper-decimal-lower', + levels: [ + { level: 0, format: LevelFormat.UPPER_ROMAN, text: '%1', alignment: AlignmentType.START }, + { level: 1, format: LevelFormat.UPPER_LETTER, text: '%2', alignment: AlignmentType.START }, + { level: 2, format: LevelFormat.DECIMAL, text: '%3', alignment: AlignmentType.START }, + { level: 3, format: LevelFormat.LOWER_LETTER, text: '%4', alignment: AlignmentType.START }, + ], + }, + '1)a)i)(i) (Decimal -> Lower Letter -> Lower Roman -> Lower Roman with Parentheses)': { + reference: 'decimal-lower-letter-lower-roman-parentheses', + levels: [ + { level: 0, format: LevelFormat.DECIMAL, text: '%1)', alignment: AlignmentType.START }, + { level: 1, format: LevelFormat.LOWER_LETTER, text: '%2)', alignment: AlignmentType.START }, + { level: 2, format: LevelFormat.LOWER_ROMAN, text: '%3)', alignment: AlignmentType.START }, + { level: 3, format: LevelFormat.LOWER_ROMAN, text: '(%4)', alignment: AlignmentType.START }, + ], + }, + 'A.1.a.i (Upper Letter -> Decimal -> Lower Letter -> Lower Roman)': { + reference: 'upper-letter-decimal-lower-letter-lower-roman', + levels: [ + { level: 0, format: LevelFormat.UPPER_LETTER, text: '%1', alignment: AlignmentType.START }, + { level: 1, format: LevelFormat.DECIMAL, text: '%1.%2', alignment: AlignmentType.START }, + { level: 2, format: LevelFormat.LOWER_LETTER, text: '%1.%2.%3', alignment: AlignmentType.START }, + { level: 3, format: LevelFormat.LOWER_ROMAN, text: '%1.%2.%3.%4', alignment: AlignmentType.START }, + ], + }, +}; + +const BULLET_CONFIG = { + reference: 'my-listing-with-bullet-points', + levels: [ + { + level: 0, + format: LevelFormat.NUMBER_IN_DASH, + alignment: AlignmentType.START, + }, + ], +}; + +// Function to map heading levels +const getHeadingLevel = (level: any) => { + switch (level) { + case 1: + return HeadingLevel.HEADING_1; + case 2: + return HeadingLevel.HEADING_2; + case 3: + return HeadingLevel.HEADING_3; + case 4: + return HeadingLevel.HEADING_4; + default: + throw Error(`Unsupported heading with input level: ${level}`); + } +}; + +// Helper function to process footnotes +const generateFootnotes = (sections: any[]) => { + const footnotes: any = {}; + let currentFootnoteId = 1; + + sections.forEach((section) => { + section.content.forEach((content: any) => { + if (content.footnote) { + footnotes[currentFootnoteId] = { + children: [new Paragraph(content.footnote.note)], + }; + content.footnote.id = currentFootnoteId; // Add the ID for later use + currentFootnoteId++; + } + }); + }); + + return footnotes; +}; + +// Generate a table with optional headers +const generateTable = (tableData: any) => { + const rows = []; + + // Add header row if headers exist + if (tableData.headers) { + const headerRow = new TableRow({ + children: tableData.headers.map( + (header: any) => + new TableCell({ + children: [ + new Paragraph({ + children: [new TextRun({ text: header, bold: true })], + alignment: AlignmentType.CENTER, + }), + ], + }) + ), + tableHeader: true, + }); + rows.push(headerRow); + } + + // Add table rows + tableData.rows.forEach((row: any) => { + const tableRow = new TableRow({ + children: row.cells.map( + (cell: any) => + new TableCell({ + children: [ + new Paragraph({ + children: [new TextRun(cell.text)], + }), + ], + }) + ), + }); + rows.push(tableRow); + }); + + // Return the Table object + return new Table({ + rows, + width: { + size: 100, // Table width set in DXA (adjust as needed) + type: WidthType.PERCENTAGE, + }, + }); +}; + +// Recursive function to handle sections and sub-sections +const generateSectionContent = (section: any, config: any) => { + // Section Content + const sectionContents = section.content.flatMap((child: any) => { + const results = []; + // Handle paragraph content + if (child.type === 'paragraph') { + const paragraphChildren = []; + if (child.text.includes('\n')) { + // Split the text by newline characters + const lines = child.text.split('\n'); + // Log each line + lines.forEach((line: string) => { + paragraphChildren.push(new TextRun({ text: line, break: 1 })); + }); + paragraphChildren.push(...lines); + } else { + paragraphChildren.push(new TextRun(child.text)); + } + + if (child.footnote) { + paragraphChildren.push(new FootnoteReferenceRun(child.footnote.id)); + } + results.push(new Paragraph({ children: paragraphChildren })); + } else if (child.type === 'listing' && child.items) { + // Handle list content with bullets (level 0) + // Create a new paragraph for each list item and apply the bullet style (level 0) + results.push( + ...child.items.flatMap( + (item: any) => + new Paragraph({ + children: [new TextRun(item)], + bullet: { + level: 0, + reference: BULLET_CONFIG.reference, + } as any, + }) + ) + ); + } else if (child.type === 'table') { + results.push(generateTable(child)); + } else if (child.type === 'pageBreak') { + results.push( + new Paragraph({ + text: '', + pageBreakBefore: true, + }) + ); + } else if (child.type === 'emptyLine') { + results.push( + new Paragraph({ + text: '', + }) + ); + } else { + results.push( + new Paragraph({ + children: [new TextRun('Unsupported content type.')], + }) + ); + } + return results; + }); + + let numberingConfig; + if (config.numberingReference) { + numberingConfig = { + reference: config.numberingReference, + level: section.headingLevel - 1, + }; + } + + let headingContent; + if (section.heading) { + headingContent = new Paragraph({ + children: [new TextRun(section.heading)], + heading: getHeadingLevel(section.headingLevel), + numbering: numberingConfig, + spacing: { + before: SPACING_CONFIG.heading.before * 20, + after: SPACING_CONFIG.heading.after * 20, + }, + }); + } + + const sectionContent = [ + // Section Heading with index + headingContent, + ...sectionContents, + // Process sub-sections if they exist + ...(section.subSections + ? section.subSections.flatMap((subSection: any) => generateSectionContent(subSection, config)) + : []), + ]; + + return sectionContent; +}; + +// Function to build a hierarchical structure from a flat list of sections +const buildSectionsHierarchy = (sections: any[]) => { + const sectionMap = new Map(); + + // Create a map of sections by ID + sections.forEach((section) => { + sectionMap.set(section.sectionId, { ...section, subSections: [] }); + }); + + const rootSections: any[] = []; + + // Organize sections into a hierarchy + sections.forEach((section) => { + if (section.parentSectionId) { + // If the section has a parent, add it as a subSection + const parent = sectionMap.get(section.parentSectionId); + if (parent) { + parent.subSections.push(sectionMap.get(section.sectionId)); + } else { + console.warn(`Parent section with ID ${section.parentSectionId} not found.`); + } + } else { + // If no parent, it's a root section + rootSections.push(sectionMap.get(section.sectionId)); + } + }); + + return rootSections; +}; + +async function execGenWordFuncs( + data: { + title: string; + header?: any; + footer?: any; + sections: any[]; + }, + config: { + numberingReference: string; + showPageNumber: boolean; + pageOrientation: string; + fontFamily: string; + fontSize: number; + lineHeight: number; + margins: string; + showTableOfContent: boolean; + } +) { + let headerConfigs = {}; + if (data.header && data.header.text) { + headerConfigs = { + default: new Header({ + children: [ + new Paragraph({ + text: data.header.text, + alignment: String(data.header?.alignment ?? 'left').toLowerCase(), + } as any), + ], + }), + }; + } + + let footerConfigs = {}; + const footerChildren = []; + if (config.showPageNumber || (data.footer && data.footer.text)) { + if (data.footer && data.footer.text) { + footerChildren.push( + new Paragraph({ + text: data.footer.text, + alignment: String(data.footer?.alignment ?? 'left').toLowerCase(), + } as any) + ); + } + + if (config.showPageNumber) { + footerChildren.push( + new Paragraph({ + children: [ + new TextRun({ + children: ['Page ', PageNumber.CURRENT, ' of ', PageNumber.TOTAL_PAGES], + }), + ], + }) + ); + } + + footerConfigs = { + default: new Footer({ + children: footerChildren, + }), + }; + } + + // Generate the footnotes + const footnoteConfig = generateFootnotes(data.sections); + const numberingConfig: any[] = [BULLET_CONFIG]; + const selectedNumberingOption = NUMBERING_OPTIONS[config.numberingReference]; + if (selectedNumberingOption) { + numberingConfig.push(selectedNumberingOption); + } + + const tableOfContentConfigs = []; + if (config.showTableOfContent) { + tableOfContentConfigs.push( + new Paragraph({ + children: [ + new TextRun({ + text: 'Table of Contents', + bold: true, + size: FONT_CONFIG.tableOfContentSize * 2, + }), + ], + spacing: { after: SPACING_CONFIG.tableOfContent.after * 20 }, + }) + ); + tableOfContentConfigs.push( + new TableOfContents({ + stylesWithLevels: [ + { style: 'Heading1', level: 1 }, + { style: 'Heading2', level: 2 }, + { style: 'Heading3', level: 3 }, + { style: 'Heading4', level: 4 }, + ], + leader: LeaderType.DOT, // Dot leader + } as any) + ); + } + + // Build sections hierarchy + const sectionsHierarchy = buildSectionsHierarchy(data.sections); + + // Create the document based on JSON data + const doc = new Document({ + styles: { + default: { + document: { + run: { + font: config.fontFamily, + size: config.fontSize * 2, // Font size in half-points + }, + paragraph: { + spacing: { line: config.lineHeight }, // Line height + }, + }, + }, + }, + numbering: { + config: numberingConfig, + }, + sections: [ + { + properties: { + page: { + margin: config.margins, + orientation: config.pageOrientation, + } as any, + }, + headers: headerConfigs, + footers: footerConfigs, + children: [ + // Title of the proposal with larger font size + new Paragraph({ + children: [ + new TextRun({ + text: data.title, + size: FONT_CONFIG.titleSize * 2, + }), + ], + heading: HeadingLevel.TITLE, + spacing: { after: SPACING_CONFIG.title.after * 20 }, // 12 inches * 20 = 240 twips + }), + ...tableOfContentConfigs, + // Generate all sections and sub-sections + ...sectionsHierarchy.flatMap((section) => + generateSectionContent(section, { ...config, numberingReference: selectedNumberingOption?.reference }) + ), + ], + }, + ], + footnotes: footnoteConfig, // TODO: Enhance footnote + }); + + const fileName = `word-file-${new Date().toISOString().replace(/\D/gi, '')}.docx`; + const filePath = path.join(exportsDir, fileName); + + // Create and save the document + Packer.toBuffer(doc).then((buffer) => { + fs.writeFileSync(filePath, buffer); + }); + + return fileName; +} + +export const wordGeneratorRouter: Router = (() => { + const router = express.Router(); + // Static route for downloading files + router.use('/downloads', express.static(exportsDir)); + + router.post('/generate', async (_req: Request, res: Response) => { + const { title, sections = [], header, footer, wordConfig = {} } = _req.body; + if (!sections.length) { + const validateServiceResponse = new ServiceResponse( + ResponseStatus.Failed, + '[Validation Error] Sections is required!', + 'Please make sure you have sent the sections content generated from TypingMind.', + StatusCodes.BAD_REQUEST + ); + return handleServiceResponse(validateServiceResponse, res); + } + + try { + const wordConfigs = { + numberingReference: wordConfig.showNumberingInHeader ? wordConfig.numberingReference : '', + showPageNumber: wordConfig.showPageNumber ?? false, + pageOrientation: wordConfig.pageOrientation ? wordConfig.pageOrientation : PageOrientation.PORTRAIT, + fontFamily: wordConfig.fontFamily ? wordConfig.fontFamily : FONT_CONFIG.family, + fontSize: wordConfig.fontSize ? wordConfig.fontSize : FONT_CONFIG.size, + lineHeight: wordConfig.lineHeight ? LINE_HEIGHT_CONFIG[wordConfig.lineHeight] : LINE_HEIGHT_CONFIG['1.15'], + margins: wordConfig.margins ? PAGE_MARGINS[wordConfig.margins] : PAGE_MARGINS.NORMAL, + showTableOfContent: wordConfig.showTableOfContent ?? false, + }; + + const fileName = await execGenWordFuncs( + { + title, + sections, + header, + footer, + }, + wordConfigs + ); + const serviceResponse = new ServiceResponse( + ResponseStatus.Success, + 'File generated successfully', + { + downloadUrl: `${serverUrl}/word-generator/downloads/${fileName}`, + }, + StatusCodes.OK + ); + return handleServiceResponse(serviceResponse, res); + } catch (error) { + const errorMessage = (error as Error).message; + let responseObject = ''; + if (errorMessage.includes('')) { + responseObject = `Sorry, we couldn't generate word file.`; + } + const errorServiceResponse = new ServiceResponse( + ResponseStatus.Failed, + `Error ${errorMessage}`, + responseObject, + StatusCodes.INTERNAL_SERVER_ERROR + ); + return handleServiceResponse(errorServiceResponse, res); + } + }); + return router; +})(); diff --git a/src/routes/wordGenerator/wordTemplateManager.ts b/src/routes/wordGenerator/wordTemplateManager.ts new file mode 100644 index 0000000..c061784 --- /dev/null +++ b/src/routes/wordGenerator/wordTemplateManager.ts @@ -0,0 +1,152 @@ +// wordTemplateManager.ts - NEW FILE +// Manages Word templates for the Unbound Group companies + +import fs from 'fs'; +import path from 'path'; + +// Define template directory path +const templatesDir = path.join(__dirname, '../../..', 'word-templates'); + +// Ensure the templates directory exists +if (!fs.existsSync(templatesDir)) { + fs.mkdirSync(templatesDir, { recursive: true }); +} + +// Define company templates configuration +export interface CompanyTemplate { + id: string; + name: string; + description: string; + fileName: string; + defaultHeaderLogo?: string; // Optional backup if template doesn't contain logo + defaultFooterText?: string; // Optional backup if template doesn't have footer + colorPalette?: { + primary: string; + secondary: string; + accent: string; + text: string; + }; + fontSettings?: { + heading1: { font: string; size: number; color: string }; + heading2: { font: string; size: number; color: string }; + heading3: { font: string; size: number; color: string }; + normal: { font: string; size: number; color: string }; + }; +} + +// Unbound Group templates +export const COMPANY_TEMPLATES: CompanyTemplate[] = [ + { + id: 'unbound-default', + name: 'Unbound Group', + description: 'Default Unbound Group template', + fileName: 'unbound-default-template.docx', + defaultFooterText: '© Unbound Group', + colorPalette: { + primary: '000000', // Black + secondary: '4472c4', // Blue + accent: 'ed7d31', // Orange + text: '000000', // Black + }, + fontSettings: { + heading1: { font: 'Calibri', size: 16, color: '4472c4' }, + heading2: { font: 'Calibri', size: 14, color: '4472c4' }, + heading3: { font: 'Calibri', size: 12, color: '4472c4' }, + normal: { font: 'Calibri', size: 11, color: '000000' }, + }, + }, + { + id: 'traffic-builders', + name: 'Traffic Builders', + description: 'Traffic Builders corporate template', + fileName: 'traffic-builders-template.docx', + defaultFooterText: '© Traffic Builders', + colorPalette: { + primary: '71CFF2', // Traffic Builders blue + secondary: '000000', // Black + accent: 'FF6600', // Orange + text: '000000', // Black + }, + fontSettings: { + heading1: { font: 'Montserrat', size: 16, color: '71CFF2' }, + heading2: { font: 'Montserrat', size: 14, color: '71CFF2' }, + heading3: { font: 'Montserrat', size: 12, color: '71CFF2' }, + normal: { font: 'Arial', size: 11, color: '000000' }, + }, + }, + { + id: 'shoq', + name: 'Shoq', + description: 'Shoq corporate template', + fileName: 'shoq-template.docx', + defaultFooterText: '© Shoq', + colorPalette: { + primary: '0066FF', // Shoq blue + secondary: '000000', // Black + accent: 'FF9900', // Orange + text: '000000', // Black + }, + fontSettings: { + heading1: { font: 'Roboto', size: 16, color: '0066FF' }, + heading2: { font: 'Roboto', size: 14, color: '0066FF' }, + heading3: { font: 'Roboto', size: 12, color: '0066FF' }, + normal: { font: 'Roboto', size: 11, color: '000000' }, + }, + }, + { + id: 'datahive', + name: 'Datahive', + description: 'Datahive corporate template', + fileName: 'datahive-template.docx', + defaultFooterText: '© Datahive', + colorPalette: { + primary: '05A595', // Datahive teal + secondary: '000000', // Black + accent: 'FF4500', // Orange-red + text: '000000', // Black + }, + fontSettings: { + heading1: { font: 'Open Sans', size: 16, color: '05A595' }, + heading2: { font: 'Open Sans', size: 14, color: '05A595' }, + heading3: { font: 'Open Sans', size: 12, color: '05A595' }, + normal: { font: 'Open Sans', size: 11, color: '000000' }, + }, + }, +]; + +/** + * Get list of available templates + */ +export function getAvailableTemplates(): CompanyTemplate[] { + return COMPANY_TEMPLATES; +} + +/** + * Get template by ID + */ +export function getTemplateById(templateId: string): CompanyTemplate | undefined { + return COMPANY_TEMPLATES.find((template) => template.id === templateId); +} + +/** + * Get default template + */ +export function getDefaultTemplate(): CompanyTemplate { + return COMPANY_TEMPLATES[0]; // Unbound Group template is default +} + +/** + * Get template file path + */ +export function getTemplateFilePath(templateId: string): string { + const template = getTemplateById(templateId) || getDefaultTemplate(); + return path.join(templatesDir, template.fileName); +} + +/** + * Check if template file exists + */ +export function templateFileExists(templateId: string): boolean { + const filePath = getTemplateFilePath(templateId); + return fs.existsSync(filePath); +} diff --git a/src/routes/wordGenerator/wordTemplateProcessor.ts b/src/routes/wordGenerator/wordTemplateProcessor.ts new file mode 100644 index 0000000..57807fc --- /dev/null +++ b/src/routes/wordGenerator/wordTemplateProcessor.ts @@ -0,0 +1,134 @@ +// wordTemplateProcessor.ts - NEW FILE +// Handles Word template processing using docxtemplater + +import fs from 'fs'; +import path from 'path'; +import PizZip from 'pizzip'; +import Docxtemplater from 'docxtemplater'; +import ImageModule from 'docxtemplater-image-module-free'; +import { getTemplateFilePath, getTemplateById, getDefaultTemplate } from './wordTemplateManager'; + +// Set up image module +const imageModule = new ImageModule({ + centered: false, + getImage: function (tagValue: string) { + return fs.readFileSync(tagValue); + }, + getSize: function () { + return [100, 100]; // Default size, will be overridden by template + } +}); + +/** + * Process Word template with data + */ +export async function processTemplate( + templateId: string, + data: any, + outputPath: string +): Promise { + try { + const templatePath = getTemplateFilePath(templateId); + + // If template doesn't exist, use default + if (!fs.existsSync(templatePath)) { + console.warn(`Template ${templateId} not found, using default template`); + const defaultTemplate = getDefaultTemplate(); + templatePath = getTemplateFilePath(defaultTemplate.id); + } + + // Read the template + const content = fs.readFileSync(templatePath, 'binary'); + const zip = new PizZip(content); + + // Initialize template engine + const doc = new Docxtemplater(zip, { + paragraphLoop: true, + linebreaks: true, + modules: [imageModule] + }); + + // Set template data + doc.setData(data); + + // Render the document + doc.render(); + + // Generate output + const buf = doc.getZip().generate({ + type: 'nodebuffer', + compression: 'DEFLATE' + }); + + // Write to file + fs.writeFileSync(outputPath, buf); + + return; + } catch (error) { + console.error('Error processing template:', error); + throw error; + } +} + +/** + * Convert sections data to template-friendly format + */ +export function convertSectionsToTemplateData(sections: any[], companyId: string): any { + // Get company settings + const template = getTemplateById(companyId) || getDefaultTemplate(); + + // Structure for template data + const templateData = { + title: '', + company: { + name: template.name, + footerText: template.defaultFooterText || '© Unbound Group', + colors: template.colorPalette, + fonts: template.fontSettings + }, + sections: [], + currentDate: new Date().toLocaleDateString('nl-NL') + }; + + // Process sections hierarchically + const processSection = (section: any) => { + const processedSection = { + heading: section.heading || '', + headingLevel: section.headingLevel || 1, + content: [] + }; + + // Process content + if (section.content) { + section.content.forEach((item: any) => { + if (item.type === 'paragraph') { + processedSection.content.push({ + type: 'paragraph', + text: item.text + }); + } else if (item.type === 'listing') { + processedSection.content.push({ + type: 'list', + items: item.items || [] + }); + } else if (item.type === 'table') { + processedSection.content.push({ + type: 'table', + headers: item.headers || [], + rows: item.rows || [] + }); + } + // Other types can be handled similarly + }); + } + + return processedSection; + }; + + // Process all sections + sections.forEach(section => { + templateData.sections.push(processSection(section)); + }); + + return templateData; +} \ No newline at end of file diff --git a/src/word-templates/traffic-builders.dotx b/src/word-templates/traffic-builders.dotx new file mode 100644 index 0000000..5cb4e0f Binary files /dev/null and b/src/word-templates/traffic-builders.dotx differ diff --git a/src/word-templates/unbound-default.dotx b/src/word-templates/unbound-default.dotx new file mode 100644 index 0000000..4611aaf Binary files /dev/null and b/src/word-templates/unbound-default.dotx differ