Tuesday, October 30, 2012

How to successfully publish custom Document Set content types - Part 2: It's like butter.

(For a recap of the situation and the problems I encountered, refer to the previous post - Part 1: I got 99 problems...)

So there I was, desperately needing to set Inherits="TRUE" on my custom Doc Set content type definitions so that I could get them to publish through a Content Type Hub, and knowing that the consequence of that setting would be losing all my custom XML Documents. What to do? I had already written all the definitions in CAML/XML, and I sure didn't want to re-do all that work in C# in a feature receiver -- I had over 40 definitions! Hard-coding the construction of all of those CTypes would be a nightmare.

Well, the answer was staring me right in the face. I already had the XML. Even if I set Inherits="TRUE" and caused SharePoint to conveniently ignore all my work, it didn't actually erase my work.  SharePoint would build the CType definitions in the site level Content Type collection without using my code, but once my feature got activated, the Elements.xml files that contained my definitions would be deployed to the 14 hive.  Therefore, they would be accessible in SharePoint. And I knew all the parts would be in place by the time a feature receiver FeatureActivated() method got fired, so all I had to do would be to grab the files with my custom CType definitions, cycle through them and copy the XML Documents from the Elements.xml files into the definitions in the site.

So I started preparing an event receiver to do just that.  And I was getting ready to reach right into the 14 hive and pull my files out, but let's face it, that would be kind of ugly.  Luckily, as I was poking around the internet looking for ideas on how to pull this whole thing off, I ran across this great post by Stefan Stanev, which has one particularly beautiful (albeit long) line of LINQ.  Lo and behold, with a little tweaking, I could pull my CType definitions right out of the feature receiver's properties, and never have to touch the file system!  I was sold.

Here, now, is my event receiver.  Comments provided to help explain things step by step.

using System;
using System.Xml;
using System.Xml.Linq;
using System.Linq;
using System.Runtime.InteropServices;
using System.Security.Permissions;
using Microsoft.SharePoint;
using Microsoft.SharePoint.Security;
using Microsoft.SharePoint.Utilities;
using System.Collections.Generic;
using Microsoft.SharePoint.Administration;
using System.Globalization;
using System.IO;

namespace My.ContentTypes.Features.MyCTypeDefinitions
{
    [Guid("a4cc1ae2-d1c6-4e7a-bef2-da1c26a0c96f")]
    public class MyCTypeDefinitionsEventReceiver : SPFeatureReceiver
    {
        // the id of the description field, so we can set it to HIDDEN later
        protected readonly String _docSetDescFieldID = "CBB92DA4-FD46-4C7D-AF6C-3128C2A5576E";

        public override void FeatureActivated(SPFeatureReceiverProperties properties)
        {
            using (SPSite site = properties.Feature.Parent as SPSite)
            {
                // pull all the content type definitions from our elements.xml
                // files as XElements, and put them in a list
                List<XElement> cTypeDefElements =
                    properties.Definition.GetElementDefinitions(CultureInfo.GetCultureInfo((int)site.RootWeb.Language)).Cast<SPElementDefinition>()
                    .Select(def => XElement.Parse(def.XmlDefinition.OuterXml))
                    .Where(ctdefel => ctdefel.Name.LocalName == "ContentType" && ctdefel.Attribute("ID").Value.Contains("0x0120D520"))
                    .ToList();

                // iterate through all my content type definitions
                foreach (XElement myCTypeDef in cTypeDefElements)
                {
                    // apply my XML docs to the definition on the site
                    CopyXMLDocsToSite(myCTypeDef, site);
                }

                site.RootWeb.Update();
            }
        }

        private void CopyXMLDocsToSite(XElement myCTypeDef, SPSite site)
        {
            // get all the XmlDocuments from my custom content type definition
            List<XElement> myXmlDocs = myCTypeDef.Descendants().Where(doc => doc.Parent.Name.LocalName == "XmlDocument").ToList();

            // get the actual content type from the site
            SPContentTypeId cTypeID = new SPContentTypeId(myCTypeDef.Attribute("ID").Value.ToString());
            SPContentType installedCType = site.RootWeb.ContentTypes[cTypeID];

            // get the existing XmlDocuments in the installed definition on the site
            // as XElements, and add them to a list
            List<XElement> installedDocs = installedCType.XmlDocuments.Cast<string>().Select(elem => XElement.Parse(elem)).ToList();

            // iterate through the ones we are customizing and add them
            foreach (XElement docToAdd in myXmlDocs)
            {
                // find the doc we want to replace
                XElement docToDelete = installedDocs.Find(doc => doc.Name.LocalName.Equals(docToAdd.Name.LocalName));

                // if there is one, delete it from the collection
                if (docToDelete != null)
                    installedCType.XmlDocuments.Delete(docToDelete.Name.NamespaceName);

                // if it is the receivers, insert ours into the existing "Receivers" doc
                // so we keep the default, inherited ones as well
                if (docToAdd.Name.LocalName == "Receivers" && docToDelete != null)
                {
                    AddNodesIntoExistingXElement(docToAdd, docToDelete, "Receiver");
                    SaveXElementAsXMLDocument(docToDelete, installedCType);
                }
                else
                {
                    // otherwise, just clean our definition and add it
                    CleanXElement(docToAdd);
                    SaveXElementAsXMLDocument(docToAdd, installedCType);
                }
            }

            // while we have a reference to the content type definition from the site,
            // change any other default field settings
            ChangeInheritedFieldSettings(installedCType);

            // write the changes into the database
            installedCType.Update(true);
        }

        private void AddNodesIntoExistingXElement(XElement xElementToAdd, XElement existingXElement, string nodeName)
        {
            // extract the nodes
            List<XElement> nodesToAdd = xElementToAdd.Descendants().Where(node => node.Name.LocalName == nodeName).ToList();

            // add into existing xdoc
            foreach (XElement node in nodesToAdd)
            {
                existingXElement.Add(node);
            }
        }

        private void CleanXElement(XElement xElement)
        {
            // check for attributes
            List<XAttribute> atts = null;
            if (xElement.HasAttributes)
                atts = xElement.Attributes().ToList();

            // replace the guts of the XElement using descendants
            // so we get rid of comments and only keep useful XML
            xElement.ReplaceAll(xElement.Descendants());

            // re-add the attributes if there are any
            if (atts != null)
                xElement.ReplaceAttributes(atts);
        }

        private void SaveXElementAsXMLDocument(XElement xElement, SPContentType installedCType)
        {
            // turn the XElement into an XmlDocument
            XmlDocument newDoc = new XmlDocument();
            newDoc.Load(xElement.CreateReader());

            // add it back to the content type definition on the site
            installedCType.XmlDocuments.Add(newDoc);
        }

        private void ChangeInheritedFieldSettings(SPContentType installedCType)
        {
            // get the installed content type definition schema
            // so we can access the default fields that were
            // inherited from the parent content type
            XElement cTypeSchema = XElement.Parse(installedCType.SchemaXmlWithResourceTokens);

            // set the "Document Set Description" field to be Hidden
            cTypeSchema.Descendants().Single(node => node.Name.LocalName == "FieldRef" && node.Attribute("ID")
                                     .Value.ToUpper().Contains(_docSetDescFieldID.ToUpper()))
                                     .SetAttributeValue("Hidden", "TRUE");

            /*
             *  make any other changes to inherited fields here
             */

            // save the edited schema back into the site
            installedCType.SchemaXmlWithResourceTokens = cTypeSchema.ToString(SaveOptions.DisableFormatting);
        }
    }
}


A couple more comments about what's going on there -- I had more than just the Doc Set content types in the feature, but none of the other content types had XML Documents, so I was only concerned with my custom types that derived from Doc Set. Which is why I only pull the content type defs that have an ID that contains the base ID for Doc Set. And I knew that I didn't want any default values for any of the XML Documents, which is why I delete the installed XML Documents and just replace them with mine, except for the Event Receivers, where I did want to keep the default XML Docs, so in that case I just add mine in to what's already there. Also, I had to use the ReplaceAll() method to get rid of comments that I had in my definitions, because ultimately pushing comments into the CType defs on the site did not work well. However, ReplaceAll() also got rid of the attributes, which led to a couple null reference exceptions when SharePoint choked on certain XML Docs not having the LastModified attribute.  So I had to save the attributes and re-add them after using ReplaceAll().  And finally, even though I tried to set a particular default field to be Hidden in the FieldRefs section, having Inherits="TRUE" means that SharePoint ignores that customization as well, so I had to set that attribute through code.

And with that, I have my custom XML Documents, and Inherits="TRUE", which ultimately makes publishing the content types smooth like butter.

EDIT (18 Dec 2012) : Decided to refactor the code a little bit to try and push it in a slightly more flexible, usable direction.  And for some, uh, other, personal reasons as well.  In any case, this code is not fully tested.  I did deploy and activate it once in debug to make sure it didn't choke on anything, and it ran fine.  So I'm assuming that means everything went well and if I were to publish the CTypes and then start using them, everything would work smoothly.  But, that's just an assumption.  So if you decide to borrow some of this code, just know that it has changed from when I originally wrote the post, and be ready for a bug or two.  (I'd like to hear about them if they pop up, so I can keep improving on it.)

6 comments:

  1. Awesome post thank you.

    ReplyDelete
  2. Was pulling my hair out on this...THANK YOU!

    ReplyDelete
  3. You saved my life.
    Deployment in SharePoint is a nightmare.
    thank you
    Sylvain

    ReplyDelete
  4. Is there any update in this solution? I used this code with success and everything workfs fine, but when I update the document set content type (for example with an additional content type inside the document set) it don't update anymore in existing lists.

    Any suggestions someone? :)

    ReplyDelete
    Replies
    1. I'd try the following things:

      - make sure in your Managed Metadata Service Connection properties, you have the check box for "Push-down Content Type Publishing updates from the Content Type Gallery to sub-sites and lists using the content type" checked.

      - On the site you want the change pushed to, go into site collection administration, click on the "content type publishing" link, and check the box for "Refresh all published content types on next update."

      - make the change to your content type on the hub, and be sure to re-publish it.

      - run the "Content Type Subscriber" timer job in Central Admin.

      Delete
  5. This comment has been removed by the author.

    ReplyDelete