Search PDF content in sitecore

To people who have not tried to do this themselves, this seems like and easy task. All we need to do is get all the text content and load it in the search index. Initially i thought i had a good solution with PdfSharp using code that i found from this stack overflow post.  It seemed to be working fine until i attempted to run my site on Azure.   It apparently uses lower level OS based API calls that are just not available on Azure using the new Sitecore Paas setup.

There are several paid libraries that claim to be able to accomplish just this, however like most developers i wasn’t about to pitch buying a license to read PDF content to my clients. So the search continued.  After many hours (which i hope to save you from here) i came across a solution that did the trick (for the most part).

Reading PDF content

This code does require PdfSharp as a dependency, get it here on nuget.

NOTE: this code was adapted from this stack overflow post and is not entirely my own.  Although i don’t think it’s the poster on stack overflow who originated the code either.  Credit is due somewhere, but not quite sure where.

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Web;
using PdfSharp.Pdf;
using PdfSharp.Pdf.IO;
using Sitecore.Data.Items;

namespace IHN.Feature.Component
{
	/// <summary>
	/// Addapted from code found here http://stackoverflow.com/questions/83152/reading-pdf-documents-in-net
	/// </summary>

	public class SitecorePdfParser
	{
		private int _numberOfCharsToKeep = 15;
		private PdfDocument _doc;

		public SitecorePdfParser(Item item): this(new MediaItem(item))
		{
		}
		public SitecorePdfParser(MediaItem item)
		{
			if (item.MimeType != "application/pdf")
				return;
			Stream s = item.GetMediaStream();
			_doc = PdfReader.Open(s);
		}

		public SitecorePdfParser(PdfDocument document)
		{
			_doc = document;
		}

		public IEnumerable<string> ExtractText()
		{
			if (_doc == null)
				yield break;
			foreach (PdfPage page in _doc.Pages)
			{
				for (int index = 0; index < page.Contents.Elements.Count; index++)
				{

					PdfDictionary.PdfStream stream = page.Contents.Elements.GetDictionary(index).Stream;
					foreach (string text in ExtractTextFromPdfBytes(stream.Value))
					{
						yield return text;
					}
				}
			}
		}
		/// <summary>
		/// This method processes an uncompressed Adobe (text) object
		/// and extracts text.
		/// </summary>

		/// <param name="input">uncompressed</param>
		/// <returns></returns>
		public IEnumerable<string> ExtractTextFromPdfBytes(byte[] input)
		{
			if (input == null || input.Length == 0) yield break;
			StringBuilder resultString = new StringBuilder();
			bool inTextObject = false;
			bool nextLiteral = false;
			int bracketDepth = 0;
			char[] previousCharacters = new char[_numberOfCharsToKeep];
			for (int j = 0; j < _numberOfCharsToKeep; j++) previousCharacters[j] = ' '; 			foreach (byte t in input) 			{ 				char c = (char)t; 				if (inTextObject) 				{ 					// Position the text 					if (bracketDepth == 0) 					{ 						if (CheckToken(new[] { "TD", "Td" }, previousCharacters) || CheckToken(new[] { "'", "T*", "\"" }, previousCharacters) || CheckToken(new[] { "Tj" }, previousCharacters)) 						{ 							if (resultString.Length > 0)
							{
								yield return CleanupContent(resultString.ToString());
								resultString.Clear();
							}
						}
					}

					if (bracketDepth == 0 &&
						CheckToken(new string[] { "ET" }, previousCharacters))
					{
						inTextObject = false;
						if (resultString.Length > 0)
						{
							yield return CleanupContent(resultString.ToString());
							resultString.Clear();
						}
						continue;
					}

					if (c == '(' && bracketDepth == 0 && !nextLiteral)
					{
						bracketDepth = 1;
					}
					else if (c == ')' && bracketDepth == 1 && !nextLiteral)
					{
						bracketDepth = 0;
					}
					else if (bracketDepth == 1)
					{
						if (c == '\\' && !nextLiteral)
						{
							nextLiteral = true;
						}
						else
						{
							if (c == ' ')
							{
								if (resultString.Length > 0)
								{
									yield return CleanupContent(resultString.ToString());
									resultString.Clear();
								}
							}
							else if ((c >= '!' && c <= '~') || 									 (c >= 128 && c < 255))
							{
								resultString.Append(c);
							}
							nextLiteral = false;
						}
					}
				}

