While Sharepoint 2013 (and the current iteration of Sharepoint Online) has been improving developer access via the REST api, there are a few areas where it still falls flat on its face.

One of these areas is accessing Sharepoint's term store, sometimes known as Taxonomy or Managed Metadata -- there is, unfortunately, no way of accessing this information outside of the SOAP api, /_vti_bin. There is currently one option out there for avoiding direct access, though.

Meet JSOM, Microsoft's javascript library for interacting with Sharepoint. While it's a horrible abomination of a library, we need it for accessing the term store. Fortunately, it's not impossible to integrate with AngularJS. Let's get started.

var context = SP.ClientContext.get_current();

self.getTermSetsFromGroup = function (groupName) {
    var taxSession = SP.Taxonomy.TaxonomySession.getTaxonomySession(context),
        termStore = taxSession.getDefaultSiteCollectionTermStore(),
        termGroups = termStore.get_groups(),
        termGroup = termGroups.getByName(groupName),
        termSets = termGroup.get_termSets(),
        deferred = $q.defer();

        context.load(termSets);
        context.executeQueryAsync(termSetsLoader(termSets, deferred));

        return deferred.promise;
};

Here we use the JSOM api to load all of the term sets within a term group, which itself is in a term store. We load the "default" term store since we only have access to one within Sharepoint Online.

The initial query is started with context.load(). We have to retrieve these results with a callback function on executeQueryAsync(), to which we pass the JSOM termSets object and a deferred object we'll resolve later. And, of course, we return a promise; we want a nice abstraction around JSOM, since it doesn't play nice with our standard asynchronous promise libraries whatsoever.

var TermSet = function (spTermSet) {
    this.id = spTermSet.get_id().toString();
    this.name = spTermSet.get_name();
    this.crawledProperty = "owstaxId" + this.name.replace(/ /g, 'x0020');
    this.terms = [];
};

var termSetsLoader = function (spTermSets, deferred) {
    return function () {
        var termSets = [],
            termSetsEnum = spTermSets.getEnumerator();

        while (termSetsEnum.moveNext()) {
            termSets.push(new TermSet(termSetsEnum.get_current()));
        }
        deferred.resolve(termSets);
    };
};

This function is relatively straight forward. Get each term set from the JSOM api and resolve them all in a list. We use a constructor to create a the object that we actually end up returning. Now we've got our term sets. Let's get the rest of the information.

self.loadAllTermsFromTermSet = function (groupName, termSetName) {
    var taxSession = SP.Taxonomy.TaxonomySession.getTaxonomySession(context),
        termStore = taxSession.getDefaultSiteCollectionTermStore(),
        termGroups = termStore.get_groups(),
        termGroup = termGroups.getByName(groupName),
        termSets = termGroup.get_termSets(),
        termSet = termSets.getByName(termSetName),
        terms = termSet.getAllTerms(),
        deferred = $q.defer();

        context.load(terms);
        context.executeQueryAsync(resolveWithTermsAsTree(terms, organizeByParent, deferred));

        return deferred.promise;
    };

Load the term set and pass it to another function. Here we pass a callback function (organizeByParent, which we will go over later) and again the deferred object.

var Term = function (spTerm) {
    var self = this;
    self.children = [];
    self.id = spTerm.get_id().toString();
    self.name = spTerm.get_name();
    self.description = spTerm.get_description();
    // parent is actually useless without another call to the api (thanks, JSOM)
    self.parent = spTerm.get_parent();
    self.parentId = undefined;
    self.path = spTerm.get_pathOfTerm();

    self.properties = spTerm.get_localCustomProperties();
    // Convert the property strings to booleans
    // right now we assume custom properties are either 'true' or not.
    _.each(self.properties, function (value, key) {
        // Delete the key and use the lowercase equivalent
        // Should always be a string.
        delete self.properties[key];
        self.properties[key.toLowerCase()] = value === 'true';
    });
};

var resolveWithTermsAsTree = function (spTerms, callback, deferred) {
    return function () {
        var termsEnum = spTerms.getEnumerator(),
            termsFlat = {},
            termsTree = [],
            term;

        while (termsEnum.moveNext()) {
            var spTerm = termsEnum.get_current();

            term = new Term(spTerm);

            if (spTerm.get_isRoot()) {
                // root terms don't have parents
                term.parent = undefined;
                // If term is a root term, immediately add it to the terms tree
                termsTree.push(term);
            }
            // Add to the flat terms
            termsFlat[term.id] = term;
        }

        callback(termsFlat);
        deferred.resolve(termsTree);
    };
};

Now we do two primary things: For each term in the term set, add it to a flat 'dictionary' along with its id as the object key (again using a constructor, Term). Then, if the term is a root term, add it to the first level of our hierarchy. We resolve with the tree list, which will have its children populated in the callback function. Next, we can get to organizing our flat term structure.

var organizeByParent = function (terms) {
    var keys = Object.keys(terms),
        cur,
        node;
    for (var i = 0; i < keys.length; i++) {
        cur = keys[i];
        // Skip root terms
        if (typeof terms[cur].parent !== 'undefined') {
            // The term path is the *only* hierarchy data microsoft supplies, unfortunately.
            var termPath = terms[cur].path.split(';'),
                termParent = termPath[termPath.length - 2];
            // Since term names aren't necessarily unique across the whole tree,
            // we have to walk the entire tree for the correct term.
            //
            // Depending on how many terms you have, you may want to sort into a list
            // by term name first in order to do something like a binary search
            for (var c = 0; c < keys.length; c++) {
                node = keys[c];
                // We've found our parent!
                if (terms[node].name === termParent) {
                    // Finally assign the parent id for later usage
                    terms[cur].parentId = terms[node].id;
                    terms[node].children.push(terms[cur]);
                }
            }
        }
    }
};

This is simple:

  1. Go through each term
  2. Get its full path (terms delimited by semicolons)
  3. Find its parent according to this path, which is stored by name (not id)
  4. Give the term the correct parent id
  5. Add the term to the parent's list of children

There is only one problem with this approach. It only works with two levels of the taxonomy hierarchy! Alas, that's all we needed for our use case.

Normally, Sharepoint Online's REST api gets you rather far. It is unfortunate that we have to go through so much trouble just to get a simple hierarchy of terms. I'm sure there are multiple ways to make this more efficient and I'd love your feedback!

Here is the whole thing packed into an angular service:

(function () {
  'use strict';

  angular.module('sharepointServices', [])
    .service('SPTaxonomyService', ['$q', function ($q) {
      var self = this,
        context = SP.ClientContext.get_current();

      self.getTermSetsFromGroup = function (groupName) {
        var taxSession = SP.Taxonomy.TaxonomySession.getTaxonomySession(context),
          termStore = taxSession.getDefaultSiteCollectionTermStore(),
          termGroups = termStore.get_groups(),
          termGroup = termGroups.getByName(groupName),
          termSets = termGroup.get_termSets(),
          deferred = $q.defer();

        context.load(termSets);
        context.executeQueryAsync(termSetsLoader(termSets, deferred));

        return deferred.promise;
      };

      var termSetsLoader = function (spTermSets, deferred) {
        return function () {
          var termSets = [],
            termSetsEnum = spTermSets.getEnumerator();

          while (termSetsEnum.moveNext()) {
            termSets.push(new TermSet(termSetsEnum.get_current()));
          }
          deferred.resolve(termSets);
        };
      };

      var TermSet = function (spTermSet) {
        this.id = spTermSet.get_id().toString();
        this.name = spTermSet.get_name();
        this.crawledProperty = "owstaxId" + this.name.replace(/ /g, 'x0020');
        this.terms = [];
      };

      self.loadAllTermsFromTermSet = function (groupName, termSetName) {
        var taxSession = SP.Taxonomy.TaxonomySession.getTaxonomySession(context),
          termStore = taxSession.getDefaultSiteCollectionTermStore(),
          termGroups = termStore.get_groups(),
          termGroup = termGroups.getByName(groupName),
          termSets = termGroup.get_termSets(),
          termSet = termSets.getByName(termSetName),
          terms = termSet.getAllTerms(),
          deferred = $q.defer();

        context.load(terms);
        context.executeQueryAsync(resolveWithTermsAsTree(terms, organizeByParent, deferred));

        return deferred.promise;
      };

      var resolveWithTermsAsTree = function (spTerms, callback, deferred) {
        return function () {
          var termsEnum = spTerms.getEnumerator(),
            termsFlat = {},
            termsTree = [],
            term;

          while (termsEnum.moveNext()) {
            var spTerm = termsEnum.get_current();

            term = new Term(spTerm);

            if (spTerm.get_isRoot()) {
              // root terms don't have parents
              term.parent = undefined;
              // If term is a root term, immediately add it to the terms tree
              termsTree.push(term);
            }
            // Add to the flat terms
            termsFlat[term.id] = term;
          }

          callback(termsFlat);
          deferred.resolve(termsTree);
        };
      };

      var Term = function (spTerm) {
        var self = this;
        self.children = [];
        self.id = spTerm.get_id().toString();
        self.name = spTerm.get_name();
        self.description = spTerm.get_description();
        // parent is actually useless without another call to the api (thanks, JSOM)
        self.parent = spTerm.get_parent();
        self.parentId = undefined;
        self.path = spTerm.get_pathOfTerm();

        self.properties = spTerm.get_localCustomProperties();
        // Convert the property strings to booleans
        // right now we assume custom properties are either 'true' or not.
        _.each(self.properties, function (value, key) {
          // Delete the key and use the lowercase equivalent
          // Should always be a string.
          delete self.properties[key];
          self.properties[key.toLowerCase()] = value === 'true';
        });
      };

      var organizeByParent = function (terms) {
        var keys = Object.keys(terms),
          cur,
          node;
        for (var i = 0; i < keys.length; i++) {
          cur = keys[i];
          // Skip root terms
          if (typeof terms[cur].parent !== 'undefined') {
            // The term path is the *only* hierarchy data microsoft supplies, unfortunately.
            var termPath = terms[cur].path.split(';'),
              termParent = termPath[termPath.length - 2];
            // Since term names aren't necessarily unique across the whole tree,
            // we have to walk the entire tree for the correct term.
            //
            // Depending on how many terms you have, you may want to sort into a list
            // by term name first in order to do something like a binary search
            for (var c = 0; c < keys.length; c++) {
              node = keys[c];
              // We've found our parent!
              if (terms[node].name === termParent) {
                // Finally assign the parent id for later usage
                terms[cur].parentId = terms[node].id;
                terms[node].children.push(terms[cur]);
              }
            }
          }
        }
      };
    }]);
})();

Comments

comments powered by Disqus