Google Website Translator Gadget

Monday, 22 July 2013

Silverlight 5 PivotViewer Localization

We’ve recently started using the Silverlight 5 PivotViewer control in On Key Express as a great way for visualizing the Work Orders showing the work list that needs to be completed/has been completed.  Here is some screenshots showing the different “cards” of information for the Work Orders at different zoom levels.

image

Small Card View

image

Large Card View

One of the great features of On Key Express is the ability for clients to translate the system into any language.  This is great feature for customers who have engineers working across the globe as they can customise the system into any language they which to support.  We supply the default English translation but the clients can even decide to change the English translation if they have certain client specific terminology they want to use.

This language feature however imposes an important restriction of any third party control that we use – we need the ability to translate any resource displayed by the control.  We make extensive use of the excellent Telerik RadControls for Silverlight.  Fortunately these controls are fully localizable by hooking up a custom ResourceManager to override the default resources being used by the Grids and other controls. 

The PivotViewer control has quite a few resources.  It ships with support for a few languages out-of-the-box through providing the resources in separate System.Windows.Controls.Pivot.resources.dll resource assemblies.  By setting the ItemCulture property you are able to use the different out-of-the-box translations shipped with the control.  However, unlike the Telerik controls there is however no easy way to hook into the control to override the resources being used.  We can create additional resources assemblies for different languages but as mentioned previously, the client decides what additional languages they want to support. 

We therefore needed a way to hook into the PivotViewer to inject our own Resource Provider that will use the client provided resource translations.  Technically we store the clients translations in a database that is periodically synchronised with the client devices.  After looking at the PivotViewer using Reflector, we confirmed that it makes use of the usual an internal static Resources class that is generated whenever you add .resx files to a project.  The Resources wrapper internally makes use of a ResourceManager that uses the current culture to access the language specific resource file.  We needed the ability to intercept the calls being made by this ResourceManager.  We also wanted the ability to still use the existing Microsoft resource assembly as the default fallback mechanism for any of the resources that we don’t want the clients to translate.  There includes the numerous exceptions and other design time resources which the clients aren’t interested in. 

With this in mind, we created the following PivotViewerResourceManager wrapper class:

public class PivotViewerResourceManager : ResourceManager
{
public const string PivotViewerResourcePrefix = "MSPivot";
private PivotViewerResourceManager(IResourceProvider provider) : base("System.Windows.Controls.Pivot.Properties.Resources", typeof(PivotViewer).Assembly)
{
ResourceProvider = provider;
}
private IResourceProvider ResourceProvider { get; set; }
public override string GetString(string name, CultureInfo culture)
{
string resourceString = null;
// First attempt reading the "MSPivot" prefixed resources from any external provider
if (ResourceProvider != null)
resourceString = ResourceProvider.GetString(PivotViewerResourcePrefix + name, culture);
// If no external resource value found, read from the base MS pivot viewer resource file
if (string.IsNullOrEmpty(resourceString))
resourceString = base.GetString(name, culture);
return resourceString;
}
public static void InjectResourceManager(IResourceProvider provider)
{
Check.ArgumentNotNull(provider, "provider");
Type resources = typeof(PivotViewer).Assembly.GetType("System.Windows.Controls.Pivot.Properties.Resources");
FieldInfo fieldInfo = resources.GetField("resourceMan", BindingFlags.Static | BindingFlags.NonPublic);
fieldInfo.SetValue(null, new PivotViewerResourceManager(provider));
}
}

Notice that the class is a simple ResourceManager wrapper around the existing Microsoft resource assembly.  When override the GetString method to allow us to first do an external lookup for the resource using our IResourceProvider interface.  If we do not have an external translation, we simply delegate to the Microsoft provided resource.  We adopted the convention of prefixing all the client provided translations for the PivotViewer with the “MSPivot” prefix as it makes it easier to identify all the translations related directly to the control.  With all of this in place we simply added a trigger to the PivotViewer XAML to load and inject our PivotViewerResourceManager when the control is loaded via the InjectResourceManager method.

Here is a sample screen shot of our ResourceManager in action with the PivotViewer.  Notice that some of the resources for which we provide translations are show in a different language whilst the rest of the control falls back to using the default MS provider resources. 

image

Sweet!

Tuesday, 02 April 2013

Automatically Generate TFS Release Notes to PDF

Background