				// Store the recent characters for
				// when we have to go back for a checking
				for (int j = 0; j < _numberOfCharsToKeep - 1; j++)
				{
					previousCharacters[j] = previousCharacters[j + 1];
				}
				previousCharacters[_numberOfCharsToKeep - 1] = c;

				if (!inTextObject && CheckToken(new string[] { "BT" }, previousCharacters))
				{
					inTextObject = true;
				}
			}
		}
		private string CleanupContent(string text)
		{
			string[] patterns = { @"\\\(", @"\\\)", @"\\226", @"\\222", @"\\223", @"\\224", @"\\340", @"\\342", @"\\344", @"\\300", @"\\302", @"\\304", @"\\351", @"\\350", @"\\352", @"\\353", @"\\311", @"\\310", @"\\312", @"\\313", @"\\362", @"\\364", @"\\366", @"\\322", @"\\324", @"\\326", @"\\354", @"\\356", @"\\357", @"\\314", @"\\316", @"\\317", @"\\347", @"\\307", @"\\371", @"\\373", @"\\374", @"\\331", @"\\333", @"\\334", @"\\256", @"\\231", @"\\253", @"\\273", @"\\251", @"\\221" };
			string[] replace = { "(", ")", "-", "'", "\"", "\"", "à", "â", "ä", "À", "Â", "Ä", "é", "è", "ê", "ë", "É", "È", "Ê", "Ë", "ò", "ô", "ö", "Ò", "Ô", "Ö", "ì", "î", "ï", "Ì", "Î", "Ï", "ç", "Ç", "ù", "û", "ü", "Ù", "Û", "Ü", "®", "™", "«", "»", "©", "'" };

			for (int i = 0; i < patterns.Length; i++)
			{
				string regExPattern = patterns[i];
				Regex regex = new Regex(regExPattern, RegexOptions.IgnoreCase);
				text = regex.Replace(text, replace[i]);
			}

			return text;
		}
		/// <summary>
		/// Check if a certain 2 character token just came along (e.g. BT)
		/// </summary>

		/// <param name="search">the searched token</param>
		/// <param name="recent">the recent character array</param>
		/// <returns></returns>
		private bool CheckToken(string[] tokens, char[] recent)
		{
			foreach (string token in tokens)
			{
				if (token.Length > 1)
				{
					if ((recent[_numberOfCharsToKeep - 3] == token[0]) &&
						(recent[_numberOfCharsToKeep - 2] == token[1]) &&
						((recent[_numberOfCharsToKeep - 1] == ' ') ||
						(recent[_numberOfCharsToKeep - 1] == 0x0d) ||
						(recent[_numberOfCharsToKeep - 1] == 0x0a)) &&
						((recent[_numberOfCharsToKeep - 4] == ' ') ||
						(recent[_numberOfCharsToKeep - 4] == 0x0d) ||
						(recent[_numberOfCharsToKeep - 4] == 0x0a))
						)
					{
						return true;
					}
				}
				else
				{
					return false;
				}
			}
			return false;
		}
	}
}

Then we need to wire this up to the index crawler to make sure that the index uses this class to populate the search index with our Pdf content.

We need to implement a Sitecore IComputedIndexField class to accomplish this.

	public class IndexPdfContent : IComputedIndexField
	{
		public object ComputeFieldValue(IIndexable indexable)
		{
			try
			{
				var sitecoreIndexable = indexable as SitecoreIndexableItem;

				if (sitecoreIndexable == null) return null;

				var pdfContent = new SitecorePdfParser(new MediaItem(sitecoreIndexable)).ExtractText().ToList();

				if (pdfContent.Count == 0) return null;

				return string.Join(" ", pdfContent);
			}
			catch (Exception e)
			{
				Log.Error("Unable to assemble PDF content for the search index ", e, this);
				return null;
			}
		}
	}

