diff --git a/src/database/schemas/management/documentjob.schema.js b/src/database/schemas/management/documentjob.schema.js new file mode 100644 index 0000000..71018c6 --- /dev/null +++ b/src/database/schemas/management/documentjob.schema.js @@ -0,0 +1,47 @@ +import mongoose from 'mongoose'; +const { Schema } = mongoose; + +const documentJobSchema = new Schema( + { + name: { + type: String, + required: true, + unique: true, + }, + objectType: { type: String, required: false }, + object: { + type: Schema.Types.ObjectId, + refPath: 'objectType', + required: true, + }, + state: { + type: { type: String, required: true, default: 'queued' }, + percent: { type: Number, required: false }, + }, + documentTemplate: { + type: Schema.Types.ObjectId, + ref: 'documentTemplate', + required: true, + }, + documentPrinter: { + type: Schema.Types.ObjectId, + ref: 'documentPrinter', + required: true, + }, + content: { + type: String, + required: false, + }, + }, + { timestamps: true } +); + +// Add virtual id getter +documentJobSchema.virtual('id').get(function () { + return this._id.toHexString(); +}); + +// Configure JSON serialization to include virtuals +documentJobSchema.set('toJSON', { virtuals: true }); + +export const documentJobModel = mongoose.model('documentJob', documentJobSchema); diff --git a/src/database/schemas/management/documentprinter.schema.js b/src/database/schemas/management/documentprinter.schema.js new file mode 100644 index 0000000..3f3ca46 --- /dev/null +++ b/src/database/schemas/management/documentprinter.schema.js @@ -0,0 +1,55 @@ +import mongoose from 'mongoose'; +const { Schema } = mongoose; + +const connectionSchema = new Schema( + { + interface: { type: String, required: true }, + protocol: { type: String, required: true }, + host: { type: String, required: true }, + port: { type: Number, required: false } + }, + { _id: false } +); + +const documentPrinterSchema = new Schema( + { + name: { + type: String, + required: true, + unique: true + }, + connection: { type: connectionSchema, required: true }, + currentDocumentSize: { + type: Schema.Types.ObjectId, + ref: 'documentSize', + required: false + }, + tags: [{ type: String }], + online: { type: Boolean, required: true, default: false }, + active: { type: Boolean, required: true, default: true }, + state: { + type: { type: String, required: true, default: 'offline' }, + message: { type: String, required: false }, + progress: { type: Number, required: false } + }, + connectedAt: { type: Date, default: null }, + host: { type: Schema.Types.ObjectId, ref: 'host', required: true }, + queue: [ + { type: Schema.Types.ObjectId, ref: 'documentJob', required: false } + ] + }, + { timestamps: true } +); + +// Add virtual id getter +documentPrinterSchema.virtual('id').get(function () { + return this._id.toHexString(); +}); + +// Configure JSON serialization to include virtuals +documentPrinterSchema.set('toJSON', { virtuals: true }); + +export const documentPrinterModel = mongoose.model( + 'documentPrinter', + documentPrinterSchema +); diff --git a/src/database/schemas/management/documentsize.schema.js b/src/database/schemas/management/documentsize.schema.js index 0e2fd40..a3da49d 100644 --- a/src/database/schemas/management/documentsize.schema.js +++ b/src/database/schemas/management/documentsize.schema.js @@ -6,18 +6,23 @@ const documentSizeSchema = new Schema( name: { type: String, required: true, - unique: true, + unique: true }, width: { type: Number, required: true, - default: 0, + default: 0 }, height: { type: Number, required: true, - default: 0, + default: 0 }, + infiniteHeight: { + type: Boolean, + required: true, + default: false + } }, { timestamps: true } ); @@ -30,4 +35,7 @@ documentSizeSchema.virtual('id').get(function () { // Configure JSON serialization to include virtuals documentSizeSchema.set('toJSON', { virtuals: true }); -export const documentSizeModel = mongoose.model('documentSize', documentSizeSchema); +export const documentSizeModel = mongoose.model( + 'documentSize', + documentSizeSchema +); diff --git a/src/socket/sockethost.js b/src/socket/sockethost.js index 1d2affb..f5d2070 100644 --- a/src/socket/sockethost.js +++ b/src/socket/sockethost.js @@ -2,12 +2,18 @@ import log4js from 'log4js'; // Load configuration import { loadConfig } from '../config.js'; import { CodeAuth, createAuthMiddleware } from '../auth/auth.js'; -import { editObject, getObject, listObjects } from '../database/database.js'; +import { + newObject, + editObject, + getObject, + listObjects +} from '../database/database.js'; import { hostModel } from '../database/schemas/management/host.schema.js'; import { UpdateManager } from '../updates/updatemanager.js'; import { ActionManager } from '../actions/actionmanager.js'; import { getModelByName } from '../utils.js'; import { EventManager } from '../events/eventmanager.js'; +import { TemplateManager } from '../templates/templatemanager.js'; const config = loadConfig(); @@ -25,6 +31,7 @@ export class SocketHost { this.updateManager = new UpdateManager(this); this.actionManager = new ActionManager(this); this.eventManager = new EventManager(this); + this.templateManager = new TemplateManager(this); this.codeAuth = new CodeAuth(); this.setupSocketEventHandlers(); } @@ -34,12 +41,33 @@ export class SocketHost { this.socket.on('authenticate', this.handleAuthenticate.bind(this)); this.socket.on('updateHost', this.handleUpdateHost.bind(this)); this.socket.on('getObject', this.handleGetObject.bind(this)); + this.socket.on('newObject', this.handleNewObject.bind(this)); this.socket.on('editObject', this.handleEditObject.bind(this)); this.socket.on('listObjects', this.handleListObjects.bind(this)); + this.socket.on( + 'subscribeToObjectUpdates', + this.handleSubscribeToObjectUpdatesEvent.bind(this) + ); + this.socket.on( + 'unsubscribeToObjectUpdates', + this.handleUnsubscribeToObjectUpdatesEvent.bind(this) + ); this.socket.on( 'subscribeToObjectActions', this.handleSubscribeToObjectActions.bind(this) ); + this.socket.on( + 'subscribeToObjectEvent', + this.handleSubscribeToObjectEventEvent.bind(this) + ); + this.socket.on( + 'unsubscribeObjectEvent', + this.handleUnsubscribeObjectEventEvent.bind(this) + ); + this.socket.on( + 'renderTemplatePDF', + this.handleRenderTemplatePDFEvent.bind(this) + ); this.socket.on('objectEvent', this.handleObjectEventEvent.bind(this)); this.socket.on('disconnect', this.handleDisconnect.bind(this)); } @@ -107,6 +135,16 @@ export class SocketHost { }); } + async handleNewObject(data, callback) { + const object = await newObject({ + model: getModelByName(data.objectType), + newData: data.newData, + owner: this.host, + ownerType: 'host' + }); + callback(object); + } + async handleEditObject(data, callback) { const object = await editObject({ model: getModelByName(data.objectType), @@ -151,6 +189,13 @@ export class SocketHost { ); } + async handleSubscribeToObjectUpdatesEvent(data) { + const result = await this.updateManager.subscribeToObjectUpdate( + data._id, + data.objectType + ); + } + async handleSubscribeToObjectActions(data) { await this.actionManager.subscribeToObjectActions( data._id, @@ -158,6 +203,78 @@ export class SocketHost { ); } + async handleSubscribeToObjectEventEvent(data) { + await this.eventManager.subscribeToObjectEvent( + data._id, + data.objectType, + data.eventType + ); + } + + async handleUnsubscribeObjectEventEvent(data) { + await this.eventManager.removeObjectEventsListener( + data._id, + data.objectType, + data.eventType + ); + } + + async handleUnsubscribeToObjectUpdatesEvent(data) { + await this.updateManager.unsubscribeToObjectUpdate( + data._id, + data.objectType + ); + } + + async handleRenderTemplatePDFEvent(data, callback) { + const result = await this.templateManager.renderPDF( + data._id, + data.content, + data.object, + 1 + ); + callback(result); + } + + async setDevicesState(state, online, connectedAt) { + logger.info('Setting devices state to', state, 'and online to', online); + + const documentPrinters = await listObjects({ + model: getModelByName('documentPrinter'), + filter: { host: this.host._id } + }); + const printers = await listObjects({ + model: getModelByName('printer'), + filter: { host: this.host._id } + }); + logger.debug( + 'Retrieved', + documentPrinters.length, + 'document printers and', + printers.length, + 'printers' + ); + for (const documentPrinter of documentPrinters) { + await editObject({ + model: getModelByName('documentPrinter'), + id: documentPrinter._id, + updateData: { state: state, online: online, connectedAt: connectedAt }, + owner: this.host, + ownerType: 'host' + }); + } + for (const printer of printers) { + await editObject({ + model: getModelByName('printer'), + id: printer._id, + updateData: { state: state, online: online, connectedAt: connectedAt }, + owner: this.host, + ownerType: 'host' + }); + } + logger.info('Devices state set to', state, 'and online to', online); + } + async handleDisconnect() { if (this.authenticated) { await editObject({ @@ -173,6 +290,13 @@ export class SocketHost { }); this.authenticated = false; } + await this.actionManager.removeAllListeners(); + await this.eventManager.removeAllListeners(); + await this.setDevicesState( + { type: 'offline', message: 'Host disconnected.' }, + false, + null + ); logger.info('External host disconnected. Socket ID:', this.id); } } diff --git a/src/socket/socketuser.js b/src/socket/socketuser.js index c41ee5b..69d3d20 100644 --- a/src/socket/socketuser.js +++ b/src/socket/socketuser.js @@ -64,11 +64,16 @@ export class SocketUser { 'previewTemplate', this.handlePreviewTemplateEvent.bind(this) ); + this.socket.on( + 'renderTemplatePDF', + this.handleRenderTemplatePDFEvent.bind(this) + ); this.socket.on( 'generateHostOtp', this.handleGenerateHostOtpEvent.bind(this) ); this.socket.on('objectAction', this.handleObjectActionEvent.bind(this)); + this.socket.on('disconnect', this.handleDisconnect.bind(this)); } async handleAuthenticateEvent(data, callback) { @@ -196,6 +201,15 @@ export class SocketUser { callback(result); } + async handleRenderTemplatePDFEvent(data, callback) { + const result = await this.templateManager.renderPDF( + data._id, + data.content, + data.object, + 1 + ); + callback(result); + } async handleGenerateHostOtpEvent(data, callback) { const result = await generateHostOTP(data._id); callback(result); @@ -210,7 +224,9 @@ export class SocketUser { ); } - handleDisconnect() { + async handleDisconnect() { + await this.actionManager.removeAllListeners(); + await this.eventManager.removeAllListeners(); logger.info('External user disconnected:', this.socket.user?.username); } } diff --git a/src/templates/assets/basetemplate.ejs b/src/templates/assets/basetemplate.ejs index 63d32ac..9465a5e 100644 --- a/src/templates/assets/basetemplate.ejs +++ b/src/templates/assets/basetemplate.ejs @@ -15,26 +15,21 @@ @@ -45,5 +40,10 @@ + <% if (typeof previewPaginationScript !== 'undefined' && previewPaginationScript) { %> + + <% } %> diff --git a/src/templates/assets/previewtemplate.ejs b/src/templates/assets/previewtemplate.ejs index ee8ff82..25e4d7b 100644 --- a/src/templates/assets/previewtemplate.ejs +++ b/src/templates/assets/previewtemplate.ejs @@ -1,3 +1,5 @@
-
<%- content %>
+
+
<%- content %>
+
diff --git a/src/templates/pdffactory.js b/src/templates/pdffactory.js new file mode 100644 index 0000000..4792941 --- /dev/null +++ b/src/templates/pdffactory.js @@ -0,0 +1,55 @@ +import log4js from 'log4js'; +import { loadConfig } from '../config.js'; + +const config = loadConfig(); +const logger = log4js.getLogger('PDF Factory'); +logger.level = config.server.logLevel; + +/** + * Generates a PDF from HTML content using Puppeteer + * @param {string} html - The HTML content to convert to PDF + * @param {Object} options - PDF generation options + * @param {number} options.width - Document width in mm + * @param {number} options.height - Document height in mm + * @returns {Promise} The PDF buffer + */ +export async function generatePDF(html, options = {}) { + try { + // Dynamically import puppeteer to handle cases where it might not be installed + const puppeteer = await import('puppeteer'); + + const browser = await puppeteer.default.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'] + }); + + const page = await browser.newPage(); + + // Set content with HTML + await page.setContent(html, { + waitUntil: 'networkidle0' + }); + + // Generate PDF with specified dimensions + const pdfBuffer = await page.pdf({ + format: options.format || undefined, + width: options.width ? `${options.width}mm` : undefined, + height: options.height ? `${options.height}mm` : undefined, + printBackground: true, + preferCSSPageSize: true, + margin: { + top: '0mm', + right: '0mm', + bottom: '0mm', + left: '0mm' + } + }); + + await browser.close(); + + return pdfBuffer; + } catch (error) { + logger.error('Error generating PDF:', error.message); + throw error; + } +} diff --git a/src/templates/templatemanager.js b/src/templates/templatemanager.js index 2061312..b17514e 100644 --- a/src/templates/templatemanager.js +++ b/src/templates/templatemanager.js @@ -12,7 +12,9 @@ import utc from 'dayjs/plugin/utc.js'; import timezone from 'dayjs/plugin/timezone.js'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; -import { getObject } from '../database/database.js'; +import { getObject, listObjects } from '../database/database.js'; +import { getModelByName } from '../utils.js'; +import { generatePDF } from './pdffactory.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -28,7 +30,9 @@ logger.level = config.server.logLevel; let baseTemplate; let baseCSS; let previewTemplate; +let renderTemplateEjs; let contentPlaceholder; +let previewPaginationScript; async function loadTemplates() { // Synchronously load files @@ -41,10 +45,18 @@ async function loadTemplates() { join(__dirname, '/assets/previewtemplate.ejs'), 'utf8' ); + renderTemplateEjs = fs.readFileSync( + join(__dirname, '/assets/rendertemplate.ejs'), + 'utf8' + ); contentPlaceholder = fs.readFileSync( join(__dirname, '/assets/contentplaceholder.ejs'), 'utf8' ); + previewPaginationScript = fs.readFileSync( + join(__dirname, '/assets/previewpagination.js'), + 'utf8' + ); } loadTemplates(); @@ -60,6 +72,12 @@ function getNodeStyles(attributes) { if (attributes?.height) { styles += `height: ${attributes.height};`; } + if (attributes?.maxWidth) { + styles += `max-width: ${attributes.maxWidth};`; + } + if (attributes?.maxHeight) { + styles += `max-height: ${attributes.maxHeight};`; + } if (attributes?.gap && attributes?.vertical != 'true') { styles += `column-gap: ${attributes.gap};`; } @@ -96,6 +114,15 @@ function getNodeStyles(attributes) { if (attributes?.scale) { styles += `transform: scale(${attributes.scale});`; } + if (attributes?.textAlign) { + styles += `text-align: ${attributes.textAlign};`; + } + if (attributes?.textSize) { + styles += `font-size: ${attributes.textSize};`; + } + if (attributes?.wordWrap) { + styles += `word-wrap: ${attributes.wordWrap};`; + } return styles; } @@ -152,7 +179,9 @@ async function transformCustomElements(content) { class: 'documentBarcode', 'jsbarcode-displayValue': 'false', 'jsbarcode-value': node.content[0], - 'jsbarcode-format': node.attrs.format + 'jsbarcode-format': node.attrs.format, + 'jsbarcode-width': node.attrs.barcodeWidth, + 'jsbarcode-margin': 0 } } ], @@ -193,6 +222,27 @@ async function transformCustomElements(content) { } }; }), + tree => + tree.match({ tag: 'ProgressBar' }, node => { + return { + ...node, + tag: 'div', + attrs: { + class: 'documentProgressBar', + style: getNodeStyles(node.attrs) + }, + content: [ + { + tag: 'div', + attrs: { + class: 'documentProgressBarInner', + style: `width: ${Math.round((node.content[0] || 0) * 100)}%` + } + } + ] + }; + }), + tree => tree.match({ tag: 'DateTime' }, node => { const dateTime = dayjs.utc(node.content[0]); @@ -204,6 +254,91 @@ async function transformCustomElements(content) { style: getNodeStyles(node.attrs) } }; + }), + tree => + tree.match({ tag: 'Table' }, node => { + return { + ...node, + tag: 'table', + attrs: { + class: 'documentTable', + style: getNodeStyles(node.attrs) + } + }; + }), + tree => + tree.match({ tag: 'Row' }, node => { + const rowType = node.attrs?.type?.toLowerCase() || ''; + + // Transform Col children based on the row type (header/footer/body) + const transformCols = content => { + if (!Array.isArray(content)) return content; + return content.map(child => { + if (typeof child === 'string' || child == null) { + return child; + } + if (child.tag !== 'Col') { + return child; + } + + const baseAttrs = { + ...child.attrs, + style: getNodeStyles(child.attrs) + }; + + if (rowType === 'header') { + // Header row columns become table headers + return { + ...child, + tag: 'th', + attrs: baseAttrs + }; + } + + // Footer and body rows both use ; footer is distinguished by the row class + return { + ...child, + tag: 'td', + attrs: baseAttrs + }; + }); + }; + + const content = transformCols(node.content); + + if (rowType === 'header') { + return { + ...node, + tag: 'tr', + content, + attrs: { + class: 'documentTableRowHeader', + style: getNodeStyles(node.attrs) + } + }; + } + + if (rowType === 'footer') { + return { + ...node, + tag: 'tr', + content, + attrs: { + class: 'documentTableRowFooter', + style: getNodeStyles(node.attrs) + } + }; + } + + return { + ...node, + tag: 'tr', + content, + attrs: { + class: 'documentTableRow', + style: getNodeStyles(node.attrs) + } + }; }) ]).process(content); @@ -211,6 +346,13 @@ async function transformCustomElements(content) { } export class TemplateManager { + constructor() { + this.fc = { + listObjects: this.listObjects.bind(this), + getObject: this.getObject.bind(this), + formatDate: this.formatDate.bind(this) + }; + } /** * Previews an EJS template by rendering it with provided data * @param {string} templateString - The EJS template as a string @@ -218,7 +360,14 @@ export class TemplateManager { * @param {Object} options - EJS rendering options * @returns {Promise} The rendered HTML string */ - async renderTemplate(id, content, data = {}, scale, options = {}) { + async renderTemplate( + id, + content, + data = {}, + scale = 1, + options = {}, + preview = true + ) { try { // Set default options for EJS rendering const defaultOptions = { @@ -242,11 +391,28 @@ export class TemplateManager { } const documentSize = documentTemplate.documentSize; + if (documentSize == null) { + return { error: 'Document template size not found.' }; + } - var templateData = data; + // Validate content parameter + if (content == null || typeof content !== 'string') { + return { error: 'Template content is required and must be a string.' }; + } + // Make sure data has default undefefined values and then merge with data + var templateData = {}; if (documentTemplate.global == true) { - templateData = { content: contentPlaceholder }; + templateData = { content: contentPlaceholder, fc: this.fc }; + } else { + const objectType = documentTemplate?.objectType; + const model = getModelByName(objectType); + const defaultKeys = Object.keys(model.schema.obj); + const defaultValues = {}; + for (const key of defaultKeys) { + defaultValues[key] = null; + } + templateData = { ...defaultValues, ...data, fc: this.fc }; } // Render the template @@ -259,39 +425,79 @@ export class TemplateManager { var templateWithParentContent; if (documentTemplate.parent != undefined) { + // Validate parent content + if ( + documentTemplate.parent.content == null || + typeof documentTemplate.parent.content !== 'string' + ) { + return { + error: 'Parent template content is required and must be a string.' + }; + } templateWithParentContent = await ejs.render( documentTemplate.parent.content, - { content: templateContent }, + { content: templateContent, fc: this.fc }, defaultOptions ); } else { templateWithParentContent = templateContent; } + // Validate rendered content before transformation + if ( + templateWithParentContent == null || + typeof templateWithParentContent !== 'string' + ) { + return { error: 'Failed to render template content.' }; + } + const templateHtml = await transformCustomElements( templateWithParentContent ); - const previewHtml = await ejs.render( - previewTemplate, - { content: templateHtml }, - defaultOptions - ); + // Validate transformed HTML + if (templateHtml == null || typeof templateHtml !== 'string') { + return { error: 'Failed to transform template content.' }; + } + + var innerHtml = null; + + if (preview == true) { + innerHtml = await ejs.render( + previewTemplate, + { content: templateHtml }, + defaultOptions + ); + } else { + innerHtml = await ejs.render( + renderTemplateEjs, + { content: templateHtml }, + defaultOptions + ); + } + + // Validate inner HTML + if (innerHtml == null || typeof innerHtml !== 'string') { + return { error: 'Failed to render inner template content.' }; + } const baseHtml = await ejs.render( baseTemplate, { - content: previewHtml, - width: `${documentSize.width}mm`, - height: `${documentSize.height}mm`, + content: innerHtml, + width: documentSize.width, + height: documentSize.height, scale: `${scale}`, - baseCSS: baseCSS + baseCSS: baseCSS, + previewPaginationScript: preview ? previewPaginationScript : '' }, defaultOptions ); const previewObject = { - html: baseHtml + html: baseHtml, + width: documentSize.width, + height: documentSize.height }; return previewObject; @@ -315,4 +521,73 @@ export class TemplateManager { return false; } } + + /** + * Renders a template to PDF format + * @param {string} id - The document template ID + * @param {string} content - The template content + * @param {Object} data - Data object to pass to the template + * @param {number} scale - Scale factor for rendering + * @param {Object} options - EJS rendering options + * @returns {Promise} Object containing PDF buffer or error + */ + async renderPDF(id, content, data = {}, options = {}) { + try { + logger.debug('Rendering PDF for template:', id); + + const renderedTemplate = await this.renderTemplate( + id, + content, + data, + 1, + options, + false + ); + + if (renderedTemplate.error != undefined) { + return { error: renderedTemplate.error }; + } + const baseHtml = renderedTemplate.html; + + // Generate PDF using PDF factory + const pdfBuffer = await generatePDF(baseHtml, { + width: renderedTemplate.width, + height: renderedTemplate.height + }); + + const pdfObject = { + pdf: pdfBuffer + }; + return pdfObject; + } catch (error) { + logger.warn('Error whilst rendering PDF:', error.message); + return { error: error.message }; + } + } + + async listObjects(objectType, filter = {}, populate = []) { + const model = getModelByName(objectType); + if (model == undefined) { + throw new Error('Farm Control: Object type not found.'); + } + const objects = await listObjects({ + model, + filter, + populate + }); + return objects; + } + + formatDate(date, format) { + return dayjs(date).format(format); + } + + async getObject(objectType, id) { + const model = getModelByName(objectType); + if (model == undefined) { + throw new Error('Farm Control: Object type not found.'); + } + const object = await getObject({ model, id, cached: true }); + return object; + } }