Friday, August 20, 2010

BIRT Flash and Gadget Scripting

In my last post I blogged about how Chart Scripting works in BIRT. If you are using Actuate BIRT, you also will have access to Flash Charts and Gadgets. Scripting for these two report items is very similar to the standard Chart scripting model.



Flash Charts
Currently Flash charts support 14 script event triggers that can be hooked to make chart modifications.



The event firing order is shown below.



As you can see the order is very similar to that of normal BIRT charts. The before and after dataset filled events are fired first for each dataset (category and value). Next the beforeRendering event is fired and then the before and after draw series events are fired for each series. Between the before and after draw series events the before and after draw data point events are fired for each point in the series. The before and after draw series events (and the events contained in them) are fired for each series in the chart. After all series are processed, the before and after draw effect events are fired for each effect in the chart. Effects can be used to alter the chart at runtime, allowing font manipulation, blurring, glowing, shadowing, beveling, and animation of different objects of the chart. Effects are added to the chart using the effects editor.



Using the beforeDrawEffect event script you can alter any of the effects added to the chart. For example to turn off the shadow effect in the above chart you could use the following script.



function beforeDrawEffect( effect, fcsc )
{
var effectName = "MyTitleEffect";
if ( effectName.equals( effect.getAction().getName() ) ){
if( effect.getAction().getShadow() != null ){
effect.getAction().getShadow().setEnabled(false);
effect.getAction().getFont().setEnabled(false);
}
}
}



The getEffect().getAction().getName() call returns the name of the effect as defined in the effect editor. This can be used to determine which effect definition your script is operating on. The getAction allows access to the different effect features. Note that if the effect is not enabled the getter method for the effect will return null. In the above script we first check to see if the getShadow method returns null before turning it off.

Finally the afterRendering event is triggered.

These events function much the same way as described in my earlier post. You will notice that event function prototypes are just a little different. For example instead of a chart and icsc parameter you will get a flashChart and fcsc parameter in certain events. For practical purposes these are just extended chart and icsc objects and still provide most of the standard object features. For example to set the chart title to the value of a report parameter using the reportContext, you could use a script like the following.



function beforeRendering( flashChart, fcsc )
{
var parmChartTitle = fcsc.getExternalContext().getScriptable().getParameterValue("MyChartTitle");
flashChart.getChart().getTitle().getLabel().getCaption().setValue(parmChartTitle)
}



Flash Gadgets
BIRT Flash Gadget report items are used to display data in a unique dynamic graphic. Currently Actuate BIRT supports thermometer, cylinder, bullet, spark line, meter gauge or linear gauge gadgets. These gadgets also support server side scripting.



Twelve event triggers are supported and can be used to alter how the gadget is displayed. The events that are triggered will depend on the gadget and which features the gadget is using. For example this meter gadget:



Produces the following event order.



The beforeRendering event is called first and can be used to modify the flash gadget before it is rendered. Next before and after draw region events are trigger for each of the three regions in the meter gadget. This gadget contains two thresholds, so the before and after threshold events are triggered next for each threshold. The gadget also contains two needles so the before and after draw needle events are fired. The threshold label contains a font effect and the threshold areas are animated. This produces before and after draw effect triggers for each. Finally the afterRendering event is triggered.

Retrieving Gadget Values
BIRT Flash Gadgets use value definitions to link the gadget to a BIRT dataset. The definitions are assigned in the Gadget Editor. The runtime values for these definitions can be retrieved in script using the gadget object. For example, in the beforeRendering you could use the following script:


function beforeRendering( gadget, fgsc )
{
var vals=gadget.getResultValues();
for( i = 0; i < vals.size(); i++ ){
if( vals.get(i).getValue() > 20 ){
//do something
}
}
}


The number of result values will depend on the gadget. For example, In a Spark Line gadget there will be multiple values. In a meter gadget, there will be a result value for each needle value.

Region Events
You can customize the colors, transparency, labels, and range values of any region using the beforeRendering or beforeDrawRegion scripts. See the example below.


function beforeDrawRegion( region, fgsc )
{
if( region.getLabel().equals("A") ){
region.setColor("00ff00");
}
//Display value range of region.
region.setLabel( "(" + region.getStartValue() + ", " + region.getEndValue() + ")" );

}

