Created
August 15, 2020 23:21
Generating Rich Server-Side Reports In Lucee CFML 5.3.6.61
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<cfscript> | |
// Setup the DEMO DATA for our report generation. | |
project = { | |
id: 1, | |
name: "My Important Report" | |
}; | |
photos = [ | |
{ | |
id: 329, | |
clientFilename: "vicky_ryder.jpg", | |
remoteUrl: "https://bennadel.com/images/header/photos/vicky_ryder.jpg" | |
}, | |
{ | |
id: 362, | |
clientFilename: "vicky_ryder_2.jpg", | |
remoteUrl: "https://bennadel.com/images/header/photos/vicky_ryder_2.jpg" | |
} | |
]; | |
// When setting up the report generator, we need to give it a "scratch" directory in | |
// which it can store all of the intermediary files as it prepares the ZIP archive. I | |
// could have used something like getTempDirectory(); but, I like have an explicit | |
// folder so that I can check the scratch directory as part of the debugging process. | |
// -- | |
// NOTE: This could be an application-cached singleton (it is stateless). But, for | |
// the sake of the demo, I'm just re-creating it on every request. | |
generator = new report.ReportGenerator( expandPath( "./temp" ) ); | |
// Generate a report for the given Project and Photos. | |
// -- | |
// NOTE: There are other ways to do this, like returning a file-path or a remote URL | |
// at which the ZIP has been stored; but, for the sake of the demo, I'm just going to | |
// have the report generator return the BINARY payload for the ZIP. | |
zipContent = generator.generateReport( project, photos ); | |
// Stream ZIP content back to user. | |
header | |
name = "content-disposition" | |
value = "attachment; filename=""my-important-project.zip"";" | |
; | |
content | |
type = "application/zip" | |
variable = zipContent | |
; | |
</cfscript> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
component | |
output = false | |
hint = "I generate an HTML report." | |
{ | |
/** | |
* I initialize the report generator to use the given scratch directory for its | |
* temporary files. | |
* | |
* @scratchDirectory I am the temporary scratch folder. | |
*/ | |
public void function init( required string scratchDirectory ) { | |
variables.scratchDirectory = arguments.scratchDirectory; | |
// As part of the report generation, "client-side" files, like CSS and JavaScript | |
// files, will be inlined within the report payload. The assets directory is | |
// where these client-side files live. | |
variables.assetsDirectory = ( getDirectoryFromPath( getCurrentTemplatePath() ) & "assets" ); | |
} | |
// --- | |
// PUBLIC METHODS. | |
// --- | |
/** | |
* I generate a report for the given data and returns a ZIP payload for the report. | |
* | |
* @project I am the project data. | |
* @photos I am the photos to be downloaded and included in the report. | |
*/ | |
public binary function generateReport( | |
required struct project, | |
required array photos | |
) { | |
var zipFile = withTempDirectory( | |
( tempDirectory ) => { | |
// NOTE: The "Report Name" folder will become the root folder that is | |
// exposed when the user expands the ZIP archive file on their local file | |
// system. As such, the name of this folder should be human-readable and | |
// meaningful to the user. | |
var reportName = "report-#dateFormat( now(), 'yyyy-mm-dd' )#"; | |
var workingDirectory = ( tempDirectory & "/" & reportName ); | |
var photosDirectory = ( workingDirectory & "/photos" ); | |
directoryCreate( workingDirectory ); | |
directoryCreate( photosDirectory ); | |
// STEP 1: Download the photo binaries. | |
downloadPhotos( photos, photosDirectory ); | |
// STEP 2: Render the HTML report template. | |
renderReport( project, photos, workingDirectory ); | |
// STEP 3: Generate and return the ZIP archive (binary). | |
return( generateZip( tempDirectory, workingDirectory ) ); | |
} | |
); | |
return( zipFile ); | |
} | |
// --- | |
// PRIVATE METHODS. | |
// --- | |
/** | |
* I download the given photos into the given directory. Each photo will be stored | |
* with the clientFilename associated with the photo record. The results of the | |
* download operations are returns (each with a "success" flag). | |
* | |
* @photos I am the collection of photos to download. | |
* @photosDirectory I am the directory into which the photos will be downloaded. | |
*/ | |
private array function downloadPhotos( | |
required array photos, | |
required string photosDirectory | |
) { | |
// For performance reasons, we're going to download the photos in parallel (using | |
// Lucee's parallel iteration features). However, so as not to overwhelm the | |
// server, we're going to use a limited number of threads). | |
var results = photos.map( | |
( photo ) => { | |
try { | |
// NOTE: We're using Lucee's VIRTUAL FILE SYSTEM features to read the | |
// source file from a REMOTE URL and then copy that file to the local | |
// file-system. | |
fileCopy( | |
photo.remoteUrl, | |
( photosDirectory & "/" & photo.clientFilename ) | |
); | |
return({ | |
photo: photo, | |
success: true | |
}); | |
} catch ( any error ) { | |
return({ | |
photo: photo, | |
success: false, | |
error: error | |
}); | |
} | |
}, | |
// Perform map using parallel iteration. | |
true, | |
// Maximum parallel threads (20 is default). | |
6 | |
); | |
return( results ); | |
} | |
/** | |
* I generate and return the ZIP binary of the working directory. | |
* | |
* @tempDirectory I am the scratch directory for the report generator. | |
* @workingDirectory I am the directory being archived. | |
*/ | |
private binary function generateZip( | |
tempDirectory, | |
workingDirectory | |
) { | |
var zipFile = ( tempDirectory & "/report.zip" ); | |
// CAUTION: In production, I wouldn't necessarily use "compress" for this since | |
// it will waste time and CPU resources trying to compress the image binaries | |
// (which are already a compressed file-format). But, for the sake of the demo, | |
// this is the easiest way to produce the archive file. | |
compress( | |
format = "zip", | |
source = workingDirectory, | |
target = zipFile, | |
includeBaseFolder = true | |
); | |
return( fileReadBinary( zipFile ) ); | |
} | |
/** | |
* I return the Script content for the report (reading in the files from the scripts | |
* directory within the "client side" assets). | |
*/ | |
private string function inlineScripts() { | |
var inlinedContent = directoryList( assetsDirectory & "/scripts" ) | |
.map( | |
( filePath ) => { | |
return( | |
"<!-- #encodeForHtml( getFileFromPath( filePath ) )#. -->" & | |
"<script type=""text/javascript"">" & | |
fileRead( filePath ) & | |
"</script>" | |
); | |
} | |
) | |
.toList( chr( 10 ) ) | |
; | |
return( inlinedContent ); | |
} | |
/** | |
* I return the CSS content for the report (reading in the files from the styles | |
* directory within the "client side" assets). | |
*/ | |
private string function inlineStyles() { | |
var inlinedContent = directoryList( assetsDirectory & "/styles" ) | |
.map( | |
( filePath ) => { | |
return( | |
"<!-- #encodeForHtml( getFileFromPath( filePath ) )#. -->" & | |
"<style type=""text/css"">" & | |
fileRead( filePath ) & | |
"</style>" | |
); | |
} | |
) | |
.toList( chr( 10 ) ) | |
; | |
return( inlinedContent ); | |
} | |
/** | |
* I render the main report template (consuming a CFML file to create an HTML file) | |
* for the given projects and photos. | |
* | |
* @project I am the project data. | |
* @photos I am the photos data. | |
* @workingDirectory I am the working directory into which the HTML file is saved. | |
*/ | |
private void function renderReport( | |
required struct project, | |
required array photos, | |
required string workingDirectory | |
) { | |
// This will tell the Lucee Compiler to preserve the key-casing for the following | |
// config object, which we are going to embed in the HTML report page. While | |
// ColdFusion is not case-sensitive, JavaScript very much is. As such, it's | |
// important that our config object have predictable and consistent key-casing. | |
processingDirective preserveCase = true { | |
// Since this object is going to be inlined in the HTML, we have to be | |
// careful to create a sanitized version of the data. We don't want to leak | |
// sensitive information. | |
var config = { | |
project: { | |
id: project.id, | |
name: project.name | |
}, | |
photos: photos.map( | |
( photo ) => { | |
return({ | |
id: photo.id, | |
clientFilename: photo.clientFilename | |
}); | |
} | |
) | |
}; | |
} | |
// In order to prevent variables within the CFML template from "leaking" into the | |
// report-generator page scope, we're going to use an IIFE (Immediately Invoked | |
// Function Expression) with an explicit LOCALMODE so that we "trap" any unscoped | |
// variables (such as those created in a CFLoop tag). | |
var reportHtml = ( | |
function() localmode = "modern" { | |
savecontent variable = "local.reportContent" { | |
include "./templates/index.cfm"; | |
} | |
return( reportContent ); | |
} | |
)(); | |
fileWrite( ( workingDirectory & "/index.htm" ), reportHtml ); | |
} | |
/** | |
* I create and manage a temp directory for the current report. The given callback | |
* will be invoked with the temp directory as its argument. Then, once the callback | |
* completed, the temp directory will be deleted. | |
* | |
* @callback I am the Function to invoke with the temp directory. | |
*/ | |
private any function withTempDirectory( required function callback ) { | |
var name = ( scratchDirectory & "/report-#createUniqueId()#" ); | |
var CREATE_PATH_IF_NOT_EXISTS = true; | |
var RECURSE_DIRECTORIES = true; | |
directoryCreate( name, CREATE_PATH_IF_NOT_EXISTS ); | |
try { | |
return( callback( name ) ); | |
} finally { | |
directoryDelete( name, RECURSE_DIRECTORIES ); | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<cfscript> | |
/** | |
* I generate a report for the given data and returns a ZIP payload for the report. | |
* | |
* @project I am the project data. | |
* @photos I am the photos to be downloaded and included in the report. | |
*/ | |
public binary function generateReport( | |
required struct project, | |
required array photos | |
) { | |
var zipFile = withTempDirectory( | |
( tempDirectory ) => { | |
// NOTE: The "Report Name" folder will become the root folder that is | |
// exposed when the user expands the ZIP archive file on their local file | |
// system. As such, the name of this folder should be human-readable and | |
// meaningful to the user. | |
var reportName = "report-#dateFormat( now(), 'yyyy-mm-dd' )#"; | |
var workingDirectory = ( tempDirectory & "/" & reportName ); | |
var photosDirectory = ( workingDirectory & "/photos" ); | |
directoryCreate( workingDirectory ); | |
directoryCreate( photosDirectory ); | |
// STEP 1: Download the photo binaries. | |
downloadPhotos( photos, photosDirectory ); | |
// STEP 2: Render the HTML report template. | |
renderReport( project, photos, workingDirectory ); | |
// STEP 3: Generate and return the ZIP archive (binary). | |
return( generateZip( tempDirectory, workingDirectory ) ); | |
} | |
); | |
return( zipFile ); | |
} | |
</cfscript> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<cfscript> | |
/** | |
* I download the given photos into the given directory. Each photo will be stored | |
* with the clientFilename associated with the photo record. The results of the | |
* download operations are returns (each with a "success" flag). | |
* | |
* @photos I am the collection of photos to download. | |
* @photosDirectory I am the directory into which the photos will be downloaded. | |
*/ | |
private array function downloadPhotos( | |
required array photos, | |
required string photosDirectory | |
) { | |
// For performance reasons, we're going to download the photos in parallel (using | |
// Lucee's parallel iteration features). However, so as not to overwhelm the | |
// server, we're going to use a limited number of threads). | |
var results = photos.map( | |
( photo ) => { | |
try { | |
// NOTE: We're using Lucee's VIRTUAL FILE SYSTEM features to read the | |
// source file from a REMOTE URL and then copy that file to the local | |
// file-system. | |
fileCopy( | |
photo.remoteUrl, | |
( photosDirectory & "/" & photo.clientFilename ) | |
); | |
return({ | |
photo: photo, | |
success: true | |
}); | |
} catch ( any error ) { | |
return({ | |
photo: photo, | |
success: false, | |
error: error | |
}); | |
} | |
}, | |
// Perform map using parallel iteration. | |
true, | |
// Maximum parallel threads (20 is default). | |
6 | |
); | |
return( results ); | |
} | |
</cfscript> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<cfscript> | |
/** | |
* I render the main report template (consuming a CFML file to create an HTML file) | |
* for the given projects and photos. | |
* | |
* @project I am the project data. | |
* @photos I am the photos data. | |
* @workingDirectory I am the working directory into which the HTML file is saved. | |
*/ | |
private void function renderReport( | |
required struct project, | |
required array photos, | |
required string workingDirectory | |
) { | |
// This will tell the Lucee Compiler to preserve the key-casing for the following | |
// config object, which we are going to embed in the HTML report page. While | |
// ColdFusion is not case-sensitive, JavaScript very much is. As such, it's | |
// important that our config object have predictable and consistent key-casing. | |
processingDirective preserveCase = true { | |
// Since this object is going to be inlined in the HTML, we have to be | |
// careful to create a sanitized version of the data. We don't want to leak | |
// sensitive information. | |
var config = { | |
project: { | |
id: project.id, | |
name: project.name | |
}, | |
photos: photos.map( | |
( photo ) => { | |
return({ | |
id: photo.id, | |
clientFilename: photo.clientFilename | |
}); | |
} | |
) | |
}; | |
} | |
// In order to prevent variables within the CFML template from "leaking" into the | |
// report-generator page scope, we're going to use an IIFE (Immediately Invoked | |
// Function Expression) with an explicit LOCALMODE so that we "trap" any unscoped | |
// variables (such as those created in a CFLoop tag). | |
var reportHtml = ( | |
function() localmode = "modern" { | |
savecontent variable = "local.reportContent" { | |
include "./templates/index.cfm"; | |
} | |
return( reportContent ); | |
} | |
)(); | |
fileWrite( ( workingDirectory & "/index.htm" ), reportHtml ); | |
} | |
</cfscript> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<cfscript> | |
/** | |
* I return the Script content for the report (reading in the files from the scripts | |
* directory within the "client side" assets). | |
*/ | |
private string function inlineScripts() { | |
var inlinedContent = directoryList( assetsDirectory & "/scripts" ) | |
.map( | |
( filePath ) => { | |
return( | |
"<!-- #encodeForHtml( getFileFromPath( filePath ) )#. -->" & | |
"<script type=""text/javascript"">" & | |
fileRead( filePath ) & | |
"</script>" | |
); | |
} | |
) | |
.toList( chr( 10 ) ) | |
; | |
return( inlinedContent ); | |
} | |
/** | |
* I return the CSS content for the report (reading in the files from the styles | |
* directory within the "client side" assets). | |
*/ | |
private string function inlineStyles() { | |
var inlinedContent = directoryList( assetsDirectory & "/styles" ) | |
.map( | |
( filePath ) => { | |
return( | |
"<!-- #encodeForHtml( getFileFromPath( filePath ) )#. -->" & | |
"<style type=""text/css"">" & | |
fileRead( filePath ) & | |
"</style>" | |
); | |
} | |
) | |
.toList( chr( 10 ) ) | |
; | |
return( inlinedContent ); | |
} | |
</cfscript> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<cfscript> | |
/** | |
* I generate and return the ZIP binary of the working directory. | |
* | |
* @tempDirectory I am the scratch directory for the report generator. | |
* @workingDirectory I am the directory being archived. | |
*/ | |
private binary function generateZip( | |
tempDirectory, | |
workingDirectory | |
) { | |
var zipFile = ( tempDirectory & "/report.zip" ); | |
// CAUTION: In production, I wouldn't necessarily use "compress" for this since | |
// it will waste time and CPU resources trying to compress the image binaries | |
// (which are already a compressed file-format). But, for the sake of the demo, | |
// this is the easiest way to produce the archive file. | |
compress( | |
format = "zip", | |
source = workingDirectory, | |
target = zipFile, | |
includeBaseFolder = true | |
); | |
return( fileReadBinary( zipFile ) ); | |
} | |
</cfscript> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<cfoutput> | |
<!doctype html> | |
<html lang="en"> | |
<head> | |
<meta charset="utf-8" /> | |
<title> | |
#encodeForHtml( config.project.name )# | |
</title> | |
<!--- Slurp the CSS files into the report content. ---> | |
#inlineStyles()# | |
</head> | |
<body> | |
<h1> | |
#encodeForHtml( config.project.name )# | |
</h1> | |
<cfloop index="photo" array="#config.photos#"> | |
<h2> | |
#encodeForHtml( photo.clientFilename )# | |
</h2> | |
<p class="photo"> | |
<a | |
href="./photos/#encodeForHtmlAttribute( photo.clientFilename )#" | |
target="_blank"> | |
<img | |
src="./photos/#encodeForHtmlAttribute( photo.clientFilename )#" | |
alt="#encodeForHtmlAttribute( photo.clientFilename )#" | |
/> | |
</a> | |
</p> | |
</cfloop> | |
<!--- | |
Inline the CONFIG object into the page (window.config) so that it can be | |
consumed by the embedded JavaScript files. We don't actually need that for | |
this demo; but, it showcases the ability to embed data. | |
---> | |
<script type="text/javascript"> | |
window.config = JSON.parse( "#encodeForJavaScript( serializeJson( config ) )#" ); | |
</script> | |
<!--- Slurp the JavaScript files into the report content. ---> | |
#inlineScripts()# | |
</body> | |
</html> | |
</cfoutput> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment