Tuesday, August 16, 2016

TDS Post Deploy Step: Trigger a site publish

TDS 5.5 introduced the ability to create custom post deployment steps, which are executed after a successful deployment and package install. Hedgehog is currently asking the community to contribute custom post deploy steps, so that we can gather a repository and share the knowledge (and reduce headaches). Below is a quick contribution that will trigger a site publish after a deployment.

Problem: We all know that sometimes a deployment adds content items that need to be published for a site to work and render properly. That's why TDS has a built-in post deploy step to publish the items that were just deployed. But what about all those content and media items that our content editors prepare in anticipation of a deployment? To publish those, we're often kicking off a full site publish.

Solution: This post deployment step will automatically trigger a site publish after a successful deployment.

Installation:
You can download the code for the PublishSite post deploy step here.
If you haven't yet, check out the instructions on how to add a custom post deploy step to your TDS project.
Once it's added to your TDS project, you can configure the following parameters:

  • language - one or more comma-separated languages for which the publish will be performed. If omitted, a publish will be triggered for all available languages.
  • target - one or more comma-separated target databases. If omitted, a publish will be triggered for all available publishing targets.
  • mode - can be either 'full', 'smart', or 'incremental'. If omitted, the default option will be 'smart'

Example:
language=en-us,en-ca&target=cds1,cds2&mode=smart


If you have custom post deploy steps of your own, we'd love to hear about them.

Wednesday, February 24, 2016

TDS 5.5: Quick Shoutout

This is a really quick and short shout out to the TDS 5.5 Delta Deploy feature. I’ve been using the beta version of TDS 5.5 for a few weeks, and it's made quite the impact already. I love it! Check out this before and after of my deployment package:


Last week, our production deployment package was 2,040KB with all items included, and installation took about 3 minutes.

This week’s deployment package was only 171KB and it went through in approximately 20 seconds as it only included the items my team worked on for the current release.


TDS 5.5 will be available at the end of March, 2016. Contact Hedgehog Development for more info! 

Tuesday, November 24, 2015

Basics: How to publish an item programmatically to all targets in Sitecore

Here's a tidbit that I found myself googling about and I couldn't find an answer to.. How to publish an item to all available publishing targets.

I needed to do this to avoid hard-coding database names when publishing programmatically. So I went in to see how Sitecore does it from the ribbon command (Sitecore.Shell.Framework.Commands.PublishNow)

After some digging around in the Kernel, I ended up with something along these lines:

 Database contextDatabase = Sitecore.Context.Database;  
 Item itemToPublish = contextDatabase.GetItem("/sitecore/content/home"); //some item that needs to be published  
 //get all the available targets  
 List<Database> databases = new List<Database>();  
 ItemList targets = PublishManager.GetPublishingTargets(contextDatabase);  
 foreach (Item targetItem in targets)  
 {  
  Database database = Factory.GetDatabase(targetItem[FieldIDs.PublishingTargetDatabase]);  
   if (database != null)  
   {  
     databases.Add(database);  
   }  
 }  
 List<Language> languages = new List<Language>();  
 languages.Add(itemToPublish.Language);  
 //invoking the static PublishManager.PublishItem  
 PublishManager.PublishItem(itemToPublish, databases.ToArray(), languages.ToArray(), false, true);  

If working within the Sitecore Client, you will want to use Sitecore.Context.ContentDatabase instead of Sitecore.Context.Database

Also, if you want to publish in all languages, you can use LanguageManager.GetLanguages(contextDatabase) instead.

Monday, September 28, 2015

Sitecore Web Forms for Marketers as a Service

Customizing the WFFM module for Sitecore has provided post material for quite a few blogs, including this one. It's a favorite among clients, and it's fairly extensible. However, we all know that once in a while, we get the occasional request for a marketing form, which the module simply does not work for. My last endeavor into forms included a fancy animated multiple step wizard with interdependent fields, which would be an immense undertaking to implement with WFFM versus a simple custom .net form.

So the question became, how do I combine the value we get out of WFFM setup, save actions and reporting with the amazing designs and form flowcharts, which the design teams come up with and which our front end developers code into clean and beautiful html5. Especially in cases where we've already developed quite a few save actions that post to various client CRMs, create leads, and talk to third parties. Who wants to recode all that for a single custom form?

So I started working towards a concept, which would provide an endpoint for WFFM form submissions. From anywhere. It is fairly simple and straight forward to implement (wait till we get to the code part), but it becomes powerful in that it allows developers to have full control over the rendered html and still take advantage of the WFFM save actions.

Step 1. Run WFFM save actions on submitted form data.

