Share via


Time categories with WPF and Exchange - Part II

Today’s post is a continuation of yesterday’s post. Today, we’ll look at how the app works its magic.

To communicate with our Microsoft Exchange server, the first thing to do is to choose the technology. In our case we’ll use Exchange Web Services, which provide a SOAP-based interface that is very simple to use. There is an SDK that simplifies the use, but because we’re just doing a simple call, we can make do with the proxy classes from the WSDL file.

To add the reference, simply follow the instructions on how to create a proxy reference. The only thing I did differently was not using the .NET Framework 2.0-based classes; simply go through the defaults and you should be fine. Also, I used ExchangeNs as the namespace name for the project; you can adjust your using statements accordingly.

Roll up your sleeves, and let’s get going.

First, let’s look at the window itself. We know from the XAML file that we’ll need at least two events: one for the Refresh button and one for the Copy button. We’ll also initialize the input boxes with some defaults to make the app easier to use.

 namespace TimeCategories
{
  #region Namespaces.
 
  using System;
  using System.Collections.Generic;
  using System.ComponentModel;
  using System.Linq;
  using System.Windows;
  using System.Windows.Controls;
  using System.Xml;
  using TimeCategories.ExchangeNs;
 
  #endregion Namespaces.
 
  /// <summary>
  /// Interaction logic for MainWindow.xaml
  /// </summary>
  public partial class MainWindow : Window
  {
    public MainWindow()
    {
      InitializeComponent();
 
      this.SetDefaultControlValues();
    }
 
    /// <summary>
    /// Set default values for the UI controls.
    /// </summary>
    private void SetDefaultControlValues()
    {
      this.ServerBox.Text = "YOUR-SERVER-HERE";
      this.StartDateBox.SelectedDate =
          PreviousOrSameMonday(DateTime.Today);
      this.EndDateBox.SelectedDate = DateTime.Today;
    }
...

So far, this is pretty straightforward. There are ways of finding the Exchange server for a user if your network is set up for this, but I’m glossing over that. I’ve also introduces a helper function, PreviousOrSameMonday, so here is the implementation for that.

 /// <summary>
/// Returns <paramref name="value"/> if it's a Monday, else the
/// previous Monday.
/// </summary>
private static DateTime PreviousOrSameMonday(DateTime value)
{
  while (value.DayOfWeek != DayOfWeek.Monday)
  {
    value = value.AddDays(-1);
  }
 
  return value;
}

Now, I’ll take the easier button first, and declare a property with the data to copy and the event handler. If any data is available when the button is clicked, we’ll copy that to the clipboard.

 /// <summary>Text data to set on the clipboard.</summary>
public string ClipboardData { get; set; }
 
/// <summary>
/// Copies whatever ClipboardData we might have as plain text.
/// </summary>
private void CopyToClipboardClick(object sender, RoutedEventArgs e)
{
  if (String.IsNullOrEmpty(this.ClipboardData))
  {
    return;
  }
 
  Clipboard.SetText(this.ClipboardData, TextDataFormat.UnicodeText);
}

The next step is populating the category times when the Refresh button is clicked. Because this operation will usually take a while, I’ll use a BackgroundWorker to perform this work. In the mean time, I want the button to be disabled so the user can’t click it again, and I’ll set the cursor to AppStarting, which shows the arrow but gives an indication that work is going on.

 /// <summary>
/// To refresh the categories, we'll disable the 'Refresh'
/// button to prevent multiple clicks, and start a background 
/// task.
/// </summary>
private void RefreshButtonClick(object sender, RoutedEventArgs e)
{
  var previousCursor = this.Cursor;
  RefreshItemsTask task = new RefreshItemsTask(this);
  this.RefreshButton.IsEnabled = false;
  this.Cursor = System.Windows.Input.Cursors.AppStarting;
  this.ForceCursor = true;
  task.RunWorkerCompleted += (_, __) =>
  {
    this.RefreshButton.IsEnabled = true;
    this.Cursor = previousCursor;
    this.ForceCursor = false;
  };
  task.RunWorkerAsync();
}

With this code, the UI will remain responsive, so the user can resize the window, minimize it, update the controls, copy the old data to the clipboard, etc. Now I’m going to introduce a tiny Utils class that’s not worth discussing much, and then a class that will hold the data we get from our Exchange server.