And finally wire it up to the indexer

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
	<sitecore>
		<contentSearch>
			<indexConfigurations>
				<defaultLuceneIndexConfiguration>
					<documentOptions>
						<fields hint="raw:AddComputedIndexField">
							<!-- indexes pdf contents into index _content field to allow PDF search -->
							<field fieldName="_pdfcontent" type="[NAMESPACE].IndexPdfContent, [DLL NAME]" />
						</fields>
					</documentOptions>
				</defaultLuceneIndexConfiguration>
				<defaultSolrIndexConfiguration>
					<documentOptions>
						<fields hint="raw:AddComputedIndexField">
							<!-- indexes pdf contents into index _content field to allow PDF search -->
							<field fieldName="_pdfcontent" type="[NAMESPACE].IndexPdfContent, [DLL NAME]" />
						</fields>
					</documentOptions>
				</defaultSolrIndexConfiguration>
				<defaultCloudIndexConfiguration>
					<documentOptions>
						<fields hint="raw:AddComputedIndexField">
							<!-- indexes pdf contents into index _content field to allow PDF search -->
							<field fieldName="pdf_content" cloudFieldName="pdf_content" type="[NAMESPACE].IndexPdfContent, [DLL NAME]" />
						</fields>
					</documentOptions>
				</defaultCloudIndexConfiguration>
			</indexConfigurations>
		</contentSearch>
	</sitecore>
</configuration>

Ending Results

Now we have our search index populated with PDF contents. So if someone wants to find a PDF with a text search it’s as simple as querying the index on the field assigned in the xml with the users search text.

Disclaimer

While this solution is quite good, it’s not perfect. If you have text in PDF images, it won’t find that. Additionally I’ve noticed that in rare cases words might be broken up when they’re being extracted. Presumably this is due to PDF formatting. If you happen to figure out how to resolve this completely, let me know and i’d love to update this code.

Sitecore RTE Button Postprocessing

There may be times that you want to modify the way stock Sitecore RTE buttons work without actually modifying stock Sitecore files.  An easy way to accomplish this is to override the Telerik editor commands manually using a custom js file.

Some common uses of this technique could include

  1. Adding classing to injected elements.
  2. Wrapping injected elements in a wrapping element.
  3. Adding a sibling html element for an icon perhaps.
  4. Modifying the markup for SEO needs.
  5. Modifying the markup to build a responsive website.

Find the operation to patch

The first thing you need to do is find the RTE command for the button you’d like to add post processing to.  Easiest way to do this is by using your browsers inspect feature on the button you’d like to enhance.

finding-command

The class of the span element that makes up the button is the name of the command you’re interested in.  At this point you can start writing your javascript.

The Javascript

var	RadEditorCommandList = Telerik.Web.UI.Editor.CommandList;

var table = RadEditorCommandList["InsertTable"];
RadEditorCommandList["InsertTable"] = function (commandName, editor, args) {
	table(commandName, editor, args);
	var p = editor.getSelectedElement().parentNode.parentNode.parentNode;
	p.classList.add("editor-table")
};

This code will modify the insert table button to add a class of editor-table to the table after it’s injected.

So what are we doing here, let’s analyze it.

  1. Get the telerik editor command list object.  This object stores the javascript that drives each of the buttons in the editor.
  2. Save the original command into a custom variable called table
  3. Replace the method attacked to the telerik editor command list with our own function
  4. Using the telerik editor object to get the selected element after the table is inserted and traverse up to the
    node
  5. Add a class of editor-table to the table root

 
You’ll likely need to utilize the debugger to drop breakpoints down in your code and use the console to identify the correct element you’ll need to manipulate.

Having Sitecore add your javascript to the editor

There’s a simple config patch to add your javascript to the RTE editor.

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
	<sitecore>
		<clientscripts>
			<htmleditor>
				<script key="customsrc" src="/relative/path/to/customSitecore.js" language="JavaScript"/>
			</htmleditor>
		</clientscripts>
	</sitecore>
</configuration>

Using this method is not limited to ONLY postprocessing, but you could essentially take stock methods and do whatever you please with them. Sky’s the limit, so go have fun with it.

Installing Azure Search in Sitecore

Azure search became a new option as of Sitecore 8.2 update 1.  It’s a great search provider with all kinds of features out of the box.  Most notably:

  • Supports Lucene query syntax for queries
  • Autocomplete and type-ahead
  • Hit highlighting (show the context in which the keywords appeared)
  • Geo-spacial awareness (show geographical search results to a user)

