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.

About these ads

20 Responses to “Theme-amajig: Updating SPTHEMES.XML Through a Feature”

  1. VSeWSS: At Least It Knows CAML « Solutionizing .NET Says:

    […] lab of the VSeWSS-based SharePoint Hands on Labs (download) as an example for my latest post on using features to update SPTHEMES.XML. The process went something like this: VSeWSS Ignoring Feature […]

  2. Peter Seale Says:

    Awesome, I needed this info. I think the only time we’d have a problem is when adding a new server to the farm–I don’t know of a way to cover that scenario. In my case, I think it’s sufficient to say “deactivate feature/wait until timer jobs finish/reactivate feature.”

    If you have a better solution, by all means let me know. -Peter

  3. Keith Dahlby Says:

    Glad someone else might find this useful. :) Technically you shouldn’t need to wait for deactivation to reactivate since SubmitJobNow() kills other instances of the job.

    Another option would be to create an admin page to manage Theme features, with a Reploy button that would call FeatureThemesJob.InstallThemes(…). I’ve been looking for an excuse to make an admin page for a while – I’ll see what I can come up with.

    (Love the SO HOT! PDF icon, btw.)

  4. Theme-amajig Refactored: Using Feature Properties « Solutionizing .NET Says:

    […] Refactored: Using Feature Properties July 13, 2008 — Keith Dahlby In a previous post, I described a feature that would take install and retract modifications to SPTHEMES.XML. Peter […]

  5. fmuntean Says:

    I am working on a solution that will not require the change of SPTHEMES.xml file however I have a problem with packaging the custom theme files into a solution.
    Can you please advise on how to package only the theme files withouth the SPTHEMES.xml please?

  6. Keith Dahlby Says:

    Lab 7 – Page Branding on MSSharePointDeveloper.com (Exercise 3) shows how to package up a theme using Microsoft’s Visual Studio Extensions for SharePoint. If you use a different solution tool (WSPBuilder, STSDEV, etc), just replicate that structure (IMAGES\ThemeName and THEMES\ThemeName) in your TEMPLATE folder.

    MSSPDev Content: http://blogs.msdn.com/pandrew/archive/2008/06/11/MSSharePointDeveloper-Content.aspx

  7. Peter Seale Says:

    I’m running through this scenario with my PDF icon change, and I’ve come across a problem. If you “retract the solution”, the following will happen back-to-back:

    1. Feature will be deactivated
    2. This kicks off the FeatureDeactivating() event, which kicks off the “SubmitJobNow()” function, which schedules (delete) timer jobs to run “immediately”
    3. Solution is retracted, which in this case means removing the DLL from the GAC.

    So. If steps #2 and #3 happen close enough together, the timer jobs won’t have a chance to run, because they rely on the DLL that is at that moment being removed.

    Ugh. As for my thing, I’m going to just put a tiny disclaimer that says “sorry, can’t guarantee the deactivate will work.” I can’t imagine anyone will care, my project enables the PDF icon. THE PDF ICON. It’s not the end of the world if you can’t undo it, automatically.

    I’m thinking real installers are the best “bulletproof” way to do filesystem modifications. But I’ll press on with the Feature/Solution approach today.

  8. Keith Dahlby Says:

    Have you tried calling stsadm -o execadmsvcjobs before you retract? That should at least kick off your job (which should run pretty quickly) before the retract job is executed.

    I think this should be filed under “things an admin should know” when working with solutions with features that start jobs, including anything that uses SPWebConfigModification.

  9. STSDEV Theme Solution Generator « Solutionizing .NET Says:

    […] we developers all know the answer is yes. I’ve previously discussed updating SPTHEMES.XML, and there are several tools (WSPBuilder, STSDEV, VSeWSS (in […]

  10. David Says:

    Can you provide the code. Some of the fragments will not compile in a complete class. for Exampe:
    if (node == null)
    docThemes.DocumentElement.AppendChild(toInsert);
    else
    node.ParentNode.ReplaceChild(toInsert, node);
    }

    Node is not defined.

  11. Pieter Says:

    well it looks like a nice post, but I’m wondering if it even will succesfully build…

    I haven’t tried the code yet, but one of the first things I saw was the following:

    if (themeNode != null)
    existingThemeNode.ParentNode.RemoveChild(themeNode);
    }

    but over the whole bunch of code there is no declaration for “existingthemenode”, so how in the “sharepoint” world will this code ever work? and I agree with the previous poster about the “node.Parentnode….” ….. it simply doesn’t exist…

    Regards,

  12. Keith Dahlby Says:

    David, Pieter ~

    Thanks for pointing out the error – it seems those code segments were copied from different iterations of the final product. Over time the variable changed from existingThemeNode to themeNode to just node – the post should be consistent now. I’ve also uploaded a working sample solution to CodePlex here.

    Cheers ~
    Keith

  13. Automatically Updating the SPTheme.xml File « Devology Solutions Blog Says:

    […] I found this originally on Keith Dahlby’s blog at: Theme-amajig: Updating SPTHEMES.XML Through a Feature […]

  14. Vitaly Mogoreanu Says:

    Nice idea. Have you actually run it on a farm? I had to change the code to iterate through the servers, not services as following:

    foreach (SPServer server in farm.Servers)
    {
        switch (server.Role)
        {
            case SPServerRole.Application:
            case SPServerRole.SingleServer:
            case SPServerRole.WebFrontEnd:
                //Submit Job
                break;
            default:
                break;
        }
    }
    • Keith Dahlby Says:

      Thanks for the comment! Indeed I haven’t actually run it on a farm (I rarely use themes), however I modeled the job definition on SPWebConfigJobDefinition which seems to work fine. I’m glad you were able to get it to work – has anyone else seen this issue?

      • Vitaly Mogoreanu Says:

        I am not a fan of themes either. However I do not know another way to style site management or user management pages (_layouts/people.aspx). Another possible use of this approach is to copy .browser files to AppBrowser folders of your application on different front ends. Basically anything else MS left behind
        Thanks for the article!

  15. Andy Burns Says:

    Vitaly – there is another option for that than just themes – the AlternateCssUrl. This has a couple of benefits:
    1) It applies to the DatePicker’s pop-up calendar (which, curiously, doesn’t obey themes)
    2) Applying a them copies it to your site. There is only 1 copy of the files for the alternate CSS approach.

    Although only the Publishing sites have a user interface for setting the AlternateCssUrl, all sites (SPWebs) have this property, and you can set it in a feature receiver…

    http://www.novolocus.com/2008/11/10/where-to-put-css-when-branding/

    http://www.novolocus.com/2008/10/20/style-the-datepicker-using-an-alternate-css/

  16. George Says:

    Nice work – does exactly what I need to do. Thanks for the example!


Comments are closed.

Follow

Get every new post delivered to your Inbox.

%d bloggers like this: