STSDev Theme Solution Generator Update

It took a bit longer than expected, but I have finally revisited my STSDEV Theme Solution Generator. I just posted a new release on CodePlex—STSDEV 1.3b with Theme Generator—and the full source is now available in my CodePlex repository. The big theme-related addition in this version is an option to automatically apply Heather Solomon’s @import technique, as a simple checkbox for now. There’s a lot more it could do for us (move other theme assets into LAYOUTS, fix image references in the CSS, etc), but it’s a start. I also fixed a TemplateFiles bug in STSDEV that has annoyed me for a while.

I’m curious what the community thinks I should do with the Generator. If STSDev were more mature, I would just release a Generator DLL for a specific release, but for now it’s still relatively volatile and the APIs I’m using will probably (hopefully, in some cases) change. There’s also the issue of STSDev vs. STSDev 2008. And maybe more importantly, is this even a tool worth maintaining as more than a proof-of-concept? I’m open to your thoughts/suggestions.

Advertisements
Posted in Branding, SharePoint, Tools. Tags: , , . Comments Off on STSDev Theme Solution Generator Update

STSDEV Theme Solution Generator

SharePoint for End Users just put up up a post about custom themes that echoed the same process that has been repeated several times before:

  1. Log into your SharePoint server
  2. Copy an existing theme
  3. Rename OLDTHEME.INF to NEWTHEME.INF and inside replace OldTheme with NewTheme
  4. Modify TEMPLATE\LAYOUTS\1033\SPTHEMES.XML
  5. Modify theme.css, images, etc.

I like this article in particular because it points out to the “end user” the un/ghosting caveat of SPD customizations that administrators despise and most developers learn on day 1. But it still advises the changes be made directly on the file system, with no mention of how that should be done. Sure it’s an end user audience, but modifying the hive should bump it squarely out of beginner territory.

That said, one of the comments got me thinking:

I have just one question. Is there a way to deploy custom themes as a solution?

Well, we developers all know the answer is yes. I’ve previously discussed updating SPTHEMES.XML, and there are several tools (WSPBuilder, STSDEV, VSeWSS (in theory)…) that can handle “solutionizing” the theme resources, but there has to be a better way. And through STSDEV‘s solution provider interface, there is!

Imaginiff

Suppose getting started on a new theme were as simple as this:

  1. Create Solution
    STS Theme Generator - Create Solution
  2. Pick Theme Template
  3. Enter Theme Details
  4. Solutionized!

The solution provider copies the existing theme, renames the .INF, does the search and replace, and even builds a farm-scoped feature complete with receiver to update SPTHEMES.XML. The current STSDEV release (1.3) doesn’t populate the feature receiver assembly, but after adding that to feature.xml the solution should be ready to customize and deploy.

I need to clean up the code a bit before I put it on CodePlex, but in the meantime you can download the binary here: STSDEV 1.3a with Theme Generator. If you’re new to STSDEV, check out Ted‘s Tutorial Screencasts (scroll down) to get started. And if you have any problems or feature requests, please let me know. One idea that I’m already planning to implement is an option to convert a theme to use Heather Solomon‘s @import technique.

Update 12/11/2008: An updated release is available here: STSDEV 1.3b with Theme Generator

Theme-amajig Refactored: Using Feature Properties

In a previous post, I described a feature that would take install and retract modifications to SPTHEMES.XML. Peter Seale suggested providing a method to reapply the changes without a deactivate/activate cycle, specifically for new servers added to a farm. It should be as simple as providing a user interface to call FeatureThemesJob.InstallThemes, but that presents a bit of a problem: InstallThemes expects the name of the themes file, which I declare in the feature receiver. So before we can work on a reapplication interface, let’s move that file name to a more accessible location.

The Revised Feature

A better way to store the theme file name would be as a Feature Property:

<Feature
  Id="E2F8D046-607D-4BB6-93CC-2C04CF04099E"
  Title="SPHOLS Themes"
  Description="Installs SPHOLS and SPHOLSX themes on farm."
  Version="1.0.0.0"
  Scope="Farm"
  ReceiverAssembly="MyBranding, ..."
  ReceiverClass="MyBranding.MyThemesFeatureReceiver"
  xmlns="http://schemas.microsoft.com/sharepoint/">
  <ElementManifests>
    <ElementFile Location="SPTHEMES.XML" />
  </ElementManifests>
  <Properties>
    <Property Key="Solutionizing:ThemesFile" Value="SPTHEMES.XML" />
  </Properties>