While Azure search can be utilized by any install of sitecore, it is most notably useful in an azure cloud implementation.  Which has also changed in 8.2 update 1 and my friend and fellow MVP Bas Lijten wrote an amazing blog post about.  Important note, this install can be done automatically if you’re using the Azure ARM scripts that Sitecore provides.  In other words If your Sitecore is on Azure, make it easy on yourself and use the ARM scripts.

I’m also extremely happy to say that it’s quite easy to set up Azure Search in Sitecore.  Those of you who went through the toil of setting up Solr should be particularly pleased about that.

Get it installed

Step 1

Install Azure Search on an azure subscription.

  • Add new
  • Web + Mobile
  • Select Azure Search
  • Click Create

installing

Step 2

Get your access id

  • Find your new Azure Search in the resources list
  • Click Settings
  • Click Keys
  • Copy your primary key

configure

Step 3

Add connection string

  • serviceUrl – The url which Azure has given your search service, see above screenshot for where to find it (it’s blurred out, you can’t use mine!)
  • apiVersion – The version of the rest api to utilize for Azure search.  You’re unlikely to need to change this right now
  • apiKey – The key you retrieved in step 2
<connectionStrings>
	<!-- Your other connection strings -->
	<add name="cloud.search" connectionString="serviceUrl=https://********.search.windows.net;apiVersion=2015-02-28;apiKey=***********************************" />
</connectionStrings>

Step 4

Configure Sitecore

  • Remove or disable
    • App_Config\Include\Sitecore.Speak.ContentSearch.Lucene.config
    • App_Config\Include\ContentTesting\Sitecore.ContentTesting.Lucene.IndexConfiguration.config
    • App_Config\Include\FXM\Sitecore.FXM.Lucene.DomainsSearch.DefaultIndexConfiguration.config
    • App_Config\Include\FXM\Sitecore.FXM.Lucene.DomainsSearch.Index.Master.config
    • App_Config\Include\FXM\Sitecore.FXM.Lucene.DomainsSearch.Index.Web.config
    • App_Config\Include\ListManagement\Sitecore.ListManagement.Lucene.Index.List.config
    • App_Config\Include\ListManagement\Sitecore.ListManagement.Lucene.IndexConfiguration.config
    • App_Config\Include\Social\Sitecore.Social.Lucene.Index.Analytics.Facebook.config
    • App_Config\Include\Social\Sitecore.Social.Lucene.Index.Master.config
    • App_Config\Include\Social\Sitecore.Social.Lucene.Index.Web.config
    • App_Config\Include\Social\Sitecore.Social.Lucene.IndexConfiguration.config
    • App_Config\Include\Sitecore.ContentSearch.Lucene.DefaultIndexConfiguration.config
    • App_Config\Include\Sitecore.ContentSearch.Lucene.DefaultIndexConfiguration.Xdb.config
    • App_Config\Include\Sitecore.ContentSearch.Lucene.Index.Analytics.config
    • App_Config\Include\Sitecore.ContentSearch.Lucene.Index.Core.config
    • App_Config\Include\Sitecore.ContentSearch.Lucene.Index.Master.config
    • App_Config\Include\Sitecore.ContentSearch.Lucene.Index.Web.config
    • App_Config\Include\Sitecore.Marketing.Definitions.MarketingAssets.Repositories.Lucene.Index.Master.config
    • App_Config\Include\Sitecore.Marketing.Definitions.MarketingAssets.Repositories.Lucene.Index.Web.config
    • App_Config\Include\Sitecore.Marketing.Definitions.MarketingAssets.Repositories.Lucene.IndexConfiguration.config
    • App_Config\Include\Sitecore.Marketing.Lucene.Index.Master.config
    • App_Config\Include\Sitecore.Marketing.Lucene.Index.Web.config
    • App_Config\Include\Sitecore.Marketing.Lucene.IndexConfiguration.config
  • Enable
    • App_Config\Include\Sitecore.ContentSearch.Azure.DefaultIndexConfiguration.config.disabled
    • App_Config\Include\Sitecore.ContentSearch.Azure.Index.Analytics.config.disabled
    • App_Config\Include\Sitecore.ContentSearch.Azure.Index.Core.config.disabled
    • App_Config\Include\Sitecore.ContentSearch.Azure.Index.Master.config.disabled
    • App_Config\Include\Sitecore.ContentSearch.Azure.Index.Web.config.disabled
    • App_Config\Include\Sitecore.Marketing.Azure.Index.Master.config.disabled
    • App_Config\Include\Sitecore.Marketing.Azure.Index.Web.config.disabled
    • App_Config\Include\Sitecore.Marketing.Azure.IndexConfiguration.config.disabled
    • App_Config\Include\Sitecore.Marketing.Definitions.MarketingAssets.Repositories.Azure.Index.Master.config.disabled
    • App_Config\Include\Sitecore.Marketing.Definitions.MarketingAssets.Repositories.Azure.Index.Web.config.disabled
    • App_Config\Include\Sitecore.Marketing.Definitions.MarketingAssets.Repositories.Azure.IndexConfiguration.config.disabled
    • App_Config\Include\ContentTesting\Sitecore.ContentTesting.Azure.IndexConfiguration.config.disabled
    • App_Config\Include\FXM\Sitecore.FXM.Azure.DomainsSearch.DefaultIndexConfiguration.config.disabled
    • App_Config\Include\FXM\Sitecore.FXM.Azure.DomainsSearch.Index.Master.config.disabled
    • App_Config\Include\FXM\Sitecore.FXM.Azure.DomainsSearch.Index.Web.config.disabled
    • App_Config\Include\ListManagement\Sitecore.ListManagement.Azure.Index.List.config.disabled
    • App_Config\Include\ListManagement\Sitecore.ListManagement.Azure.IndexConfiguration.config.disabled
    • App_Config\Include\Social\Sitecore.Social.Azure.Index.Master.config.disabled
    • App_Config\Include\Social\Sitecore.Social.Azure.Index.Web.config.disabled
    • App_Config\Include\Social\Sitecore.Social.Azure.IndexConfiguration.config.disabled