At Pragma we've been improving on some of our deployment practices during the past few releases.  I've always been a great proponent of trying to automate as much of the deployment process as possible.  One of the aspects that we improved on recently was the ability to automatically generate the Release Notes from our Team Foundation Server repository with every daily build of our software. This automation gives us quite a few benefits:
  1. Visibility into what's included with every build from the beginning of a release
  2. Ability to QA the Release Notes earlier in the development cycle as part of our daily builds
  3. Use of our existing TFS work items as the source for feedback, i.e. no context is lost between developers telling the technical writer what to include in the Release Notes.  The technical writer only needs to check the grammar and spelling of the TFS Work Item feedback.
  4. No manual intervention required to get the bulk of the Release Notes document generated
After doing some research on the web and looking at solutions like TFSChangeLog and this CodeProject article, we decided to roll our own due to some unique requirements within our environment.  The solution turned out to work quite elegantly and I hope this blog post will provide some pointers for other folks on  how to go about implementing something similar for their own environments.

Requirements

Here is a run-down of some of our requirements:
  1. We want to be able to merge some manually authored content into the Release Notes document.  Sections like What's New, Modifications and Maintenance typically contain functionality that are implemented by various TFS Work Items (Product Backlog Items, Tasks etc).  So we wanted the ability to manually author these sections and merge the content with remainder of the document that is automatically generated.
  2. We want the ability to selectively filter out some Work Items from being included in the Release Notes document by running a custom TFS Query.  An example of Work Items that we want to exclude are the internal bugs that were discovered for new functionality not yet released into production. 
  3. We want to use MS Word to author the manual content to make use of our existing corporate stylesheets
  4. We want the output to be available as a PDF
  5. We want command line support to allow us to automate the process as part of our daily builds using TeamCity
  6. We want the PDF to use bookmarks to enable easy navigation between the different sections
  7. We want the PDF to use our corporate branding/style
With the list of requirements,  we set out to build a solution to satisfy all of these.

Solution

Setting up a Word template to satisfy requirement #1 and #3 was a snap to do.  As we want the final output to be available as PDF, we save the Word document as a PDF once it has been updated.  To satisfy requirement #2 we created a TFS Query with the relevant query conditions to filter out unwanted Work Items. 

With the manually created PDF and the TFS Query returning the list of work items to use, we started looking at some code for generating a PDF from the Work Item contents.  We started off by creating a CommandLineOptions class to encapsulate all the different command line parameters to satisfy requirement #5:

public enum AppAction
{
View = 0,
Export = 1
}
public class CommandLineOptions
{
public CommandLineOptions()
{
}
#region Properties
public AppAction Action { get; set; }
public string BuildNumber { get; set; }
public string ExportFile { get; set; }
public bool LinkWorkItems { get; set; }
public string ManualReleaseNotesFile { get; set; }
public string TfsProject { get; set; }
public string TfsQueryHierarchy { get; set; }
public string TfsQueryName { get; set; }
public string TfsServerUrl { get; set; }
public QueryDefinition TfsQuery { get; set; }
#endregion
#region Methods
public static CommandLineOptions Default()
{
return new CommandLineOptions
{
Action = AppAction.View,
TfsServerUrl = "http://my-server:8080/coll",
TfsProject = "MyProject",
TfsQueryHierarchy = "Shared Queries",
TfsQueryName = "Release Notes",
ExportFile = "ReleaseNotes",
BuildNumber = string.Empty,
NewReleaseNotesFile = string.Empty,
LinkWorkItems = false,
};
}
#endregion
}

The AppAction enumeration is used to indicate whether to run the app as a command line utility or to run the application using a GUI which allows you to manually specify the parameters to use for driving the report generation process - this is especially useful for testing purposes.  Just for reference, here is a quick view of the user interface:


The next step was to create a ReportRunner class to take these settings and generate the report in the PDF format:

