Skip to content

Instantly share code, notes, and snippets.

@bennadel
Created August 15, 2020 23:21
Generating Rich Server-Side Reports In Lucee CFML 5.3.6.61
<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>
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 );
}
}
}
<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>
<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>
<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>
<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>
<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>
<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