Step 5

Rebuild your index

  • From desktop Sitecore button -> Control Panel.  From launchpad click the Control Panel button
  • Indexing
  • Indexing Manager
  • Select all indexes
  • Execute

Now you’re good to go!

Sitecore doesn’t ship with wrappers to achieve the advanced search features that Azure Search provides, so you’ll likely need to create your own search service to utilize these features until Sitecore has a plan to wrap this in it’s linq to search feature.

Watch out!

If you have a custom search field with a preceding underscore, you need to make sure you give it an additional attribute of cloudFieldName like you can see on Sitecore’s _templatename field.  This is because Azure Search actually doesn’t allow a field to start with an underscore.

              <field fieldName="_templatename"        cloudFieldName="templatename_1"      boost="1f" type="System.String"   settingType="Sitecore.ContentSearch.Azure.CloudSearchFieldConfiguration, Sitecore.ContentSearch.Azure" />

Personalization State For Tag Managers

There have often been clients of mine who have asked the question “how do we use Google Analytics to track the value of our personalization campaigns?”.  The first answer is usually that there isn’t a good way to do so, not built in anyway.  So we build something!  I’ve set up a static method to generate personalization stats for the current page and drops it in a meta-tag.  That way you can easily set up any tag management analytics tool to keep track of a user based on personalization states.

How it works

Every time a user sets up a block of rules to trigger a personalization state.  They give it a name, i propose that they use this name as a sort of campaign tag withing the tag manager.  Here’s an example of a block of rules.

personalizationform

Using the above highlighted name as the key value for this particular personalization block would allow us to generate a meta-tag that looks like this:

<meta name="personalizationState" content="Target User">

If multiple personalization states were to have been triggered their names would be added to the content of the meta-tag as a comma delimited list.

Show me the code

	public class PersonalizationState
	{
		public static string GenerateMetaTags()
		{
			StringBuilder sb = new StringBuilder();
			RuleStack stack = new RuleStack();
			var references = Sitecore.Context.Item.Visualization.GetRenderings(Sitecore.Context.Device, false);
			try
			{
				foreach (RenderingReference reference in references)
				{
					ConditionalRenderingsRuleContext context = new ConditionalRenderingsRuleContext(references.ToList(), reference);
					foreach (Rule<ConditionalRenderingsRuleContext> rule in reference.Settings.Rules.Rules)
					{
						rule.Condition.Evaluate(context, stack);
						if ((stack.Count != 0) && (bool) stack.Pop() && rule.UniqueId != ID.Null)
						{
							sb.Append(",").Append(rule.Name);
							break;
						}
					}
				}
			}
			catch (Exception e)
			{
				Log.Warn("Problem aggregating rules for personalization context, metatag may be incorrect", e, e);
			}
			return sb.Length > 0 ? $@"<meta name=""personalizationState"" content=""{sb.ToString(1, sb.Length - 1)}"">" : "";
		}
	}