</Feature>

And we can remove references to THEMES_FILE from our receiver:

namespace MyBranding {
  public class MyThemesFeatureReceiver : SPFeatureReceiver {
    public override void FeatureActivated(SPFeatureReceiverProperties properties) {
      if (properties == null)
        throw new ArgumentNullException("properties");
      FeatureThemesJob.InstallThemes(properties.Definition);
    }

    public override void FeatureDeactivating(SPFeatureReceiverProperties properties) {
      if (properties == null)
        throw new ArgumentNullException("properties");
      FeatureThemesJob.DeleteThemes(properties.Definition);
    }

    public override void FeatureInstalled(SPFeatureReceiverProperties properties) { }
    public override void FeatureUninstalling(SPFeatureReceiverProperties properties) { }
  }
}

Note that this code is now completely feature-agnostic, reusable for any themes feature.

FeatureThemesJob

Other than purging the logic to persist _themesFile, which I’ll leave as an exercise for the reader, we just need to update Execute to use our new feature property:

    private const string PROP_THEMES_FILE = "Solutionizing:ThemesFile"
    private const string SPTHEMES_PATH = @"TEMPLATE\LAYOUTS\1033\SPTHEMES.XML";
    public override void Execute(Guid targetInstanceId) {
      SPFeatureDefinition fDef = Farm.FeatureDefinitions[_featureID];
      if (fDef != null) {
        SPFeatureProperty themesFileProp = fDef.Properties[PROP_THEMES_FILE];
        if(themesFileProp == null)
          throw new SPException(string.Format("Feature '{0}' is missing property '{1}'.", fDef.DisplayName, PROP_THEMES_FILE));

        DoMerge(SPUtility.GetGenericSetupPath(SPTHEMES_PATH), Path.Combine(fDef.RootDirectory, themesFileProp.Value));
      }
    }

But since we’re in a refactoring mood, we might as well extract the code to retrieve the themes file path:

    internal const string PROP_THEMES_FILE = "Solutionizing:ThemesFile"
    private const string ERR_FEATURE_NOT_FOUND = "Feature '{0}' not found in farm.";
    private const string ERR_MISSING_PROPERTY = "Feature '{0}' is missing property '{1}'.";
    internal string ThemesFilePath {
      get {
        SPFeatureDefinition fDef = Farm.FeatureDefinitions[_featureID];
        if (fDef == null)
          throw new SPException(string.Format(ERR_FEATURE_NOT_FOUND, _featureID));

        SPFeatureProperty prop = fDef.Properties[PROP_THEMES_FILE];
        if (prop == null)
          throw new SPException(string.Format(ERR_MISSING_PROPERTY, fDef.DisplayName, PROP_THEMES_FILE));

        return Path.Combine(fDef.RootDirectory, prop.Value);
      }
    }

Which makes Execute rather elegant:

    private const string SPTHEMES_PATH = @"TEMPLATE\LAYOUTS\1033\SPTHEMES.XML";
    public override void Execute(Guid targetInstanceId) {
      DoMerge(SPUtility.GetGenericSetupPath(SPTHEMES_PATH), ThemesFilePath);
    }

Now everything we need for the timer job is available from the feature definition. The next step is to build an interface to run the job on demand. Stay tuned!

Theme-amajig: Updating SPTHEMES.XML Through a Feature

Love them or hate them, SharePoint themes do have their uses in some approaches to branding. As Dan Lewis recently posted, the process to create a theme is pretty simple, but there’s one step that makes me cringe: edit SPTHEMES.XML. I’m not aware of any out-of-box mechanism to facilitate this change, so I figured it might be worthwhile to build one.

In researching the idea, I found this post by Rick Kierner. He’s on the right track, but his solution only deploys the theme to the local server and a web-scoped feature isn’t really appropriate for a server-wide change. Ultimately I came up with the following solution, which should be easier to maintain and is perhaps a bit more SharePointy.

The Feature

First, we create a farm-scoped feature with a single <ElementFile /> node:

<Feature
  Id="E2F8D046-607D-4BB6-93CC-2C04CF04099E"
  Title="SPHOLS Themes"
  Description="Installs SPHOLS and SPHOLSX themes on farm."
  Version="1.0.0.0"
  Scope="Farm"
  ReceiverAssembly="MyBranding, ..."
  ReceiverClass="MyBranding.MyThemesFeatureReceiver"
  xmlns="http://schemas.microsoft.com/sharepoint/">
  <ElementManifests>
    <ElementFile Location="SPTHEMES.XML" />
  </ElementManifests>