 public static class Utils
{
  /// <summary>Handy zero-length string array.</summary>
  public static readonly string[] EmptyStringArray = new string[0];
 
  /// <summary>
  /// Returns <paramref name="value"/> if it has any content;
  /// an empty array otherwise.
  /// </summary>
  public static string[] ValueOrEmpty(string[] value)
  {
    if (value == null || value.Length == 0)
    {
      return EmptyStringArray;
    }
 
    return value;
  }
}
 
public class ProjectedCalendarItem
{
  public string Id { get; set; }
  public string Subject { get; set; }
  public string[] Categories { get; set; }
  public bool IsAllDayEvent { get; set; }
  public bool IsMeeting { get; set; }
  public TimeSpan Duration { get; set; }
}

Now comes the real fun. I’ve broken this up quite a bit because the formatting on the blog is a bit weird, but hopefully you will be able to follow along. First, we’ll capture the state of the controls and a reference to the window when the refreshing task is created.

 public class RefreshItemsTask : BackgroundWorker
{
  private readonly string server;
  private readonly DateTime startDate;
  private readonly DateTime endDate;
  private readonly MainWindow window;
 
  /// <summary>
  /// Create a background task to refresh the items on the 
  /// specified window.
  /// </summary>
  public RefreshItemsTask(MainWindow window)
  {
    // We can use all the controls to read their values 
    // because we're constructed on the window UI thread.
    this.window = window;
    this.server = window.ServerBox.Text.Trim();
    this.startDate = window.StartDateBox.SelectedDate.Value;
    this.endDate = window.EndDateBox.SelectedDate.Value;
  }

Next, the OnDoWork override shows an outline of what needs to happen: we’ll set up the client, ask for calendar items within the range, and then categorize them with a couple of LINQ queries.

 protected override void OnDoWork(DoWorkEventArgs e)
{
  // Setup the binding and remote address.
  string url = "https://" + this.server + "/EWS/exchange.asmx";
  var remoteAddress = new System.ServiceModel.EndpointAddress(url);
 
  var binding = new System.ServiceModel.BasicHttpBinding(
    System.ServiceModel.BasicHttpSecurityMode.Transport);
  binding.ReaderQuotas.MaxNameTableCharCount = 1024 * 1024;
  binding.Security.Transport.ClientCredentialType = 
    System.ServiceModel.HttpClientCredentialType.Windows;
 
  var client = new ExchangeServicePortTypeClient(binding, remoteAddress);
  try
  {
    // Get the items and store them in a local list.
    FindItemResponseMessageType findResponse = FindCalendarItems(client);
    var items = CreateProjectedCalendarItems(findResponse).ToList();
 
    // 'Flatten' the items into category/item pairs.
    var q = from item in items
            from category in item.Categories
            select new { category, item };
 
    // Now group the flattened category/item pairs by category
    // and then sort them.
    var groups = q.ToLookup(pair => pair.category, pair => pair.item);
    var orderedGroups = groups.OrderBy(group => group.Key);
 
    e.Result = orderedGroups;
  }
  finally
  {
    client.Close();
  }
}

A few interesting things to note on the code above are the use of an anonymous type in the query, to hold the category and the item, and the ToLookup() overload that allows us to project both the key of the group and the element to add to the group. If you use the version with only one selector, you’ll find that the values in the groups are the pairs themselves, which repeat the category information.

Now let’s look at the FindCalendarItems helper. The way the request is built is to use the type for the FindItem operation and populate it with what we’d like to find out. In our case, we’re doing the following:

  • We ask for specific properties to be returned by filling in the ItemShape property. In our case, we start with the ID only, and ask for a few other known fields.
  • We ask for the items in a specific range by setting the Item property to a CalendarView with start and end dates. This takes care of handling things like recurring items that take place during that window of time.
  • Finally, we ask the items from a specific folder, in our case the well-known calendar folder.
 private FindItemResponseMessageType FindCalendarItems(
  ExchangeServicePortTypeClient client)
{
  // Build a request to find the items.
  var version = new RequestServerVersion();
  version.Version = ExchangeVersionType.Exchange2007;
 
  var findItem = new FindItemType()
  {
    ItemShape = new ItemResponseShapeType()
    {
      BaseShape = DefaultShapeNamesType.IdOnly,
      AdditionalProperties = new BasePathToElementType[]
      {
        new PathToUnindexedFieldType()
        { FieldURI = UnindexedFieldURIType.itemSubject },
        new PathToUnindexedFieldType()
        { FieldURI = UnindexedFieldURIType.calendarDuration },
        new PathToUnindexedFieldType()
        { FieldURI = UnindexedFieldURIType.calendarIsAllDayEvent },
        new PathToUnindexedFieldType()
        { FieldURI = UnindexedFieldURIType.calendarIsMeeting },
        new PathToUnindexedFieldType()
        { FieldURI = UnindexedFieldURIType.itemCategories },
      }
    },
    Item = new CalendarViewType()
    {
      StartDate = startDate,
      EndDate = endDate.AddDays(1),
    },
    ParentFolderIds = new BaseFolderIdType[]
    { 
      new DistinguishedFolderIdType() 
      { Id = DistinguishedFolderIdNameType.calendar }
    }
  };
 
  FindItemResponseType response;
  client.FindItem(null, null, version, null, findItem, out response);
 
  FindItemResponseMessageType findResponse = 
    ((FindItemResponseMessageType)response.ResponseMessages.Items[0]);
  return findResponse;
}

With all this information, we get back a response that we can then turn into our helper projection class. This is pretty straightforward. The only remarkable things are setting the categories to an empty array instead of a null array to simplify handling later on, and parsing the duration string into a TimeSpan by using XmlConvert.

 private IEnumerable<ProjectedCalendarItem> CreateProjectedCalendarItems(
  FindItemResponseMessageType findResponse)
{
  var realItems = findResponse.RootFolder.Item as ArrayOfRealItemsType;
  foreach (var responseItem in realItems.Items)
  {
    CalendarItemType calendarItem = responseItem as CalendarItemType;
    if (calendarItem == null)
    {
      continue;
    }
 
    yield return new ProjectedCalendarItem()
    {
      Id = calendarItem.ItemId.Id,
      Subject = calendarItem.Subject,
      Categories = Utils.ValueOrEmpty(calendarItem.Categories),
      IsAllDayEvent = calendarItem.IsAllDayEvent,
      IsMeeting = calendarItem.IsMeeting,
      Duration = XmlConvert.ToTimeSpan(calendarItem.Duration),
    };
  }
}

With all these pieces, the only thing left to do is populate the UI, which the background worker does in its overriden OnRunWorkCompleted method. Here you can see that we create TreeViewItem instances with the category name and total minutes in the header, and under those header we will include the actual tasks.

 protected override void OnRunWorkerCompleted(RunWorkerCompletedEventArgs e)
{
  base.OnRunWorkerCompleted(e);
 
  // We can use the window again, as we're back on the UI thread.
  if (e.Result != null)
  {
    // The ToLookup would have returned an 
    // ILookup<string, ProjectedCalendarItem>, but we
    // sorted this result in the background thread already.
    var orderedGroups = e.Result as 
      IEnumerable<IGrouping<string, ProjectedCalendarItem>>;
 
    // Populate the tree view.
    this.window.TimeCategoriesBox.ItemsSource = 
      orderedGroups.Select(group => new TreeViewItem()
        {
          Header = CategoryGroupHeader(group),
          ItemsSource = group.Select(ItemDescription)
        });
 
    // Create the plain text representation for the clipboard.
    string clipboardTitle =
        "Time category summary for " + 
        this.startDate.ToShortDateString() + " - " + 
        this.endDate.ToShortDateString();
    string clipboardBody = String.Join(
      Environment.NewLine, orderedGroups.Select(CategoryGroupHeader));
    this.window.ClipboardData = clipboardTitle +
        Environment.NewLine + clipboardBody;
  }
 
  if (e.Error != null)
  {
    MessageBox.Show(this.window, e.Error.Message);
  }
}
 
private static string ItemDescription(ProjectedCalendarItem item)
{
  return item.Subject + " - " + item.Duration;
}
 
private static string CategoryGroupHeader(
  IGrouping<string, ProjectedCalendarItem> group)
{
  return group.Key + 
    " (" + group.Sum(i => i.Duration.TotalMinutes) + " minutes)";
}

There are many things to improve to this app to suit your work style: ignore all-day events, add an “uncategorized” category for completeness in reporting, look for duplicates or double-booked times, filter out by category, etc. But this should help you get started on creating a responsive WPF application that works with Exchange Web Services.

Enjoy!