It would then be utilized in your cshtml (or using similar techniques in aspx, if you’re into that sort of thing) like so.

@Html.Raw(PersonalizationState.GenerateMetaTags())

Token Manager Custom Token Button

After hearing from some content authors that Token Manager was difficult to train newer people to use, i set out to build a system to create RTE toolbar shortcuts to tokens, to make it as easy and simple as possible to insert a token

custombutton

AutoTokens have a shortcut for this feature, if you look at the implementation of the AutoToken interface you’ll see you can override a property for TokenButton.

    public override TokenButton TokenButton()
    {
        return new TokenButton()
        {
            Name = "Insert a spiffy date token",
            Icon = "Office/32x32/explosion.png"
        };
    }

However you can add this for any token manually in the RTE profile.

  1. Create a new button using the template {3C8BD8A1-280B-4278-BB8B-21FA3B87AF0F} – Rich Editor Button
  2. in the field “Click” add “TokenManager” + {unique identifier without white space}.  It doesn’t really matter what the identifier is you choose as long as it’s unique.
    1. for example it could be TokenManagerDateToken
  3. Under shortcut put the query string variables for the token category and name
    1. for example ?Category=Demo Token&Token=Date Token

This can ease up on the learning curve for Token Manager and make it a system that any level of author can use.

Token Manager AutoToken

While using Token Manager for some simple dynamic property tokens i realized that as it was the process is a little too complicated.  There are several steps needed to create a token, it involves creating a template that inherits a token base template, implementing the class, registering it with the config.  Quite a lot of effort and runaround, and this is all well and good for tokens that are created by content authors.  However, most of the tokens I’ve created are dynamic tokens generated by the backend without the need of a consistent content author managed source.

AutoTokens fix all that.  With AutoTokens there is one single step to create a token.  Implement the interface AutoToken in an assembly that’s loaded in the app pool.

	public class DemoToken : AutoToken
	{
		public override TokenButton TokenButton()
		{
			return new TokenButton()
			{
				Name = "Insert a spiffy date token",
				Icon = "Office/32x32/explosion.png"
			};
		}

		public DemoToken() : base("Demo Token", "Office/32x32/explosion.png", "Date Token")
		{
		}

		public override string Value(NameValueCollection extraData)
		{
			int month;
			int.TryParse(extraData["month"], out month);
			return CultureInfo.CurrentCulture.DateTimeFormat.GetAbbreviatedMonthName(month) + extraData["day"];
		}

		public override IEnumerable<ITokenData> ExtraData()
		{
			yield return new MonthData();
			yield return new DayData();
		}
	}

That’s it!  One Step!  Token Manager will generate a token collection and add a token to it