We'll need to define a couple of classes for consumers of the WFFM service

   public class FormData
    {
        public string FormId { get; set; }

        public IEnumerable<FormField> Fields { get; set; } 
    }
    public class FormField
    {
        public string FieldName { get; set; }

        public string FieldValue { get; set; }
    }
And the meaty part - the FormProcessor, which is responsible for running the WFFM save actions, and of course has a dependency on the Sitecore and WFFM assemblies. With the help of a reflection tool, we can imitate what WFFM does behind the scenes here:

    public class FormProcessor
    {
        public FormProcessorResult Process(FormData data)
        {
            FormProcessorResult result = new FormProcessorResult();

            if (string.IsNullOrEmpty(data.FormId))
            {
                result.Success = false;
                result.ResultMessage = "Invalid Form Id";
            }
            else
            {
                bool failed = false;

                ID formId = new ID(data.FormId);
                FormItem formItem = FormItem.GetForm(formId);

                if (formItem != null)
                {
                    //Get form fields of the WFFM
                    FieldItem[] formFields = formItem.FieldItems;

                    //Create collection of fields
                    List<AdaptedControlResult> adaptedFields = new List<AdaptedControlResult>();
                    foreach (FormField field in data.Fields)
                    {
                        FieldItem formFieldItem = formFields.FirstOrDefault(x => x.Name == field.FieldName);
                        if (formFieldItem != null)
                        {
                            adaptedFields.Add(GetControlResult(field.FieldValue, formFieldItem));
                        }
                        else
                        {
                            // log and bail out
                            Log.Warn(string.Format("Field Item {0} not found for form with ID {1}", field.FieldName, data.FormId), this);
                            failed = true;
                            result.Success = false;
                            result.ResultMessage = string.Format("Invalid field name: {0}", field.FieldName);
                            break;
                        }
                    }

                    if (!failed)
                    {
                        // Get form action definitions
                        List<ActionDefinition> actionDefinitions = new List<ActionDefinition>();
                        ListDefinition definition = formItem.ActionsDefinition;
                        if (definition.Groups.Count > 0 && definition.Groups[0].ListItems.Count > 0)
                        {
                            foreach (GroupDefinition group in definition.Groups)
                            {
                                foreach (ListItemDefinition item in group.ListItems)
                                {
                                    actionDefinitions.Add(new ActionDefinition(item.ItemID, item.Parameters)
                                                              {
                                                                  UniqueKey = item.Unicid
                                                              });
                                }
                            }
                        }

                        //Execute form actions
                        foreach (ActionDefinition actionDefinition in actionDefinitions)
                        {
                            try
                            {
                                ActionItem action = ActionItem.GetAction(actionDefinition.ActionID);
                                if (action != null)
                                {
                                    if (action.ActionType == ActionType.Save)
                                    {
                                        object saveAction = ReflectionUtil.CreateObject(action.Assembly, action.Class,
                                                                                        new object[0]);
                                        ReflectionUtils.SetXmlProperties(saveAction, actionDefinition.Paramaters, true);
                                        ReflectionUtils.SetXmlProperties(saveAction, action.GlobalParameters, true);
                                        if (saveAction is ISaveAction)
                                        {
                                            ((ISaveAction) saveAction).Execute(formId, adaptedFields, null);
                                        }
                                    }
                                }
                            }
                            catch (Exception ex)
                            {
                                // log and bail out
                                Log.Warn(ex.Message, ex, this);
                                result.Success = false;
                                result.ResultMessage = actionDefinition.GetFailureMessage();
                                failed = true;

                                break;
                            }

                        }

                        if (!failed)
                        {
                            // set successful result
                            result.Success = true;
                            result.ResultMessage = formItem.SuccessMessage;
                        }
                    }
                }
                else
                {
                    result.Success = false;
                    result.ResultMessage = "Form not found: invalid form Id";
                }
            }

            return result;
        }

        private AdaptedControlResult GetControlResult(string fieldValue, FieldItem fieldItem)
        {
            //Populate fields with values
            ControlResult controlResult = new ControlResult(fieldItem.Name, HttpUtility.UrlDecode(fieldValue), string.Empty)
            {
                FieldID = fieldItem.ID.ToString(),
                FieldName = fieldItem.Name,
                Value = HttpUtility.UrlDecode(fieldValue),
                Parameters = string.Empty
            };
            return new AdaptedControlResult(controlResult, true);
        }
    }