function beforeRendering( gadget, fgsc )
{
//First Region
gadget.getRegion().get(0).setEndValue(25);
//Second Region
gadget.getRegion().get(1).setStartValue(25);




Threshold Events
Thresholds, like regions can also be customized using script. Virtually anything that is set in the designer can be changed in an event trigger. For example:


function beforeDrawThreshold( threshold, fgsc )
{
if( threshold.getLabel().equals("MyThreshold") ){
threshold.setShowValueInside(true);
threshold.setShowValueOnTop(false);
threshold.setStartValue(10);
threshold.setColor("0000ee");
// Display the value range of threshold.
threshold.setLabel( "(" + threshold.getStartValue() + ", " + threshold.getEndValue() + ")" );
}
}

function beforeRendering( gadget, fgsc )
{
gadget.getThresholds().get(0).setMarkerColor("ff0000");
}


In these scripts, we change the starting value, color, label and label location in the beforeDrawThreshold. The marker color is also changed in the beforeRendering script.

AddOn Events
BIRT Gadgets support the concept of an AddOn. An AddOn allows the designer to place images, text, and various shapes on top of the gadget. If the gadget contains an AddOn the before and afterDrawAddOn events will be triggered, allowing you to customize the AddOn at runtime. For example, the following script customizes two AddOns.


function beforeDrawAddOn( addOn, fgsc )
{
// Change add-on styles.
if ( addOn.getName().equals("TitleAddOn") )
{
addOn.setLabel( "Meter Gauge" );
addOn.setFontColor( "ff0000" );
addOn.setFontSize( 28 );
addOn.setY( 35 );
}
else if ( addOn.getName().equals( "ArcAddOn" ) )
{
addOn.setFillColor( "ffff00" );
}
}


AddOns can even be created and placed on the gadget at runtime. For example the following script adds a text AddOn to the gadget using the beforeRendering event.


function beforeRendering( gadget, fgsc )
{
importPackage( Packages.com.actuate.birt.flash.gadgets );
importPackage( Packages.com.actuate.birt.flash.gadgets.impl );

// Add a text add-on.
var textAddOn = GadgetsFactory.eINSTANCE.createTextAddOn();
textAddOn.setAddOnType( AddOnType.TEXT );
textAddOn.setName("DescAddOn");
textAddOn.setShowAddOn(true);
gadget.getAddOns().add( textAddOn );
gadget.setZOrderPosition(1);

textAddOn.setLabel("This sample demonstrates how to add an add-on by scripting.");
textAddOn.setX(2);
textAddOn.setY(20);
textAddOn.setFontSize( 12 );
textAddOn.setHorizontalAlignment( HorizontalAlignmentType.LEFT );
textAddOn.setVerticalAlignment( VerticalAlignmentType.TOP );
textAddOn.setWrap( true );
textAddOn.setTextBoxBorderColor("000000"); //Black
textAddOn.setTextBoxBackgroundColor("c0c0c0");
textAddOn.setWrapMaxWidth( 30 );
textAddOn.setWrapMaxHeight( 50 );

}


Needle Events
If your gadget contains a needle, the before and afterDrawNeedle events will be triggered. Using script the needle can be customized. The size, color, shape, and various other properties of the needle can be customized. These events are passed a NeedleWrapper instance, which can be used to get the specific needle type. For example the following two scripts differentiate between a normal gadget needle and a meter gadget needle.


function beforeDrawNeedle( needleWrapper, fgsc )
{
importPackage( Packages.com.actuate.birt.flash.gadgets );
importPackage( Packages.com.actuate.birt.flash.gadgets.impl );
// Change needle styles.
needleWrapper.getNeedle().setShape( Shape.DIAMOND );
needleWrapper.getNeedle().setFillColor("2cf83a");
needleWrapper.getNeedle().setBorderColor("007f00");
}

function beforeDrawNeedle( needleWrapper, fgsc )
{
//Set Needle size to 25%
needleWrapper.getMeterNeedle().setSize(25);
needleWrapper.getMeterNeedle().setBackgroundColor("ff0000");
}



Effect Events
The effect events were described above in the flash charting section.

Several examples used in this post are available at BIRT-Exchange.

Monday, August 16, 2010

BIRT For Perforce

I recently stumbled across a project that has BIRT reports about Perforce.  It looks like if you are using Perforce for your version control, you can now use BIRT reports to gain insight into that system.

http://public.perforce.com/wiki/BIRT_Reports

Thursday, August 12, 2010

BIRT Charting – Scripting Overview

The BIRT Chart Engine currently supports building and running charts within the BIRT designer or externally. The engine is fully extensible using the Eclipse extension mechanisms and supports client side interactivity and server side event scripting. Fourteen main chart types, such as bar charts, line charts, and scatter charts are supported. Additionally most chart types support many subtypes such as stacked bar charts, 2D with depth, or 3D charts. When using the chart engine within BIRT the charts can be rendered as PNG, JPG, BMP, or SVG.

The chart engine also supports a sophisticated server side event model that allows the developer to customize the charts dynamically. This post is an overview of most of the chart scripting events available.

If you are interested in learning more about client side chart scripting, see these posts:
ClientSide Script:
Calling Client Side JavaScript from a BIRT Chart
More on Chart Interactivity

General BIRT Server Side Scripting
BIRT Chart event scripting is based on the standard BIRT report scripting model. A primer for report scripting is available here. When the BIRT report engine processes reports they are executed in two phases (Generation and Presentation). The generation phase connects to data sources collects and processes the values and creates the report items. The presentation phase renders the content to the particular output (HTML, paginated HTML, PDF, PPT, XLS, DOC, or PS). These phases can be executed in one or two processes, depending on how you call the engine.

If you use the Report Engine API (RE API), the RunAndRenderTask is one process. To run it in two processes you would use a RunTask and then a RenderTask. If you are using the BIRT Viewer, the /Run and /Preview mappings use one process and the /frameset mapping uses two processes. This concept is very important to understand when scripting as it affects the order in which the events are triggered. When using one process the event order for a data item would look something like:

1st instance
onPrepare
onCreate
onRender
2nd instance
onCreate
onRender.

The same data item with two processes would look like:

1st instance
onPrepare
onCreate
2nd instance
onCreate

onRender first instance
onRender second instance

Using two processes offers many advantages. First a binary file called the report document (.rptdocument) is created which can be rendered many times at a later date without re-executing all the queries. It is also required to support TOC bookmarks and paginated HTML.

Chart Server Side Scripting
When using charts in a BIRT report, the engine supports over thirty events. These events can be used to change the data, interactivity, or presentation of the chart. All of these events are fired in the presentation phase (at render time) of the report and can be written in Java or JavaScript.

It is possible to change chart properties at generation time, but these changes must be made in a report event that happens prior to the chart being created, like the beforeFactory event. This approach is described here and here.

It is vital to understand when chart events are fired, if you plan on making script changes to your chart. Take for instance the following chart.



This is a standard Bar chart with a marker line and curve fitting line. If two processes are used to generate the report that contains this chart, the following event firing order will be used.




This diagram shows most of the available events, but not all events are fired for every chart type. For example before and afterDrawMarker events are only fired for chart types that support makers and these events are usually fired in between the before and afterDrawDataPoint scripts. The beforeDrawSeriesTitle event only fires for Pie charts.

Notice the chart events do not start until after the beforeRender report level event.

Chart Script Context
Before getting into each of the events, a good understanding of the chart script context object will be helpful. All BIRT Chart events are passed a chart script context object. This object will be labeled icsc in the function definition. You can use the chart script context object to get the current instance of the chart model by calling:


icsc.getChartInstance();



You can also use it to get the reportContext that is associated with all other report scripting events. See the scripting primer referenced above for more details. This object can be used to get parameter values, resource values, locales, the current http request, global variables, page variables, etc. To access these functions with a JavaScript event use the following:


rpCtx = icsc.getExternalContext().getScriptable();


This is equivalent to the reportContext variable. So to get a report parameter value, use:


rpCtx.getParameterValue(“MyParameter”);


or to access the http request use (this only works in the Viewer unless you put the http request object in the report engines App Context):


rptCtx.getHttpServletRequest().getRequestURL();


If you are using a Java Event handler use:


IReportContext rpCtx = (IReportContext)icsc.getExternalContext().getObject();


Data Set Filled Events
The first set of chart events that fire are the data set filled events. These events are fired when data organizing and generation of runtime series is occurring for the chart. In These events you can change many properties of the chart model, such as series expressions, grouping, labels, formatting, and series visibility. These events are fired once for the category series and once for each runtime series that is generated based on how the chart is designed. Generally you will get one runtime series per design time series and one additional one for the category series. So if you define a bar chart with product code on the x-axis and quantity ordered on the y-axis, this would produce two runtime series. Each series would contain an array of values. For example ProdA, ProdB, and ProdC may be the category series and 55, 98, and 32 may be the value series. If the chart also contained a line series then this would constitute on additional runtime series containing its values. If you use the optional grouping feature this could generate many runtime series, depending on the number of groups the optional grouping expression produced. In the above example, if the following expression was entered for the optional grouping.


if( row["QUANTITYORDERED"] > 50 ){
"high";
}else{
"low";
}


This would produce a total of three runtime series. 1 (ProdA,ProdB,ProdC), 2 ( 55, 98, null) and 3 (null ,null, 32).

Note that all series have to have the same number of points. In the afterDataSetFilled script these values can be check and or modified.

beforeGeneration Event
The beforeGeneration event is executed before chart building begins and after all series data points have been created. You can make changes to the chart model here and can even alter the datasets that the chart uses. In the data set filled events, many runtime series may have been generated. Every series in a chart will have a unique series identifier. You can specify this on the third tab of the chart wizard. If you use optional grouping and many runtime series are generated for each design time series, a new series identifier will be created for each runtime series. To interrogate these series use code similar to the following (Chart With Axis Example):



var xAxis = chart.getAxes().get(0);
var yAxis = xAxis.getAssociatedAxes().get(0);
var xSeriesDef = xAxis.getSeriesDefinitions().get(0);
var ySeriesDef = yAxis.getSeriesDefinitions().get(0);

var ySeries = ySeriesDef.getRunTimeSeries();
var numberofrunseries = ySeries.size();
//get the first runtime series identifier
ySeries.get(0).getSeriesIdentifier();



Computation Events
The before and afterComputations events are called next. These events are called just before and right after all the calculations for the chart’s rendering properties are processed. These computations include all bounds objects for the chart, the series rendering hints, and data point hints, which store the plot location and values for each series. Using these scripts these values can be modified before the chart is actually rendered.

afterGeneration/beforeRender Event
These events are fired after the chart is built but before it renders each part of the chart. When the chart engine is building it stores all information in the GeneratedChartState object. This object is passed to these methods and can be used to make changes before the chart is rendered. You can use the following code to get the plotComputations object that was populated in the computations event:


compu =gcs.getComputations();


You can even change how the chart engine processes interactivity triggers. For example assume you have a class that implements the IActionRenderer interface.



package my.action.handler;
import java.io.Serializable;
import org.eclipse.birt.chart.computation.DataPointHints;
import org.eclipse.birt.chart.event.StructureSource;
import org.eclipse.birt.chart.event.StructureType;
import org.eclipse.birt.chart.model.attribute.ActionType;
import org.eclipse.birt.chart.model.attribute.TooltipValue;
import org.eclipse.birt.chart.model.data.Action;
import org.eclipse.birt.chart.render.ActionRendererAdapter;
public class MyActionHandler extends ActionRendererAdapter implements Serializable{

private static final long serialVersionUID = 1169308658661902989L;

public void processAction( Action action, StructureSource source )
{
if ( ActionType.SHOW_TOOLTIP_LITERAL.equals( action.getType( ) ) )
{
TooltipValue tv = (TooltipValue) action.getValue( );
if ( StructureType.SERIES_DATA_POINT.equals( source.getType( ) ) )
{
final DataPointHints dph = (DataPointHints) source.getSource( );
String MyToolTip = "My Value is " + dph.getDisplayValue() + "--" +dph.getBaseDisplayValue();
tv.setText( MyToolTip );
}
}
}
}


You could instantiate an instance of this class in the beforeFactory event of the report:


importPackage(Packages.my.action.handler);
MyActionRenderer = new MyActionHandler();
reportContext.setPersistentGlobalVariable("myactionrenderer", MyActionRenderer);


And then set it in the beforeRender event like:


mar = icsc.getExternalContext().getScriptable().getPersistentGlobalVariable("myactionrenderer");
gcs.getRunTimeContext().setActionRenderer(mar);



DrawBlock Events
Charts in BIRT are made up of rendering areas called blocks. Each block will have properties such as rendering bounds, anchor points, outline properties, background properties, and child blocks. The whole chart is the main chart block and within this block is the title block, the plot block and the legend block.



The before and afterDraw block events are called multiple times, once for each chart part that constitutes a block.

The before and afterDraw block events are first called for the whole chart, the title block, plot block, and finally for the legend block. Nested within the plot block and legend block other events will fire as illustrated in the event diagram. The plot block events are fired once for each runtime series as well. So if you have 3 runtime series, the before and afterDrawBlock event will be called three times for the plot block.

To know which block the event is firing for you can use a script similar to:


if ( block.isLegend( ) )
{
block.getOutline( ).setVisible( true );
block.getOutline( ).getColor( ).set( 21,244,231 );
block.setBackground( ColorDefinitionImpl.YELLOW( ) );
block.setAnchor( Anchor.NORTH_LITERAL );
}
else if ( block.isPlot( ) )
{
}
else if ( block.isTitle( ) )
{
}
else if ( block.isCustom( ) ) //Main Block
{
}


You can use these events to modify the calculated bounds of any block, but this may result in unattractive charts. To change the location of the legend the following script could be used.


if (block.isLegend())
{
var wid = block.getBounds().getWidth();
var lft = block.getBounds().getLeft();

block.getBounds().setLeft(lft-20.00);
block.getBounds().setWidth(wid+20.00);
}


Marker Range and Line Events
The before and afterDrawMarker range and line events are called before any of the series are drawn. These events are only called for charts with axis that actually contain marker ranges (or lines). Using these events the marker ranges (or lines) can be modified. By default the location of the markers are set in the chart builder, but you can set the location using an index like:


markerLine.setValue(NumberDataElementImpl.create(intcountformaker));


The index is associated with the runtime series data point index and not the data point value. If the index is set to 0 the marker will be set to the first data point. You can use this in combination with other events like the afterDataSetFilled event to make the marker dynamic.

Draw Series Events
For each runtime series that is generated, the before and afterDraw series events will be fired. You can use the series identifier to determine which series is being processed. You can also check the specific type that of series that is being rendered.


if( series.getClass() == LineSeriesImpl ){
series.getLineAttributes().setThickness(5);
}
if( series.getClass() == BarSeriesImpl ){
if( series.getSeriesIdentifier() == “Series 1”){
}
}


Also note that this event is called for the category series.

Within these events you can modify the series before it is rendered. For example if you want to change a drill through hyperlink to point to a different report you could use the following script.


triglen = series.getTriggers().size();
if( triglen >0 ){
mytarget = series.getTriggers().get(0).getAction().getValue().getURLValues( ).get(0).getBaseUrl();
newtarget = mytarget.replace("reporttarget1.rptdesign", "reporttarget2.rptdesign");
series.getTriggers().get(0).getAction().getValue().getURLValues( ).get(0).setBaseUrl(newtarget);

}


You can also get all the data points for the series by using script like the following:


if( series.getClass() == BarSeriesImpl ){
var pts = isr.getSeriesRenderingHints().getDataPoints();
for( i=0; i< pts.length; i++ ){
var mydp = pts[i];
if( mydp.getOrthogonalValue() > 35 ){
//do something
}
}
}



DataPoint and Data Point Label Events
For each runtime series that is not a category series, the before and afterDrawDataPoint and before and afterDrawDataPointLabel events are fired. These occur between the before and afterDrawSeries events. These events can be used to modify how the data point and label are rendered.

The fill object passed to the before and afterDrawDataPoint events will be an instance of one of the following classes depending on how you have setup the palette.

GradientImpl
ColorDefinitionImpl
ImageImpl (EmbeddedImageImpl and PatternImageImpl extend ImageImpl)



It is important to know which type you are dealing with if you plan on making changes to the fill object. You can check the fill type with script similar to this:



if( fill.getClass().isAssignableFrom(GradientImpl)){
fill.setStartColor(ColorDefinitionImpl.create( 0, 0, 255 ));
fill.setEndColor(ColorDefinitionImpl.create(255,255,225 ));
}



You can set the fill object based on the data point value by using a script like (Assumes Color Fill):


val = dph.getOrthogonalValue();
if (val == 10){
fill.set(255, 0, 0);
}


Using the data point label scripts you can modify the label format and value.


var newlabel = label.getCaption().getValue().replace(",", "'");
label.getCaption().setValue(newlabel);

if( dph.getBaseDisplayValue() == 50 ){
label.setVisible(true);

label.getCaption().getFont().setBold(true);
label.getCaption().getFont().setSize(14);
label.getCaption().getFont().setName("Arial");
}else{
label.setVisible(false);
}



Curve Fitting Events
If the series contains a visible curve fitting line, the before and afterDrawFittingCurve events will be trigged after all the data point and data point label scripts have fired. You can use this event to modify the line attributes and label properties for the fitting curve.

Axis Title and Axis Label Events
After all series have been rendered the before and afterDrawAxisLabel events are fired for every label that appears on the axis. The before and afterDrawAxisTitle events are then fired. This process starts with the category axis and then proceeds for each value axis. You can use these events to modify the labels that are rendered. The axis type can be checked to determine which axis fired the event.


//LINEAR_LITERAL
//LOGARITHMIC_LITERAL
//TEXT_LITERAL
//DATE_TIME_LITERAL
if( context.getExternalContext().getScriptable().getParameterValue("NewParameter") == "date"){
if (axis.getType() == AxisType.TEXT_LITERAL)
{
value = label.getCaption().getValue();
var dtf = new SimpleDateFormat("MM/dd/yy");
var dt = new Date(value);
var fn = dtf.format(dt);
label.getCaption().setValue(fn);
}
}
You can also check the axis title to determine which axis fired the event.
if( axis.getTitle( ).getCaption( ).getValue( ) ==
"myYaxisTitle" )
{
label.getCaption( ).setColor(
ColorDefinitionImpl.BLUE( ) );
}
if ( axis.getType( ) == AxisType.DATE_TIME_LITERAL )
{
label.getCaption( ).setColor(
ColorDefinitionImpl.RED( ) );
}



Legend Item Events
The final set of events that are fired that can affect the chart are the before and afterDrawLegendItem events. These events can be used to customize the look of the chart legend.

This event is passed the LegendEntryRenderingHints (lerh) object and the bounds for the specific entry. The bounds object is for legend item graphic and not the text. Using these objects you can customize the legend. You can hide an entry using the bounds object.


if( lerh.getLabel().getCaption().getValue().compareToIgnoreCase("series3") == 0 ){
bounds.setHeight(0);
bounds.setWidth(0);
lerh.getLabel().setVisible(false);
}


You can modify the fill object using the lerh.getFill() function and modify the label using the lerh.getLabel() function.


importPackage( Packages.org.eclipse.birt.chart.model.attribute.impl );
label = lerh.getLabel();
labelString = label.getCaption().getValue();
if( labelString == "series2" ){
label.getCaption( ).getColor( ).set( 32, 168, 255 );
label.getCaption().getFont().setItalic(true);
label.getCaption().getFont().setRotation(5);
label.getCaption().getFont().setStrikethrough(true);
label.getCaption().getFont().setSize(12);
label.getCaption().getFont().setName("Arial");
label.getOutline().setVisible(true);
label.getOutline().setThickness(3);

var mycolor = ColorDefinitionImpl.BLUE();
r = mycolor.getRed();
g = mycolor.getGreen();
b = mycolor.getBlue();
lerh.getFill().set(r, g, b);

}



You can check the fill object type before modifying it as described in the DataPoint and Data Point Label Events described earlier.

Chart Scripting Examples
Here are some example reports/posts that illustrate using the chart scripting model.
BIRT Resizing Charts
BIRT - Reversing a Chart Scale
Resize Pie Chart based on optional grouping count
Add spacing to stacked bar optional grouped chart
Turning a Chart series off in script
Set Chart Axis Label Interval with Script
Using Chart Script to Define a specific series color
Chart Script to Alter Palette - Gradient Pie
Adjust BAR Location in Bar Chart

Tuesday, August 03, 2010

Functions and Controls Moves to Eclipse Labs

Don't you love it when technology just works?  I recently had this experience when I moved a couple of BIRT projects to Eclipse Labs.  I have moved all of my BIRT projects that were on Google Code over to the Eclipse Labs section of Google Code.  You can access them here:

The one question might be why create an update site that is separate from the project sites?  Well the short answer is that I want to make it easy for someone to get access to the projects we have created from a single site.  In addition, it is easy for me to add more content without having to modify the existing versions on the projects.

In the next couple of weeks, I hope to add a couple of new features to the update site that will make it easier to create team based reporting projects.