diff --git a/force-app/main/default/layouts/Generated_Document__c-Generated Document Layout.layout-meta.xml b/force-app/main/default/layouts/Generated_Document__c-Generated Document Layout.layout-meta.xml index 4ae9ae1..38483d1 100644 --- a/force-app/main/default/layouts/Generated_Document__c-Generated Document Layout.layout-meta.xml +++ b/force-app/main/default/layouts/Generated_Document__c-Generated Document Layout.layout-meta.xml @@ -1,5 +1,14 @@ + AssignRecordLabel + ChangeOwnerOne + ChangeRecordType + Clone + OmniChannelRouteWork + PrintableView + RecordShareHierarchy + Share + Submit true true @@ -28,10 +37,6 @@ Edit Priority__c - - Edit - RequestedBy__c - Edit OwnerId @@ -82,45 +87,45 @@ Edit - CorrelationId__c + RequestJSON__c - - Edit - RequestJSON__c + RequestJSON03__c Edit - RequestJSON02__c + RequestJSON05__c Edit - RequestJSON03__c + RequestJSON07__c Edit - RequestJSON04__c + RequestJSON09__c + + Edit - RequestJSON05__c + CorrelationId__c Edit - RequestJSON06__c + RequestJSON02__c Edit - RequestJSON07__c + RequestJSON04__c Edit - RequestJSON08__c + RequestJSON06__c Edit - RequestJSON09__c + RequestJSON08__c Edit @@ -193,13 +198,20 @@ Readonly - CreatedById + RequestedBy__c + + + Readonly + Created_Date__c + + true + Readonly - LastModifiedById + Last_Modified_Date__c @@ -220,6 +232,19 @@ OutputFormat__c Template__c + + Record + + Edit + StandardButton + 0 + + + Delete + StandardButton + 1 + + RelatedFileList diff --git a/force-app/main/default/objects/Docgen_Template__c/Docgen_Template__c.object-meta.xml b/force-app/main/default/objects/Docgen_Template__c/Docgen_Template__c.object-meta.xml index b3a899c..e15c66a 100644 --- a/force-app/main/default/objects/Docgen_Template__c/Docgen_Template__c.object-meta.xml +++ b/force-app/main/default/objects/Docgen_Template__c/Docgen_Template__c.object-meta.xml @@ -6,6 +6,22 @@ Text + + View + Action override created by Lightning App Builder during activation. + Docgen_Template_Record_Page + Small + false + Flexipage + + + View + Action override created by Lightning App Builder during activation. + Docgen_Template_Record_Page + Large + false + Flexipage + Deployed ReadWrite Configuration for document generation templates. Each template references a DOCX file in Salesforce Files and defines how data is fetched and merged. diff --git a/force-app/main/default/objects/Generated_Document__c/fields/Created_Date__c.field-meta.xml b/force-app/main/default/objects/Generated_Document__c/fields/Created_Date__c.field-meta.xml new file mode 100644 index 0000000..fd52e8b --- /dev/null +++ b/force-app/main/default/objects/Generated_Document__c/fields/Created_Date__c.field-meta.xml @@ -0,0 +1,12 @@ + + + Created_Date__c + Displays the date and time when the generated document record was created. + false + CreatedDate + BlankAsZero + + false + false + DateTime + diff --git a/force-app/main/default/objects/Generated_Document__c/fields/Last_Modified_Date__c.field-meta.xml b/force-app/main/default/objects/Generated_Document__c/fields/Last_Modified_Date__c.field-meta.xml new file mode 100644 index 0000000..bf28f9f --- /dev/null +++ b/force-app/main/default/objects/Generated_Document__c/fields/Last_Modified_Date__c.field-meta.xml @@ -0,0 +1,12 @@ + + + Last_Modified_Date__c + Displays the date and time when the generated document record was last modified. + false + LastModifiedDate + BlankAsZero + + false + false + DateTime + diff --git a/force-app/main/default/permissionsets/Docgen_User.permissionset-meta.xml b/force-app/main/default/permissionsets/Docgen_User.permissionset-meta.xml index a8aed7c..8fdde5d 100644 --- a/force-app/main/default/permissionsets/Docgen_User.permissionset-meta.xml +++ b/force-app/main/default/permissionsets/Docgen_User.permissionset-meta.xml @@ -179,6 +179,16 @@ Generated_Document__c.ScheduledRetryTime__c true + + false + Generated_Document__c.Created_Date__c + true + + + false + Generated_Document__c.Last_Modified_Date__c + true + diff --git a/sfdx-project.json b/sfdx-project.json index 424fee4..15665c1 100644 --- a/sfdx-project.json +++ b/sfdx-project.json @@ -1,8 +1,8 @@ { "packageDirectories": [ { - "versionName": "ver 0.3.6", - "versionNumber": "0.3.6.NEXT", + "versionName": "ver 0.3.7", + "versionNumber": "0.3.7.NEXT", "path": "force-app/main", "default": true, "package": "docgen", @@ -29,6 +29,7 @@ "docgen@0.3.3-1": "04tWS000002UXlNYAW", "docgen@0.3.4-1": "04tWS000002UYmHYAW", "docgen@0.3.5-1": "04tWS000002ZSczYAG", - "docgen@0.3.6-1": "04tWS000002agXRYAY" + "docgen@0.3.6-1": "04tWS000002agXRYAY", + "docgen@0.3.7-1": "04tWS000002bS7RYAU" } } \ No newline at end of file diff --git a/src/convert/soffice.ts b/src/convert/soffice.ts index b079edd..cd9e8b0 100644 --- a/src/convert/soffice.ts +++ b/src/convert/soffice.ts @@ -252,7 +252,7 @@ export class LibreOfficeConverter { // Execute LibreOffice conversion const outputPath = path.join(jobWorkdir, 'input.pdf'); - await this.executeLibreOffice( + const conversion = await this.executeLibreOffice( inputPath, jobWorkdir, timeout, @@ -260,7 +260,20 @@ export class LibreOfficeConverter { ); // Read PDF from output - const pdfBuffer = await fs.readFile(outputPath); + let pdfBuffer: Buffer; + try { + pdfBuffer = await fs.readFile(outputPath); + } catch (error: any) { + if (error?.code === 'ENOENT') { + const stderr = conversion.stderr ? ` stderr: ${conversion.stderr.trim()}` : ''; + const stdout = conversion.stdout ? ` stdout: ${conversion.stdout.trim()}` : ''; + throw new ConversionFailedError( + `LibreOffice did not produce PDF output.${stderr}${stdout}`, + { correlationId } + ); + } + throw error; + } logger.debug( { correlationId, outputPath, size: pdfBuffer.length }, 'Read PDF from output' @@ -300,7 +313,7 @@ export class LibreOfficeConverter { outputDir: string, timeout: number, correlationId: string - ): Promise { + ): Promise<{ stdout: string; stderr: string }> { // Create unique user profile directory to prevent lock conflicts const userProfile = path.join(outputDir, '.libreoffice-profile'); @@ -341,6 +354,7 @@ export class LibreOfficeConverter { { correlationId, stdout: stdout ? stdout.trim() : '' }, 'LibreOffice conversion completed' ); + return { stdout, stderr }; } catch (error: any) { // Check if error is timeout if (error.killed || error.signal === 'SIGTERM') { diff --git a/src/templates/docx-postprocess.ts b/src/templates/docx-postprocess.ts index c590045..cae2760 100644 --- a/src/templates/docx-postprocess.ts +++ b/src/templates/docx-postprocess.ts @@ -163,6 +163,9 @@ function addRowSuppressionMarkers( if (fieldPaths.length === 0) { return rowXml; } + if (fieldPaths.some((fieldPath) => !isRootDataPath(data, fieldPath))) { + return rowXml; + } const token = `__DOCGEN_ROW_${rowMarkers.length}__`; rowMarkers.push({ @@ -195,13 +198,68 @@ function extractSimpleFieldPaths(xml: string): string[] { return [...fields]; } +function extractWordText(xml: string): string { + const texts: string[] = []; + const matches = xml.matchAll(/]*>([\s\S]*?)<\/w:t>/g); + + for (const match of matches) { + texts.push(decodeXmlText(match[1])); + } + + return texts.join(''); +} + +function decodeXmlText(text: string): string { + return text + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&'); +} + function resolvePath(data: Record, fieldPath: string): unknown { - return fieldPath.split('.').reduce((current, part) => { + const directValue = fieldPath.split('.').reduce((current, part) => { if (current && typeof current === 'object' && part in current) { return (current as Record)[part]; } return undefined; }, data); + + if (directValue !== undefined || fieldPath.includes('.')) { + return directValue; + } + + const rootKeys = Object.keys(data); + if (rootKeys.length === 1) { + const rootValue = data[rootKeys[0]]; + if (rootValue && typeof rootValue === 'object' && fieldPath in rootValue) { + return (rootValue as Record)[fieldPath]; + } + } + + return undefined; +} + +function isRootDataPath(data: Record, fieldPath: string): boolean { + const firstPart = fieldPath.split('.')[0]; + if (firstPart.startsWith('$')) { + return false; + } + if (firstPart in data) { + return true; + } + if (fieldPath.includes('.')) { + return false; + } + + const rootKeys = Object.keys(data); + if (rootKeys.length !== 1) { + return false; + } + + const rootValue = data[rootKeys[0]]; + return Boolean(rootValue && typeof rootValue === 'object' && fieldPath in rootValue); } function isBlankValue(value: unknown): boolean { @@ -225,6 +283,8 @@ async function postProcessXmlParts(zip: JSZip, context: DocxPostProcessContext): let xml = await file.async('string'); xml = applyRowSuppression(xml, context.rowMarkers); + xml = removeEmptyTableCellParagraphs(xml); + xml = removeRowlessTables(xml); xml = applyEditableControls(xml, context.controls); zip.file(path, xml); } @@ -255,6 +315,46 @@ function applyRowSuppression(xml: string, rowMarkers: RowMarker[]): string { return nextXml; } +function removeEmptyTableCellParagraphs(xml: string): string { + return xml.replace(//g, (cellXml) => { + let fallbackParagraph = ''; + const cleanedCellXml = cellXml.replace(//g, (paragraphXml) => { + if (!isRemovableEmptyParagraph(paragraphXml)) { + return paragraphXml; + } + fallbackParagraph ||= paragraphXml; + return ''; + }); + + if (hasTableCellBlockContent(cleanedCellXml) || !fallbackParagraph) { + return cleanedCellXml; + } + + return cleanedCellXml.replace('', `${fallbackParagraph}`); + }); +} + +function hasTableCellBlockContent(cellXml: string): boolean { + return //g, (tableXml) => + / +): Promise> { + const templateArrayBuffer = template.buffer.slice( + template.byteOffset, + template.byteOffset + template.byteLength + ) as ArrayBuffer; + const commands = await listCommands(templateArrayBuffer, ['{{', '}}']); + const loopPaths = commands + .filter((command) => command.type === 'FOR') + .map((command) => parseSimpleLoopPath(command.code)) + .filter((path): path is string => Boolean(path)); + + let normalizedData: Record | null = null; + + for (const loopPath of loopPaths) { + const currentValue = resolveDataPath(normalizedData ?? data, loopPath); + if (currentValue == null) { + normalizedData ??= cloneData(data) as Record; + setArrayAtPath(normalizedData, loopPath); + } + } + + return normalizedData ?? data; +} + +function parseSimpleLoopPath(commandCode: string): string | null { + const match = /^\S+\s+IN\s+([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)$/i.exec( + commandCode.trim() + ); + if (!match || match[1].startsWith('$')) { + return null; + } + return match[1]; +} + +function resolveDataPath(data: Record, fieldPath: string): unknown { + return fieldPath.split('.').reduce((current, part) => { + if (current && typeof current === 'object' && part in current) { + return (current as Record)[part]; + } + return undefined; + }, data); +} + +function setArrayAtPath(data: Record, fieldPath: string): void { + const parts = fieldPath.split('.'); + let current: Record = data; + + for (const part of parts.slice(0, -1)) { + if (current[part] == null) { + current[part] = {}; + } + if (typeof current[part] !== 'object' || Array.isArray(current[part])) { + return; + } + current = current[part]; + } + + const lastPart = parts[parts.length - 1]; + if (current[lastPart] == null) { + current[lastPart] = []; + } +} + +function cloneData(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(cloneData); + } + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record).map(([key, childValue]) => [ + key, + cloneData(childValue), + ]) + ); + } + return value; +} + /** * Validate data object for common issues * diff --git a/test/convert.test.ts b/test/convert.test.ts index 5f3d2c9..ab53ad7 100644 --- a/test/convert.test.ts +++ b/test/convert.test.ts @@ -204,6 +204,24 @@ describe('LibreOfficeConverter', () => { expect(mockedFsPromises.rm).toHaveBeenCalled(); }); + it('should report LibreOffice stderr when no PDF output is produced', async () => { + mockExecFileAsync.mockResolvedValue({ + stdout: '', + stderr: 'source file could not be loaded\n', + }); + const enoent: any = new Error('missing pdf'); + enoent.code = 'ENOENT'; + mockedFsPromises.readFile.mockRejectedValueOnce(enoent); + + const docxBuffer = Buffer.from('mock docx content'); + + await expect( + converter.convertToPdf(docxBuffer, { correlationId: 'missing-output-test' }) + ).rejects.toThrow(/LibreOffice did not produce PDF output.*source file could not be loaded/i); + + expect(mockedFsPromises.rm).toHaveBeenCalled(); + }); + it('should track stats correctly', async () => { const initialStats = converter.getStats(); expect(initialStats.totalConversions).toBe(0); diff --git a/test/templates/docx-postprocess.test.ts b/test/templates/docx-postprocess.test.ts index 7f1ad58..bbefb21 100644 --- a/test/templates/docx-postprocess.test.ts +++ b/test/templates/docx-postprocess.test.ts @@ -101,4 +101,133 @@ describe('DOCX template post-processing', () => { expect(documentXml).not.toContain('Remove'); expect(documentXml).not.toContain('__DOCGEN_ROW_'); }); + + it('collapses blank address lines inside a populated table row', async () => { + const template = await createTestDocxFromBodyXml(` + + + Ship To Address: + + {{Quote.Street}} + {{Quote.City}} + {{Quote.State}} + {{Quote.Country}} + + + + `); + + const result = await mergeTemplate( + template, + { + Quote: { + Street: '16 Great Marlborough Street', + City: 'London', + State: '', + Country: 'United Kingdom', + }, + }, + baseOptions + ); + + const documentXml = await readDocxXml(result, 'word/document.xml'); + expect(documentXml).toContain('Ship To Address:'); + expect(documentXml).toContain('16 Great Marlborough Street'); + expect(documentXml).toContain('London'); + expect(documentXml).toContain('United Kingdom'); + expect(documentXml).not.toContain('Quote.State'); + expect(documentXml).not.toContain('__DOCGEN_PARAGRAPH_'); + expect(documentXml.match(/ { + const template = await createTestDocxFromBodyXml(` + + + Account {{Account.Name}} + + {{Account.OptionalLine1}} + {{Account.OptionalLine2}} + + + + `); + + const result = await mergeTemplate( + template, + { + Account: { + Name: 'Acme', + OptionalLine1: null, + OptionalLine2: '', + }, + }, + baseOptions + ); + + const documentXml = await readDocxXml(result, 'word/document.xml'); + const cells = documentXml.match(//g) ?? []; + expect(cells).toHaveLength(2); + expect(cells[1].match(/ { + const template = await createTestDocxFromBodyXml(` + + {{FOR item IN Account.LineItems}} + {{INS $item.Name}} + {{END-FOR item}} + + After table + `); + + const result = await mergeTemplate( + template, + { + Account: { + Name: 'Acme', + LineItems: null, + }, + }, + baseOptions + ); + + const documentXml = await readDocxXml(result, 'word/document.xml'); + expect(documentXml).toContain('After table'); + expect(documentXml).not.toContain(' { + const template = await createTestDocxFromBodyXml(` + + {{FOR item IN Account.LineItems}} + + {{INS $item.Name}} + {{INS $item.Amount}} + + {{END-FOR item}} + + `); + + const result = await mergeTemplate( + template, + { + Account: { + LineItems: [ + { Name: 'Service A', Amount: '100' }, + { Name: 'Service B', Amount: '200' }, + ], + }, + }, + baseOptions + ); + + const documentXml = await readDocxXml(result, 'word/document.xml'); + expect(documentXml).toContain('Service A'); + expect(documentXml).toContain('Service B'); + expect(documentXml).not.toContain('__DOCGEN_ROW_'); + }); }); diff --git a/test/templates/merge.test.ts b/test/templates/merge.test.ts index 24e9da2..4356425 100644 --- a/test/templates/merge.test.ts +++ b/test/templates/merge.test.ts @@ -3,10 +3,16 @@ import type { MergeOptions } from '../../src/types'; // Mock docx-templates jest.mock('docx-templates', () => { - return jest.fn().mockImplementation(() => { + const createReport = jest.fn().mockImplementation(() => { // Simple mock that returns a buffer return Promise.resolve(Buffer.from('merged document content')); }); + + return { + __esModule: true, + default: createReport, + listCommands: jest.fn().mockResolvedValue([]), + }; }); describe('Template Merge', () => {