Step 2. Create a WebApi endpoint for clients to post to.
Now that we have the basic setup, we can create a simple API controller:

    public class WffmController : ApiController
    {
        [HttpPost]
        public IHttpActionResult Post(FormData data)
        {
            FormProcessor processor = new FormProcessor();
            FormProcessorResult result = processor.Process(data);

            if (!result.Success)
            {
                return new BadRequestErrorMessageResult(result.ResultMessage, this);
            }

            return new OkResult(this);
        }
Step 3. Register routes

var config = GlobalConfiguration.Configuration;
            config.Routes.MapHttpRoute("DefaultApiRoute",
                                     "api/{controller}/{id}",
                                     new { id = RouteParameter.Optional });

Step 4. Use with any form anywhere.

            var formFields = new List<FormField>();
            formFields.Add(new FormField
            {
                FieldName = "First Name",
                FieldValue = data.FirstName
            });
            formFields.Add(new FormField
            {
                FieldName = "Last Name",
                FieldValue = data.LastName
            });
            formFields.Add(new FormField
            {
                FieldName = "Email",
                FieldValue = data.Email
            });

            FormData formData = new FormData();
            formData.FormId = MY_WFFM_FORM_ITEM_ID; // form item id from Sitecore
            formData.Fields = formFields;



            using (WebClient client = new WebClient())
            {
                client.UploadString(RemoteUrl, "POST", JsonConvert.SerializeObject(formData));
            }

Cons:
- certain WFFM out-of-the-box features will be lost - validation! validation! validation!
- once created, the form needs to remain fairly immutable since the form item ID and the field items are the contract with any client that will submit data.

I hope that someone would find this useful the next time they're faced with a similar problem. 

Monday, March 16, 2015

Sitecore error with Lucene Thai Analyzer

ManagedPoolThread #1 2015:03:12 08:32:28 ERROR Exception
Exception: System.Reflection.TargetInvocationException
Message: Exception has been thrown by the target of an invocation.
Source: mscorlib
   at System.RuntimeMethodHandle.InvokeMethod(Object target, Object[] arguments, Signature sig, Boolean constructor)
   at System.Reflection.RuntimeMethodInfo.UnsafeInvokeInternal(Object obj, Object[] parameters, Object[] arguments)
   at System.Reflection.RuntimeMethodInfo.Invoke(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
   at System.Reflection.MethodBase.Invoke(Object obj, Object[] parameters)
   at (Object , Object[] )
   at Sitecore.Pipelines.CorePipeline.Run(PipelineArgs args)
   at Sitecore.Jobs.Job.ThreadEntry(Object state)

Nested Exception

Exception: System.NotSupportedException
Message: PORT ISSUES
Source: Lucene.Net.Contrib.Analyzers
   at Lucene.Net.Analysis.Th.ThaiAnalyzer.ReusableTokenStream(String fieldName, TextReader reader)
   at Lucene.Net.Index.DocInverterPerField.ProcessFields(IFieldable[] fields, Int32 count)
   at Lucene.Net.Index.DocFieldProcessorPerThread.ProcessDocument()
   at Lucene.Net.Index.DocumentsWriter.UpdateDocument(Document doc, Analyzer analyzer, Term delTerm)
   at Lucene.Net.Index.IndexWriter.UpdateDocument(Term term, Document doc, Analyzer analyzer)
   at Sitecore.ContentSearch.LuceneProvider.LuceneUpdateContext.UpdateDocument(Object itemToUpdate, Object criteriaForUpdate, IExecutionContext[] executionContexts)
   at Sitecore.ContentSearch.SitecoreItemCrawler.DoUpdate(IProviderUpdateContext context, SitecoreIndexableItem indexable)
   at Sitecore.ContentSearch.LuceneProvider.LuceneIndex.PerformUpdate(IEnumerable`1 indexableUniqueIds, IndexingOptions indexingOptions)

In a single day, we saw this error appear over 9000 times on a production environment.

From what I understand (since 7.0+) Sitecore by default provides full mapping of all available Lucene.net analyzers. They are configured under:
indexConfigurations > defaultLuceneIndexConfiguration > analyzer > param desc="map"
Based on the context of the content that's indexed/searched, Sitecore will (with reflection) figure out which mapping to use. Here’s a great post explaining execution contexts - http://www.sitecore.net/learn/blogs/technical-blogs/sitecore-7-development-team/posts/2013/08/execution-contexts-explained.aspx

So the Thai Analyzer seems to be a bit broken (read not implemented) from what I see in the Lucene.Net source. The Analyzer calls the constructor for ThaiWordFilter with a token stream and that constructor just throws the exception we see. You can decompile the Lucene.Net.Contrib.Analyzers.dll or look at the source at http://lucenenet.apache.org/.

public ThaiWordFilter(TokenStream input): base(input)
{
  throw new NotSupportedException("PORT ISSUES");
  //breaker = BreakIterator.getWordInstance(new Locale("th"));
  //termAtt = AddAttribute<TermAttribute>();
  //offsetAtt = AddAttribute<OffsetAttribute>();
}

Removing or commenting out the Thai analyzer (the below mapEntry) from the execution context mappings in the Sitecore.ContentSearch.Lucene.DefaultIndexConfiguration.config should result in indexing/searching in th-TH to fall back to the standard analyzer and will get rid of the error in your log files.

             <mapEntry type="Sitecore.ContentSearch.LuceneProvider.Analyzers.PerExecutionContextAnalyzerMapEntry, Sitecore.ContentSearch.LuceneProvider">
                <param hint="executionContext" type="Sitecore.ContentSearch.CultureExecutionContext, Sitecore.ContentSearch">
                  <param hint="cultureInfo" type="System.Globalization.CultureInfo, mscorlib">
                    <param hint="name">th-TH</param>
                  </param>
                </param>
                <param desc="analyzer" type="Sitecore.ContentSearch.LuceneProvider.Analyzers.DefaultPerFieldAnalyzer, Sitecore.ContentSearch.LuceneProvider">
                  <param desc="defaultAnalyzer" type="Lucene.Net.Analysis.Th.ThaiAnalyzer, Lucene.Net.Contrib.Analyzers">
                    <param hint="version">Lucene_30</param>
                  </param>
                </param>
              </mapEntry>

If anyone has come across this before, I'd love to hear from you!


Update: Pavel Veller (@pveller) pointed out to me that this issue has been fixed with Sitecore 7.2 Update 3. As per the release notes:
  • Thai Analyzer from Lucene.Net was not fully implemented and could sometimes throw Not Supported exceptions. The analyzer has been removed from the default Lucene index configuration. The default analyzer will be used instead. (420234)

Wednesday, March 11, 2015

Searchable Language Selector

If you have ever worked in a Sitecore instance with a lot of languages, you may have noticed that sometimes it could be quite time consuming (and frustrating) to look for the language you need in the language picker. This isn't as much a developer problem as it is an issue for the content editors who often make edits in multiple languages. So, here's a quick and easy client-side solution.

The language selector is generated by an xml control located here: \sitecore\shell\Applications\Content Manager\Galleries\Languages\Gallery Languages.xml

A couple of modifications to add a search box, and a couple of javascript functions later, and we now have a searchable language selector:



You can find the modified control up on GitHub. Let me know what you guys think!

Update: This modification is now also available for download from the Sitecore Marketplace.

Thursday, February 5, 2015

Error when rendering WFFM form

I came across an interesting WFFM exception on a production CM environment today. It turned out to be a configuration error, so I decided to share
[InvalidOperationException: folder]
   Sitecore.Form.Core.Configuration.ThemesManager.GetThemeName(Item form, ID fieldID) +434
   Sitecore.Form.Core.Configuration.ThemesManager.GetThemeUrl(Item form, Boolean deviceDependant) +270
   Sitecore.Form.Core.Configuration.ThemesManager.ScriptsTags(Item form, Item contextItem) +49
   Sitecore.Form.Core.Configuration.ThemesManager.RegisterCssScript(Page page, Item form, Item contextItem) +184
   Sitecore.Form.Web.UI.Controls.SitecoreSimpleFormAscx.OnInit(EventArgs e) +233
   System.Web.UI.Control.InitRecursive(Control namingContainer) +186
   System.Web.UI.Control.AddedControl(Control control, Int32 index) +189
   Sitecore.Form.Core.Renderings.FormRender.OnInit(EventArgs e) +846
   System.Web.UI.Control.InitRecursive(Control namingContainer) +186
   System.Web.UI.Control.InitRecursive(Control namingContainer) +291
   System.Web.UI.Control.InitRecursive(Control namingContainer) +291
   System.Web.UI.Control.InitRecursive(Control namingContainer) +291
   System.Web.UI.Control.InitRecursive(Control namingContainer) +291
   System.Web.UI.Page.ProcessRequestMain(Boolean includeStagesBeforeAsyncPoint, Boolean includeStagesAfterAsyncPoint) +2098

The method - Sitecore.Form.Core.Configuration.ThemesManager.GetThemeName(Item form, ID fieldID) - looks at the Form ID that's configured as the Forms root ID in the site definition.
string formsRootForSite = SiteUtils.GetFormsRootForSite(Context.Site);
Item item = form;
if (form.TemplateID != IDs.FormFolderTemplateID)
{
    item = form.Database.GetItem(formsRootForSite);
}
Assert.IsNotNull(item, "folder");
In my case, the configured ID did not match the actual forms folder item ID in Sitecore.