From 36400583715e4790313032cf9e4544471ca50b49 Mon Sep 17 00:00:00 2001 From: dat-devuap Date: Sat, 14 Dec 2024 23:38:18 +0700 Subject: [PATCH 01/40] feat(powerpoint generator): add powerpoint generator route with basic generator logic --- package-lock.json | 131 +++++- package.json | 1 + .../powerpointGeneratorModel.ts | 9 + .../powerpointGeneratorRouter.ts | 403 ++++++++++++++++++ src/server.ts | 2 + 5 files changed, 544 insertions(+), 2 deletions(-) create mode 100644 src/routes/powerpointGenerator/powerpointGeneratorModel.ts create mode 100644 src/routes/powerpointGenerator/powerpointGeneratorRouter.ts diff --git a/package-lock.json b/package-lock.json index b105ce9..f04288e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "jsdom": "^24.0.0", "path": "^0.12.7", "pino-http": "^9.0.0", + "pptxgenjs": "^3.12.0", "swagger-ui-express": "^5.0.0", "uuidv4": "^6.2.13", "youtube-transcript": "^1.1.0", @@ -3478,6 +3479,11 @@ "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", "dev": true }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -5584,6 +5590,11 @@ "node": ">=10.19.0" } }, + "node_modules/https": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/https/-/https-1.0.0.tgz", + "integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==" + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -5659,6 +5670,25 @@ "node": ">= 4" } }, + "node_modules/image-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.1.1.tgz", + "integrity": "sha512-541xKlUw6jr/6gGuk92F+mYM5zaFAc5ahphvkqvNe2bQ6gVBkd6bfrmVJ2t4KDAfikAYZyIqTnktX3i6/aQDrQ==", + "dependencies": { + "queue": "6.0.2" + }, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -6242,6 +6272,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -6477,6 +6512,44 @@ "node": "*" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "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==", + "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==" + }, + "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==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -6525,6 +6598,14 @@ "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==", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -7527,6 +7608,11 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "dev": true }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -7982,6 +8068,30 @@ } } }, + "node_modules/pptxgenjs": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/pptxgenjs/-/pptxgenjs-3.12.0.tgz", + "integrity": "sha512-ZozkYKWb1MoPR4ucw3/aFYlHkVIJxo9czikEclcUVnS4Iw/M+r+TEwdlB3fyAWO9JY1USxJDt0Y0/r15IR/RUA==", + "dependencies": { + "@types/node": "^18.7.3", + "https": "^1.0.0", + "image-size": "^1.0.0", + "jszip": "^3.7.1" + } + }, + "node_modules/pptxgenjs/node_modules/@types/node": { + "version": "18.19.68", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.68.tgz", + "integrity": "sha512-QGtpFH1vB99ZmTa63K4/FU8twThj4fuVSBkGddTp7uIL/cuoLWIUSL2RcOaigBhfR+hg5pgGkBnkoOxrTVBMKw==", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/pptxgenjs/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -8052,6 +8162,11 @@ "node": ">= 0.6.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, "node_modules/process-warning": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz", @@ -8178,6 +8293,14 @@ "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" }, + "node_modules/queue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "dependencies": { + "inherits": "~2.0.3" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -8988,6 +9111,11 @@ "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==" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -10610,8 +10738,7 @@ "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, "node_modules/util/node_modules/inherits": { "version": "2.0.3", diff --git a/package.json b/package.json index 7164cb2..8ded5d4 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "jsdom": "^24.0.0", "path": "^0.12.7", "pino-http": "^9.0.0", + "pptxgenjs": "^3.12.0", "swagger-ui-express": "^5.0.0", "uuidv4": "^6.2.13", "youtube-transcript": "^1.1.0", diff --git a/src/routes/powerpointGenerator/powerpointGeneratorModel.ts b/src/routes/powerpointGenerator/powerpointGeneratorModel.ts new file mode 100644 index 0000000..41830d9 --- /dev/null +++ b/src/routes/powerpointGenerator/powerpointGeneratorModel.ts @@ -0,0 +1,9 @@ +import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; +import { z } from 'zod'; + +extendZodWithOpenApi(z); + +export type PowerPointGeneratorResponse = z.infer; +export const PowerpointGeneratorResponseSchema = z.object({ + filepath: z.string(), +}); diff --git a/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts b/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts new file mode 100644 index 0000000..7170dec --- /dev/null +++ b/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts @@ -0,0 +1,403 @@ +import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; +import express, { Request, Response, Router } from 'express'; +import fs from 'fs'; +import { StatusCodes } from 'http-status-codes'; +import path from 'path'; +import pptxgen from 'pptxgenjs'; + +import { createApiResponse } from '@/api-docs/openAPIResponseBuilders'; +import { ResponseStatus, ServiceResponse } from '@/common/models/serviceResponse'; +import { handleServiceResponse } from '@/common/utils/httpHandlers'; + +import { PowerpointGeneratorResponseSchema } from './powerpointGeneratorModel'; +export const CUST_NAME = 'S.T.A.R. Laboratories'; +export const COMPRESS = true; + +export const powerpointGeneratorRegistry = new OpenAPIRegistry(); +powerpointGeneratorRegistry.register('PowerpointGenerator', PowerpointGeneratorResponseSchema); +powerpointGeneratorRegistry.registerPath({ + method: 'post', + path: '/generate', + tags: ['Generate Powerpoint Presentation'], + responses: createApiResponse(PowerpointGeneratorResponseSchema, 'Success'), +}); + +// Helper function to detect number, percent, or currency +function detectType(value: string) { + // Regular expression patterns + const numberPattern = /^[+-]?\d+(\.\d+)?$/; // Matches general numbers + const percentPattern = /^[+-]?\d+(\.\d+)?%$/; // Matches percentages + const currencyPattern = /^[€$]\d+(\.\d+)?$/; // Matches currency values (e.g., $, €) + + if (currencyPattern.test(value)) { + return 'currency'; + } else if (percentPattern.test(value)) { + return 'percent'; + } else if (numberPattern.test(value)) { + return 'number'; + } else { + return 'text'; // Default to text if no match + } +} + +// Helper function to get alignment based on detected type +function getAlignment(type: string) { + switch (type) { + case 'number': + case 'percent': + case 'currency': + return 'center'; + default: + return 'left'; + } +} + +function defineMasterSlides(pptx: any) { + // Define the TITLE_SLIDE MasterSlide with vertically aligned header and subheader + pptx.defineSlideMaster({ + title: 'TITLE_SLIDE', + objects: [ + { + // Header (Section Title) + placeholder: { + options: { + name: 'header', + type: 'title', + x: '10%', // 10% from the left side of the slide for responsiveness + y: '20%', // Positioned 20% from the top of the slide + w: '80%', // Width adjusted to 80% of the slide width + h: 0.75, // Fixed height for the header + // color: "363636", // Dark gray color for the title + align: 'center', // Center-align the text horizontally + valign: 'middle', // Vertically align the text to the middle + margin: 0, + fontSize: 52, + }, + text: '(title placeholer)', // Placeholder text for the title + }, + }, + { + // Subheader (Subsection Title) + placeholder: { + options: { + name: 'subheader', + type: 'body', + x: '10%', // 10% from the left side of the slide for responsiveness + y: '35%', // Positioned 30% from the top, below the header + w: '80%', // Width adjusted to 80% of the slide width + h: 0.5, // Fixed height for the subheader + // color: "6C6C6C", // Light gray color for the subheader + align: 'center', // Center-align the text horizontally + valign: 'middle', // Vertically align the text to the middle + margin: 0, + }, + text: '(subtitle placeholder)', // Placeholder text for the subheader + }, + }, + ], + }); + + pptx.defineSlideMaster({ + title: 'MASTER_SLIDE', + background: { color: 'E1E1E1', transparency: 50 }, + margin: [0.5, 0.25, 1.0, 0.25], // top, left, bottom, right + slideNumber: { + x: 0.0, + y: 6.9, + h: 0.6, + color: 'FFFFFF', + fontSize: 10, + align: 'center', // Center text horizontally + valign: 'middle', // Center text vertically + bold: true, + }, + objects: [ + // Footer background + { rect: { x: 0.0, y: 6.9, w: '100%', h: 0.6, fill: { color: '003b75' } } }, + // Footer Section + { + placeholder: { + options: { + name: 'footer', + type: 'body', + x: 0.0, + y: 6.9, + w: '100%', // Extend across the full width of the slide + h: 0.6, // Match the height of the footer background + align: 'center', // Center text horizontally + valign: 'middle', // Center text vertically + color: 'FFFFFF', // White text for contrast + fontSize: 12, // Suitable size for footer text + }, + text: '(footer text placeholder)', // Default footer text + }, + }, + // Header (Title) + { + placeholder: { + options: { + name: 'header', + type: 'title', + x: '10%', + y: '5%', + w: '80%', + h: 1.0, + margin: 0.2, + align: 'center', + valign: 'middle', + // color: "404040", + // fontSize: 24, // Dynamically chosen for visibility + }, + text: '(slide title placeholder)', // Default placeholder for title + }, + }, + // Content (Body) + { + placeholder: { + options: { + name: 'body', + type: 'body', + x: '10%', + y: '20%', + w: '80%', + h: '60%', // Responsive height + fontSize: 24, // Suitable for body text + }, + text: '(supports custom placeholder text!)', + }, + }, + ], + }); +} + +async function execGenSlidesFuncs(slides: any[]) { + // STEP 1: Instantiate new PptxGenJS object + const pptx = new pptxgen(); + + // STEP 2: Set Presentation props (as QA test only - these are not required) + // pptx.title = "PptxGenJS Test Suite Presentation"; + // pptx.subject = "PptxGenJS Test Suite Export"; + // pptx.author = "TypingMind Custom"; + // pptx.company = CUST_NAME; + // pptx.revision = "15"; + // pptx.theme = { headFontFace: "Arial Light" }; + // pptx.theme = { bodyFontFace: "Arial" }; + + // STEP 3: Set layout + // LAYOUT_16x9 Yes 10 x 5.625 inches + // LAYOUT_16x10 No 10 x 6.25 inches + // LAYOUT_4x3 No 10 x 7.5 inches + // LAYOUT_WIDE No 13.3 x 7.5 inches + pptx.layout = 'LAYOUT_WIDE'; + + // STEP 4: Create Master Slides (from the old `pptxgen.masters.js` file - `gObjPptxMasters` items) + defineMasterSlides(pptx); + + // STEP 5: Run requested test + slides.forEach((slideData, index) => { + const { type, title, subtitle, chartContent, content = [] } = slideData; + if (!type || !title) { + throw new Error(`Slide ${index + 1} is missing required properties: type or title.`); + } + + if (type === 'title_slide') { + // Add a title slide + const slide = pptx.addSlide({ masterName: 'TITLE_SLIDE' }); + slide.addText(title, { placeholder: 'header' }); + if (subtitle) { + slide.addText(subtitle, { placeholder: 'subheader' }); + } + } else if (type === 'content_slide') { + // Add a content slide + const slide = pptx.addSlide({ masterName: 'MASTER_SLIDE' }); + slide.addText(title, { placeholder: 'header' }); + + // Add content based on contentType + if (content.length === 1) { + slide.addText(content[0], { placeholder: 'body' }); + } else if (content.length > 1) { + const bullets = content.map((item: string) => ({ + text: item, + options: { bullet: true, valign: 'top' }, + })); + + slide.addText(bullets, { placeholder: 'body', valign: 'top' }); + } else { + throw new Error(`Invalid content length on slide ${index + 1}`); + } + } else if (type === 'table_slide') { + // Table Slide + const slide = pptx.addSlide({ masterName: 'MASTER_SLIDE' }); + slide.addText(title, { placeholder: 'header' }); + + // Map content to tableData with alternating row colors and alignment + const tableHeaders = content[0]; + const tableData = [ + tableHeaders.map((header: string) => ({ + text: header, + options: { + bold: true, + color: 'FFFFFF', + fill: '003b75', // Dark blue background for headers + align: 'center', + valign: 'middle', + }, + })), + ...content.slice(1).map((row: any[], rowIndex: number) => + row.map((cell) => { + const cellType = detectType(cell); + const align = getAlignment(cellType); + + return { + text: cell, + options: { + fill: rowIndex % 2 === 0 ? 'E8F1FA' : 'DDEBF7', // Alternating row colors + align, + valign: 'middle', + color: '000000', // Black text + bold: rowIndex === 0, // Bold headers + }, + }; + }) + ), + ]; + + slide.addTable(tableData, { + x: '10%', // Position aligned with placeholder + y: '20%', + w: '80%', // Table width + h: '60%', // Table height + border: { + pt: 1, // Border thickness + color: '000000', // Black border + }, + fontSize: 14, // Font size for table text + placeholder: 'body', + } as any); + } else if (type === 'chart_slide' && chartContent) { + // Add a slide with the custom master slide + const slide = pptx.addSlide({ masterName: 'MASTER_SLIDE' }); + + // Set the slide title + slide.addText(title, { + placeholder: 'header', + }); + + // Handle chart content based on chart type + const { data: chartData, type: chartType } = chartContent; + // Default to line chart if no type is provided + if (chartType === 'pie') { + slide.addChart(pptx.ChartType.pie, chartData, { + x: '10%', // Position aligned with placeholder + y: '20%', + w: '80%', // Table width + h: '60%', // Table height + showLegend: true, + showCategoryAxis: true, + showValueAxis: true, + showPercent: true, + dataLabelPosition: 'outside', + placeholder: 'body', + } as any); + } else if (chartType === 'line') { + slide.addChart(pptx.ChartType.line, chartData, { + x: '10%', // Position aligned with placeholder + y: '20%', + w: '80%', // Table width + h: '60%', // Table height + showLegend: true, + showCategoryAxis: true, + showValueAxis: true, + dataLabelPosition: 'outside', + placeholder: 'body', + } as any); + } else if (chartType === 'bar') { + slide.addChart(pptx.ChartType.bar, chartData, { + x: '10%', // Position aligned with placeholder + y: '20%', + w: '80%', // Table width + h: '60%', // Table height + showLegend: true, + showCategoryAxis: true, + showValueAxis: true, + placeholder: 'body', + } as any); + } else if (chartType === 'doughnut') { + slide.addChart(pptx.ChartType.doughnut, chartData, { + x: '10%', // Position aligned with placeholder + y: '20%', + w: '80%', // Table width + h: '60%', // Table height + showPercent: true, + showLegend: true, + placeholder: 'body', + } as any); + } else { + throw new Error(`Invalid chart type: ${chartType}`); + } + } else { + throw new Error(`Invalid slide type: ${type}`); + } + }); + + // LAST: Export Presentation + // Ensure the exports directory exists + const exportsDir = path.join(__dirname, 'powerpoint-exports'); + if (!fs.existsSync(exportsDir)) { + fs.mkdirSync(exportsDir); + } + + const fileName = `your-presentation-${new Date().toISOString().replace(/\D/gi, '')}`; + const filePath = path.join(__dirname, 'powerpoint-exports', fileName); + + const result = await pptx.writeFile({ + fileName: filePath, + compression: COMPRESS, + }); + + return result; +} + +export const powerpointGeneratorRouter: Router = (() => { + const router = express.Router(); + router.post('/generate', async (_req: Request, res: Response) => { + const { slides = [] } = _req.body; + if (!slides.length) { + const validateServiceResponse = new ServiceResponse( + ResponseStatus.Failed, + '[Validation Error] Presentation slides is required!', + 'Please make sure you have sent the slide content generated from TypingMind.', + StatusCodes.BAD_REQUEST + ); + return handleServiceResponse(validateServiceResponse, res); + } + + try { + const filePath = await execGenSlidesFuncs(slides); + console.log('file path -> ', filePath); + const serviceResponse = new ServiceResponse( + ResponseStatus.Success, + 'File generated successfully', + { + filepath: filePath, + }, + 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 powerpoint file.`; + } + const errorServiceResponse = new ServiceResponse( + ResponseStatus.Failed, + `Error ${errorMessage}`, + responseObject, + StatusCodes.INTERNAL_SERVER_ERROR + ); + return handleServiceResponse(errorServiceResponse, res); + } + }); + return router; +})(); diff --git a/src/server.ts b/src/server.ts index 806cef3..262a117 100644 --- a/src/server.ts +++ b/src/server.ts @@ -11,6 +11,7 @@ import requestLogger from '@/common/middleware/requestLogger'; import { healthCheckRouter } from '@/routes/healthCheck/healthCheckRouter'; import { articleReaderRouter } from './routes/articleReader/articleReaderRouter'; +import { powerpointGeneratorRouter } from './routes/powerpointGenerator/powerpointGeneratorRouter'; import { transcriptRouter } from './routes/youtubeTranscript/transcriptRouter'; const logger = pino({ name: 'server start' }); const app: Express = express(); @@ -36,6 +37,7 @@ app.use('/health-check', healthCheckRouter); app.use('/images', express.static('public/images')); app.use('/transcript', transcriptRouter); app.use('/get-content', articleReaderRouter); +app.use('/powerpoint-generator', powerpointGeneratorRouter); // Swagger UI app.use(openAPIRouter); From b41e56d8413627dcaf4bbedb67ad913e71a1423f Mon Sep 17 00:00:00 2001 From: dat-devuap Date: Sun, 15 Dec 2024 07:42:09 +0700 Subject: [PATCH 02/40] feat(powerpoint generator): add static route for downloading files --- .../powerpointGeneratorRouter.ts | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts b/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts index 7170dec..eae3787 100644 --- a/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts +++ b/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts @@ -22,6 +22,13 @@ powerpointGeneratorRegistry.registerPath({ responses: createApiResponse(PowerpointGeneratorResponseSchema, 'Success'), }); +// Create folder to contains generated files +const exportsDir = path.join(__dirname, '../../..', 'powerpoint-exports'); +// Ensure the exports directory exists +if (!fs.existsSync(exportsDir)) { + fs.mkdirSync(exportsDir, { recursive: true }); +} + // Helper function to detect number, percent, or currency function detectType(value: string) { // Regular expression patterns @@ -340,26 +347,22 @@ async function execGenSlidesFuncs(slides: any[]) { } }); - // LAST: Export Presentation - // Ensure the exports directory exists - const exportsDir = path.join(__dirname, 'powerpoint-exports'); - if (!fs.existsSync(exportsDir)) { - fs.mkdirSync(exportsDir); - } - const fileName = `your-presentation-${new Date().toISOString().replace(/\D/gi, '')}`; - const filePath = path.join(__dirname, 'powerpoint-exports', fileName); + const filePath = path.join(exportsDir, fileName); - const result = await pptx.writeFile({ + await pptx.writeFile({ fileName: filePath, compression: COMPRESS, }); - return result; + return fileName + '.pptx'; } export const powerpointGeneratorRouter: 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 { slides = [] } = _req.body; if (!slides.length) { @@ -373,13 +376,12 @@ export const powerpointGeneratorRouter: Router = (() => { } try { - const filePath = await execGenSlidesFuncs(slides); - console.log('file path -> ', filePath); + const fileName = await execGenSlidesFuncs(slides); const serviceResponse = new ServiceResponse( ResponseStatus.Success, 'File generated successfully', { - filepath: filePath, + downloadUrl: `/downloads/${fileName}`, }, StatusCodes.OK ); From e35d105d084671334e800c48d54f9b608d11c107 Mon Sep 17 00:00:00 2001 From: dat-devuap Date: Sun, 15 Dec 2024 07:49:13 +0700 Subject: [PATCH 03/40] feat(powerpoint generator): update download url --- src/routes/powerpointGenerator/powerpointGeneratorRouter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts b/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts index eae3787..4b2fd43 100644 --- a/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts +++ b/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts @@ -381,7 +381,7 @@ export const powerpointGeneratorRouter: Router = (() => { ResponseStatus.Success, 'File generated successfully', { - downloadUrl: `/downloads/${fileName}`, + downloadUrl: `/powerpoint-generator/downloads/${fileName}`, }, StatusCodes.OK ); From 71b4e0204f584405b13522f2c0c08e36f21766e2 Mon Sep 17 00:00:00 2001 From: dat-devuap Date: Sun, 15 Dec 2024 11:02:27 +0700 Subject: [PATCH 04/40] feat(powerpoint generator): add fully download url in the response --- src/routes/powerpointGenerator/powerpointGeneratorRouter.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts b/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts index 4b2fd43..22584cd 100644 --- a/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts +++ b/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts @@ -12,7 +12,6 @@ import { handleServiceResponse } from '@/common/utils/httpHandlers'; import { PowerpointGeneratorResponseSchema } from './powerpointGeneratorModel'; export const CUST_NAME = 'S.T.A.R. Laboratories'; export const COMPRESS = true; - export const powerpointGeneratorRegistry = new OpenAPIRegistry(); powerpointGeneratorRegistry.register('PowerpointGenerator', PowerpointGeneratorResponseSchema); powerpointGeneratorRegistry.registerPath({ @@ -29,6 +28,8 @@ if (!fs.existsSync(exportsDir)) { fs.mkdirSync(exportsDir, { recursive: true }); } +const serverUrl = process.env.RENDER_EXTERNAL_URL || 'http://localhost:3000'; + // Helper function to detect number, percent, or currency function detectType(value: string) { // Regular expression patterns @@ -381,7 +382,7 @@ export const powerpointGeneratorRouter: Router = (() => { ResponseStatus.Success, 'File generated successfully', { - downloadUrl: `/powerpoint-generator/downloads/${fileName}`, + downloadUrl: `${serverUrl}/powerpoint-generator/downloads/${fileName}`, }, StatusCodes.OK ); From 64b2ddabc78bfc9be500248bf3e20bdfec6745d9 Mon Sep 17 00:00:00 2001 From: dat-devuap Date: Sun, 15 Dec 2024 11:11:03 +0700 Subject: [PATCH 05/40] feat(powerpoint generator): small master layout updates --- src/routes/powerpointGenerator/powerpointGeneratorRouter.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts b/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts index 22584cd..6e4dbf9 100644 --- a/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts +++ b/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts @@ -93,7 +93,7 @@ function defineMasterSlides(pptx: any) { x: '10%', // 10% from the left side of the slide for responsiveness y: '35%', // Positioned 30% from the top, below the header w: '80%', // Width adjusted to 80% of the slide width - h: 0.5, // Fixed height for the subheader + h: 1.25, // Fixed height for the subheader // color: "6C6C6C", // Light gray color for the subheader align: 'center', // Center-align the text horizontally valign: 'middle', // Vertically align the text to the middle @@ -263,7 +263,6 @@ async function execGenSlidesFuncs(slides: any[]) { align, valign: 'middle', color: '000000', // Black text - bold: rowIndex === 0, // Bold headers }, }; }) From 47ee0acff4344ffbcdac2808d35b3587fca7f2ac Mon Sep 17 00:00:00 2001 From: dat-devuap Date: Sun, 15 Dec 2024 13:41:37 +0700 Subject: [PATCH 06/40] feat(powerpoint generator): add cron job to remove expired files every hours --- package-lock.json | 19 ++++++++++ package.json | 2 ++ .../powerpointGeneratorRouter.ts | 35 +++++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/package-lock.json b/package-lock.json index f04288e..ab6ffa6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "helmet": "^7.1.0", "http-status-codes": "^2.3.0", "jsdom": "^24.0.0", + "node-cron": "^3.0.3", "path": "^0.12.7", "pino-http": "^9.0.0", "pptxgenjs": "^3.12.0", @@ -39,6 +40,7 @@ "@release-it/conventional-changelog": "^8.0.1", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", + "@types/node-cron": "^3.0.11", "@types/supertest": "^6.0.2", "@types/swagger-ui-express": "^4.1.6", "@typescript-eslint/eslint-plugin": "^7.13.1", @@ -1821,6 +1823,12 @@ "undici-types": "~6.20.0" } }, + "node_modules/@types/node-cron": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz", + "integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==", + "dev": true + }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", @@ -7266,6 +7274,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/node-cron": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", + "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", + "dependencies": { + "uuid": "8.3.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-package-data": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", diff --git a/package.json b/package.json index 8ded5d4..801c723 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "helmet": "^7.1.0", "http-status-codes": "^2.3.0", "jsdom": "^24.0.0", + "node-cron": "^3.0.3", "path": "^0.12.7", "pino-http": "^9.0.0", "pptxgenjs": "^3.12.0", @@ -48,6 +49,7 @@ "@release-it/conventional-changelog": "^8.0.1", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", + "@types/node-cron": "^3.0.11", "@types/supertest": "^6.0.2", "@types/swagger-ui-express": "^4.1.6", "@typescript-eslint/eslint-plugin": "^7.13.1", diff --git a/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts b/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts index 6e4dbf9..64e5f7f 100644 --- a/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts +++ b/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts @@ -2,6 +2,7 @@ import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; 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 pptxgen from 'pptxgenjs'; @@ -28,6 +29,40 @@ 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'; // Helper function to detect number, percent, or currency From bc689c6579f8c7ff798ce8727c1107b205c9062b Mon Sep 17 00:00:00 2001 From: dat-devuap Date: Sun, 15 Dec 2024 21:19:31 +0700 Subject: [PATCH 07/40] feat(powerpoint generator): add slide config settings --- .../powerpointGeneratorRouter.ts | 176 +++++++++++------- 1 file changed, 105 insertions(+), 71 deletions(-) diff --git a/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts b/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts index 64e5f7f..09d33e8 100644 --- a/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts +++ b/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts @@ -11,7 +11,6 @@ import { ResponseStatus, ServiceResponse } from '@/common/models/serviceResponse import { handleServiceResponse } from '@/common/utils/httpHandlers'; import { PowerpointGeneratorResponseSchema } from './powerpointGeneratorModel'; -export const CUST_NAME = 'S.T.A.R. Laboratories'; export const COMPRESS = true; export const powerpointGeneratorRegistry = new OpenAPIRegistry(); powerpointGeneratorRegistry.register('PowerpointGenerator', PowerpointGeneratorResponseSchema); @@ -65,6 +64,30 @@ cron.schedule('0 * * * *', () => { const serverUrl = process.env.RENDER_EXTERNAL_URL || 'http://localhost:3000'; +// Define configurable options for layout, font size, and font family +const defaultSlideConfig = { + layout: 'LAYOUT_WIDE', // Default: LAYOUT_WIDE, enum: LAYOUT_16x9 10 x 5.625 inches, LAYOUT_16x10 10 x 6.25 inches, LAYOUT_16x10 10 x 6.25 inches, LAYOUT_4x3 10 x 7.5 inches + titleFontSize: 52, // Emphasize the main topic in Title Slide + headerFontSize: 32, // The slide headers in the Content Slide + bodyFontSize: 24, // The main text font size + fontFamily: 'Calibri', // Default font family for the slide, Calibri, Arial + backgroundColor: '#FFFFFF', // Default background color + textColor: '#000000', // Text color + showFooter: true, // Display footer or not + showSlideNumber: true, // Display slide number or not + footerBackgroundColor: '#003B75', // Default background color + footerText: 'footer text', // Footer text content. + footerTextColor: '#FFFFFF', // Default footer color + footerFontSize: 10, // Default footer font size + showTableBorder: true, // Show table border or not + tableHeaderBackgroundColor: '#003B75', // Background of table header, // Dark blue background for headers + tableHeaderTextColor: '#FFFFFF', // Table header color + tableBorderThickness: 1, // pt: 1, // Border thickness + tableBorderColor: '#000000', // Black border + tableFontSize: 14, // Font size inside the table + tableTextColor: '#000000', // Text color inside the table +}; + // Helper function to detect number, percent, or currency function detectType(value: string) { // Regular expression patterns @@ -95,11 +118,54 @@ function getAlignment(type: string) { } } -function defineMasterSlides(pptx: any) { +function defineMasterSlides(pptx: any, config: any) { + const slideNumberConfig = + config.showFooter && config.showSlideNumber + ? { + x: 0.0, + y: 6.9, + h: 0.6, + align: 'center', // Center text horizontally + valign: 'middle', // Center text vertically + fontSize: config.footerFontSize, + fontFace: config.fontFamily, + color: config.footerTextColor, + bold: true, + } + : undefined; + + const footerConfigs = config.showFooter + ? [ + // Footer background + { rect: { x: 0.0, y: 6.9, w: '100%', h: 0.6, fill: { color: config.footerBackgroundColor } } }, + // Footer Section + { + placeholder: { + options: { + name: 'footer', + type: 'body', + x: 0.0, + y: 6.9, + w: '100%', // Extend across the full width of the slide + h: 0.6, // Match the height of the footer background + align: 'center', // Center text horizontally + valign: 'middle', // Center text vertically + color: config.footerTextColor, // White text for contrast + fontSize: config.footerFontSize, // Suitable size for footer text + fontFace: config.fontFamily, // Set font face + }, + text: config.footerText, // Default footer text + }, + }, + ] + : []; + // Define the TITLE_SLIDE MasterSlide with vertically aligned header and subheader pptx.defineSlideMaster({ title: 'TITLE_SLIDE', + slideNumber: slideNumberConfig, objects: [ + ...footerConfigs, { // Header (Section Title) placeholder: { @@ -110,11 +176,12 @@ function defineMasterSlides(pptx: any) { y: '20%', // Positioned 20% from the top of the slide w: '80%', // Width adjusted to 80% of the slide width h: 0.75, // Fixed height for the header - // color: "363636", // Dark gray color for the title align: 'center', // Center-align the text horizontally valign: 'middle', // Vertically align the text to the middle margin: 0, - fontSize: 52, + fontSize: config.titleFontSize, + fontFace: config.fontFamily, // Set font face + color: config.textColor, }, text: '(title placeholer)', // Placeholder text for the title }, @@ -129,10 +196,12 @@ function defineMasterSlides(pptx: any) { y: '35%', // Positioned 30% from the top, below the header w: '80%', // Width adjusted to 80% of the slide width h: 1.25, // Fixed height for the subheader - // color: "6C6C6C", // Light gray color for the subheader align: 'center', // Center-align the text horizontally valign: 'middle', // Vertically align the text to the middle margin: 0, + fontSize: config.headerFontSize, + fontFace: config.fontFamily, // Set font face + color: config.textColor, }, text: '(subtitle placeholder)', // Placeholder text for the subheader }, @@ -142,39 +211,13 @@ function defineMasterSlides(pptx: any) { pptx.defineSlideMaster({ title: 'MASTER_SLIDE', - background: { color: 'E1E1E1', transparency: 50 }, - margin: [0.5, 0.25, 1.0, 0.25], // top, left, bottom, right - slideNumber: { - x: 0.0, - y: 6.9, - h: 0.6, - color: 'FFFFFF', - fontSize: 10, - align: 'center', // Center text horizontally - valign: 'middle', // Center text vertically - bold: true, + background: { + color: config.backgroundColor, }, + margin: [0.5, 0.25, 1.0, 0.25], // top, left, bottom, right + slideNumber: slideNumberConfig, objects: [ - // Footer background - { rect: { x: 0.0, y: 6.9, w: '100%', h: 0.6, fill: { color: '003b75' } } }, - // Footer Section - { - placeholder: { - options: { - name: 'footer', - type: 'body', - x: 0.0, - y: 6.9, - w: '100%', // Extend across the full width of the slide - h: 0.6, // Match the height of the footer background - align: 'center', // Center text horizontally - valign: 'middle', // Center text vertically - color: 'FFFFFF', // White text for contrast - fontSize: 12, // Suitable size for footer text - }, - text: '(footer text placeholder)', // Default footer text - }, - }, + ...footerConfigs, // Header (Title) { placeholder: { @@ -188,8 +231,9 @@ function defineMasterSlides(pptx: any) { margin: 0.2, align: 'center', valign: 'middle', - // color: "404040", - // fontSize: 24, // Dynamically chosen for visibility + color: config.textColor, + fontSize: config.headerFontSize, // Dynamically chosen for visibility + fontFace: config.fontFamily, // Set font face }, text: '(slide title placeholder)', // Default placeholder for title }, @@ -204,7 +248,9 @@ function defineMasterSlides(pptx: any) { y: '20%', w: '80%', h: '60%', // Responsive height - fontSize: 24, // Suitable for body text + color: config.textColor, + fontSize: config.bodyFontSize, // Suitable for body text + fontFace: config.fontFamily, // Set font face }, text: '(supports custom placeholder text!)', }, @@ -213,30 +259,17 @@ function defineMasterSlides(pptx: any) { }); } -async function execGenSlidesFuncs(slides: any[]) { +async function execGenSlidesFuncs(slides: any[], config: any) { // STEP 1: Instantiate new PptxGenJS object const pptx = new pptxgen(); - // STEP 2: Set Presentation props (as QA test only - these are not required) - // pptx.title = "PptxGenJS Test Suite Presentation"; - // pptx.subject = "PptxGenJS Test Suite Export"; - // pptx.author = "TypingMind Custom"; - // pptx.company = CUST_NAME; - // pptx.revision = "15"; - // pptx.theme = { headFontFace: "Arial Light" }; - // pptx.theme = { bodyFontFace: "Arial" }; - - // STEP 3: Set layout - // LAYOUT_16x9 Yes 10 x 5.625 inches - // LAYOUT_16x10 No 10 x 6.25 inches - // LAYOUT_4x3 No 10 x 7.5 inches - // LAYOUT_WIDE No 13.3 x 7.5 inches - pptx.layout = 'LAYOUT_WIDE'; - - // STEP 4: Create Master Slides (from the old `pptxgen.masters.js` file - `gObjPptxMasters` items) - defineMasterSlides(pptx); - - // STEP 5: Run requested test + // STEP 2: Set layout + pptx.layout = config.layout; + + // STEP 3: Create Master Slides (from the old `pptxgen.masters.js` file - `gObjPptxMasters` items) + defineMasterSlides(pptx, config); + + // STEP 4: Run requested test slides.forEach((slideData, index) => { const { type, title, subtitle, chartContent, content = [] } = slideData; if (!type || !title) { @@ -259,7 +292,7 @@ async function execGenSlidesFuncs(slides: any[]) { if (content.length === 1) { slide.addText(content[0], { placeholder: 'body' }); } else if (content.length > 1) { - const bullets = content.map((item: string) => ({ + const bullets = content.map((item: any) => ({ text: item, options: { bullet: true, valign: 'top' }, })); @@ -276,18 +309,18 @@ async function execGenSlidesFuncs(slides: any[]) { // Map content to tableData with alternating row colors and alignment const tableHeaders = content[0]; const tableData = [ - tableHeaders.map((header: string) => ({ + tableHeaders.map((header: any) => ({ text: header, options: { bold: true, - color: 'FFFFFF', - fill: '003b75', // Dark blue background for headers + color: config.tableHeaderTextColor, + fill: config.tableHeaderBackgroundColor, align: 'center', valign: 'middle', }, })), - ...content.slice(1).map((row: any[], rowIndex: number) => - row.map((cell) => { + ...content.slice(1).map((row: any, rowIndex: number) => + row.map((cell: any) => { const cellType = detectType(cell); const align = getAlignment(cellType); @@ -297,7 +330,7 @@ async function execGenSlidesFuncs(slides: any[]) { fill: rowIndex % 2 === 0 ? 'E8F1FA' : 'DDEBF7', // Alternating row colors align, valign: 'middle', - color: '000000', // Black text + color: config.tableTextColor, // Black text }, }; }) @@ -377,8 +410,6 @@ async function execGenSlidesFuncs(slides: any[]) { } else { throw new Error(`Invalid chart type: ${chartType}`); } - } else { - throw new Error(`Invalid slide type: ${type}`); } }); @@ -399,7 +430,7 @@ export const powerpointGeneratorRouter: Router = (() => { router.use('/downloads', express.static(exportsDir)); router.post('/generate', async (_req: Request, res: Response) => { - const { slides = [] } = _req.body; + const { slides = [], slideConfig = {} } = _req.body; if (!slides.length) { const validateServiceResponse = new ServiceResponse( ResponseStatus.Failed, @@ -411,7 +442,10 @@ export const powerpointGeneratorRouter: Router = (() => { } try { - const fileName = await execGenSlidesFuncs(slides); + const fileName = await execGenSlidesFuncs(slides, { + ...defaultSlideConfig, + ...slideConfig, + }); const serviceResponse = new ServiceResponse( ResponseStatus.Success, 'File generated successfully', From 2fe10740bfb0947bec7fe880918c5ca83f195207 Mon Sep 17 00:00:00 2001 From: dat-devuap Date: Mon, 16 Dec 2024 07:56:17 +0700 Subject: [PATCH 08/40] feat(powerpoint generator): update mapping slide configs --- .../powerpointGeneratorRouter.ts | 46 +++++++++++++++++-- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts b/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts index 09d33e8..fa02fa1 100644 --- a/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts +++ b/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts @@ -73,8 +73,8 @@ const defaultSlideConfig = { fontFamily: 'Calibri', // Default font family for the slide, Calibri, Arial backgroundColor: '#FFFFFF', // Default background color textColor: '#000000', // Text color - showFooter: true, // Display footer or not - showSlideNumber: true, // Display slide number or not + showFooter: false, // Display footer or not + showSlideNumber: false, // Display slide number or not footerBackgroundColor: '#003B75', // Default background color footerText: 'footer text', // Footer text content. footerTextColor: '#FFFFFF', // Default footer color @@ -443,8 +443,46 @@ export const powerpointGeneratorRouter: Router = (() => { try { const fileName = await execGenSlidesFuncs(slides, { - ...defaultSlideConfig, - ...slideConfig, + layout: slideConfig.layout === '' ? defaultSlideConfig.layout : slideConfig.layout, // Default: LAYOUT_WIDE, enum: LAYOUT_16x9 10 x 5.625 inches, LAYOUT_16x10 10 x 6.25 inches, LAYOUT_4x3 10 x 7.5 inches + titleFontSize: slideConfig.titleFontSize === 0 ? defaultSlideConfig.titleFontSize : slideConfig.titleFontSize, // Default: 52, Emphasize the main topic in Title Slide + headerFontSize: + slideConfig.headerFontSize === 0 ? defaultSlideConfig.headerFontSize : slideConfig.headerFontSize, // Default: 32, The slide headers in the Content Slide + bodyFontSize: slideConfig.bodyFontSize === 0 ? defaultSlideConfig.bodyFontSize : slideConfig.bodyFontSize, // Default: 24, The main text font size + fontFamily: slideConfig.fontFamily === '' ? defaultSlideConfig.fontFamily : slideConfig.fontFamily, // Default: 'Calibri', Default font family for the slide, Calibri, Arial + backgroundColor: + slideConfig.backgroundColor === '' ? defaultSlideConfig.backgroundColor : slideConfig.backgroundColor, // Default: '#FFFFFF', Default background color + textColor: slideConfig.textColor === '' ? defaultSlideConfig.textColor : slideConfig.textColor, // Default: '#000000', Text color + showFooter: slideConfig.showFooter === 0 ? defaultSlideConfig.showFooter : slideConfig.showFooter, // Default: false, Display footer or not + showSlideNumber: + slideConfig.showSlideNumber === 0 ? defaultSlideConfig.showSlideNumber : slideConfig.showSlideNumber, // Default: false, Display slide number or not + footerBackgroundColor: + slideConfig.footerBackgroundColor === '' + ? defaultSlideConfig.footerBackgroundColor + : slideConfig.footerBackgroundColor, // Default: '#003B75', Default footer background color + footerText: slideConfig.footerText === '' ? defaultSlideConfig.footerText : slideConfig.footerText, // Default: 'footer text', Footer text content. + footerTextColor: + slideConfig.footerTextColor === '' ? defaultSlideConfig.footerTextColor : slideConfig.footerTextColor, // Default: '#FFFFFF', Default footer text color + footerFontSize: + slideConfig.footerFontSize === 0 ? defaultSlideConfig.footerFontSize : slideConfig.footerFontSize, // Default: 10, Default footer font size + showTableBorder: + slideConfig.showTableBorder === 0 ? defaultSlideConfig.showTableBorder : slideConfig.showTableBorder, // Default: true, Show table border or not + tableHeaderBackgroundColor: + slideConfig.tableHeaderBackgroundColor === '' + ? defaultSlideConfig.tableHeaderBackgroundColor + : slideConfig.tableHeaderBackgroundColor, // Default: '#003B75', Dark blue background for headers + tableHeaderTextColor: + slideConfig.tableHeaderTextColor === '' + ? defaultSlideConfig.tableHeaderTextColor + : slideConfig.tableHeaderTextColor, // Default: '#FFFFFF', Table header text color + tableBorderThickness: + slideConfig.tableBorderThickness === 0 + ? defaultSlideConfig.tableBorderThickness + : slideConfig.tableBorderThickness, // Default: 1 pt, Border thickness + tableBorderColor: + slideConfig.tableBorderColor === '' ? defaultSlideConfig.tableBorderColor : slideConfig.tableBorderColor, // Default: '#000000', Black border + tableFontSize: slideConfig.tableFontSize === 0 ? defaultSlideConfig.tableFontSize : slideConfig.tableFontSize, // Default: 14, Font size inside the table + tableTextColor: + slideConfig.tableTextColor === '' ? defaultSlideConfig.tableTextColor : slideConfig.tableTextColor, // Default: '#000000', Text color inside the table }); const serviceResponse = new ServiceResponse( ResponseStatus.Success, From 46c05dafa1ae80a4f4e7e8ea2144e5f64835206b Mon Sep 17 00:00:00 2001 From: dat-devuap Date: Mon, 16 Dec 2024 08:43:25 +0700 Subject: [PATCH 09/40] feat(powerpoint generator): update mapping footer configs --- .../powerpointGeneratorRouter.ts | 55 ++++++++++--------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts b/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts index fa02fa1..3bf4f2d 100644 --- a/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts +++ b/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts @@ -134,38 +134,37 @@ function defineMasterSlides(pptx: any, config: any) { } : undefined; - const footerConfigs = config.showFooter - ? [ - // Footer background - { rect: { x: 0.0, y: 6.9, w: '100%', h: 0.6, fill: { color: config.footerBackgroundColor } } }, - // Footer Section - { - placeholder: { - options: { - name: 'footer', - type: 'body', - x: 0.0, - y: 6.9, - w: '100%', // Extend across the full width of the slide - h: 0.6, // Match the height of the footer background - align: 'center', // Center text horizontally - valign: 'middle', // Center text vertically - color: config.footerTextColor, // White text for contrast - fontSize: config.footerFontSize, // Suitable size for footer text - fontFace: config.fontFamily, // Set font face - }, - text: config.footerText, // Default footer text - }, - }, - ] - : []; + // Init footer config objects + const footerBackgroundObject = { + rect: { x: 0.0, y: 6.9, w: '100%', h: 0.6, fill: { color: config.footerBackgroundColor } }, + }; + const footerTextObject = { + placeholder: { + options: { + name: 'footer', + ype: 'body', + x: 0.0, + y: 6.9, + w: '100%', // Extend across the full width of the slide + h: 0.6, // Match the height of the footer background + align: 'center', // Center text horizontally + valign: 'middle', // Center text vertically + color: config.footerTextColor, // White text for contrast + fontSize: config.footerFontSize, // Suitable size for footer text + fontFace: config.fontFamily, // Set font face + }, + text: config.footerText, // Default footer text + }, + }; // Define the TITLE_SLIDE MasterSlide with vertically aligned header and subheader pptx.defineSlideMaster({ title: 'TITLE_SLIDE', slideNumber: slideNumberConfig, objects: [ - ...footerConfigs, + // Footer background + config.showFooter ? footerBackgroundObject : undefined, + config.showFooter ? footerTextObject : undefined, { // Header (Section Title) placeholder: { @@ -217,7 +216,9 @@ function defineMasterSlides(pptx: any, config: any) { margin: [0.5, 0.25, 1.0, 0.25], // top, left, bottom, right slideNumber: slideNumberConfig, objects: [ - ...footerConfigs, + // Footer background + config.showFooter ? footerBackgroundObject : undefined, + config.showFooter ? footerTextObject : undefined, // Header (Title) { placeholder: { From c05ffc031a204b143cad03e5cdaa4843bed782df Mon Sep 17 00:00:00 2001 From: dat-devuap Date: Mon, 16 Dec 2024 09:04:12 +0700 Subject: [PATCH 10/40] feat(powerpoint generator): update mapping default for footer config --- .../powerpointGeneratorRouter.ts | 79 +++++++++++++------ 1 file changed, 53 insertions(+), 26 deletions(-) diff --git a/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts b/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts index 3bf4f2d..f560e75 100644 --- a/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts +++ b/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts @@ -135,36 +135,60 @@ function defineMasterSlides(pptx: any, config: any) { : undefined; // Init footer config objects - const footerBackgroundObject = { - rect: { x: 0.0, y: 6.9, w: '100%', h: 0.6, fill: { color: config.footerBackgroundColor } }, - }; - const footerTextObject = { - placeholder: { - options: { - name: 'footer', - ype: 'body', - x: 0.0, - y: 6.9, - w: '100%', // Extend across the full width of the slide - h: 0.6, // Match the height of the footer background - align: 'center', // Center text horizontally - valign: 'middle', // Center text vertically - color: config.footerTextColor, // White text for contrast - fontSize: config.footerFontSize, // Suitable size for footer text - fontFace: config.fontFamily, // Set font face + let footerBackgroundObject, footerTextObject; + if (config.showFooter) { + footerBackgroundObject = { + rect: { x: 0.0, y: 6.9, w: '100%', h: 0.6, fill: { color: config.footerBackgroundColor } }, + }; + + footerTextObject = { + placeholder: { + options: { + name: 'footer', + ype: 'body', + x: 0.0, + y: 6.9, + w: '100%', // Extend across the full width of the slide + h: 0.6, // Match the height of the footer background + align: 'center', // Center text horizontally + valign: 'middle', // Center text vertically + color: config.footerTextColor, // White text for contrast + fontSize: config.footerFontSize, // Suitable size for footer text + fontFace: config.fontFamily, // Set font face + }, + text: config.footerText, // Default footer text }, - text: config.footerText, // Default footer text - }, - }; + }; + } else { + footerBackgroundObject = { + rect: { x: 0.0, y: 6.9, w: '100%', h: 0.6, fill: { color: 'FFFFFF' } }, + }; + + footerTextObject = { + placeholder: { + options: { + name: 'footer', + ype: 'body', + x: 0.0, + y: 6.9, + w: '100%', // Extend across the full width of the slide + h: 0.6, // Match the height of the footer background + align: 'center', // Center text horizontally + valign: 'middle', // Center text vertically + color: config.footerTextColor, // White text for contrast + fontSize: config.footerFontSize, // Suitable size for footer text + fontFace: config.fontFamily, // Set font face + }, + text: '', // Default footer text + }, + }; + } // Define the TITLE_SLIDE MasterSlide with vertically aligned header and subheader pptx.defineSlideMaster({ title: 'TITLE_SLIDE', slideNumber: slideNumberConfig, objects: [ - // Footer background - config.showFooter ? footerBackgroundObject : undefined, - config.showFooter ? footerTextObject : undefined, { // Header (Section Title) placeholder: { @@ -205,6 +229,9 @@ function defineMasterSlides(pptx: any, config: any) { text: '(subtitle placeholder)', // Placeholder text for the subheader }, }, + // Footer + footerBackgroundObject, + footerTextObject, ], }); @@ -216,9 +243,6 @@ function defineMasterSlides(pptx: any, config: any) { margin: [0.5, 0.25, 1.0, 0.25], // top, left, bottom, right slideNumber: slideNumberConfig, objects: [ - // Footer background - config.showFooter ? footerBackgroundObject : undefined, - config.showFooter ? footerTextObject : undefined, // Header (Title) { placeholder: { @@ -256,6 +280,9 @@ function defineMasterSlides(pptx: any, config: any) { text: '(supports custom placeholder text!)', }, }, + // Footer + footerBackgroundObject, + footerTextObject, ], }); } From 758971da8c764c46f331304c7ec32b6a98023b96 Mon Sep 17 00:00:00 2001 From: dat-devuap Date: Mon, 16 Dec 2024 23:21:26 +0700 Subject: [PATCH 11/40] feat(powerpoint generator): update mapping slide config attributes --- .../powerpointGeneratorRouter.ts | 132 +++++++++--------- 1 file changed, 64 insertions(+), 68 deletions(-) diff --git a/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts b/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts index f560e75..7579b0a 100644 --- a/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts +++ b/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts @@ -67,9 +67,9 @@ const serverUrl = process.env.RENDER_EXTERNAL_URL || 'http://localhost:3000'; // Define configurable options for layout, font size, and font family const defaultSlideConfig = { layout: 'LAYOUT_WIDE', // Default: LAYOUT_WIDE, enum: LAYOUT_16x9 10 x 5.625 inches, LAYOUT_16x10 10 x 6.25 inches, LAYOUT_16x10 10 x 6.25 inches, LAYOUT_4x3 10 x 7.5 inches - titleFontSize: 52, // Emphasize the main topic in Title Slide + titleFontSize: 44, // Emphasize the main topic in Title Slide headerFontSize: 32, // The slide headers in the Content Slide - bodyFontSize: 24, // The main text font size + bodyFontSize: 22, // The main text font size fontFamily: 'Calibri', // Default font family for the slide, Calibri, Arial backgroundColor: '#FFFFFF', // Default background color textColor: '#000000', // Text color @@ -134,56 +134,6 @@ function defineMasterSlides(pptx: any, config: any) { } : undefined; - // Init footer config objects - let footerBackgroundObject, footerTextObject; - if (config.showFooter) { - footerBackgroundObject = { - rect: { x: 0.0, y: 6.9, w: '100%', h: 0.6, fill: { color: config.footerBackgroundColor } }, - }; - - footerTextObject = { - placeholder: { - options: { - name: 'footer', - ype: 'body', - x: 0.0, - y: 6.9, - w: '100%', // Extend across the full width of the slide - h: 0.6, // Match the height of the footer background - align: 'center', // Center text horizontally - valign: 'middle', // Center text vertically - color: config.footerTextColor, // White text for contrast - fontSize: config.footerFontSize, // Suitable size for footer text - fontFace: config.fontFamily, // Set font face - }, - text: config.footerText, // Default footer text - }, - }; - } else { - footerBackgroundObject = { - rect: { x: 0.0, y: 6.9, w: '100%', h: 0.6, fill: { color: 'FFFFFF' } }, - }; - - footerTextObject = { - placeholder: { - options: { - name: 'footer', - ype: 'body', - x: 0.0, - y: 6.9, - w: '100%', // Extend across the full width of the slide - h: 0.6, // Match the height of the footer background - align: 'center', // Center text horizontally - valign: 'middle', // Center text vertically - color: config.footerTextColor, // White text for contrast - fontSize: config.footerFontSize, // Suitable size for footer text - fontFace: config.fontFamily, // Set font face - }, - text: '', // Default footer text - }, - }; - } - // Define the TITLE_SLIDE MasterSlide with vertically aligned header and subheader pptx.defineSlideMaster({ title: 'TITLE_SLIDE', @@ -229,9 +179,31 @@ function defineMasterSlides(pptx: any, config: any) { text: '(subtitle placeholder)', // Placeholder text for the subheader }, }, - // Footer - footerBackgroundObject, - footerTextObject, + // Footer background + config.showFooter + ? { rect: { x: 0.0, y: 6.9, w: '100%', h: 0.6, fill: { color: config.footerBackgroundColor } } } + : {}, + // Footer Text + config.showFooter + ? { + placeholder: { + options: { + name: 'footer', + type: 'body', + x: 0.0, + y: 6.9, + w: '100%', // Extend across the full width of the slide + h: 0.6, // Match the height of the footer background + align: 'center', // Center text horizontally + valign: 'middle', // Center text vertically + color: config.footerTextColor, // White text for contrast + fontSize: config.footerFontSize, // Suitable size for footer text + fontFace: config.fontFamily, // Set font face + }, + text: config.footerText, // Default footer text + }, + } + : {}, ], }); @@ -272,7 +244,7 @@ function defineMasterSlides(pptx: any, config: any) { x: '10%', y: '20%', w: '80%', - h: '60%', // Responsive height + h: config.showFooter ? '60%' : '70%', // Responsive height color: config.textColor, fontSize: config.bodyFontSize, // Suitable for body text fontFace: config.fontFamily, // Set font face @@ -280,9 +252,31 @@ function defineMasterSlides(pptx: any, config: any) { text: '(supports custom placeholder text!)', }, }, - // Footer - footerBackgroundObject, - footerTextObject, + // Footer background + config.showFooter + ? { rect: { x: 0.0, y: 6.9, w: '100%', h: 0.6, fill: { color: config.footerBackgroundColor } } } + : {}, + // Footer Text + config.showFooter + ? { + placeholder: { + options: { + name: 'footer', + type: 'body', + x: 0.0, + y: 6.9, + w: '100%', // Extend across the full width of the slide + h: 0.6, // Match the height of the footer background + align: 'center', // Center text horizontally + valign: 'middle', // Center text vertically + color: config.footerTextColor, // White text for contrast + fontSize: config.footerFontSize, // Suitable size for footer text + fontFace: config.fontFamily, // Set font face + }, + text: config.footerText, // Default footer text + }, + } + : {}, ], }); } @@ -365,15 +359,19 @@ async function execGenSlidesFuncs(slides: any[], config: any) { ), ]; + const tableBorderConfigs = config.showTableBorder + ? { + pt: config.tableBorderThickness, // Border thickness + color: config.tableBorderColor, // Black border + } + : undefined; + slide.addTable(tableData, { x: '10%', // Position aligned with placeholder y: '20%', w: '80%', // Table width h: '60%', // Table height - border: { - pt: 1, // Border thickness - color: '000000', // Black border - }, + border: tableBorderConfigs, fontSize: 14, // Font size for table text placeholder: 'body', } as any); @@ -480,9 +478,8 @@ export const powerpointGeneratorRouter: Router = (() => { backgroundColor: slideConfig.backgroundColor === '' ? defaultSlideConfig.backgroundColor : slideConfig.backgroundColor, // Default: '#FFFFFF', Default background color textColor: slideConfig.textColor === '' ? defaultSlideConfig.textColor : slideConfig.textColor, // Default: '#000000', Text color - showFooter: slideConfig.showFooter === 0 ? defaultSlideConfig.showFooter : slideConfig.showFooter, // Default: false, Display footer or not - showSlideNumber: - slideConfig.showSlideNumber === 0 ? defaultSlideConfig.showSlideNumber : slideConfig.showSlideNumber, // Default: false, Display slide number or not + showFooter: slideConfig.showFooter ?? defaultSlideConfig.showFooter, // Default: false, Display footer or not + showSlideNumber: slideConfig.showSlideNumber ?? defaultSlideConfig.showSlideNumber, // Default: false, Display slide number or not footerBackgroundColor: slideConfig.footerBackgroundColor === '' ? defaultSlideConfig.footerBackgroundColor @@ -492,8 +489,7 @@ export const powerpointGeneratorRouter: Router = (() => { slideConfig.footerTextColor === '' ? defaultSlideConfig.footerTextColor : slideConfig.footerTextColor, // Default: '#FFFFFF', Default footer text color footerFontSize: slideConfig.footerFontSize === 0 ? defaultSlideConfig.footerFontSize : slideConfig.footerFontSize, // Default: 10, Default footer font size - showTableBorder: - slideConfig.showTableBorder === 0 ? defaultSlideConfig.showTableBorder : slideConfig.showTableBorder, // Default: true, Show table border or not + showTableBorder: slideConfig.showTableBorder ?? defaultSlideConfig.showTableBorder, // Default: true, Show table border or not tableHeaderBackgroundColor: slideConfig.tableHeaderBackgroundColor === '' ? defaultSlideConfig.tableHeaderBackgroundColor From 572aaa2bd18ee98a0b7052a18aa2627a90d69cd5 Mon Sep 17 00:00:00 2001 From: dat-devuap Date: Tue, 31 Dec 2024 11:54:01 +0700 Subject: [PATCH 12/40] feat(word generator): add word generator functions --- package-lock.json | 69 +++ package.json | 1 + .../wordGenerator/wordGeneratorModel.ts | 9 + .../wordGenerator/wordGeneratorRouter.ts | 542 ++++++++++++++++++ src/server.ts | 2 + 5 files changed, 623 insertions(+) create mode 100644 src/routes/wordGenerator/wordGeneratorModel.ts create mode 100644 src/routes/wordGenerator/wordGeneratorRouter.ts diff --git a/package-lock.json b/package-lock.json index ab6ffa6..73af284 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "body-parser": "^1.20.2", "cheerio": "^1.0.0-rc.12", "cors": "^2.8.5", + "docx": "^9.1.0", "dotenv": "^16.4.5", "envalid": "^8.0.0", "express": "^4.19.2", @@ -3877,6 +3878,39 @@ "node": ">=6.0.0" } }, + "node_modules/docx": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/docx/-/docx-9.1.0.tgz", + "integrity": "sha512-XOtseSTRrkKN/sV5jNBqyLazyhNpWfaUhpuKc22cs+5DavNjRQvchnohb0g0S+x/96/D06U/i0/U/Gc4E5kwuQ==", + "dependencies": { + "@types/node": "^22.7.5", + "hash.js": "^1.1.7", + "jszip": "^3.10.1", + "nanoid": "^5.0.4", + "xml": "^1.0.1", + "xml-js": "^1.6.8" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/docx/node_modules/nanoid": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.9.tgz", + "integrity": "sha512-Aooyr6MXU6HpvvWXKoVoXwKMs/KyVakWwg7xQfv5/S/RIgJMy0Ifa45H9qqYy7pTCszrHzP21Uk4PZq2HpEM8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -5468,6 +5502,15 @@ "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==", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -7130,6 +7173,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -9027,6 +9075,11 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "node_modules/sax": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", + "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==" + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -11788,6 +11841,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", + "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==" + }, + "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==", + "dependencies": { + "sax": "^1.2.4" + }, + "bin": { + "xml-js": "bin/cli.js" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/package.json b/package.json index 801c723..9cde457 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "body-parser": "^1.20.2", "cheerio": "^1.0.0-rc.12", "cors": "^2.8.5", + "docx": "^9.1.0", "dotenv": "^16.4.5", "envalid": "^8.0.0", "express": "^4.19.2", diff --git a/src/routes/wordGenerator/wordGeneratorModel.ts b/src/routes/wordGenerator/wordGeneratorModel.ts new file mode 100644 index 0000000..58ea784 --- /dev/null +++ b/src/routes/wordGenerator/wordGeneratorModel.ts @@ -0,0 +1,9 @@ +import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; +import { z } from 'zod'; + +extendZodWithOpenApi(z); + +export type WordGeneratorResponse = z.infer; +export const WordGeneratorResponseSchema = z.object({ + filepath: z.string(), +}); diff --git a/src/routes/wordGenerator/wordGeneratorRouter.ts b/src/routes/wordGenerator/wordGeneratorRouter.ts new file mode 100644 index 0000000..8f4a81d --- /dev/null +++ b/src/routes/wordGenerator/wordGeneratorRouter.ts @@ -0,0 +1,542 @@ +import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; +import { + AlignmentType, + Document, + Footer, + FootnoteReferenceRun, + Header, + HeadingLevel, + LevelFormat, + Packer, + PageNumber, + PageOrientation, + Paragraph, + Table, + TableCell, + 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 { createApiResponse } from '@/api-docs/openAPIResponseBuilders'; +import { ResponseStatus, ServiceResponse } from '@/common/models/serviceResponse'; +import { handleServiceResponse } from '@/common/utils/httpHandlers'; + +import { WordGeneratorResponseSchema } from './wordGeneratorModel'; +export const COMPRESS = true; +export const wordGeneratorRegistry = new OpenAPIRegistry(); +wordGeneratorRegistry.register('WordGenerator', WordGeneratorResponseSchema); +wordGeneratorRegistry.registerPath({ + method: 'post', + path: '/generate', + tags: ['Generate Word file'], + 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 points + family: 'Arial', // Font family +}; + +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 HeadingLevel.HEADING_5; + } +}; + +// 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 = [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 { + 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, + }; + } + + const sectionContent = [ + // Section Heading with index + new Paragraph({ + children: [new TextRun(section.heading)], + heading: getHeadingLevel(section.headingLevel), + numbering: numberingConfig, + }), + ...sectionContents, + // Process sub-sections if they exist + ...(section.subSections + ? section.subSections.flatMap((subSection: any) => generateSectionContent(subSection, config)) + : []), + ]; + + return sectionContent; +}; + +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; + } +) { + 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); + } + + // 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: 36 * 2, // Title size (36pt) + }), + ], + heading: HeadingLevel.TITLE, + spacing: { after: 12 * 20 }, // 12pt * 20 = 240 twips + }), + + // Generate all sections and sub-sections + ...data.sections.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) => { + console.log('Go here'); + 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.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, + }; + + 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/server.ts b/src/server.ts index 262a117..d2a8ce4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -12,6 +12,7 @@ import { healthCheckRouter } from '@/routes/healthCheck/healthCheckRouter'; import { articleReaderRouter } from './routes/articleReader/articleReaderRouter'; import { powerpointGeneratorRouter } from './routes/powerpointGenerator/powerpointGeneratorRouter'; +import { wordGeneratorRouter } from './routes/wordGenerator/wordGeneratorRouter'; import { transcriptRouter } from './routes/youtubeTranscript/transcriptRouter'; const logger = pino({ name: 'server start' }); const app: Express = express(); @@ -38,6 +39,7 @@ app.use('/images', express.static('public/images')); app.use('/transcript', transcriptRouter); app.use('/get-content', articleReaderRouter); app.use('/powerpoint-generator', powerpointGeneratorRouter); +app.use('/word-generator', wordGeneratorRouter); // Swagger UI app.use(openAPIRouter); From c4a0adc1d5cf3db6d6c51fce5c78b63dc2c45ada Mon Sep 17 00:00:00 2001 From: dat-devuap Date: Tue, 31 Dec 2024 21:24:20 +0700 Subject: [PATCH 13/40] feat(word generator): support adding table of content --- .../wordGenerator/wordGeneratorRouter.ts | 60 +++++++++++++++++-- 1 file changed, 55 insertions(+), 5 deletions(-) diff --git a/src/routes/wordGenerator/wordGeneratorRouter.ts b/src/routes/wordGenerator/wordGeneratorRouter.ts index 8f4a81d..941de71 100644 --- a/src/routes/wordGenerator/wordGeneratorRouter.ts +++ b/src/routes/wordGenerator/wordGeneratorRouter.ts @@ -6,6 +6,7 @@ import { FootnoteReferenceRun, Header, HeadingLevel, + LeaderType, LevelFormat, Packer, PageNumber, @@ -13,6 +14,7 @@ import { Paragraph, Table, TableCell, + TableOfContents, TableRow, TextRun, WidthType, @@ -82,10 +84,26 @@ cron.schedule('0 * * * *', () => { const serverUrl = process.env.RENDER_EXTERNAL_URL || 'http://localhost:3000'; const FONT_CONFIG = { - size: 12, // Font size in points + 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 @@ -333,6 +351,10 @@ const generateSectionContent = (section: any, config: any) => { children: [new TextRun(section.heading)], heading: getHeadingLevel(section.headingLevel), numbering: numberingConfig, + spacing: { + before: SPACING_CONFIG.heading.before * 20, + after: SPACING_CONFIG.heading.after * 20, + }, }), ...sectionContents, // Process sub-sections if they exist @@ -359,6 +381,7 @@ async function execGenWordFuncs( fontSize: number; lineHeight: number; margins: string; + showTableOfContent: boolean; } ) { let headerConfigs = {}; @@ -414,6 +437,33 @@ async function execGenWordFuncs( 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) + ); + } + // Create the document based on JSON data const doc = new Document({ styles: { @@ -448,13 +498,13 @@ async function execGenWordFuncs( children: [ new TextRun({ text: data.title, - size: 36 * 2, // Title size (36pt) + size: FONT_CONFIG.titleSize * 2, }), ], heading: HeadingLevel.TITLE, - spacing: { after: 12 * 20 }, // 12pt * 20 = 240 twips + spacing: { after: SPACING_CONFIG.title.after * 20 }, // 12 inches * 20 = 240 twips }), - + ...tableOfContentConfigs, // Generate all sections and sub-sections ...data.sections.flatMap((section) => generateSectionContent(section, { ...config, numberingReference: selectedNumberingOption?.reference }) @@ -482,7 +532,6 @@ export const wordGeneratorRouter: Router = (() => { router.use('/downloads', express.static(exportsDir)); router.post('/generate', async (_req: Request, res: Response) => { - console.log('Go here'); const { title, sections = [], header, footer, wordConfig = {} } = _req.body; if (!sections.length) { const validateServiceResponse = new ServiceResponse( @@ -503,6 +552,7 @@ export const wordGeneratorRouter: Router = (() => { 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( From 5ca042735276b6379921add0fe73368df2d91a72 Mon Sep 17 00:00:00 2001 From: dat-devuap Date: Tue, 31 Dec 2024 22:58:14 +0700 Subject: [PATCH 14/40] feat(word generator): handle mapping without section heading --- src/routes/wordGenerator/wordGeneratorRouter.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/routes/wordGenerator/wordGeneratorRouter.ts b/src/routes/wordGenerator/wordGeneratorRouter.ts index 941de71..1acf8fe 100644 --- a/src/routes/wordGenerator/wordGeneratorRouter.ts +++ b/src/routes/wordGenerator/wordGeneratorRouter.ts @@ -217,7 +217,7 @@ const getHeadingLevel = (level: any) => { case 4: return HeadingLevel.HEADING_4; default: - throw HeadingLevel.HEADING_5; + throw Error(`Unsupported heading with input level: ${level}`); } }; @@ -345,9 +345,9 @@ const generateSectionContent = (section: any, config: any) => { }; } - const sectionContent = [ - // Section Heading with index - new Paragraph({ + let headingContent; + if (section.heading) { + headingContent = new Paragraph({ children: [new TextRun(section.heading)], heading: getHeadingLevel(section.headingLevel), numbering: numberingConfig, @@ -355,7 +355,12 @@ const generateSectionContent = (section: any, config: any) => { 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 From 84f7426048f2c5bcc7deaf4e5c8aa13dfd2a0e35 Mon Sep 17 00:00:00 2001 From: dat-devuap Date: Wed, 1 Jan 2025 10:19:44 +0700 Subject: [PATCH 15/40] feat(word generator): handle line break in paragraph --- .../wordGenerator/wordGeneratorRouter.ts | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/routes/wordGenerator/wordGeneratorRouter.ts b/src/routes/wordGenerator/wordGeneratorRouter.ts index 1acf8fe..1f7f75d 100644 --- a/src/routes/wordGenerator/wordGeneratorRouter.ts +++ b/src/routes/wordGenerator/wordGeneratorRouter.ts @@ -298,7 +298,19 @@ const generateSectionContent = (section: any, config: any) => { const results = []; // Handle paragraph content if (child.type === 'paragraph') { - const paragraphChildren = [new TextRun(child.text)]; + 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)); } @@ -327,6 +339,12 @@ const generateSectionContent = (section: any, config: any) => { pageBreakBefore: true, }) ); + } else if (child.type === 'emptyLine') { + results.push( + new Paragraph({ + text: '', + }) + ); } else { results.push( new Paragraph({ From 088f1514e6eaf5b3b00915691c414aa4ef1b6722 Mon Sep 17 00:00:00 2001 From: dat-devuap Date: Wed, 1 Jan 2025 10:54:00 +0700 Subject: [PATCH 16/40] feat(word generator): allow showing or hiding header numering in prompt --- src/routes/wordGenerator/wordGeneratorRouter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/wordGenerator/wordGeneratorRouter.ts b/src/routes/wordGenerator/wordGeneratorRouter.ts index 1f7f75d..e33693a 100644 --- a/src/routes/wordGenerator/wordGeneratorRouter.ts +++ b/src/routes/wordGenerator/wordGeneratorRouter.ts @@ -568,7 +568,7 @@ export const wordGeneratorRouter: Router = (() => { try { const wordConfigs = { - numberingReference: wordConfig.numberingReference ?? '', + numberingReference: wordConfig.showNumberingInHeader ? wordConfig.numberingReference : '', showPageNumber: wordConfig.showPageNumber ?? false, pageOrientation: wordConfig.pageOrientation ? wordConfig.pageOrientation : PageOrientation.PORTRAIT, fontFamily: wordConfig.fontFamily ? wordConfig.fontFamily : FONT_CONFIG.family, From 6794de1091378e752237c5166247dd2083900dd8 Mon Sep 17 00:00:00 2001 From: dat-devuap Date: Sat, 4 Jan 2025 21:34:03 +0700 Subject: [PATCH 17/40] feat(powerpoint-generator): update openapi doc generator --- src/api-docs/openAPIDocumentGenerator.ts | 8 +- src/api-docs/openAPIRequestBuilders.ts | 18 +++ .../powerpointGeneratorModel.ts | 107 ++++++++++++++++++ .../powerpointGeneratorRouter.ts | 12 +- 4 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 src/api-docs/openAPIRequestBuilders.ts diff --git a/src/api-docs/openAPIDocumentGenerator.ts b/src/api-docs/openAPIDocumentGenerator.ts index b08b956..92a44ef 100644 --- a/src/api-docs/openAPIDocumentGenerator.ts +++ b/src/api-docs/openAPIDocumentGenerator.ts @@ -2,10 +2,16 @@ import { OpenApiGeneratorV3, OpenAPIRegistry } from '@asteasolutions/zod-to-open import { articleReaderRegistry } from '@/routes/articleReader/articleReaderRouter'; import { healthCheckRegistry } from '@/routes/healthCheck/healthCheckRouter'; +import { powerpointGeneratorRegistry } from '@/routes/powerpointGenerator/powerpointGeneratorRouter'; import { transcriptRegistry } from '@/routes/youtubeTranscript/transcriptRouter'; export function generateOpenAPIDocument() { - const registry = new OpenAPIRegistry([healthCheckRegistry, transcriptRegistry, articleReaderRegistry]); + const registry = new OpenAPIRegistry([ + healthCheckRegistry, + transcriptRegistry, + articleReaderRegistry, + powerpointGeneratorRegistry, + ]); const generator = new OpenApiGeneratorV3(registry.definitions); return generator.generateDocument({ diff --git a/src/api-docs/openAPIRequestBuilders.ts b/src/api-docs/openAPIRequestBuilders.ts new file mode 100644 index 0000000..33fb430 --- /dev/null +++ b/src/api-docs/openAPIRequestBuilders.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +export function createApiRequestBody( + schema: z.ZodTypeAny, + mediaType: string = 'application/json', + description: string = '', + required: boolean = true +) { + return { + content: { + [mediaType]: { + schema: schema, + }, + }, + description, + required, + }; +} diff --git a/src/routes/powerpointGenerator/powerpointGeneratorModel.ts b/src/routes/powerpointGenerator/powerpointGeneratorModel.ts index 41830d9..c7af1f5 100644 --- a/src/routes/powerpointGenerator/powerpointGeneratorModel.ts +++ b/src/routes/powerpointGenerator/powerpointGeneratorModel.ts @@ -7,3 +7,110 @@ export type PowerPointGeneratorResponse = z.infer; +export const PowerpointGeneratorRequestBodySchema = z.object({ + slides: z + .array( + z.object({ + type: z + .enum(['title_slide', 'content_slide', 'table_slide', 'chart_slide']) + .describe( + "The type of slide, either 'title_slide' (title-only), 'content_slide' (title and content), 'table_slide' (title with tabular content), or 'chart_slide' (title with chart content)." + ), + title: z.string().describe('The title of the slide.'), + content: z + .array( + z + .array(z.string()) + .describe("It could be a row in the table for 'table_slide'. Each row is an array of strings.") + ) + .optional() + .describe( + "The content of the slide. For 'title_slide', this is not required. For 'content_slide', it can be a string (paragraph) or an array of strings (list). For 'table_slide', it is an array of rows, where each row is an array of strings." + ), + subtitle: z + .string() + .optional() + .describe( + 'The subtitle of the slide, which provides additional information to support the title. Only title_slide has the subtitle.' + ), + chartContent: z + .object({ + type: z + .enum(['pie', 'bar', 'line', 'doughnut']) + .describe("The type of chart, either 'pie', 'doughnut', 'bar', or 'line'."), + data: z + .array( + z.object({ + name: z.string().describe('The name of the chart series.'), + labels: z.array(z.string()).describe('The labels for the chart (e.g., categories, time periods).'), + values: z.array(z.number()).describe('The values for each label in the chart.'), + }) + ) + .describe('The chart data. Depending on the type, this can be an array of labels and values.'), + }) + .optional() + .describe( + "The chart data for 'chart_slide'. This includes various types of chart data such as pie charts, doughnut charts, bar charts, and line charts." + ), + }) + ) + .describe('A list of slides, where each slide includes its type, title, and content.'), + slideConfig: z + .object({ + layout: z + .enum(['LAYOUT_WIDE', 'LAYOUT_16x9', 'LAYOUT_16x10', 'LAYOUT_4x3']) + .describe( + 'Defines the slide layout. Options include LAYOUT_WIDE (default), LAYOUT_16x9, LAYOUT_16x10, and LAYOUT_4x3.' + ), + titleFontSize: z.number().optional().describe('Font size for the title slide. Default is 52 pt.'), + headerFontSize: z.number().optional().describe('Font size for headers in content slides. Default is 32 pt.'), + bodyFontSize: z.number().optional().describe('Font size for the main content text. Default is 24 pt.'), + fontFamily: z + .enum([ + 'Calibri', + 'Arial', + 'Courier New', + 'Georgia', + 'Helvetica', + 'Impact', + 'Lucida Console', + 'Tahoma', + 'Times New Roman', + 'Trebuchet MS', + 'Verdana', + 'Comic Sans MS', + 'Franklin Gothic Medium', + 'Century Gothic', + 'Gill Sans', + 'Garamond', + 'Palatino Linotype', + 'Segoe UI', + 'Book Antiqua', + 'Symbol', + 'Monospace', + 'Webdings', + 'Wingdings', + ]) + .describe("Font family for slide text. Default is 'Calibri'."), + backgroundColor: z.string().describe('Hex color for slide background. Default is #FFFFFF.'), + textColor: z.string().describe('Hex color for slide text. Default is #000000.'), + showFooter: z.boolean().describe('Boolean to display footer. Default is false.'), + showSlideNumber: z.boolean().describe('Boolean to display slide numbers. Default is false.'), + footerBackgroundColor: z.string().describe('Hex color for footer background. Default is #003B75.'), + footerText: z.string().optional().describe("Text content for the footer. Default is 'footer text'."), + footerTextColor: z.string().describe('Hex color for footer text. Default is #FFFFFF.'), + footerFontSize: z.number().optional().describe('Font size for footer text. Default is 10 pt.'), + showTableBorder: z.boolean().describe('Boolean to display table borders. Default is true.'), + tableHeaderBackgroundColor: z.string().describe('Hex color for table header background. Default is #003B75.'), + tableHeaderTextColor: z.string().describe('Hex color for table header text. Default is #FFFFFF.'), + tableBorderThickness: z.number().optional().describe('Thickness of table borders in points. Default is 1 pt.'), + tableBorderColor: z.string().describe('Hex color for table borders. Default is #000000.'), + tableFontSize: z.number().optional().describe('Font size for text inside tables. Default is 14 pt.'), + tableTextColor: z.string().describe('Hex color for table text. Default is #000000.'), + }) + .describe( + 'Configuration settings for customizing slide properties, including layout, font sizes, font family, colors, footer settings, and table appearance.' + ), +}); diff --git a/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts b/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts index 7579b0a..8d0f543 100644 --- a/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts +++ b/src/routes/powerpointGenerator/powerpointGeneratorRouter.ts @@ -6,18 +6,24 @@ import cron from 'node-cron'; import path from 'path'; import pptxgen from 'pptxgenjs'; +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 { PowerpointGeneratorResponseSchema } from './powerpointGeneratorModel'; +import { PowerpointGeneratorRequestBodySchema, PowerpointGeneratorResponseSchema } from './powerpointGeneratorModel'; export const COMPRESS = true; + +// API Doc definition export const powerpointGeneratorRegistry = new OpenAPIRegistry(); powerpointGeneratorRegistry.register('PowerpointGenerator', PowerpointGeneratorResponseSchema); powerpointGeneratorRegistry.registerPath({ method: 'post', - path: '/generate', - tags: ['Generate Powerpoint Presentation'], + path: '/powerpoint-generator/generate', + tags: ['Powerpoint Generator'], + request: { + body: createApiRequestBody(PowerpointGeneratorRequestBodySchema, 'application/json'), + }, responses: createApiResponse(PowerpointGeneratorResponseSchema, 'Success'), }); From 2dfbe2d722add486686f2ccd7650b631e0eb29bc Mon Sep 17 00:00:00 2001 From: dat-devuap Date: Sun, 5 Jan 2025 04:31:27 +0700 Subject: [PATCH 18/40] feat(word generator): update openapi doc generator --- src/api-docs/openAPIDocumentGenerator.ts | 2 + .../wordGenerator/wordGeneratorModel.ts | 131 +++++++++++++++++- .../wordGenerator/wordGeneratorRouter.ts | 10 +- 3 files changed, 139 insertions(+), 4 deletions(-) diff --git a/src/api-docs/openAPIDocumentGenerator.ts b/src/api-docs/openAPIDocumentGenerator.ts index 92a44ef..33ff0d2 100644 --- a/src/api-docs/openAPIDocumentGenerator.ts +++ b/src/api-docs/openAPIDocumentGenerator.ts @@ -3,6 +3,7 @@ import { OpenApiGeneratorV3, OpenAPIRegistry } from '@asteasolutions/zod-to-open import { articleReaderRegistry } from '@/routes/articleReader/articleReaderRouter'; import { healthCheckRegistry } from '@/routes/healthCheck/healthCheckRouter'; import { powerpointGeneratorRegistry } from '@/routes/powerpointGenerator/powerpointGeneratorRouter'; +import { wordGeneratorRegistry } from '@/routes/wordGenerator/wordGeneratorRouter'; import { transcriptRegistry } from '@/routes/youtubeTranscript/transcriptRouter'; export function generateOpenAPIDocument() { @@ -11,6 +12,7 @@ export function generateOpenAPIDocument() { transcriptRegistry, articleReaderRegistry, powerpointGeneratorRegistry, + wordGeneratorRegistry, ]); const generator = new OpenApiGeneratorV3(registry.definitions); diff --git a/src/routes/wordGenerator/wordGeneratorModel.ts b/src/routes/wordGenerator/wordGeneratorModel.ts index 58ea784..b2947c5 100644 --- a/src/routes/wordGenerator/wordGeneratorModel.ts +++ b/src/routes/wordGenerator/wordGeneratorModel.ts @@ -3,7 +3,136 @@ import { z } from 'zod'; extendZodWithOpenApi(z); +// Define Word Generator Response Schema export type WordGeneratorResponse = z.infer; export const WordGeneratorResponseSchema = z.object({ - filepath: z.string(), + 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 BaseSectionSchema = z.object({ + 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).', + }), + content: z.array(ContentSchema).optional().openapi({ + description: 'Content contained within the section, including paragraphs, tables, etc.', + }), +}); + +// Extend the base schema with subSections +const SectionSchema = BaseSectionSchema.extend({ + subSections: z.array(BaseSectionSchema).optional().openapi({ + description: 'Subsections within the main section.', + }), +}); + +// 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 e33693a..a8551b0 100644 --- a/src/routes/wordGenerator/wordGeneratorRouter.ts +++ b/src/routes/wordGenerator/wordGeneratorRouter.ts @@ -25,18 +25,22 @@ 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 { WordGeneratorResponseSchema } from './wordGeneratorModel'; +import { WordGeneratorRequestBodySchema, WordGeneratorResponseSchema } from './wordGeneratorModel'; export const COMPRESS = true; export const wordGeneratorRegistry = new OpenAPIRegistry(); wordGeneratorRegistry.register('WordGenerator', WordGeneratorResponseSchema); wordGeneratorRegistry.registerPath({ method: 'post', - path: '/generate', - tags: ['Generate Word file'], + path: '/word-generator/generate', + tags: ['Word Generator'], + request: { + body: createApiRequestBody(WordGeneratorRequestBodySchema, 'application/json'), + }, responses: createApiResponse(WordGeneratorResponseSchema, 'Success'), }); From 48058ca739ff97059470da3b5953087e6ad12110 Mon Sep 17 00:00:00 2001 From: dat-devuap Date: Sun, 5 Jan 2025 11:05:06 +0700 Subject: [PATCH 19/40] docs(readme): remove outdated items and add details on description and setup instruction --- README.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 205b261..a31c094 100644 --- a/README.md +++ b/README.md @@ -16,20 +16,26 @@ Plugins Server is used by some built-in plugins on Typing Mind (e.g., Web Page R Plugins Server is open-sourced and is intended to be self-hosted by individual users for private use only. +**Note**: The Plugins Server only provides an endpoint for retrieving server-side processing results. To make the plugin work, you must also install a TypingMind's plugin configured to send requests to this server endpoint. + ## 🔌 How to use (for Typing Mind users) Two simple steps: -1. Deploy this repo on any hosting provider that supports NodeJS (e.g., Render.com, AWS, etc.). (We also provide a Dockerfile for easy deployment on Docker-supported hosting providers) -2. Use the server endpoint URL in your Settings page of Typing Mind's plugins. +1. Deploy this repo on any hosting provider that supports NodeJS (e.g., Render.com, AWS, etc.). (We also provide a Dockerfile for easy deployment on Docker-supported hosting providers). +2. Install your desired TypingMind's plugin. Update the server endpoint URL in your Settings page. Follow this guide for detailed instructions: [How to Deploy Plugins Server on Render.com](https://docs.typingmind.com/plugins/plugins-server/how-to-deploy-plugins-server-on-render) +Follow this guide for setting up a TypingMind's plugin [Build a TypingMind Plugin](https://docs.typingmind.com/plugins/build-a-typingmind-plugin)) + ## List of available endpoints After deploying, visit your Plugins Server URL to see the list of available endpoints (served in Swagger UI). -Here are the latest endpoints from our public servers: https://plugins.typingmind.com/ (**Note**: this public server only hosts the API documentation. You cannot use this Public Server as your proxy. You must deploy your own Plugins Server to use all the available endpoints). +In your local development environment, visit http://localhost:3000 to access the page. + +**Note**: this public server only hosts the API documentation. You cannot use this Public Server as your proxy. You must deploy your own Plugins Server to use all the available endpoints. ## 🛠️ Development (for Typing Mind plugins developers) From 7a832e891126b0e2c6ead10db6d66cb71be8b67a Mon Sep 17 00:00:00 2001 From: dat-devuap Date: Sun, 5 Jan 2025 11:09:03 +0700 Subject: [PATCH 20/40] docs(readme): format readme --- README.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a31c094..3f69e98 100644 --- a/README.md +++ b/README.md @@ -23,11 +23,10 @@ Plugins Server is open-sourced and is intended to be self-hosted by individual u Two simple steps: 1. Deploy this repo on any hosting provider that supports NodeJS (e.g., Render.com, AWS, etc.). (We also provide a Dockerfile for easy deployment on Docker-supported hosting providers). -2. Install your desired TypingMind's plugin. Update the server endpoint URL in your Settings page. - -Follow this guide for detailed instructions: [How to Deploy Plugins Server on Render.com](https://docs.typingmind.com/plugins/plugins-server/how-to-deploy-plugins-server-on-render) + 👉 Follow this guide for detailed instructions: [How to Deploy Plugins Server on Render.com](https://docs.typingmind.com/plugins/plugins-server/how-to-deploy-plugins-server-on-render) -Follow this guide for setting up a TypingMind's plugin [Build a TypingMind Plugin](https://docs.typingmind.com/plugins/build-a-typingmind-plugin)) +2. Install your desired TypingMind's plugin. Update the server endpoint URL in your Settings page. + 👉 Follow this guide for setting up a TypingMind's plugin [Build a TypingMind Plugin](https://docs.typingmind.com/plugins/build-a-typingmind-plugin) ## List of available endpoints From 99a5a4f8612eceb336887a031dc384aed594a73f Mon Sep 17 00:00:00 2001 From: dat-devuap Date: Sun, 5 Jan 2025 11:11:30 +0700 Subject: [PATCH 21/40] docs(readme): reformat readme --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3f69e98..b30ad88 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,12 @@ Plugins Server is open-sourced and is intended to be self-hosted by individual u Two simple steps: 1. Deploy this repo on any hosting provider that supports NodeJS (e.g., Render.com, AWS, etc.). (We also provide a Dockerfile for easy deployment on Docker-supported hosting providers). - 👉 Follow this guide for detailed instructions: [How to Deploy Plugins Server on Render.com](https://docs.typingmind.com/plugins/plugins-server/how-to-deploy-plugins-server-on-render) + +👉 Follow this guide for detailed instructions: [How to Deploy Plugins Server on Render.com](https://docs.typingmind.com/plugins/plugins-server/how-to-deploy-plugins-server-on-render) 2. Install your desired TypingMind's plugin. Update the server endpoint URL in your Settings page. - 👉 Follow this guide for setting up a TypingMind's plugin [Build a TypingMind Plugin](https://docs.typingmind.com/plugins/build-a-typingmind-plugin) + +👉 Follow this guide for setting up a TypingMind's plugin [Build a TypingMind Plugin](https://docs.typingmind.com/plugins/build-a-typingmind-plugin) ## List of available endpoints From 866bd59d3b136ae428736e16aca87a1b04f0b7d7 Mon Sep 17 00:00:00 2001 From: dat-devuap Date: Sun, 5 Jan 2025 11:12:41 +0700 Subject: [PATCH 22/40] docs(readme): reformat readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b30ad88..5ff7dcc 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,10 @@ Two simple steps: 1. Deploy this repo on any hosting provider that supports NodeJS (e.g., Render.com, AWS, etc.). (We also provide a Dockerfile for easy deployment on Docker-supported hosting providers). -👉 Follow this guide for detailed instructions: [How to Deploy Plugins Server on Render.com](https://docs.typingmind.com/plugins/plugins-server/how-to-deploy-plugins-server-on-render) - 2. Install your desired TypingMind's plugin. Update the server endpoint URL in your Settings page. +👉 Follow this guide for detailed instructions: [How to Deploy Plugins Server on Render.com](https://docs.typingmind.com/plugins/plugins-server/how-to-deploy-plugins-server-on-render) + 👉 Follow this guide for setting up a TypingMind's plugin [Build a TypingMind Plugin](https://docs.typingmind.com/plugins/build-a-typingmind-plugin) ## List of available endpoints From d0e6d4e7b6ae6c5a629548287ca0f500789a9302 Mon Sep 17 00:00:00 2001 From: dat-devuap Date: Sun, 5 Jan 2025 11:14:21 +0700 Subject: [PATCH 23/40] docs(readme): reformat readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5ff7dcc..6005bbf 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,9 @@ Two simple steps: 2. Install your desired TypingMind's plugin. Update the server endpoint URL in your Settings page. -👉 Follow this guide for detailed instructions: [How to Deploy Plugins Server on Render.com](https://docs.typingmind.com/plugins/plugins-server/how-to-deploy-plugins-server-on-render) +Follow this guide for detailed instructions: [How to Deploy Plugins Server on Render.com](https://docs.typingmind.com/plugins/plugins-server/how-to-deploy-plugins-server-on-render) -👉 Follow this guide for setting up a TypingMind's plugin [Build a TypingMind Plugin](https://docs.typingmind.com/plugins/build-a-typingmind-plugin) +Follow this guide for setting up a TypingMind's plugin: [Build a TypingMind Plugin](https://docs.typingmind.com/plugins/build-a-typingmind-plugin) ## List of available endpoints From ab061d8689217628e0fa3adfc33bfc13f3c2bac4 Mon Sep 17 00:00:00 2001 From: dat-devuap Date: Mon, 6 Jan 2025 10:26:59 +0700 Subject: [PATCH 24/40] refactor(web page reader): change plugin name, add request params schema to the OpenAPI doc --- src/api-docs/openAPIDocumentGenerator.ts | 2 +- src/routes/articleReader/articleReaderModel.ts | 10 ---------- src/routes/webPageReader/webPageReaderModel.ts | 15 +++++++++++++++ .../webPageReaderRouter.ts} | 15 +++++++++------ src/server.ts | 4 ++-- 5 files changed, 27 insertions(+), 19 deletions(-) delete mode 100644 src/routes/articleReader/articleReaderModel.ts create mode 100644 src/routes/webPageReader/webPageReaderModel.ts rename src/routes/{articleReader/articleReaderRouter.ts => webPageReader/webPageReaderRouter.ts} (84%) diff --git a/src/api-docs/openAPIDocumentGenerator.ts b/src/api-docs/openAPIDocumentGenerator.ts index 33ff0d2..531406e 100644 --- a/src/api-docs/openAPIDocumentGenerator.ts +++ b/src/api-docs/openAPIDocumentGenerator.ts @@ -1,8 +1,8 @@ import { OpenApiGeneratorV3, OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; -import { articleReaderRegistry } from '@/routes/articleReader/articleReaderRouter'; import { healthCheckRegistry } from '@/routes/healthCheck/healthCheckRouter'; import { powerpointGeneratorRegistry } from '@/routes/powerpointGenerator/powerpointGeneratorRouter'; +import { articleReaderRegistry } from '@/routes/webPageReader/webPageReaderRouter'; import { wordGeneratorRegistry } from '@/routes/wordGenerator/wordGeneratorRouter'; import { transcriptRegistry } from '@/routes/youtubeTranscript/transcriptRouter'; diff --git a/src/routes/articleReader/articleReaderModel.ts b/src/routes/articleReader/articleReaderModel.ts deleted file mode 100644 index bfc40ef..0000000 --- a/src/routes/articleReader/articleReaderModel.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; -import { z } from 'zod'; - -extendZodWithOpenApi(z); - -export type Transcript = z.infer; -export const ArticleReaderSchema = z.object({ - title: z.string(), - content: z.string(), -}); diff --git a/src/routes/webPageReader/webPageReaderModel.ts b/src/routes/webPageReader/webPageReaderModel.ts new file mode 100644 index 0000000..8af4684 --- /dev/null +++ b/src/routes/webPageReader/webPageReaderModel.ts @@ -0,0 +1,15 @@ +import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; +import { z } from 'zod'; + +extendZodWithOpenApi(z); + +export type WebPageReaderResponse = z.infer; +export const WebPageReaderResponseSchema = z.object({ + title: z.string(), + content: z.string(), +}); + +export type WebPageReaderRequestParam = z.infer; +export const WebPageReaderRequestParamSchema = z.object({ + url: z.string().describe('The URL of the web page to retrieve content from'), +}); diff --git a/src/routes/articleReader/articleReaderRouter.ts b/src/routes/webPageReader/webPageReaderRouter.ts similarity index 84% rename from src/routes/articleReader/articleReaderRouter.ts rename to src/routes/webPageReader/webPageReaderRouter.ts index 4e76679..6c52300 100644 --- a/src/routes/articleReader/articleReaderRouter.ts +++ b/src/routes/webPageReader/webPageReaderRouter.ts @@ -10,10 +10,10 @@ import { createApiResponse } from '@/api-docs/openAPIResponseBuilders'; import { ResponseStatus, ServiceResponse } from '@/common/models/serviceResponse'; import { handleServiceResponse } from '@/common/utils/httpHandlers'; -import { ArticleReaderSchema } from './articleReaderModel'; +import { WebPageReaderRequestParamSchema, WebPageReaderResponseSchema } from './webPageReaderModel'; export const articleReaderRegistry = new OpenAPIRegistry(); -articleReaderRegistry.register('ArticleReader', ArticleReaderSchema); +articleReaderRegistry.register('Web Page Reader', WebPageReaderResponseSchema); const removeUnwantedElements = (_cheerio: any) => { const elementsToRemove = [ @@ -59,14 +59,17 @@ const fetchAndCleanContent = async (url: string) => { return { title, content: article ? article.textContent : '' }; }; -export const articleReaderRouter: Router = (() => { +export const webPageReaderRouter: Router = (() => { const router = express.Router(); articleReaderRegistry.registerPath({ method: 'get', - path: '/content', - tags: ['Article Reader'], - responses: createApiResponse(ArticleReaderSchema, 'Success'), + path: '/web-page-reader/get-content', + tags: ['Web Page Reader'], + request: { + params: WebPageReaderRequestParamSchema, + }, + responses: createApiResponse(WebPageReaderResponseSchema, 'Success'), }); router.get('/', async (_req: Request, res: Response) => { diff --git a/src/server.ts b/src/server.ts index d2a8ce4..d0e63f1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -10,8 +10,8 @@ import rateLimiter from '@/common/middleware/rateLimiter'; import requestLogger from '@/common/middleware/requestLogger'; import { healthCheckRouter } from '@/routes/healthCheck/healthCheckRouter'; -import { articleReaderRouter } from './routes/articleReader/articleReaderRouter'; import { powerpointGeneratorRouter } from './routes/powerpointGenerator/powerpointGeneratorRouter'; +import { webPageReaderRouter } from './routes/webPageReader/webPageReaderRouter'; import { wordGeneratorRouter } from './routes/wordGenerator/wordGeneratorRouter'; import { transcriptRouter } from './routes/youtubeTranscript/transcriptRouter'; const logger = pino({ name: 'server start' }); @@ -37,7 +37,7 @@ app.use(requestLogger()); app.use('/health-check', healthCheckRouter); app.use('/images', express.static('public/images')); app.use('/transcript', transcriptRouter); -app.use('/get-content', articleReaderRouter); +app.use('/web-page-reader', webPageReaderRouter); app.use('/powerpoint-generator', powerpointGeneratorRouter); app.use('/word-generator', wordGeneratorRouter); // Swagger UI From 9a7a57ef9d23b02f24f287c48b8321a904d32bc5 Mon Sep 17 00:00:00 2001 From: dat-devuap Date: Mon, 6 Jan 2025 12:10:25 +0700 Subject: [PATCH 25/40] refactor(web page reader): update success message --- src/routes/webPageReader/webPageReaderRouter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/webPageReader/webPageReaderRouter.ts b/src/routes/webPageReader/webPageReaderRouter.ts index 6c52300..80f712d 100644 --- a/src/routes/webPageReader/webPageReaderRouter.ts +++ b/src/routes/webPageReader/webPageReaderRouter.ts @@ -83,7 +83,7 @@ export const webPageReaderRouter: Router = (() => { const content = await fetchAndCleanContent(url); const serviceResponse = new ServiceResponse( ResponseStatus.Success, - 'Service is healthy', + 'Content fetched successfully', content, StatusCodes.OK ); From f82a4080c99bb633234aa103e47b637f679b0c0c Mon Sep 17 00:00:00 2001 From: dat-devuap Date: Mon, 6 Jan 2025 12:53:06 +0700 Subject: [PATCH 26/40] refactor(youtube transcript): add request params schema --- src/api-docs/openAPIDocumentGenerator.ts | 4 ++-- .../webPageReader/webPageReaderRouter.ts | 4 ++-- .../youtubeTranscript/transcriptModel.ts | 9 --------- .../youtubeTranscriptModel.ts | 14 ++++++++++++++ ...ptRouter.ts => youtubeTranscriptRouter.ts} | 19 +++++++++++-------- src/server.ts | 4 ++-- 6 files changed, 31 insertions(+), 23 deletions(-) delete mode 100644 src/routes/youtubeTranscript/transcriptModel.ts create mode 100644 src/routes/youtubeTranscript/youtubeTranscriptModel.ts rename src/routes/youtubeTranscript/{transcriptRouter.ts => youtubeTranscriptRouter.ts} (69%) diff --git a/src/api-docs/openAPIDocumentGenerator.ts b/src/api-docs/openAPIDocumentGenerator.ts index 531406e..5e7da6c 100644 --- a/src/api-docs/openAPIDocumentGenerator.ts +++ b/src/api-docs/openAPIDocumentGenerator.ts @@ -4,12 +4,12 @@ import { healthCheckRegistry } from '@/routes/healthCheck/healthCheckRouter'; import { powerpointGeneratorRegistry } from '@/routes/powerpointGenerator/powerpointGeneratorRouter'; import { articleReaderRegistry } from '@/routes/webPageReader/webPageReaderRouter'; import { wordGeneratorRegistry } from '@/routes/wordGenerator/wordGeneratorRouter'; -import { transcriptRegistry } from '@/routes/youtubeTranscript/transcriptRouter'; +import { youtubeTranscriptRegistry } from '@/routes/youtubeTranscript/youtubeTranscriptRouter'; export function generateOpenAPIDocument() { const registry = new OpenAPIRegistry([ healthCheckRegistry, - transcriptRegistry, + youtubeTranscriptRegistry, articleReaderRegistry, powerpointGeneratorRegistry, wordGeneratorRegistry, diff --git a/src/routes/webPageReader/webPageReaderRouter.ts b/src/routes/webPageReader/webPageReaderRouter.ts index 80f712d..5905454 100644 --- a/src/routes/webPageReader/webPageReaderRouter.ts +++ b/src/routes/webPageReader/webPageReaderRouter.ts @@ -67,12 +67,12 @@ export const webPageReaderRouter: Router = (() => { path: '/web-page-reader/get-content', tags: ['Web Page Reader'], request: { - params: WebPageReaderRequestParamSchema, + query: WebPageReaderRequestParamSchema, }, responses: createApiResponse(WebPageReaderResponseSchema, 'Success'), }); - router.get('/', async (_req: Request, res: Response) => { + router.get('/get-content', async (_req: Request, res: Response) => { const { url } = _req.query; if (typeof url !== 'string') { diff --git a/src/routes/youtubeTranscript/transcriptModel.ts b/src/routes/youtubeTranscript/transcriptModel.ts deleted file mode 100644 index 137d434..0000000 --- a/src/routes/youtubeTranscript/transcriptModel.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; -import { z } from 'zod'; - -extendZodWithOpenApi(z); - -export type Transcript = z.infer; -export const TranscriptSchema = z.object({ - textOnly: z.string(), -}); diff --git a/src/routes/youtubeTranscript/youtubeTranscriptModel.ts b/src/routes/youtubeTranscript/youtubeTranscriptModel.ts new file mode 100644 index 0000000..84df9b4 --- /dev/null +++ b/src/routes/youtubeTranscript/youtubeTranscriptModel.ts @@ -0,0 +1,14 @@ +import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; +import { z } from 'zod'; + +extendZodWithOpenApi(z); + +export type YoutubeTranscriptResponse = z.infer; +export const YoutubeTranscriptResponseSchema = z.object({ + textOnly: z.string(), +}); + +export type YoutubeTranscriptRequestParam = z.infer; +export const YoutubeTranscriptRequestParamSchema = z.object({ + videoId: z.string().describe('The id of the Youtube video to retrieve the transcript'), +}); diff --git a/src/routes/youtubeTranscript/transcriptRouter.ts b/src/routes/youtubeTranscript/youtubeTranscriptRouter.ts similarity index 69% rename from src/routes/youtubeTranscript/transcriptRouter.ts rename to src/routes/youtubeTranscript/youtubeTranscriptRouter.ts index 8597bc7..0c83ae0 100644 --- a/src/routes/youtubeTranscript/transcriptRouter.ts +++ b/src/routes/youtubeTranscript/youtubeTranscriptRouter.ts @@ -7,22 +7,25 @@ import { createApiResponse } from '@/api-docs/openAPIResponseBuilders'; import { ResponseStatus, ServiceResponse } from '@/common/models/serviceResponse'; import { handleServiceResponse } from '@/common/utils/httpHandlers'; -import { TranscriptSchema } from './transcriptModel'; +import { YoutubeTranscriptRequestParamSchema, YoutubeTranscriptResponseSchema } from './youtubeTranscriptModel'; -export const transcriptRegistry = new OpenAPIRegistry(); -transcriptRegistry.register('Transcript', TranscriptSchema); +export const youtubeTranscriptRegistry = new OpenAPIRegistry(); +youtubeTranscriptRegistry.register('YoutubeTranscript', YoutubeTranscriptResponseSchema); -export const transcriptRouter: Router = (() => { +export const youtubeTranscriptRouter: Router = (() => { const router = express.Router(); - transcriptRegistry.registerPath({ + youtubeTranscriptRegistry.registerPath({ method: 'get', - path: '/transcript', + path: '/youtube-transcript/get-transcript', tags: ['Youtube Transcript'], - responses: createApiResponse(TranscriptSchema, 'Success'), + request: { + query: YoutubeTranscriptRequestParamSchema, + }, + responses: createApiResponse(YoutubeTranscriptResponseSchema, 'Success'), }); - router.get('/', async (_req: Request, res: Response) => { + router.get('/get-transcript', async (_req: Request, res: Response) => { const { videoId } = _req.query; if (!videoId) { diff --git a/src/server.ts b/src/server.ts index d0e63f1..a569651 100644 --- a/src/server.ts +++ b/src/server.ts @@ -13,7 +13,7 @@ import { healthCheckRouter } from '@/routes/healthCheck/healthCheckRouter'; import { powerpointGeneratorRouter } from './routes/powerpointGenerator/powerpointGeneratorRouter'; import { webPageReaderRouter } from './routes/webPageReader/webPageReaderRouter'; import { wordGeneratorRouter } from './routes/wordGenerator/wordGeneratorRouter'; -import { transcriptRouter } from './routes/youtubeTranscript/transcriptRouter'; +import { youtubeTranscriptRouter } from './routes/youtubeTranscript/youtubeTranscriptRouter'; const logger = pino({ name: 'server start' }); const app: Express = express(); @@ -36,7 +36,7 @@ app.use(requestLogger()); // Routes app.use('/health-check', healthCheckRouter); app.use('/images', express.static('public/images')); -app.use('/transcript', transcriptRouter); +app.use('/youtube-transcript', youtubeTranscriptRouter); app.use('/web-page-reader', webPageReaderRouter); app.use('/powerpoint-generator', powerpointGeneratorRouter); app.use('/word-generator', wordGeneratorRouter); From d6942009ddcfcdb76b6a2d7fe33793b689afd85d Mon Sep 17 00:00:00 2001 From: dat-devuap Date: Tue, 7 Jan 2025 09:10:15 +0700 Subject: [PATCH 27/40] fix(youtube transcript): update successful message --- src/routes/youtubeTranscript/youtubeTranscriptRouter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/youtubeTranscript/youtubeTranscriptRouter.ts b/src/routes/youtubeTranscript/youtubeTranscriptRouter.ts index 0c83ae0..1d71a00 100644 --- a/src/routes/youtubeTranscript/youtubeTranscriptRouter.ts +++ b/src/routes/youtubeTranscript/youtubeTranscriptRouter.ts @@ -42,7 +42,7 @@ export const youtubeTranscriptRouter: Router = (() => { const textOnly = transcript.map((entry) => entry.text).join(' '); const serviceResponse = new ServiceResponse( ResponseStatus.Success, - 'Service is healthy', + 'Transcript fetched successfully', { textOnly }, StatusCodes.OK ); From 926da0cb35a8f440e3d7089e10d86e9a3209cd3b Mon Sep 17 00:00:00 2001 From: dat-devuap Date: Fri, 10 Jan 2025 06:02:42 +0700 Subject: [PATCH 28/40] feat(excel generator): init excel generator plugin --- src/api-docs/openAPIDocumentGenerator.ts | 2 + .../excelGenerator/excelGeneratorModel.ts | 17 +++ .../excelGenerator/excelGeneratorRouter.ts | 121 ++++++++++++++++++ src/server.ts | 2 + 4 files changed, 142 insertions(+) create mode 100644 src/routes/excelGenerator/excelGeneratorModel.ts create mode 100644 src/routes/excelGenerator/excelGeneratorRouter.ts diff --git a/src/api-docs/openAPIDocumentGenerator.ts b/src/api-docs/openAPIDocumentGenerator.ts index 5e7da6c..40a6788 100644 --- a/src/api-docs/openAPIDocumentGenerator.ts +++ b/src/api-docs/openAPIDocumentGenerator.ts @@ -1,5 +1,6 @@ import { OpenApiGeneratorV3, OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; +import { excelGeneratorRegistry } from '@/routes/excelGenerator/excelGeneratorRouter'; import { healthCheckRegistry } from '@/routes/healthCheck/healthCheckRouter'; import { powerpointGeneratorRegistry } from '@/routes/powerpointGenerator/powerpointGeneratorRouter'; import { articleReaderRegistry } from '@/routes/webPageReader/webPageReaderRouter'; @@ -13,6 +14,7 @@ export function generateOpenAPIDocument() { articleReaderRegistry, powerpointGeneratorRegistry, wordGeneratorRegistry, + excelGeneratorRegistry, ]); const generator = new OpenApiGeneratorV3(registry.definitions); diff --git a/src/routes/excelGenerator/excelGeneratorModel.ts b/src/routes/excelGenerator/excelGeneratorModel.ts new file mode 100644 index 0000000..642a1e9 --- /dev/null +++ b/src/routes/excelGenerator/excelGeneratorModel.ts @@ -0,0 +1,17 @@ +import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; +import { z } from 'zod'; + +extendZodWithOpenApi(z); + +// Define Word Generator Response Schema +export type ExcelGeneratorResponse = z.infer; +export const ExcelGeneratorResponseSchema = z.object({ + downloadUrl: z.string().openapi({ + description: 'The file path where the generated Word document is saved.', + }), +}); + +// Request Body Schema +export const ExcelGeneratorRequestBodySchema = z.object({}); + +export type ExcelGeneratorRequestBody = z.infer; diff --git a/src/routes/excelGenerator/excelGeneratorRouter.ts b/src/routes/excelGenerator/excelGeneratorRouter.ts new file mode 100644 index 0000000..d5e90e9 --- /dev/null +++ b/src/routes/excelGenerator/excelGeneratorRouter.ts @@ -0,0 +1,121 @@ +import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; +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 { ExcelGeneratorRequestBodySchema, ExcelGeneratorResponseSchema } from './excelGeneratorModel'; +export const COMPRESS = true; +export const excelGeneratorRegistry = new OpenAPIRegistry(); +excelGeneratorRegistry.register('ExcelGenerator', ExcelGeneratorResponseSchema); +excelGeneratorRegistry.registerPath({ + method: 'post', + path: '/excel-generator/generate', + tags: ['Excel Generator'], + request: { + body: createApiRequestBody(ExcelGeneratorRequestBodySchema, 'application/json'), + }, + responses: createApiResponse(ExcelGeneratorResponseSchema, 'Success'), +}); + +// Create folder to contains generated files +const exportsDir = path.join(__dirname, '../../..', 'excel-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'; + +async function execGenExcelFuncs() { + // TOOD: Implement code logic here + const fileName = `excel-file-${new Date().toISOString().replace(/\D/gi, '')}.docx`; + return fileName; +} + +export const excelGeneratorRouter: 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 { sheets = [] } = _req.body; // TODO: extract excel config object from request body + if (!sheets.length) { + const validateServiceResponse = new ServiceResponse( + ResponseStatus.Failed, + '[Validation Error] Sheets data is required!', + 'Please make sure you have sent the excel sheets content generated from TypingMind.', + StatusCodes.BAD_REQUEST + ); + return handleServiceResponse(validateServiceResponse, res); + } + + try { + const fileName = await execGenExcelFuncs(); + const serviceResponse = new ServiceResponse( + ResponseStatus.Success, + 'File generated successfully', + { + downloadUrl: `${serverUrl}/excel-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 excel file.`; + } + const errorServiceResponse = new ServiceResponse( + ResponseStatus.Failed, + `Error ${errorMessage}`, + responseObject, + StatusCodes.INTERNAL_SERVER_ERROR + ); + return handleServiceResponse(errorServiceResponse, res); + } + }); + return router; +})(); diff --git a/src/server.ts b/src/server.ts index a569651..0da49de 100644 --- a/src/server.ts +++ b/src/server.ts @@ -10,6 +10,7 @@ import rateLimiter from '@/common/middleware/rateLimiter'; import requestLogger from '@/common/middleware/requestLogger'; import { healthCheckRouter } from '@/routes/healthCheck/healthCheckRouter'; +import { excelGeneratorRouter } from './routes/excelGenerator/excelGeneratorRouter'; import { powerpointGeneratorRouter } from './routes/powerpointGenerator/powerpointGeneratorRouter'; import { webPageReaderRouter } from './routes/webPageReader/webPageReaderRouter'; import { wordGeneratorRouter } from './routes/wordGenerator/wordGeneratorRouter'; @@ -40,6 +41,7 @@ app.use('/youtube-transcript', youtubeTranscriptRouter); app.use('/web-page-reader', webPageReaderRouter); app.use('/powerpoint-generator', powerpointGeneratorRouter); app.use('/word-generator', wordGeneratorRouter); +app.use('/excel-generator', excelGeneratorRouter); // Swagger UI app.use(openAPIRouter); From c720ba4f31950b7b6ad1f382a333c246842cf088 Mon Sep 17 00:00:00 2001 From: dat-devuap Date: Tue, 14 Jan 2025 07:51:06 +0700 Subject: [PATCH 29/40] feat(excel generator): add mapping function --- package-lock.json | 104 ++++++++++++++++++ package.json | 1 + .../excelGenerator/excelGeneratorRouter.ts | 83 +++++++++++++- 3 files changed, 182 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 73af284..310b4f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "pptxgenjs": "^3.12.0", "swagger-ui-express": "^5.0.0", "uuidv4": "^6.2.13", + "xlsx": "^0.18.5", "youtube-transcript": "^1.1.0", "zod": "^3.22.4" }, @@ -2295,6 +2296,15 @@ "integrity": "sha512-qQLMr+8o0WC4FZGQTcJiKBVC59JylcPSrTtk6usvmIDFUOCKegapy1VHQwRbFMOFyb/inzUVqHs+eMYKDM1YeQ==", "dev": true }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/agent-base": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", @@ -2783,6 +2793,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chai": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", @@ -3049,6 +3072,15 @@ "node": ">=0.8" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3548,6 +3580,18 @@ "typescript": ">=4" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5055,6 +5099,15 @@ "node": ">= 0.6" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -9444,6 +9497,18 @@ "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", "dev": true }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -11682,6 +11747,24 @@ "node": ">=6" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -11841,6 +11924,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/xml": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", diff --git a/package.json b/package.json index 9cde457..2d6ee2f 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "pptxgenjs": "^3.12.0", "swagger-ui-express": "^5.0.0", "uuidv4": "^6.2.13", + "xlsx": "^0.18.5", "youtube-transcript": "^1.1.0", "zod": "^3.22.4" }, diff --git a/src/routes/excelGenerator/excelGeneratorRouter.ts b/src/routes/excelGenerator/excelGeneratorRouter.ts index d5e90e9..c7b4d34 100644 --- a/src/routes/excelGenerator/excelGeneratorRouter.ts +++ b/src/routes/excelGenerator/excelGeneratorRouter.ts @@ -4,6 +4,7 @@ import fs from 'fs'; import { StatusCodes } from 'http-status-codes'; import cron from 'node-cron'; import path from 'path'; +import XLSX from 'xlsx'; import { createApiRequestBody } from '@/api-docs/openAPIRequestBuilders'; import { createApiResponse } from '@/api-docs/openAPIResponseBuilders'; @@ -68,9 +69,79 @@ cron.schedule('0 * * * *', () => { const serverUrl = process.env.RENDER_EXTERNAL_URL || 'http://localhost:3000'; -async function execGenExcelFuncs() { - // TOOD: Implement code logic here - const fileName = `excel-file-${new Date().toISOString().replace(/\D/gi, '')}.docx`; +interface Table { + startCell: string; + rows: string[][]; + columns?: string[]; + sorting?: { column: string; order: 'asc' | 'desc' }; + formulas?: { column: string; formula: string }[]; + filtering?: { column: string; criteria: string }[]; + skipHeader?: boolean; +} + +interface SheetData { + sheetName: string; + tables: Table[]; +} + +export function execGenExcelFuncs(sheetsData: SheetData[]): string { + const workbook = XLSX.utils.book_new(); + + sheetsData.forEach(({ sheetName, tables }) => { + const worksheet = XLSX.utils.aoa_to_sheet([]); + + tables.forEach(({ startCell, rows, columns, skipHeader, sorting, formulas, filtering }) => { + const decodedCell = XLSX.utils.decode_cell(startCell); + const startRow = decodedCell.r; // Row index (0-based) + const startCol = decodedCell.c; // Column index (0-based) + + let rowIndex = 0; // Reset rowIndex for each table + + // Add column headers if not skipped + if (!skipHeader && columns) { + XLSX.utils.sheet_add_aoa(worksheet, [columns], { origin: { c: startCol, r: startRow + rowIndex } }); + rowIndex++; // Increment row index after adding headers + } + + // Add rows + XLSX.utils.sheet_add_aoa(worksheet, rows, { origin: { c: startCol, r: startRow + rowIndex } }); + rowIndex += rows.length; // Increment row index by the number of rows added + + // Apply sorting + if (sorting) { + const columnIndex = sorting.column.charCodeAt(0) - 65; // Convert 'A' to 0, 'B' to 1, etc. + rows.sort((a, b) => + sorting.order === 'asc' + ? a[columnIndex].localeCompare(b[columnIndex]) + : b[columnIndex].localeCompare(a[columnIndex]) + ); + } + + // Apply formulas + if (formulas) { + formulas.forEach(({ column, formula }) => { + const colIndex = XLSX.utils.decode_col(column); + rows.forEach((_, rowIdx) => { + const cellRef = XLSX.utils.encode_cell({ c: colIndex, r: startRow + rowIdx + (skipHeader ? 0 : 1) }); // Adjust row for header + worksheet[cellRef] = { t: 'n', f: formula }; + }); + }); + } + + // Apply filtering (not natively supported in XLSX; requires client-side configuration) + if (filtering) { + console.warn('Filtering is not implemented in this version.'); + } + }); + + XLSX.utils.book_append_sheet(workbook, worksheet, sheetName); + }); + + const fileName = `excel-file-${new Date().toISOString().replace(/\D/gi, '')}.xlsx`; + const filePath = path.join(exportsDir, fileName); + + XLSX.writeFile(workbook, filePath); + return fileName; } @@ -80,8 +151,8 @@ export const excelGeneratorRouter: Router = (() => { router.use('/downloads', express.static(exportsDir)); router.post('/generate', async (_req: Request, res: Response) => { - const { sheets = [] } = _req.body; // TODO: extract excel config object from request body - if (!sheets.length) { + const { sheetsData } = _req.body; // TODO: extract excel config object from request body + if (!sheetsData.length) { const validateServiceResponse = new ServiceResponse( ResponseStatus.Failed, '[Validation Error] Sheets data is required!', @@ -92,7 +163,7 @@ export const excelGeneratorRouter: Router = (() => { } try { - const fileName = await execGenExcelFuncs(); + const fileName = execGenExcelFuncs(sheetsData); const serviceResponse = new ServiceResponse( ResponseStatus.Success, 'File generated successfully', From 99bc6a70019206cb4b46f117c486dcdce5c6f3f1 Mon Sep 17 00:00:00 2001 From: dat-devuap Date: Wed, 15 Jan 2025 00:46:50 +0700 Subject: [PATCH 30/40] feat(excel generator): use exceljs, handle multiple tables with number, currency, percent formats --- package-lock.json | 791 +++++++++++++++--- package.json | 2 +- .../excelGenerator/excelGeneratorRouter.ts | 200 ++++- 3 files changed, 837 insertions(+), 156 deletions(-) diff --git a/package-lock.json b/package-lock.json index 310b4f7..d77d13d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "docx": "^9.1.0", "dotenv": "^16.4.5", "envalid": "^8.0.0", + "exceljs": "^4.4.0", "express": "^4.19.2", "express-rate-limit": "^7.2.0", "fs": "^0.0.1-security", @@ -32,7 +33,6 @@ "pptxgenjs": "^3.12.0", "swagger-ui-express": "^5.0.0", "uuidv4": "^6.2.13", - "xlsx": "^0.18.5", "youtube-transcript": "^1.1.0", "zod": "^3.22.4" }, @@ -883,6 +883,43 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@fast-csv/format": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz", + "integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isboolean": "^3.0.3", + "lodash.isequal": "^4.5.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0" + } + }, + "node_modules/@fast-csv/format/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==" + }, + "node_modules/@fast-csv/parse": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.3.6.tgz", + "integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.groupby": "^4.6.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0", + "lodash.isundefined": "^3.0.1", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/@fast-csv/parse/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==" + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -2296,15 +2333,6 @@ "integrity": "sha512-qQLMr+8o0WC4FZGQTcJiKBVC59JylcPSrTtk6usvmIDFUOCKegapy1VHQwRbFMOFyb/inzUVqHs+eMYKDM1YeQ==", "dev": true }, - "node_modules/adler-32": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", - "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, "node_modules/agent-base": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", @@ -2409,6 +2437,110 @@ "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", "dev": true }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/archiver-utils/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/archiver-utils/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==", + "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/archiver-utils/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==" + }, + "node_modules/archiver-utils/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==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2462,6 +2594,11 @@ "node": ">=4" } }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==" + }, "node_modules/async-retry": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", @@ -2497,8 +2634,7 @@ "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, "node_modules/base64-js": { "version": "1.5.1", @@ -2534,17 +2670,41 @@ "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==", "dev": true }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==" + }, "node_modules/body-parser": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", @@ -2624,7 +2784,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, "dependencies": { "balanced-match": "^1.0.0" } @@ -2645,7 +2804,6 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, "funding": [ { "type": "github", @@ -2665,12 +2823,36 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "engines": { + "node": "*" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, + "node_modules/buffer-indexof-polyfill": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", + "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "engines": { + "node": ">=0.2.0" + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -2793,19 +2975,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cfb": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", - "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", - "license": "Apache-2.0", - "dependencies": { - "adler-32": "~1.3.0", - "crc-32": "~1.2.0" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/chai": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", @@ -2824,6 +2993,17 @@ "node": ">=4" } }, + "node_modules/chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "dependencies": { + "traverse": ">=0.3.0 <0.4" + }, + "engines": { + "node": "*" + } + }, "node_modules/chalk": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", @@ -3072,15 +3252,6 @@ "node": ">=0.8" } }, - "node_modules/codepage": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", - "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3144,11 +3315,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, "node_modules/concat-stream": { "version": "2.0.0", @@ -3592,6 +3776,18 @@ "node": ">=0.8" } }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3685,6 +3881,11 @@ "node": "*" } }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==" + }, "node_modules/debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", @@ -4042,6 +4243,41 @@ "node": ">= 0.4" } }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/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==", + "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/duplexer2/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==" + }, + "node_modules/duplexer2/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==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -4094,7 +4330,6 @@ "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", - "dev": true, "dependencies": { "once": "^1.4.0" } @@ -4668,6 +4903,44 @@ "node": ">=0.8.x" } }, + "node_modules/exceljs": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", + "integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==", + "dependencies": { + "archiver": "^5.0.0", + "dayjs": "^1.8.34", + "fast-csv": "^4.3.1", + "jszip": "^3.10.1", + "readable-stream": "^3.6.0", + "saxes": "^5.0.1", + "tmp": "^0.2.0", + "unzipper": "^0.10.11", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/exceljs/node_modules/saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/exceljs/node_modules/tmp": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", + "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", + "engines": { + "node": ">=14.14" + } + }, "node_modules/execa": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", @@ -4807,6 +5080,18 @@ "integrity": "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==", "dev": true }, + "node_modules/fast-csv": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", + "integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==", + "dependencies": { + "@fast-csv/format": "4.3.5", + "@fast-csv/parse": "4.3.6" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5099,15 +5384,6 @@ "node": ">= 0.6" } }, - "node_modules/frac": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", - "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -5121,11 +5397,15 @@ "resolved": "https://registry.npmjs.org/fs/-/fs-0.0.1-security.tgz", "integrity": "sha512-3XY9e1pP0CVEUCdj5BmfIZxRBTSDycnbqhIOGec9QYtmVH2fbLpj86CFWkrNOkt/Fvty4KZG5lTglL9j/gJ87w==" }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, "node_modules/fsevents": { "version": "2.3.3", @@ -5141,6 +5421,73 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/fstream/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/fstream/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fstream/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/fstream/node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -5494,8 +5841,7 @@ "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, "node_modules/graphemer": { "version": "1.4.0", @@ -5842,7 +6188,6 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -6689,6 +7034,44 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/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==", + "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/lazystream/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==" + }, + "node_modules/lazystream/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==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -6772,6 +7155,11 @@ } } }, + "node_modules/listenercount": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==" + }, "node_modules/listr2": { "version": "8.2.5", "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.2.5.tgz", @@ -6847,17 +7235,55 @@ "integrity": "sha512-kZzYOKspf8XVX5AvmQF94gQW0lejFVgb80G85bU4ZWzoJ6C03PQg3coYAUpSTpQWelrZELd3XWgHzw4Ck5kaIw==", "dev": true }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==" + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==" + }, "node_modules/lodash.escaperegexp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", - "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", - "dev": true + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==" + }, + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==" + }, + "node_modules/lodash.isfunction": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", + "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==" + }, + "node_modules/lodash.isnil": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz", + "integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==" }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" }, "node_modules/lodash.isstring": { "version": "4.0.1", @@ -6865,6 +7291,11 @@ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", "dev": true }, + "node_modules/lodash.isundefined": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz", + "integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==" + }, "node_modules/lodash.kebabcase": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", @@ -6901,11 +7332,15 @@ "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", "dev": true }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==" + }, "node_modules/lodash.uniq": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", - "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", - "dev": true + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==" }, "node_modules/lodash.uniqby": { "version": "4.7.0", @@ -7250,7 +7685,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -7264,6 +7698,17 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/mlly": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.3.tgz", @@ -7400,6 +7845,14 @@ "node": "^16.14.0 || >=18.0.0" } }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/normalize-url": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.0.1.tgz", @@ -7496,7 +7949,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "dependencies": { "wrappy": "1" } @@ -7845,7 +8297,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -8643,7 +9094,6 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -8653,6 +9103,25 @@ "node": ">= 6" } }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/readdirp": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", @@ -9497,18 +9966,6 @@ "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", "dev": true }, - "node_modules/ssf": { - "version": "0.11.2", - "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", - "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", - "license": "Apache-2.0", - "dependencies": { - "frac": "~1.1.2" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -9864,6 +10321,21 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -10100,6 +10572,14 @@ "node": ">=18" } }, + "node_modules/traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "engines": { + "node": "*" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -10813,6 +11293,50 @@ "node": ">= 0.8" } }, + "node_modules/unzipper": { + "version": "0.10.14", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", + "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", + "dependencies": { + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "^1.0.12", + "graceful-fs": "^4.2.2", + "listenercount": "~1.0.1", + "readable-stream": "~2.3.6", + "setimmediate": "~1.0.4" + } + }, + "node_modules/unzipper/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==", + "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/unzipper/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==" + }, + "node_modules/unzipper/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==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/update-notifier": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-7.3.1.tgz", @@ -11747,24 +12271,6 @@ "node": ">=6" } }, - "node_modules/wmf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", - "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/word": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", - "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -11889,8 +12395,7 @@ "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/ws": { "version": "8.18.0", @@ -11924,27 +12429,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/xlsx": { - "version": "0.18.5", - "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", - "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", - "license": "Apache-2.0", - "dependencies": { - "adler-32": "~1.3.0", - "cfb": "~1.2.1", - "codepage": "~1.15.0", - "crc-32": "~1.2.1", - "ssf": "~0.11.2", - "wmf": "~1.0.1", - "word": "~0.3.0" - }, - "bin": { - "xlsx": "bin/xlsx.njs" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/xml": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", @@ -12082,6 +12566,79 @@ "node": ">=18.0.0" } }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/zip-stream/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/zip-stream/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/zod": { "version": "3.23.8", "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", diff --git a/package.json b/package.json index 2d6ee2f..42db61f 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "docx": "^9.1.0", "dotenv": "^16.4.5", "envalid": "^8.0.0", + "exceljs": "^4.4.0", "express": "^4.19.2", "express-rate-limit": "^7.2.0", "fs": "^0.0.1-security", @@ -41,7 +42,6 @@ "pptxgenjs": "^3.12.0", "swagger-ui-express": "^5.0.0", "uuidv4": "^6.2.13", - "xlsx": "^0.18.5", "youtube-transcript": "^1.1.0", "zod": "^3.22.4" }, diff --git a/src/routes/excelGenerator/excelGeneratorRouter.ts b/src/routes/excelGenerator/excelGeneratorRouter.ts index c7b4d34..5245330 100644 --- a/src/routes/excelGenerator/excelGeneratorRouter.ts +++ b/src/routes/excelGenerator/excelGeneratorRouter.ts @@ -1,10 +1,10 @@ import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; +import * as ExcelJS from 'exceljs'; 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 XLSX from 'xlsx'; import { createApiRequestBody } from '@/api-docs/openAPIRequestBuilders'; import { createApiResponse } from '@/api-docs/openAPIResponseBuilders'; @@ -69,43 +69,169 @@ cron.schedule('0 * * * *', () => { const serverUrl = process.env.RENDER_EXTERNAL_URL || 'http://localhost:3000'; -interface Table { - startCell: string; - rows: string[][]; - columns?: string[]; - sorting?: { column: string; order: 'asc' | 'desc' }; - formulas?: { column: string; formula: string }[]; - filtering?: { column: string; criteria: string }[]; - skipHeader?: boolean; -} - interface SheetData { sheetName: string; - tables: Table[]; + tables: { + title: string; + startCell: string; + rows: any[][]; + columns: { name: string; type: string; format: string }[]; // types that have format, number, percent, currency + skipHeader?: boolean; + sorting?: { column: string; order: 'asc' | 'desc' }; + filtering?: boolean; + }[]; +} + +// Helper function to convert column letter (e.g., 'A') to column index (e.g., 1) +function columnLetterToNumber(letter: string): number { + let column = 0; + for (let i = 0; i < letter.length; i++) { + column = column * 26 + letter.charCodeAt(i) - 'A'.charCodeAt(0) + 1; + } + return column; +} + +// Helper function to auto-fit column widths based on content +function autoFitColumns( + worksheet: ExcelJS.Worksheet, + startRow: number, + rows: any[], + numColumns: number, + startCol: number +): void { + for (let colIdx = 0; colIdx < numColumns; colIdx++) { + let maxLength = 0; + + // Check the max length of the content in the column + rows.forEach((row) => { + const cellValue = row[colIdx]; + if (cellValue != null) { + const cellLength = String(cellValue).length; + maxLength = Math.max(maxLength, cellLength); + } + }); + + // Account for the header row + const headerCell = worksheet.getCell(startRow, startCol + colIdx).value; + if (headerCell != null) { + const headerLength = String(headerCell).length; + maxLength = Math.max(maxLength, headerLength); + } + + // Set the column width + worksheet.getColumn(startCol + colIdx).width = maxLength + 2; // Adding some padding + } } export function execGenExcelFuncs(sheetsData: SheetData[]): string { - const workbook = XLSX.utils.book_new(); + const workbook = new ExcelJS.Workbook(); sheetsData.forEach(({ sheetName, tables }) => { - const worksheet = XLSX.utils.aoa_to_sheet([]); + const worksheet = workbook.addWorksheet(sheetName); - tables.forEach(({ startCell, rows, columns, skipHeader, sorting, formulas, filtering }) => { - const decodedCell = XLSX.utils.decode_cell(startCell); - const startRow = decodedCell.r; // Row index (0-based) - const startCol = decodedCell.c; // Column index (0-based) + tables.forEach(({ startCell, title, rows, columns, skipHeader, sorting, filtering }) => { + const startCol = columnLetterToNumber(startCell[0]); // Convert column letter to index (e.g., 'A' -> 1) + const startRow = parseInt(startCell.slice(1)); // Extract the row number (e.g., 'A1' -> 1) + let rowIndex = startRow; // Set the initial row index to startRow for each table - let rowIndex = 0; // Reset rowIndex for each table + // Add table name row + if (title) { + worksheet.getCell(rowIndex, startCol).value = title; + worksheet.mergeCells(rowIndex, startCol, rowIndex, startCol + columns.length - 1); + worksheet.getCell(rowIndex, startCol).alignment = { horizontal: 'center', vertical: 'middle' }; + rowIndex++; // Move to the next row + } // Add column headers if not skipped if (!skipHeader && columns) { - XLSX.utils.sheet_add_aoa(worksheet, [columns], { origin: { c: startCol, r: startRow + rowIndex } }); + columns.forEach((col, colIdx) => { + worksheet.getCell(rowIndex, startCol + colIdx).value = col.name; + }); rowIndex++; // Increment row index after adding headers } - // Add rows - XLSX.utils.sheet_add_aoa(worksheet, rows, { origin: { c: startCol, r: startRow + rowIndex } }); - rowIndex += rows.length; // Increment row index by the number of rows added + // Map headers to types + const columnTypes = columns?.map((col: any) => col.type) || []; + const columnFormats = + columns?.map((col: any) => { + let format = undefined; + switch (col.type) { + case 'number': + format = col.format || undefined; + break; + case 'percent': + format = col.format || '0.00%'; // Default to percentage format + break; + case 'currency': + format = col.format || '$#,##0'; // Default to currency format + break; + case 'date': + format = col.format || undefined; + break; + } + return format; + }) || []; + + // Add rows with data types + rows.forEach((row) => { + row.forEach((value, colIdx) => { + const cellType = columnTypes[colIdx]; + const format = columnFormats[colIdx]; + let cellValue: any = value != null ? value : ''; // Handle empty/null values + + // Check if the value is a formula + if (typeof value === 'object' && value.f) { + const formulaCell: any = { formula: value.f }; // Handle formula + if (cellType === 'percent' || cellType === 'currency' || cellType === 'number' || cellType === 'date') { + formulaCell.style = { numFmt: format }; // Apply number format + } + worksheet.getCell(rowIndex, startCol + colIdx).value = formulaCell; + } else if (value != null) { + // Assign cell type based on the header definition + switch (cellType) { + case 'number': { + cellValue = !isNaN(Number(value)) ? Math.round(Number(value)) : value; + worksheet.getCell(rowIndex, startCol + colIdx).value = cellValue; + worksheet.getCell(rowIndex, startCol + colIdx).numFmt = format || '0'; + break; + } + case 'boolean': { + cellValue = Boolean(value); + worksheet.getCell(rowIndex, startCol + colIdx).value = cellValue; + break; + } + case 'date': { + const parsedDate = new Date(value); + cellValue = !isNaN(parsedDate.getTime()) ? parsedDate : value; + worksheet.getCell(rowIndex, startCol + colIdx).value = cellValue; + worksheet.getCell(rowIndex, startCol + colIdx).numFmt = format || 'yyyy-mm-dd'; + break; + } + case 'percent': { + cellValue = !isNaN(Number(value)) ? Number(value) : value; + worksheet.getCell(rowIndex, startCol + colIdx).value = cellValue; + worksheet.getCell(rowIndex, startCol + colIdx).numFmt = format || '0.00%'; + break; + } + case 'currency': { + cellValue = !isNaN(Number(value)) ? Number(value) : value; + worksheet.getCell(rowIndex, startCol + colIdx).value = cellValue; + worksheet.getCell(rowIndex, startCol + colIdx).numFmt = format || '$#,##0'; + break; + } + case 'string': + default: { + cellValue = String(value); + worksheet.getCell(rowIndex, startCol + colIdx).value = cellValue; + break; + } + } + } else { + worksheet.getCell(rowIndex, startCol + colIdx).value = ''; // Handle empty value + } + }); + rowIndex++; // Move to the next row + }); // Apply sorting if (sorting) { @@ -117,30 +243,28 @@ export function execGenExcelFuncs(sheetsData: SheetData[]): string { ); } - // Apply formulas - if (formulas) { - formulas.forEach(({ column, formula }) => { - const colIndex = XLSX.utils.decode_col(column); - rows.forEach((_, rowIdx) => { - const cellRef = XLSX.utils.encode_cell({ c: colIndex, r: startRow + rowIdx + (skipHeader ? 0 : 1) }); // Adjust row for header - worksheet[cellRef] = { t: 'n', f: formula }; - }); - }); - } - - // Apply filtering (not natively supported in XLSX; requires client-side configuration) + // Apply filtering (not natively supported in ExcelJS; requires client-side configuration) if (filtering) { console.warn('Filtering is not implemented in this version.'); } - }); - XLSX.utils.book_append_sheet(workbook, worksheet, sheetName); + // Auto-fit column widths + autoFitColumns(worksheet, startRow, rows, columns.length, startCol); + }); }); + // Write the workbook to a file const fileName = `excel-file-${new Date().toISOString().replace(/\D/gi, '')}.xlsx`; const filePath = path.join(exportsDir, fileName); - XLSX.writeFile(workbook, filePath); + workbook.xlsx + .writeFile(filePath) + .then(() => { + console.log('File has been written to', filePath); + }) + .catch((err) => { + console.error('Error writing Excel file', err); + }); return fileName; } From 6e7638b83033791876b4a2e921b617a030ef38ed Mon Sep 17 00:00:00 2001 From: dat-devuap Date: Wed, 15 Jan 2025 09:37:05 +0700 Subject: [PATCH 31/40] feat(excel generator): support adding styles as border, alignment, and font weight --- .../excelGenerator/excelGeneratorRouter.ts | 64 +++++++++++++------ 1 file changed, 45 insertions(+), 19 deletions(-) diff --git a/src/routes/excelGenerator/excelGeneratorRouter.ts b/src/routes/excelGenerator/excelGeneratorRouter.ts index 5245330..d9df641 100644 --- a/src/routes/excelGenerator/excelGeneratorRouter.ts +++ b/src/routes/excelGenerator/excelGeneratorRouter.ts @@ -136,16 +136,34 @@ export function execGenExcelFuncs(sheetsData: SheetData[]): string { // Add table name row if (title) { - worksheet.getCell(rowIndex, startCol).value = title; + const startCell = worksheet.getCell(rowIndex, startCol); + startCell.value = title; worksheet.mergeCells(rowIndex, startCol, rowIndex, startCol + columns.length - 1); - worksheet.getCell(rowIndex, startCol).alignment = { horizontal: 'center', vertical: 'middle' }; + startCell.alignment = { horizontal: 'center', vertical: 'middle' }; + startCell.font = { bold: true }; + // Apply borders to the header cell + startCell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' }, + }; rowIndex++; // Move to the next row } // Add column headers if not skipped if (!skipHeader && columns) { columns.forEach((col, colIdx) => { - worksheet.getCell(rowIndex, startCol + colIdx).value = col.name; + const cell = worksheet.getCell(rowIndex, startCol + colIdx); + cell.value = col.name; + cell.font = { bold: true }; // Apply bold font + // Apply borders to the header cell + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' }, + }; }); rowIndex++; // Increment row index after adding headers } @@ -178,57 +196,65 @@ export function execGenExcelFuncs(sheetsData: SheetData[]): string { const cellType = columnTypes[colIdx]; const format = columnFormats[colIdx]; let cellValue: any = value != null ? value : ''; // Handle empty/null values - + const cell = worksheet.getCell(rowIndex, startCol + colIdx); // Check if the value is a formula - if (typeof value === 'object' && value.f) { - const formulaCell: any = { formula: value.f }; // Handle formula + if (typeof value === 'object' && value.formula) { + const formulaCell: any = { formula: value.formula }; // Handle formula if (cellType === 'percent' || cellType === 'currency' || cellType === 'number' || cellType === 'date') { - formulaCell.style = { numFmt: format }; // Apply number format + cell.numFmt = format; // Apply number format } - worksheet.getCell(rowIndex, startCol + colIdx).value = formulaCell; + cell.value = formulaCell; } else if (value != null) { // Assign cell type based on the header definition switch (cellType) { case 'number': { cellValue = !isNaN(Number(value)) ? Math.round(Number(value)) : value; - worksheet.getCell(rowIndex, startCol + colIdx).value = cellValue; - worksheet.getCell(rowIndex, startCol + colIdx).numFmt = format || '0'; + cell.value = cellValue; + cell.numFmt = format || '0'; break; } case 'boolean': { cellValue = Boolean(value); - worksheet.getCell(rowIndex, startCol + colIdx).value = cellValue; + cell.value = cellValue; break; } case 'date': { const parsedDate = new Date(value); cellValue = !isNaN(parsedDate.getTime()) ? parsedDate : value; - worksheet.getCell(rowIndex, startCol + colIdx).value = cellValue; - worksheet.getCell(rowIndex, startCol + colIdx).numFmt = format || 'yyyy-mm-dd'; + cell.value = cellValue; + cell.numFmt = format || 'yyyy-mm-dd'; break; } case 'percent': { cellValue = !isNaN(Number(value)) ? Number(value) : value; - worksheet.getCell(rowIndex, startCol + colIdx).value = cellValue; - worksheet.getCell(rowIndex, startCol + colIdx).numFmt = format || '0.00%'; + cell.value = cellValue; + cell.numFmt = format || '0.00%'; break; } case 'currency': { cellValue = !isNaN(Number(value)) ? Number(value) : value; - worksheet.getCell(rowIndex, startCol + colIdx).value = cellValue; - worksheet.getCell(rowIndex, startCol + colIdx).numFmt = format || '$#,##0'; + cell.value = cellValue; + cell.numFmt = format || '$#,##0'; break; } case 'string': default: { cellValue = String(value); - worksheet.getCell(rowIndex, startCol + colIdx).value = cellValue; + cell.value = cellValue; break; } } } else { - worksheet.getCell(rowIndex, startCol + colIdx).value = ''; // Handle empty value + cell.value = ''; // Handle empty value } + + // Apply borders to the cell + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' }, + }; }); rowIndex++; // Move to the next row }); From 06fc09f1eaecc21a39b4b1c6887a4b985c3a639e Mon Sep 17 00:00:00 2001 From: dat-devuap Date: Wed, 15 Jan 2025 11:00:41 +0700 Subject: [PATCH 32/40] feat(excel generator): refactor code cell value --- .../excelGenerator/excelGeneratorRouter.ts | 30 ++++++++----------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/src/routes/excelGenerator/excelGeneratorRouter.ts b/src/routes/excelGenerator/excelGeneratorRouter.ts index d9df641..49c941f 100644 --- a/src/routes/excelGenerator/excelGeneratorRouter.ts +++ b/src/routes/excelGenerator/excelGeneratorRouter.ts @@ -156,6 +156,7 @@ export function execGenExcelFuncs(sheetsData: SheetData[]): string { columns.forEach((col, colIdx) => { const cell = worksheet.getCell(rowIndex, startCol + colIdx); cell.value = col.name; + cell.alignment = { horizontal: 'center', vertical: 'middle' }; cell.font = { bold: true }; // Apply bold font // Apply borders to the header cell cell.border = { @@ -198,54 +199,52 @@ export function execGenExcelFuncs(sheetsData: SheetData[]): string { let cellValue: any = value != null ? value : ''; // Handle empty/null values const cell = worksheet.getCell(rowIndex, startCol + colIdx); // Check if the value is a formula - if (typeof value === 'object' && value.formula) { - const formulaCell: any = { formula: value.formula }; // Handle formula + if (typeof cellValue === 'object' && cellValue.formula) { + const formulaCell: any = { formula: cellValue.formula }; // Handle formula if (cellType === 'percent' || cellType === 'currency' || cellType === 'number' || cellType === 'date') { cell.numFmt = format; // Apply number format } cell.value = formulaCell; - } else if (value != null) { + } else { // Assign cell type based on the header definition switch (cellType) { case 'number': { - cellValue = !isNaN(Number(value)) ? Math.round(Number(value)) : value; + cellValue = !isNaN(Number(cellValue)) ? Math.round(Number(cellValue)) : cellValue; cell.value = cellValue; cell.numFmt = format || '0'; break; } case 'boolean': { - cellValue = Boolean(value); + cellValue = Boolean(cellValue); cell.value = cellValue; break; } case 'date': { - const parsedDate = new Date(value); - cellValue = !isNaN(parsedDate.getTime()) ? parsedDate : value; + const parsedDate = new Date(cellValue); + cellValue = !isNaN(parsedDate.getTime()) ? parsedDate : cellValue; cell.value = cellValue; cell.numFmt = format || 'yyyy-mm-dd'; break; } case 'percent': { - cellValue = !isNaN(Number(value)) ? Number(value) : value; + cellValue = !isNaN(Number(cellValue)) ? Number(cellValue) : cellValue; cell.value = cellValue; cell.numFmt = format || '0.00%'; break; } case 'currency': { - cellValue = !isNaN(Number(value)) ? Number(value) : value; + cellValue = !isNaN(Number(cellValue)) ? Number(cellValue) : cellValue; cell.value = cellValue; cell.numFmt = format || '$#,##0'; break; } case 'string': default: { - cellValue = String(value); + cellValue = String(cellValue); cell.value = cellValue; break; } } - } else { - cell.value = ''; // Handle empty value } // Apply borders to the cell @@ -261,12 +260,7 @@ export function execGenExcelFuncs(sheetsData: SheetData[]): string { // Apply sorting if (sorting) { - const columnIndex = sorting.column.charCodeAt(0) - 65; // Convert 'A' to 0, 'B' to 1, etc. - rows.sort((a, b) => - sorting.order === 'asc' - ? a[columnIndex].localeCompare(b[columnIndex]) - : b[columnIndex].localeCompare(a[columnIndex]) - ); + console.warn('Sorting is not implemented in this version.'); } // Apply filtering (not natively supported in ExcelJS; requires client-side configuration) From 4155f1c329bfaf061bafdf9fae0903b791450db1 Mon Sep 17 00:00:00 2001 From: dat-devuap Date: Wed, 15 Jan 2025 14:49:21 +0700 Subject: [PATCH 33/40] feat(excel generator): add excel configs --- .../excelGenerator/excelGeneratorRouter.ts | 125 ++++++++++++------ 1 file changed, 84 insertions(+), 41 deletions(-) diff --git a/src/routes/excelGenerator/excelGeneratorRouter.ts b/src/routes/excelGenerator/excelGeneratorRouter.ts index 49c941f..4bd2143 100644 --- a/src/routes/excelGenerator/excelGeneratorRouter.ts +++ b/src/routes/excelGenerator/excelGeneratorRouter.ts @@ -76,12 +76,30 @@ interface SheetData { startCell: string; rows: any[][]; columns: { name: string; type: string; format: string }[]; // types that have format, number, percent, currency - skipHeader?: boolean; - sorting?: { column: string; order: 'asc' | 'desc' }; - filtering?: boolean; + skipHeader: boolean; }[]; } +interface ExcelConfig { + fontFamily: string; + titleFontSize: number; + headerFontSize: number; + fontSize: number; + autoFilter: boolean; + borderStyle: ExcelJS.BorderStyle; // thin, double, dashed, thick + wrapText: boolean; +} + +const DEFAULT_EXCEL_CONFIGS: ExcelConfig = { + fontFamily: 'Calibri', + titleFontSize: 16, + headerFontSize: 11, + fontSize: 11, + autoFilter: false, + wrapText: false, + borderStyle: 'thin', +}; + // Helper function to convert column letter (e.g., 'A') to column index (e.g., 1) function columnLetterToNumber(letter: string): number { let column = 0; @@ -123,13 +141,45 @@ function autoFitColumns( } } -export function execGenExcelFuncs(sheetsData: SheetData[]): string { +export function execGenExcelFuncs(sheetsData: SheetData[], excelConfigs: ExcelConfig): string { const workbook = new ExcelJS.Workbook(); + const borderConfigs = { + top: { style: excelConfigs.borderStyle }, + left: { style: excelConfigs.borderStyle }, + bottom: { style: excelConfigs.borderStyle }, + right: { style: excelConfigs.borderStyle }, + }; + const titleAlignmentConfigs: any = { + horizontal: 'center', + vertical: 'middle', + wrapText: excelConfigs.wrapText, + }; + const titleFontConfigs: any = { + name: excelConfigs.fontFamily, + bold: true, + size: excelConfigs.titleFontSize, + }; + const headerAligmentConfigs: any = { + wrapText: excelConfigs.wrapText, + horizontal: 'center', + vertical: 'middle', + }; + const headerFontConfigs: any = { + name: excelConfigs.fontFamily, + bold: true, + size: excelConfigs.headerFontSize, + }; + const cellAlignmentConfigs: any = { + wrapText: excelConfigs.wrapText, + }; + const cellFontConfigs: any = { + name: excelConfigs.fontFamily, + size: excelConfigs.fontSize, + }; sheetsData.forEach(({ sheetName, tables }) => { const worksheet = workbook.addWorksheet(sheetName); - - tables.forEach(({ startCell, title, rows, columns, skipHeader, sorting, filtering }) => { + tables.forEach(({ startCell, title, rows, columns, skipHeader }) => { const startCol = columnLetterToNumber(startCell[0]); // Convert column letter to index (e.g., 'A' -> 1) const startRow = parseInt(startCell.slice(1)); // Extract the row number (e.g., 'A1' -> 1) let rowIndex = startRow; // Set the initial row index to startRow for each table @@ -139,15 +189,9 @@ export function execGenExcelFuncs(sheetsData: SheetData[]): string { const startCell = worksheet.getCell(rowIndex, startCol); startCell.value = title; worksheet.mergeCells(rowIndex, startCol, rowIndex, startCol + columns.length - 1); - startCell.alignment = { horizontal: 'center', vertical: 'middle' }; - startCell.font = { bold: true }; - // Apply borders to the header cell - startCell.border = { - top: { style: 'thin' }, - left: { style: 'thin' }, - bottom: { style: 'thin' }, - right: { style: 'thin' }, - }; + startCell.alignment = titleAlignmentConfigs; + startCell.font = titleFontConfigs; + startCell.border = borderConfigs; rowIndex++; // Move to the next row } @@ -156,15 +200,9 @@ export function execGenExcelFuncs(sheetsData: SheetData[]): string { columns.forEach((col, colIdx) => { const cell = worksheet.getCell(rowIndex, startCol + colIdx); cell.value = col.name; - cell.alignment = { horizontal: 'center', vertical: 'middle' }; - cell.font = { bold: true }; // Apply bold font - // Apply borders to the header cell - cell.border = { - top: { style: 'thin' }, - left: { style: 'thin' }, - bottom: { style: 'thin' }, - right: { style: 'thin' }, - }; + cell.alignment = headerAligmentConfigs; + cell.font = headerFontConfigs; + cell.border = borderConfigs; }); rowIndex++; // Increment row index after adding headers } @@ -247,25 +285,21 @@ export function execGenExcelFuncs(sheetsData: SheetData[]): string { } } - // Apply borders to the cell - cell.border = { - top: { style: 'thin' }, - left: { style: 'thin' }, - bottom: { style: 'thin' }, - right: { style: 'thin' }, - }; + // Apply styles to the cell + cell.font = cellFontConfigs; + cell.border = borderConfigs; + cell.alignment = cellAlignmentConfigs; }); rowIndex++; // Move to the next row }); - // Apply sorting - if (sorting) { - console.warn('Sorting is not implemented in this version.'); - } - - // Apply filtering (not natively supported in ExcelJS; requires client-side configuration) - if (filtering) { - console.warn('Filtering is not implemented in this version.'); + // Apply auto-filter + if (excelConfigs.autoFilter) { + const lastCol = startCol + columns.length - 1; // Calculate the last column + worksheet.autoFilter = { + from: { row: startRow + 1, column: startCol }, // Start from header row + to: { row: rowIndex - 1, column: lastCol }, // End at the last row of data + }; } // Auto-fit column widths @@ -295,7 +329,7 @@ export const excelGeneratorRouter: Router = (() => { router.use('/downloads', express.static(exportsDir)); router.post('/generate', async (_req: Request, res: Response) => { - const { sheetsData } = _req.body; // TODO: extract excel config object from request body + const { sheetsData, excelConfigs } = _req.body; // TODO: extract excel config object from request body if (!sheetsData.length) { const validateServiceResponse = new ServiceResponse( ResponseStatus.Failed, @@ -307,7 +341,16 @@ export const excelGeneratorRouter: Router = (() => { } try { - const fileName = execGenExcelFuncs(sheetsData); + const fileName = execGenExcelFuncs(sheetsData, { + fontFamily: excelConfigs.fontFamily ?? DEFAULT_EXCEL_CONFIGS.fontFamily, + titleFontSize: excelConfigs.titleFontSize ?? DEFAULT_EXCEL_CONFIGS.titleFontSize, + headerFontSize: excelConfigs.headerFontSize ?? DEFAULT_EXCEL_CONFIGS.headerFontSize, + fontSize: excelConfigs.fontSize ?? DEFAULT_EXCEL_CONFIGS.fontSize, + autoFilter: excelConfigs.autoFilter ?? DEFAULT_EXCEL_CONFIGS.autoFilter, + borderStyle: excelConfigs.borderStyle ?? DEFAULT_EXCEL_CONFIGS.borderStyle, + wrapText: excelConfigs.wrapText ?? DEFAULT_EXCEL_CONFIGS.wrapText, + }); + const serviceResponse = new ServiceResponse( ResponseStatus.Success, 'File generated successfully', From 46f01aab2706a40536c12c64895c364f98dc998a Mon Sep 17 00:00:00 2001 From: dat-devuap Date: Thu, 16 Jan 2025 08:34:38 +0700 Subject: [PATCH 34/40] feat(excel generator): update user settings fields --- .../excelGenerator/excelGeneratorRouter.ts | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/src/routes/excelGenerator/excelGeneratorRouter.ts b/src/routes/excelGenerator/excelGeneratorRouter.ts index 4bd2143..8b79025 100644 --- a/src/routes/excelGenerator/excelGeneratorRouter.ts +++ b/src/routes/excelGenerator/excelGeneratorRouter.ts @@ -82,22 +82,24 @@ interface SheetData { interface ExcelConfig { fontFamily: string; - titleFontSize: number; + tableTitleFontSize: number; headerFontSize: number; fontSize: number; + autoFitColumnWidth: boolean; autoFilter: boolean; - borderStyle: ExcelJS.BorderStyle; // thin, double, dashed, thick + borderStyle: ExcelJS.BorderStyle | null; // thin, double, dashed, thick wrapText: boolean; } const DEFAULT_EXCEL_CONFIGS: ExcelConfig = { fontFamily: 'Calibri', - titleFontSize: 16, + tableTitleFontSize: 13, headerFontSize: 11, fontSize: 11, + autoFitColumnWidth: true, autoFilter: false, wrapText: false, - borderStyle: 'thin', + borderStyle: null, }; // Helper function to convert column letter (e.g., 'A') to column index (e.g., 1) @@ -143,12 +145,14 @@ function autoFitColumns( export function execGenExcelFuncs(sheetsData: SheetData[], excelConfigs: ExcelConfig): string { const workbook = new ExcelJS.Workbook(); - const borderConfigs = { - top: { style: excelConfigs.borderStyle }, - left: { style: excelConfigs.borderStyle }, - bottom: { style: excelConfigs.borderStyle }, - right: { style: excelConfigs.borderStyle }, - }; + const borderConfigs = excelConfigs.borderStyle + ? { + top: { style: excelConfigs.borderStyle }, + left: { style: excelConfigs.borderStyle }, + bottom: { style: excelConfigs.borderStyle }, + right: { style: excelConfigs.borderStyle }, + } + : {}; const titleAlignmentConfigs: any = { horizontal: 'center', vertical: 'middle', @@ -157,7 +161,7 @@ export function execGenExcelFuncs(sheetsData: SheetData[], excelConfigs: ExcelCo const titleFontConfigs: any = { name: excelConfigs.fontFamily, bold: true, - size: excelConfigs.titleFontSize, + size: excelConfigs.tableTitleFontSize, }; const headerAligmentConfigs: any = { wrapText: excelConfigs.wrapText, @@ -303,7 +307,9 @@ export function execGenExcelFuncs(sheetsData: SheetData[], excelConfigs: ExcelCo } // Auto-fit column widths - autoFitColumns(worksheet, startRow, rows, columns.length, startCol); + if (excelConfigs.autoFitColumnWidth) { + autoFitColumns(worksheet, startRow, rows, columns.length, startCol); + } }); }); @@ -343,12 +349,16 @@ export const excelGeneratorRouter: Router = (() => { try { const fileName = execGenExcelFuncs(sheetsData, { fontFamily: excelConfigs.fontFamily ?? DEFAULT_EXCEL_CONFIGS.fontFamily, - titleFontSize: excelConfigs.titleFontSize ?? DEFAULT_EXCEL_CONFIGS.titleFontSize, + tableTitleFontSize: excelConfigs.titleFontSize ?? DEFAULT_EXCEL_CONFIGS.tableTitleFontSize, headerFontSize: excelConfigs.headerFontSize ?? DEFAULT_EXCEL_CONFIGS.headerFontSize, fontSize: excelConfigs.fontSize ?? DEFAULT_EXCEL_CONFIGS.fontSize, autoFilter: excelConfigs.autoFilter ?? DEFAULT_EXCEL_CONFIGS.autoFilter, - borderStyle: excelConfigs.borderStyle ?? DEFAULT_EXCEL_CONFIGS.borderStyle, + borderStyle: + excelConfigs.borderStyle || excelConfigs.borderStyle !== 'none' + ? excelConfigs.borderStyle + : DEFAULT_EXCEL_CONFIGS.borderStyle, wrapText: excelConfigs.wrapText ?? DEFAULT_EXCEL_CONFIGS.wrapText, + autoFitColumnWidth: excelConfigs.autoFitColumnWidth ?? DEFAULT_EXCEL_CONFIGS.autoFitColumnWidth, }); const serviceResponse = new ServiceResponse( From 68c5f509e0b31ffa266cf772503d4efcbd06a3a6 Mon Sep 17 00:00:00 2001 From: dat-devuap Date: Thu, 16 Jan 2025 12:03:06 +0700 Subject: [PATCH 35/40] feat(excel generator): handle empty object without formula --- src/routes/excelGenerator/excelGeneratorRouter.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/routes/excelGenerator/excelGeneratorRouter.ts b/src/routes/excelGenerator/excelGeneratorRouter.ts index 8b79025..a262aaa 100644 --- a/src/routes/excelGenerator/excelGeneratorRouter.ts +++ b/src/routes/excelGenerator/excelGeneratorRouter.ts @@ -241,12 +241,16 @@ export function execGenExcelFuncs(sheetsData: SheetData[], excelConfigs: ExcelCo let cellValue: any = value != null ? value : ''; // Handle empty/null values const cell = worksheet.getCell(rowIndex, startCol + colIdx); // Check if the value is a formula - if (typeof cellValue === 'object' && cellValue.formula) { - const formulaCell: any = { formula: cellValue.formula }; // Handle formula - if (cellType === 'percent' || cellType === 'currency' || cellType === 'number' || cellType === 'date') { - cell.numFmt = format; // Apply number format + if (typeof cellValue === 'object') { + if (cellValue.formula) { + const formulaCell: any = { formula: cellValue.formula }; // Handle formula + if (cellType === 'percent' || cellType === 'currency' || cellType === 'number' || cellType === 'date') { + cell.numFmt = format; // Apply number format + } + cell.value = formulaCell; + } else { + cell.value = ''; } - cell.value = formulaCell; } else { // Assign cell type based on the header definition switch (cellType) { From c10b2de25f15e238e7b9a2d01cc0ef749868d15e Mon Sep 17 00:00:00 2001 From: dat-devuap Date: Thu, 16 Jan 2025 12:15:27 +0700 Subject: [PATCH 36/40] feat(excel generator): handle empty string formula --- src/routes/excelGenerator/excelGeneratorRouter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/excelGenerator/excelGeneratorRouter.ts b/src/routes/excelGenerator/excelGeneratorRouter.ts index a262aaa..c7d15cb 100644 --- a/src/routes/excelGenerator/excelGeneratorRouter.ts +++ b/src/routes/excelGenerator/excelGeneratorRouter.ts @@ -242,7 +242,7 @@ export function execGenExcelFuncs(sheetsData: SheetData[], excelConfigs: ExcelCo const cell = worksheet.getCell(rowIndex, startCol + colIdx); // Check if the value is a formula if (typeof cellValue === 'object') { - if (cellValue.formula) { + if (cellValue.formula && cellValue.formula !== '') { const formulaCell: any = { formula: cellValue.formula }; // Handle formula if (cellType === 'percent' || cellType === 'currency' || cellType === 'number' || cellType === 'date') { cell.numFmt = format; // Apply number format From 99d0a1f0d53680262d87e46994420035c2afbad2 Mon Sep 17 00:00:00 2001 From: dat-devuap Date: Fri, 17 Jan 2025 11:24:35 +0700 Subject: [PATCH 37/40] feat(excel generator): handle empty rows or columns data --- .../excelGenerator/excelGeneratorRouter.ts | 4 +- src/routes/excelGenerator/simple.json | 100 +++++++++++++++++ src/routes/excelGenerator/test.json | 101 ++++++++++++++++++ 3 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 src/routes/excelGenerator/simple.json create mode 100644 src/routes/excelGenerator/test.json diff --git a/src/routes/excelGenerator/excelGeneratorRouter.ts b/src/routes/excelGenerator/excelGeneratorRouter.ts index c7d15cb..ff526d4 100644 --- a/src/routes/excelGenerator/excelGeneratorRouter.ts +++ b/src/routes/excelGenerator/excelGeneratorRouter.ts @@ -183,7 +183,7 @@ export function execGenExcelFuncs(sheetsData: SheetData[], excelConfigs: ExcelCo sheetsData.forEach(({ sheetName, tables }) => { const worksheet = workbook.addWorksheet(sheetName); - tables.forEach(({ startCell, title, rows, columns, skipHeader }) => { + tables.forEach(({ startCell, title, rows = [], columns = [], skipHeader }) => { const startCol = columnLetterToNumber(startCell[0]); // Convert column letter to index (e.g., 'A' -> 1) const startRow = parseInt(startCell.slice(1)); // Extract the row number (e.g., 'A1' -> 1) let rowIndex = startRow; // Set the initial row index to startRow for each table @@ -212,7 +212,7 @@ export function execGenExcelFuncs(sheetsData: SheetData[], excelConfigs: ExcelCo } // Map headers to types - const columnTypes = columns?.map((col: any) => col.type) || []; + const columnTypes = columns.map((col: any) => col.type) || []; const columnFormats = columns?.map((col: any) => { let format = undefined; diff --git a/src/routes/excelGenerator/simple.json b/src/routes/excelGenerator/simple.json new file mode 100644 index 0000000..d070227 --- /dev/null +++ b/src/routes/excelGenerator/simple.json @@ -0,0 +1,100 @@ +{ + "name": "generate_excel_file", + "parameters": { + "type": "object", + "required": ["sheetsData"], + "properties": { + "sheetsData": { + "type": "array", + "items": { + "type": "object", + "required": ["sheetName", "tables"], + "properties": { + "tables": { + "type": "array", + "items": { + "type": "object", + "required": ["startCell", "columns", "rows"], + "properties": { + "rows": { + "type": "array", + "items": { + "type": "object", + "oneOf": [ + { + "type": "string", + "description": "A static value (string or number) to be placed directly in the cell. No calculations or references to other cells." + }, + { + "type": "object", + "properties": { + "formula": { + "type": "string", + "description": "A formula to be applied to the cell (not begin with '='). It must reference other cells or perform calculations. It must not include static values as string or number. Ensure that any cell ranges used in the formula start from the first data row (excluding the header and title). For example, if the table starts at A1 with a title in row 1 and header in row 2, a valid formula would be 'SUM(A3:A10)', summing data from rows 3 to 10. Avoid creating circular references where a formula directly or indirectly references its own cell." + } + }, + "required": ["formula"] + } + ], + "description": "Each row item can contain either a static value (string or number) or a formula. Make sure its length matches the defined columns length. Avoid formulas that reference the cell they are in or lead to circular dependencies." + }, + "description": "Array of rows in the table, where each row contains either static values or formulas for the cells. If the value is static, it should be represented as a single string. If the value is a formula, it should be represented as an object with a 'formula' property." + }, + "title": { + "type": "string", + "example": "Q1 Sales Data", + "description": "The title of the table, which will be displayed in the first row." + }, + "columns": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "type"], + "properties": { + "name": { + "type": "string", + "example": "Product Name", + "description": "The name of the column." + }, + "type": { + "enum": ["string", "number", "boolean", "percent", "currency", "date"], + "type": "string", + "example": "string", + "description": "The data type of the column." + }, + "format": { + "type": "string", + "example": "$#,##0", + "description": "The format of the column (e.g., '0.00%', '$#,##0', etc.)." + } + } + }, + "description": "The list of columns in the table, each with a name, type, and optional format." + }, + "startCell": { + "type": "string", + "example": "A1", + "description": "The starting cell (e.g., 'A1') where the table will begin." + }, + "skipHeader": { + "type": "boolean", + "example": false, + "description": "Whether to skip the header row for this table." + } + } + }, + "description": "The tables to include in the sheet." + }, + "sheetName": { + "type": "string", + "example": "Sales Data", + "description": "The name of the sheet to be created in the Excel file." + } + } + }, + "description": "An array of sheet data, where each sheet contains a name and an array of tables to generate." + } + } + }, + "description": "Generate an Excel file (.xlsx) and return the URL for downloading it. If the download URL is not present in the response, inform the user that the Excel file could not be generated. Prompt the user to verify that the correct plugin server URL is being used." +} diff --git a/src/routes/excelGenerator/test.json b/src/routes/excelGenerator/test.json new file mode 100644 index 0000000..4bc36d1 --- /dev/null +++ b/src/routes/excelGenerator/test.json @@ -0,0 +1,101 @@ +{ + "name": "generate_excel_file", + "parameters": { + "type": "object", + "required": ["sheetsData"], + "properties": { + "sheetsData": { + "type": "array", + "items": { + "type": "object", + "required": ["sheetName", "tables"], + "properties": { + "tables": { + "type": "array", + "items": { + "type": "object", + "required": ["startCell", "columns", "rows"], + "properties": { + "rows": { + "type": "array", + "items": { + "type": "object", + "oneOf": [ + { + "type": "string", + "description": "A static value (string or number) to be placed directly in the cell. No calculations or references to other cells." + }, + { + "type": "object", + "properties": { + "formula": { + "type": "string", + "description": "A formula to be applied to the cell (not begin with '='). It must not include static values as string or number. Ensure that any cell ranges used in the formula must not include cells of table header and table title. For example, if the table starts at A1 with a title in row 1 and header in row 2, a valid formula would be 'SUM(A3:A10)', summing data from rows 3 to 10. Avoid creating circular references where a formula directly or indirectly references its own cell." + } + }, + "required": ["formula"] + } + ], + "description": "Each row item can contain either a static value (string or number) or a formula. Make sure it's length same as defined columns length." + }, + "required": ["rows"], + "description": "Array of rows in the table, where each row contains either static values or formulas for the cells. If the value is static, it should be represented as a single string. If the value is a formula, it should be represented as an object with a 'formula' property." + }, + "title": { + "type": "string", + "example": "Q1 Sales Data", + "description": "The title of the table, which will be displayed in the first row." + }, + "columns": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "type"], + "properties": { + "name": { + "type": "string", + "example": "Product Name", + "description": "The name of the column." + }, + "type": { + "enum": ["string", "number", "boolean", "percent", "currency", "date"], + "type": "string", + "example": "string", + "description": "The data type of the column." + }, + "format": { + "type": "string", + "example": "$#,##0", + "description": "The format of the column (e.g., '0.00%', '$#,##0', etc.)." + } + } + }, + "description": "The list of columns in the table, each with a name, type, and optional format." + }, + "startCell": { + "type": "string", + "example": "A1", + "description": "The starting cell (e.g., 'A1') where the table will begin." + }, + "skipHeader": { + "type": "boolean", + "example": false, + "description": "Whether to skip the header row for this table." + } + } + }, + "description": "The tables to include in the sheet." + }, + "sheetName": { + "type": "string", + "example": "Sales Data", + "description": "The name of the sheet to be created in the Excel file." + } + } + }, + "description": "An array of sheet data, where each sheet contains a name and an array of tables to generate." + } + } + }, + "description": "Generate an Excel file (.xlsx) and return the URL for downloading it. If the download URL is not present in the response, inform the user that the Excel file could not be generated. Prompt the user to verify that the correct plugin server URL is being used." +} From 82019e5ea1ef8665b136380471cbbb4812462187 Mon Sep 17 00:00:00 2001 From: dat-devuap Date: Fri, 17 Jan 2025 11:25:10 +0700 Subject: [PATCH 38/40] feat(excel generator): remove tmp files --- src/routes/excelGenerator/simple.json | 100 ------------------------- src/routes/excelGenerator/test.json | 101 -------------------------- 2 files changed, 201 deletions(-) delete mode 100644 src/routes/excelGenerator/simple.json delete mode 100644 src/routes/excelGenerator/test.json diff --git a/src/routes/excelGenerator/simple.json b/src/routes/excelGenerator/simple.json deleted file mode 100644 index d070227..0000000 --- a/src/routes/excelGenerator/simple.json +++ /dev/null @@ -1,100 +0,0 @@ -{ - "name": "generate_excel_file", - "parameters": { - "type": "object", - "required": ["sheetsData"], - "properties": { - "sheetsData": { - "type": "array", - "items": { - "type": "object", - "required": ["sheetName", "tables"], - "properties": { - "tables": { - "type": "array", - "items": { - "type": "object", - "required": ["startCell", "columns", "rows"], - "properties": { - "rows": { - "type": "array", - "items": { - "type": "object", - "oneOf": [ - { - "type": "string", - "description": "A static value (string or number) to be placed directly in the cell. No calculations or references to other cells." - }, - { - "type": "object", - "properties": { - "formula": { - "type": "string", - "description": "A formula to be applied to the cell (not begin with '='). It must reference other cells or perform calculations. It must not include static values as string or number. Ensure that any cell ranges used in the formula start from the first data row (excluding the header and title). For example, if the table starts at A1 with a title in row 1 and header in row 2, a valid formula would be 'SUM(A3:A10)', summing data from rows 3 to 10. Avoid creating circular references where a formula directly or indirectly references its own cell." - } - }, - "required": ["formula"] - } - ], - "description": "Each row item can contain either a static value (string or number) or a formula. Make sure its length matches the defined columns length. Avoid formulas that reference the cell they are in or lead to circular dependencies." - }, - "description": "Array of rows in the table, where each row contains either static values or formulas for the cells. If the value is static, it should be represented as a single string. If the value is a formula, it should be represented as an object with a 'formula' property." - }, - "title": { - "type": "string", - "example": "Q1 Sales Data", - "description": "The title of the table, which will be displayed in the first row." - }, - "columns": { - "type": "array", - "items": { - "type": "object", - "required": ["name", "type"], - "properties": { - "name": { - "type": "string", - "example": "Product Name", - "description": "The name of the column." - }, - "type": { - "enum": ["string", "number", "boolean", "percent", "currency", "date"], - "type": "string", - "example": "string", - "description": "The data type of the column." - }, - "format": { - "type": "string", - "example": "$#,##0", - "description": "The format of the column (e.g., '0.00%', '$#,##0', etc.)." - } - } - }, - "description": "The list of columns in the table, each with a name, type, and optional format." - }, - "startCell": { - "type": "string", - "example": "A1", - "description": "The starting cell (e.g., 'A1') where the table will begin." - }, - "skipHeader": { - "type": "boolean", - "example": false, - "description": "Whether to skip the header row for this table." - } - } - }, - "description": "The tables to include in the sheet." - }, - "sheetName": { - "type": "string", - "example": "Sales Data", - "description": "The name of the sheet to be created in the Excel file." - } - } - }, - "description": "An array of sheet data, where each sheet contains a name and an array of tables to generate." - } - } - }, - "description": "Generate an Excel file (.xlsx) and return the URL for downloading it. If the download URL is not present in the response, inform the user that the Excel file could not be generated. Prompt the user to verify that the correct plugin server URL is being used." -} diff --git a/src/routes/excelGenerator/test.json b/src/routes/excelGenerator/test.json deleted file mode 100644 index 4bc36d1..0000000 --- a/src/routes/excelGenerator/test.json +++ /dev/null @@ -1,101 +0,0 @@ -{ - "name": "generate_excel_file", - "parameters": { - "type": "object", - "required": ["sheetsData"], - "properties": { - "sheetsData": { - "type": "array", - "items": { - "type": "object", - "required": ["sheetName", "tables"], - "properties": { - "tables": { - "type": "array", - "items": { - "type": "object", - "required": ["startCell", "columns", "rows"], - "properties": { - "rows": { - "type": "array", - "items": { - "type": "object", - "oneOf": [ - { - "type": "string", - "description": "A static value (string or number) to be placed directly in the cell. No calculations or references to other cells." - }, - { - "type": "object", - "properties": { - "formula": { - "type": "string", - "description": "A formula to be applied to the cell (not begin with '='). It must not include static values as string or number. Ensure that any cell ranges used in the formula must not include cells of table header and table title. For example, if the table starts at A1 with a title in row 1 and header in row 2, a valid formula would be 'SUM(A3:A10)', summing data from rows 3 to 10. Avoid creating circular references where a formula directly or indirectly references its own cell." - } - }, - "required": ["formula"] - } - ], - "description": "Each row item can contain either a static value (string or number) or a formula. Make sure it's length same as defined columns length." - }, - "required": ["rows"], - "description": "Array of rows in the table, where each row contains either static values or formulas for the cells. If the value is static, it should be represented as a single string. If the value is a formula, it should be represented as an object with a 'formula' property." - }, - "title": { - "type": "string", - "example": "Q1 Sales Data", - "description": "The title of the table, which will be displayed in the first row." - }, - "columns": { - "type": "array", - "items": { - "type": "object", - "required": ["name", "type"], - "properties": { - "name": { - "type": "string", - "example": "Product Name", - "description": "The name of the column." - }, - "type": { - "enum": ["string", "number", "boolean", "percent", "currency", "date"], - "type": "string", - "example": "string", - "description": "The data type of the column." - }, - "format": { - "type": "string", - "example": "$#,##0", - "description": "The format of the column (e.g., '0.00%', '$#,##0', etc.)." - } - } - }, - "description": "The list of columns in the table, each with a name, type, and optional format." - }, - "startCell": { - "type": "string", - "example": "A1", - "description": "The starting cell (e.g., 'A1') where the table will begin." - }, - "skipHeader": { - "type": "boolean", - "example": false, - "description": "Whether to skip the header row for this table." - } - } - }, - "description": "The tables to include in the sheet." - }, - "sheetName": { - "type": "string", - "example": "Sales Data", - "description": "The name of the sheet to be created in the Excel file." - } - } - }, - "description": "An array of sheet data, where each sheet contains a name and an array of tables to generate." - } - } - }, - "description": "Generate an Excel file (.xlsx) and return the URL for downloading it. If the download URL is not present in the response, inform the user that the Excel file could not be generated. Prompt the user to verify that the correct plugin server URL is being used." -} From 4054b4140120aca69e2d20f153e17f85292784ad Mon Sep 17 00:00:00 2001 From: dat-devuap Date: Fri, 17 Jan 2025 12:28:15 +0700 Subject: [PATCH 39/40] feat(excel generator): refactor cell object to have type and value --- .../excelGenerator/excelGeneratorRouter.ts | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/routes/excelGenerator/excelGeneratorRouter.ts b/src/routes/excelGenerator/excelGeneratorRouter.ts index ff526d4..8695c7d 100644 --- a/src/routes/excelGenerator/excelGeneratorRouter.ts +++ b/src/routes/excelGenerator/excelGeneratorRouter.ts @@ -74,7 +74,10 @@ interface SheetData { tables: { title: string; startCell: string; - rows: any[][]; + rows: { + type: string; // static_value or formula, + value: string; + }[][]; columns: { name: string; type: string; format: string }[]; // types that have format, number, percent, currency skipHeader: boolean; }[]; @@ -234,26 +237,23 @@ export function execGenExcelFuncs(sheetsData: SheetData[], excelConfigs: ExcelCo }) || []; // Add rows with data types - rows.forEach((row) => { - row.forEach((value, colIdx) => { - const cellType = columnTypes[colIdx]; + rows.forEach((rowData) => { + rowData.forEach((cellData, colIdx) => { + const { type = 'static_value', value } = cellData; + const valueType = columnTypes[colIdx]; const format = columnFormats[colIdx]; let cellValue: any = value != null ? value : ''; // Handle empty/null values const cell = worksheet.getCell(rowIndex, startCol + colIdx); // Check if the value is a formula - if (typeof cellValue === 'object') { - if (cellValue.formula && cellValue.formula !== '') { - const formulaCell: any = { formula: cellValue.formula }; // Handle formula - if (cellType === 'percent' || cellType === 'currency' || cellType === 'number' || cellType === 'date') { - cell.numFmt = format; // Apply number format - } - cell.value = formulaCell; - } else { - cell.value = ''; + if (type == 'formula') { + const formulaCell: any = { formula: cellValue }; // Handle formula + if (valueType === 'percent' || valueType === 'currency' || valueType === 'number' || valueType === 'date') { + cell.numFmt = format; // Apply number format } + cell.value = formulaCell; } else { // Assign cell type based on the header definition - switch (cellType) { + switch (valueType) { case 'number': { cellValue = !isNaN(Number(cellValue)) ? Math.round(Number(cellValue)) : cellValue; cell.value = cellValue; From d368828185501d147b3aa077b795e4bcea9d41c4 Mon Sep 17 00:00:00 2001 From: dat-devuap Date: Sun, 19 Jan 2025 11:43:44 +0700 Subject: [PATCH 40/40] fix(word generator): error JSON schema invalid caused by ref --- .../wordGenerator/wordGeneratorModel.ts | 16 ++++----- .../wordGenerator/wordGeneratorRouter.ts | 35 ++++++++++++++++++- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/src/routes/wordGenerator/wordGeneratorModel.ts b/src/routes/wordGenerator/wordGeneratorModel.ts index b2947c5..db0f2c5 100644 --- a/src/routes/wordGenerator/wordGeneratorModel.ts +++ b/src/routes/wordGenerator/wordGeneratorModel.ts @@ -45,25 +45,25 @@ const ContentSchema = z.object({ }); // Define the base schema for a section -const BaseSectionSchema = z.object({ +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.', }), }); -// Extend the base schema with subSections -const SectionSchema = BaseSectionSchema.extend({ - subSections: z.array(BaseSectionSchema).optional().openapi({ - description: 'Subsections within the main section.', - }), -}); - // Request Body Schema export const WordGeneratorRequestBodySchema = z.object({ title: z.string().openapi({ diff --git a/src/routes/wordGenerator/wordGeneratorRouter.ts b/src/routes/wordGenerator/wordGeneratorRouter.ts index a8551b0..8823b0a 100644 --- a/src/routes/wordGenerator/wordGeneratorRouter.ts +++ b/src/routes/wordGenerator/wordGeneratorRouter.ts @@ -393,6 +393,36 @@ const generateSectionContent = (section: any, config: any) => { 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; @@ -491,6 +521,9 @@ async function execGenWordFuncs( ); } + // Build sections hierarchy + const sectionsHierarchy = buildSectionsHierarchy(data.sections); + // Create the document based on JSON data const doc = new Document({ styles: { @@ -533,7 +566,7 @@ async function execGenWordFuncs( }), ...tableOfContentConfigs, // Generate all sections and sub-sections - ...data.sections.flatMap((section) => + ...sectionsHierarchy.flatMap((section) => generateSectionContent(section, { ...config, numberingReference: selectedNumberingOption?.reference }) ), ],