public class ReportRunner
{
private TswaClientHyperlinkService _linkService;
private TfsTeamProjectCollection _teamProjectCollection;
private Project _tfsProject;
private QueryFolder _tfsQueryFolder;
private QueryItem _tfsQueryItem;
private WorkItemStore _workItemStore;
public ReportRunner()
{
}
#region Methods
public void Generate(CommandLineOptions options)
{
if (string.IsNullOrEmpty(options.ExportFile))
throw new ArgumentException("No ExportFile specified");
try
{
Cursor.Current = Cursors.WaitCursor;
Log(string.Format(CultureInfo.InvariantCulture, "Generating Release Notes Pdf to {0}...", options.ExportFile));
ConnectToTfs(options);
var workItems = GenerateReleaseNoteWorkItems();
IReleaseNotesWriter reportWriter = new ReleaseNotesPdfWriter(
new ReleaseNotesWriterSettings
{
BuildVersionNumber = options.BuildNumber,
GeneratedOn = DateTime.Now,
NewReleaseNotesFile = options.ManualReleaseNotesFile,
LinkWorkItems = options.LinkWorkItems,
FrontPageTemplate = @".\Templates\ReleaseNotes_FrontPage.pdf",
PageTemplate = @".\Templates\ReleaseNotes_PageTemplate.pdf"
},
workItems);
reportWriter.Publish(options.ExportFile);
}
finally
{
Cursor.Current = Cursors.Default;
}
Log("Completed Pdf Generation");
}
private void ConnectToTfs(CommandLineOptions options)
{
Log(string.Format(CultureInfo.InvariantCulture, "Connecting to Tfs at {0}...", options.TfsServerUrl));
// Connect to Team Foundation Server
Uri tfsUri = new Uri(options.TfsServerUrl);
_teamProjectCollection = new TfsTeamProjectCollection(tfsUri);
_linkService = _teamProjectCollection.GetService<TswaClientHyperlinkService>();
_workItemStore = _teamProjectCollection.GetService<WorkItemStore>();
_tfsProject = _workItemStore.Projects[options.TfsProject];
_tfsQueryFolder = _tfsProject.QueryHierarchy[options.TfsQueryHierarchy] as QueryFolder;
_tfsQueryItem = _tfsQueryFolder[options.TfsQueryName];
}
private List<ReleaseNoteWorkItem> GenerateReleaseNoteWorkItems()
{
Log(string.Format(CultureInfo.InvariantCulture, "Querying Tfs using {0}...", _tfsQueryItem.ToString()));
var releaseNotes = new List<ReleaseNoteWorkItem>();
var queryDefinition = _workItemStore.GetQueryDefinition(_tfsQueryItem.Id);
var variables = new Dictionary<string, string>
{
{ "project", _tfsQueryItem.Project.Name }
};
var workItemCollection = _workItemStore.Query(queryDefinition.QueryText, variables);
Log(string.Format(CultureInfo.InvariantCulture, "Found {0} Work Items", workItemCollection.Count));
foreach (WorkItem workItem in workItemCollection)
{
Log(string.Format(CultureInfo.InvariantCulture, "WI: {0}, Title: {1}", workItem.Id, workItem.Title));
// Logic to build list of work items by querying TFS fields
..
view raw ReportRunner.cs hosted with ❤ by GitHub

Using the TFS API to programmatically query the contents of the work items was quite easy to do as illustrated through the ConnectToTfs and GenerateReleaseNoteWorkItem methods above.  With the Work Item information now processed and available in memory, the final step in the generation process was to take the list of processed work items, write them to a PDF file and finally merge the contents of this generated file with the output of the manually created PDF file.  This turned out to be quite an interesting exercise and by far the most challenging aspect of the whole solution :-)

Generating the PDF

After looking at various options for generating a PDF programmatically, we decided to use the C# open source port of the well-known Java iText library called iTextSharp.  Through reading various articles on the web (there is also this great Manning Book on the Java version), we eventually figured out how to generate, merge and create a PDF to satisfy the remainder of the requirements.

On a high level, the process for generating the PDF turned out as follows:
  1. Create new blank PDF
  2. Add a front page
  3. Add (merge) the contents of the manual PDF into the new blank PDF whilst keeping track of the bookmarks contained within the manual document (see Requirement #6)
  4. Run through the list of processed work items to add the document content for them into relevant sections in the new PDF
  5. Re-bookmark the whole document to enable easy navigation throughout the whole PDF in support of requirement #6.
This high-level process was implemented in the Publish method of the ReleaseNotesPdfWriter class.

...
public List<Bookmark> Bookmarks { get; private set; }
public ReleaseNotesWriterSettings Settings { get; private set; }
public IReadOnlyList<ReleaseNoteWorkItem> WorkItems { get; private set; }
public void Publish(string outputFile)
{
var stream = new MemoryStream();
try
{
_document = new Document(PageSize.A4, 36, 36, 90, 72);
// Initialize pdf writer
_writer = PdfWriter.GetInstance(_document, stream);
_writer.PageEvent = new ReleaseNotesPdfPageEvents(Settings);
// Open document to write
_document.Open();
_document.AddTitle("Your Title");
_document.AddSubject("Release Notes");
_document.AddAuthor("Your Name");
_document.AddKeywords("Release Notes");
_document.AddCreationDate();
_document.AddCreator("ReleaseNotesGenerator");
// Add manual release notes for current release
int chapterNumber = 1;
if (!string.IsNullOrEmpty(Settings.NewReleaseNotesFile) && File.Exists(Settings.NewReleaseNotesFile))
{
Bookmarks.AddRange(Merge(Settings.NewReleaseNotesFile, 1));
if (Bookmarks.Count > 0)
chapterNumber = Bookmarks.Count;
}
// Add automatic releases notes for current release
WriteWorkItems("How do I?", ref chapterNumber, WorkItems.Where(x => x.ResolutionType == "How do I" || x.ResolutionType == "As Designed"));
WriteWorkItems("Bug Fixes", ref chapterNumber, WorkItems.Where(x => x.ResolutionType == "Bug Fix"));
WriteWorkItems("Known Issues", ref chapterNumber, WorkItems.Where(x => x.ResolutionType == "Known Issue"));
WriteWorkItems("User Manual", ref chapterNumber, WorkItems.Where(x => x.ResolutionType == "User Manual"));
CreateBookmarks();
}
catch (Exception exception)
{
throw new Exception("There has an unexpected exception occured whilst creating the release notes: " + exception.Message, exception);
}
finally
{
_document.Close();
}
File.WriteAllBytes(outputFile, stream.GetBuffer());
}
view raw RGen_Publish.cs hosted with ❤ by GitHub

To add the front page and also include a custom footer on every page containing the build version, generation time stamp as well as page number, we created a ReleaseNotesPdfPageEvents class to implement the IPdfPageEvent interface that iTextSharp provides for executing custom logic when a PDF document is opened/closed, new paragraphs, sections are added etc.  This class is then assigned to PageEvent of the PDF writer to ensure that the custom logic executes as part of the PDF generation process (see line 16).

After adding the front page, the content of the manual PDF is merged into the new document (lines 30-35). Keeping track of the bookmarks turned out to be quite an interesting exercise and is done as part of the Merge method.

After merging the existing content, we process the in-memory list of work item information by firstly grouping the Work Items based on their resolution types into sections like "How do I", "Bug Fixes" etc. (lines 38-41) and thereafter writing these sections into the document using the WriteWorkItems method:

private void WriteWorkItems(string chapterText, ref int chapterNumber, IEnumerable<ReleaseNoteWorkItem> workItems)
{
if (!workItems.Any())
return;
_document.NewPage();
string chapterTitle = string.Format(CultureInfo.InvariantCulture, "{0}. {1}", chapterNumber++, chapterText);
var chapter = new Chunk(chapterTitle, _chapterFont);
var paragraph = new Paragraph(20f, chapter);
chapter.SetLocalDestination(_writer.PageNumber.ToString(CultureInfo.InvariantCulture));
Bookmarks.Add(new Bookmark(chapterTitle, _writer.PageNumber));
_document.Add(paragraph);
_table = new PdfPTable(4);
_table.SpacingBefore = 10f;
_table.WidthPercentage = 100;
_table.DefaultCell.BorderColor = _headerColor;
_table.DefaultCell.BorderWidth = 100;
_table.DefaultCell.Padding = 100;
_table.SetWidths(new[] { 18f, 35f, 35f, 12f });
_table.DefaultCell.VerticalAlignment = Element.ALIGN_TOP;
WriteTableHeader();
foreach (var grouping in workItems.OrderBy(x => x.Area).GroupBy(x => x.Area))
{
foreach (var workItem in grouping.OrderBy(x => x.WorkItemId))
{
WriteTableRow(workItem);
}
}
_document.Add(_table);
}

For every new section we add an additional bookmark into the Bookmarks collection to make sure that we have a complete list of bookmarks for all the available sections in the document (see line 14).  The final step in the generation process is to add these bookmarks into the new document using the CreateBookmarks method.

private void CreateBookmarks()
{
PdfContentByte content = _writer.DirectContent;
PdfOutline outline = content.RootOutline;
foreach (var bookmark in Bookmarks)
{
if (bookmark.IsReleaseHeader)
outline = CreatePdfOutline(content.RootOutline, bookmark);
else
CreatePdfOutline(outline, bookmark);
}
}
private PdfOutline CreatePdfOutline(PdfOutline parent, Bookmark bookmark)
{
return new PdfOutline(parent, PdfAction.GotoLocalPage(bookmark.PageNumber, new PdfDestination(bookmark.PageNumber), _writer), bookmark.Title);
}

Conclusion

Here is a screenshot of an example report generated for our latest release to give you an idea of what is possible using the solution described above.  I think you'll agree that the content looks quite professional.


I trust you find the above mentioned pointers useful in creating your own solution.