</Feature>

Then we create our own SPTHEMES.XML with the theme(s) we would like to add, saved in the same folder as feature.xml.

<?xml version="1.0" encoding="utf-8"?>
<SPThemes xmlns="http://tempuri.org/SPThemes.xsd">
  <Templates>
    <TemplateID>SPHOLS</TemplateID>
    <DisplayName>SharePoint Hands-On Labs</DisplayName>
    <Description>A glorious theme created for the SharePoint Hands-On Labs.</Description>
    <Thumbnail>images/SPHOLS/thSPHOLS.gif</Thumbnail>
    <Preview>images/SPHOLS/thSPHOLS.gif</Preview>
  </Templates>
  <Templates>
    <TemplateID>SPHOLSX</TemplateID>
    <DisplayName>SharePoint Hands-On Labs X</DisplayName>
    <Description>An XTRA glorious theme created for the SharePoint Hands-On Labs.</Description>
    <Thumbnail>images/SPHOLS/thSPHOLS.gif</Thumbnail>
    <Preview>images/SPHOLS/thSPHOLS.gif</Preview>
  </Templates>
</SPThemes>

And finally, our feature receiver:

namespace MyBranding {
  public class MyThemesFeatureReceiver : SPFeatureReceiver {
    private const string THEMES_FILE = "SPTHEMES.XML";

    public override void FeatureActivated(SPFeatureReceiverProperties properties) {
      if (properties == null)
        throw new ArgumentNullException("properties");
      FeatureThemesJob.InstallThemes(properties.Definition, THEMES_FILE);
    }

    public override void FeatureDeactivating(SPFeatureReceiverProperties properties) {
      if (properties == null)
        throw new ArgumentNullException("properties");
      FeatureThemesJob.DeleteThemes(properties.Definition, THEMES_FILE);
    }

    public override void FeatureInstalled(SPFeatureReceiverProperties properties) { }
    public override void FeatureUninstalling(SPFeatureReceiverProperties properties) { }
  }
}

That’s it. When the feature is activated, the contents of the feature’s “themes file” are merged into the farm servers’ SPTHEMES.XML. While deactivating, those same themes will be deleted. The heavy lifting is handled by a reusable custom timer job.

FeatureThemesJob

Our custom job begins with a few persisted fields and constructors:

using System;
using System.Diagnostics;
using System.IO;
using System.Xml;
using System.Xml.XPath;
using Microsoft.SharePoint;
using Microsoft.SharePoint.Administration;
using Microsoft.SharePoint.Utilities;

namespace MyBranding {
  public class FeatureThemesJob : SPJobDefinition {
    [Persisted]
    private Guid _featureID = Guid.Empty;
    [Persisted]
    private string _themesFile = null;
    [Persisted]
    private bool _delete = false;

    public FeatureThemesJob() : base() { }

    public FeatureThemesJob(SPService service, Guid featureID, string themesFile, bool delete)
      : base("Feature Themes", service, null, SPJobLockType.None) {
      _featureID = featureID;
      _themesFile = themesFile;
      _delete = delete;

      Title = string.Format("{0} Themes for Feature {1}",
          (delete ? "Delete" : "Install"), featureID);
    }

The default constructor is required for internal use and our real constructor saves the field values and lets SPJobDefinition do its thing.

Our Execute override uses a helper method that takes two paths: the path to the server’s SPTHEMES.XML and the path to our feature’s themes file.

    private const string SPTHEMES_PATH = @"TEMPLATE\LAYOUTS\1033\SPTHEMES.XML";
    public override void Execute(Guid targetInstanceId) {
      SPFeatureDefinition fDef = Farm.FeatureDefinitions[_featureID];
      if (fDef != null)
        DoMerge(SPUtility.GetGenericSetupPath(SPTHEMES_PATH), Path.Combine(fDef.RootDirectory, _themesFile));
    }

First we open the XML files and do a bit of initial setup:

    public void DoMerge(string pathToSPThemes, string pathToMerge) {
      try {
        XmlDocument docThemes = new XmlDocument();
        docThemes.Load(pathToSPThemes);
        string nsThemes = docThemes.DocumentElement.NamespaceURI;

        XPathNavigator navThemes = docThemes.CreateNavigator();
        XmlNamespaceManager mgrThemes = new XmlNamespaceManager(navThemes.NameTable);
        mgrThemes.AddNamespace("t", nsThemes);

        XmlDocument docMerge = new XmlDocument();
        docMerge.Load(pathToMerge);

        XPathNavigator navMerge = docMerge.CreateNavigator();
        XmlNamespaceManager mgrMerge = new XmlNamespaceManager(navMerge.NameTable);
        mgrMerge.AddNamespace("t", docMerge.DocumentElement.NamespaceURI);

        bool shouldSave = false;

Now we use XPath to retrieve and iterate over a list of the themes in our feature.

        XmlNodeList mergeNodes = docMerge.SelectNodes("/t:SPThemes/t:Templates/t:TemplateID", mgrMerge);
        foreach (XmlNode mergeNode in mergeNodes) {

And use XPath again to find an existing theme with the same TemplateID.

          try {
            string xpath = string.Format("/t:SPThemes/t:Templates[t:TemplateID = '{0}']", mergeNode.InnerText);
            XmlNode node = docThemes.SelectSingleNode(xpath, mgrThemes);

If we’re deleting and we find the theme node, get rid of it.

            if (_delete) {
              if (node != null)
                node.ParentNode.RemoveChild(node);
            }

Otherwise create a Templates element for the new theme. I’m assuming this part could be cleaned up with a deeper understanding of the XML object model — help! For now I just copy the source XML into a new node in the destination document. I iterate through the new children and RemoveAllAttributes() to eliminate extra xmlns attributes that I can’t figure out how to prevent.

            else {
              XmlNode newNodeParent = mergeNode.ParentNode;
              XmlElement toInsert = docThemes.CreateElement(newNodeParent.Name, nsThemes);
              toInsert.InnerXml = newNodeParent.InnerXml;
              foreach (XmlElement xe in toInsert.ChildNodes)
                xe.RemoveAllAttributes();

              if (node == null)
                docThemes.DocumentElement.AppendChild(toInsert);
              else
                node.ParentNode.ReplaceChild(toInsert, node);
            }

If we get to this point, everything went fine and we should save our results.

            shouldSave = true;
          }
          catch (Exception ex) {
            Trace.WriteLine("Error merging theme " + mergeNode.InnerText);
            Trace.WriteLine(ex);
          }
        } // foreach mergeNode

        if (shouldSave)
          docThemes.Save(pathToSPThemes);
      }
      catch (Exception ex) {
        Trace.WriteLine("Failed to merge themes");
        Trace.WriteLine(ex);
        throw;
      }
    }

Now that the job is defined, we can add a few helpers. First, a method to run the job immediately—a technique borrowed from Vincent Rothwell—combined with a variation on AC’s technique to delete existing jobs:

    public void SubmitJobNow() {
      if (Farm.TimerService.Instances.Count < 1)
        throw new SPException("Could not run job. Timer service not found.");

      foreach (SPJobDefinition job in Service.JobDefinitions)
        if (job.Name == Name)
          job.Delete();

      Schedule = new SPOneTimeSchedule(DateTime.Now.AddHours(-2));
      Update();
    }

Next, the static methods we used earlier in the feature receiver.

    public static void InstallThemes(SPFeatureDefinition def, string themesFile) {
      MergeFeatureThemes(def, themesFile, false);
    }
    public static void DeleteThemes(SPFeatureDefinition def, string themesFile) {
      MergeFeatureThemes(def, themesFile, true);
    }

These share our final helper, which iterates through the web services on the farm and starts our job for each of them.

    private static void MergeFeatureThemes(SPFeatureDefinition def, string themesFile, bool delete) {
      if (def == null)
        throw new ArgumentNullException("def");

      SPFarm f = def.Farm;
      if (f == null)
        f = SPFarm.Local;

      foreach (SPWebService svc in new SPWebServiceCollection(f)) {
        FeatureThemesJob itj = new FeatureThemesJob(svc, def.Id, themesFile, delete);
        itj.SubmitJobNow();
      }
    }
  }
}

I’m sure there are improvements to be made, and I’m not certain I’ve properly handled all the multi-server scenarios, but it’s a start. What do you think?

Also, while we’re on the topic of features creating custom timer jobs, check out Vincent’s post on issues related to accounts and jobs. The gist is that features that create jobs should be scoped at the WebApplication or Farm level to ensure sufficient permissions to modify the configuration database.

Update 7/13/2008: Theme-amajig Refactored: Using Feature Properties

Update 11/28/2008: Bugs in the original post have been corrected. I also added a release on Codeplex with sample code: Sample Generated Theme Solution; you can generate your own with my STSDEV Theme Solution Generator.