Notice the use of MonthData and DayData. These are custom Token Manager fields, which can easily be implemented with some light knowledge of angularjs. Ultimately an angularjs snippet just needs to assign a value to the javascript variable token.data[field.Name]. For example this is the MonthData class

	public class MonthData : ITokenData
	{
		private string options;

		public MonthData()
		{
			StringBuilder sb = new StringBuilder();
			for (int i = 0; i < 12; i++)
			{
				sb.Append($"
<option value={i+1}>{CultureInfo.CurrentCulture.DateTimeFormat.GetMonthName(i+1)}</option>");
			}
			options = sb.ToString();
		}
		public object GetValue(string value)
		{
			return value;
		}

		public string Name { get; set; } = "month";
		public string Label { get; set; } = "Select the month";
		public bool Required { get; set; } = true;
		public string AngularMarkup
		{
			get
			{
				return
					$@"
<div class=""field-row {{{{field.class}}}}"">
        <span class=""field-label"">{{{{field.Label}}}} </span>
<div class=""field-data"">
            <select ng-model=""token.data[field.Name]"" >{options}</div>
</div>
";
			}
		}
		public dynamic Data { get; }
	}

Out of the box Token Manager comes with TokenData fields for

  1. IdTokenData – Opens a Sitecore tree at a specified root to select a Sitecore item
  2. StringTokenData – Enter any string value
  3. BooleanTokenData – Checkbox
  4. IntegerTokenData – Enter number

This is what we end up with.

autotoken

Sitecore Sidekick – App Tutorial / Log Viewer

IMPORTANT: this is for Sitecore Sidekick 1.1-1.2 for Sidekick 1.3+ follow this guide: Building a custom Sidekick app
Here in lies a tutorial for building your own Sidekick app.  In this example we’ll be building an app that exposes running and recently completed Sitecore jobs (like publishing, and index rebuilds).  I believe Sitecore 8.2 has something to do this out of the box, however those of us on older versions of Sitecore are at the mercy of the black box if we happen to accidentally close the publishing dialog.  It’s still going in the background and blocking further publishing operations, but is it stuck?  We don’t know!  Lets arm ourselves with knowledge!

jobviewer

You can grab the source code here in a stand alone project HERE
Note that this requires the Sitecore nuget feed.

Angularjs parts

In your visual studio solution create a folder for resources.  In this folder put all your angular code and very important edit the file properties on each js file to compile it as an embedded resource instead of the default “content”. Sidekick has a special way of serving these content that aggregates all resources into a single dll file. This minimizes installation and file system mess. As well as making the final product of any Sidekick app be simply a DLL and a .config file. No mess, no fuss!

Step one:

Build an angular factory.  To act as a hub to access the server side information.

(function () {
    'use strict';

    angular
        .module('app')
        .factory('JVFactory', JVFactory);

    JVFactory.$inject = ['$http'];

    function JVFactory($http) {
        var service = {

            getJobs: function (running) {
                var data = { "running": running};
                return $http.post("/scs/jvgetjobs.json", data);
            }
        };

        return service;

        function getData() { }
    }
})();

Step two:

Build an angular directive. This will be the root of our app from a DOM perspective.

(function () {
    'use strict';

    angular
        .module('app')
        .directive('jvdirective', jvdirective);

    function jvdirective() {

        var directive = {
            templateUrl: "/scs/jvmaster.scs",
            restrict: 'EA'
        };
        return directive;
    }

})();

Step three:

Build an angular js html template. This will be the actual markup for your app. Note that instead of the traditional html templates. In Sidekick i would recommend you use .scs files to prevent rare collisions in Sitecore’s routing of html files

<div ng-controller="jvcontroller as vm" class="jvmain">
<div class="btn" ng-if="vm.runningJobs" ng-click="vm.runningJobs = false">Show Completed Jobs</div>
<div class="btn" ng-if="!vm.runningJobs" ng-click="vm.runningJobs = true">Show Running Jobs</div>
<div ng-repeat="job in vm.jobs">
<div>
<h4>
				<a href="#" ng-click="vm.toggleDetails(job.Name+job.Category+job.TimeStarted)">{{job.Name}}</a></h4>
<strong>Processed:</strong> {{job.ProcessedItemsCount}}
<div ng-if="vm.selectedJob === job.Name+job.Category+job.TimeStarted">

					<strong>Category:</strong> {{job.Category}}

					<strong>Started:</strong> {{job.TimeStarted}}

					<strong>Job State:</strong> {{job.State}}

					<strong>Job Messages</strong>
<pre>{{job.Messages}}</pre>
</div>
</div>
</div>
</div>

Step four:

Build an angular controller. This will control the angular scope that we’re going to use to control the data on the front end.

(function () {
    'use strict';

    angular
        .module('app')
        .controller('jvcontroller', jvcontroller);

    jvcontroller.$inject = ['JVFactory', '$interval'];

    function jvcontroller(JVFactory, $interval) {
        /* jshint validthis:true */
        var vm = this;
        vm.runningJobs = true;
        vm.getJobs = function () {
            JVFactory.getJobs(vm.runningJobs).then(function (response) {
                vm.jobs = response.data;
            }, function (response) {
                vm.error = response.data;
            });
        }
        vm.toggleDetails = function (jobkey) {
            if (vm.selectedJob === jobkey)
                vm.selectedJob = false;
            else
                vm.selectedJob = jobkey;
        }
        $interval(function () {
            vm.getJobs();
        }, 500);
        vm.getJobs();
    }
})();

Misc Resources

In the resources folder you created for the angular code put all your file based assets and very important edit the file properties on each file to compile it as an embedded resource instead of the default “content”

Step one:

Css. We need to make our app look reasonable. It’s important to note that you need to make your css as app specific as possible, since all Sidekick apps run in the same angular app if you set up styles to say the div tag as a whole, it will effect other apps.

.jvmain div > div {
    border: 2px solid black;
	border-radius: 5px;
    margin-bottom: 5px;
    padding-left: 5px;
    background-color: #eee;
    display: inline-block;
    width: 96%;
    margin: 10px;
    word-wrap: break-word;
}
.jvmain div > div > div {
    margin-right: 5px;
    border: 2px solid blue;
    background-color: #ddd;
    width: 96%;
}
.jvmain strong {
	font-weight: 700;
	font-size: 14px;
}
.jvmain h4 {
	font-size: 16px;
	font-weight: 700;
}

Step Two:

Logo. For the Sidekick tile.

jvgearwheels

ASP.net parts

Step one:

Http Handler.  In Sidekick we have a special Http handler that’s wired up automatically through the Sidekick’s core.  It’s an abstract class called ScsHttpHandler.  We need to implement one of those. You’ll notice that this is where a lot of the magic bindings happen, for instance this is where you give the namespace of your resources folder that all our previous assets were created into. Note that there’s a method here GetPostData which gets all post data sent by angular and builds a dynamic C# object for you to use.

	public class JobViewerHandler : ScsHttpHandler
	{
		public JobViewerHandler(string roles, string isAdmin, string users)
			: base(roles, isAdmin, users)
		{
		}

		public override string Directive { get; set; } = "jvdirective";
		public override NameValueCollection DirectiveAttributes { get; set; }
		public override string ResourcesPath { get; set; } = "ScsJobViewer.Resources";
		public override string Icon => "/scs/jvgearwheels.png";
		public override string Name => "Job Viewer";
		public override string CssStyle => "width:600px";
		public override void ProcessRequest(HttpContextBase context)
		{
			string file = this.GetFile(context);

			if (file == "jvgetjobs.json")
			{
				this.ReturnJson(context, this.GetJobs(context));
			}
			else
			{
				this.ProcessResourceRequest(context);
			}
		}
		private object GetJobs(HttpContextBase context)
		{
			var data = GetPostData(context);
			var model = JobManager.GetJobs().Where(x => data.running ? !x.IsDone : x.IsDone).OrderBy(x => x.QueueTime);
			return model.Select(x => new JobModel(x));
		}
	}

Step two:

In order to run the above code we need a model

	public class JobModel
	{
		private readonly Job _job;
		public JobModel(Job job)
		{
			this._job = job;
		}
		public string Name => this._job.Name;
		public string Category => this._job.Category;
		public string State => this._job.Status.State.ToString();
		public string Messages
		{
			get
			{
				StringBuilder sb = new StringBuilder();
				sb.AppendLine("Job Details for " + this.Name);

				if (this._job.Options.ContextUser != null)
				{
					sb.AppendLine("Context User: " + this._job.Options.ContextUser.Name);
				}

				sb.AppendLine("Priority: " + this._job.Options.Priority);
				sb.AppendLine("Messages:");

				foreach (string s in this._job.Status.Messages)
				{
					sb.AppendLine(s);
				}

				return sb.ToString();
			}
		}
		public bool IsDone => this._job.IsDone;
		public long ProcessedItemsCount => this._job.Status.Processed;
		public long TotalItemsCount => this._job.Status.Total;
		public bool HasTotalItems => this.TotalItemsCount > 0;
		public string TimeStarted => this._job.QueueTime.ToLocalTime().ToString("G");
	}

The configuration

This step is simple, we just need to register our app with sidekick.

<?xml version="1.0"?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <pipelines>
      <scsRegister>
        <processor type="ScsJobViewer.JobViewerHandler, ScsJobViewer" >
          <!-- leave blank for any role -->
          <param name="roles"></param>
          <!-- set to "true" to only allow admins-->
          <param name="isAdmin"></param>
          <!-- leave blank for any users -->
          <param name="users"></param>
        </processor>
      </scsRegister>
    </pipelines>
  </sitecore>
</configuration>