Linakis Digital

Ideas, Thoughts, Inspiration

Install Solr 6.1 on windows as a service

Intro

According to the official Solr documentation, since version 5.0, it is not a recommended practice to have Solr run in a 3rd party container (such as Tomcat). You can read more about it here and here.

Based on the official information on how to install and run Solr, you simply extract the zip in a folder and run the executable. However, that's not all of it, unfortunately. The issue is that when following these instructions, you simply run the program. Nothing gets installed as a service, so if the server gets rebooted, you need to re-run the executable.

Furthermore, the relevant documentation page only lists how to make Solr run as a service under Linux and Unix-based systems.


Solution

Here's a rundown of what I did to get things smoothly working. Most of this stuff is from http://coding-art.blogspot.gr/2016/07/running-solr-61-as-windows-service.html and from https://www.norconex.com/how-to-run-solr5-as-a-service-on-windows/

Downloads

  1. Latest Solr (at the time of writing, this was 6.1.0). From http://www.apache.org/dyn/closer.lua/lucene/solr/6.1.0 
  2. Latest Java Server JRE (at the time of writing, 1.8.102). From http://www.oracle.com/technetwork/java/javase/downloads/server-jre8-downloads-2133154.html. You will need to accept their License Agreement.
  3. Latest NSSM. From https://nssm.cc/download.


Installations

Java

The installation instructions for the Server JRE are located at http://docs.oracle.com/javase/8/docs/technotes/guides/install/windows_server_jre.html#CFHGHHFJ. Essentially, what they are saying is "extract the bundle and simply copy the files wherever you want". There are two issues with this: 
  • the .tar.gz extention is not recognized by default in Windows. If that's the case with you, you need to download and install a compression/decompression utility that can handle this extension, such as 7-zip. You can get it from here as a portable app.
  • After extraction, I opted to copy the resultant folder "jdk1.8.0_102" into "C:\Program Files\Java"

Solr

As per the documentation, I unzipped the zip file and copied everything over to C:\Program Files\Solr-6.1.0

NSSM

Similar to Solr, I unzipped the file and copied everything over to C:\Program Files\nssm

Configurations

Java

I had to go and register an environment variable, namely JAVA_HOME, with the value of the path to my java installation (i.e. C:\Program Files\Java\jdk1.8.0_102). To do that in Windows 2012 I went to "Computer"--> right click --> "Properties" --> "Advanced System Settings" --> "Environment Variables" button and clicked on the "New" button under "System Variables".
The above is a crucial step, as without it, when you attempt to run Solr, you'll get the message that "JAVA_HOME has not been set".

Solr

First of all, open a command prompt as Administrator, change to the bin folder within the Solr installation folder (mine was C:\Program Files\Solr-6.1.0\bin) and issue the command 

solr start
You should get something like the following:

C:\Program Files\solr-6.1.0\bin>solr start
Waiting up to 30 to see Solr running on port 8983
Started Solr server on port 8983. Happy searching!
C:\Program Files\solr-6.1.0\bin>

that's great. It's running, you can verify this by opening a browser and going to http://localhost:8983/solr. Stop the server by issuing 
solr stop -all

The Program Files folder is not a very handy location to have all the Solr Core definitions and data. So I decided to host this data within C:\Inetpub. I could equally go for C:\solr. In any case, copy the contents of the folder C:\Program Files\Solr-6.1.0\server\solr onto wherever you want to have the Solr files hosted. I went for C:\Inetpub\solr\solr-6.1.0-cores. The reason behind the extra "solr" folder will be explained in a bit.

Now in C:\Inetpub\solr I went and created a file called "solr_start.cmd". I opened it with notepad and wrote the following:

"C:\Program Files\solr-6.1.0\bin\solr" start -f -p 8983 -m 512m -s C:\inetpub\solr\solr-6.1-cores

the -f parameter means that Solr should run in the foreground. The -p sets the port, the -m sets the min/max amount of memory Solr is allowed to use and the -s tells the Solr server to look for cores (and create new ones) in that particular path.

Executing the solr_start.cmd, should get you a CLI with a lot of information, and no command prompt - the server is running in the foreground. If you open the Admin UI in a browser, you should be seeing the interface again, and also see an info message in the CLI that the admin interface has been accessed. Hit Ctrl-C in the window to stop the server (after a prompt to stop the batch job, where you should say "y" as in "yes").

You're now all set to configure the service via nssm.

NSSM

Open a command prompt as administrator and go to C:\Program Files\nssm\win64 (or win32 if you run a 32 bit system, which I doubt). From there, issue the following command:
nssm install "Apache Solr 6.1"
This will open up a GUI (after propmting for Admin approval) where you can fill in the pertinent information for the service you are about to install. As per this blog post, I filled in the following
  1. Application Tab
    1. Path: C:\Inetpub\solr\solr_start.cmd
    2. Startup Directory: leave the auto-filled value (C:\Inetpub\solr)
  2. Details Tab
    1. Display Name: Apache - Solr 6.1
    2. Startup type: Automatic
  3. Log on Tab
    1. Make sure you specify an account that has administrator-level permissions (I left the "local system account" that was pre-selected)
  4. I/O Tab
    1. I/O Redirection
      1. Output (stdout): I set it to C:\Inetpub\solr\solr-6.1.0-out.txt
      2. Error (stderr): I set it to  C:\Inetpub\solr\solr-6.1.0-error.txt
    2. File rotation
      1. Check Rotate files
      2. Check Rotate while service is running
      3. Restrict rotation to files bigger than: I filled in 4Mb, i.e. 4,194,304 bytes
Hit "Install service" and you're almost set. 

You still need to start the service. So go to Control Panel --> System and Security --> Administrative Tools --> Services and locate the "Apache Solr 6.1" service and start it.

Once started, you can open up the Admin UI in the browser to verify that the instance is indeed running.

Happy searching!

Fun with WFFM Part 2

This is a continuation of my previous post, about WFFM and its quirks (or "bugs", or "features", depending on your point of view).

The Date Selector field

First of all, there are two fields for date picking: a "Date" field, that outputs three dropdowns and a "DatePicker" field that outputs a text field with a jQuery UI DatePicker attached to it. This post will be dealing with the former, though I can see that the latter might have its own issues as well.


Odd HTML output.

The html output has a rather odd structure, where there is 

  • a "span" (actually, an "asp:Label" without an associated control) that takes the content of the "title" field.
  • Three "label" tags, one for "year", one for "month" and one for "day" dropdowns.
  • Respectively, three dropdowns (year, month, day).
There is no additional markup for the labels and dropdowns, They are just output like that, one next to the other, without any grouping between them, they are all sibling HTML elements, with the three labels preceding the three dropdowns. No wrapping div for each label-dropdown pair. That's a lot of fun if you have even the slightest notion of styling the thing.

Undocumented and "Un-interfaced" properties

I mentioned three labels: "year", "month", "day". There is no way to set the values for these labels in a multilingual site, using the form designer. You have to decompile the dll, realize that there are three public properties named "YearLabel", MonthLabel"  and "DayLabel", and then go to Sitecore Content Editor, go to the "Date" field item in your form, and pull your XML-fu to write this snippet in the "Localized Parameters" for the field
<DayLabel>Day</DayLabel><MonthLabel>Month</MonthLabel><YearLabel>Year</YearLabel>
where you replace the node values with what you want written in the respective labels.

Ok, granted, that wasn't too difficult to find, but why hide the ability to edit the labels? I believe the Sitecore team simply forgot to add the required attributes to the properties ("VisualProperty", "DefaultValue", "Localizable"). However, it's really frustrating having to decompile the library just to find out how you're supposed to localize three labels, when  just a small code addition that already exists in other properties in the same field type would have made this a non-issue.

Date formats

There is a specific field in the form designer, called "Date Format". It's a dropdown, that shows a list of date display examples. The source for the droplist is at /sitecore/system/Modules/Web Forms for Marketers/Settings/Meta data/Date Formats. Usually, this is OK. However, for multilingual sites where one language prefers month before day (such as American English) whereas another one prefers day before month (such as pretty much the rest of the world), the fact that the specific parameter is not localized can (and does) pose a serious issue.As far as I know, there's no way around this without actually rolling out your own class, replacing the field type definition class in /sitecore/system/Modules/Web Forms for Marketers/Settings/Field Types/Simple Types/Date.

Multilingual month display

If you choose anything other than "MM" for month, you'll soon find out that the month dropdown always displays the months in English, no matter what language you are viewing the site in. This is due to the following code in the month dropdown population code (InitMonth metod):
            for (int i = 1; i <= 12; i++)
            {
                this.month.Items.Add(new ListItem(string.Format("{0:" + marker + "}", dateTime.AddMonths(i - 1)), i.ToString()));
            }
It should be changed to this:
            for (int i = 1; i <= 12; i++)
            {
                this.month.Items.Add(new ListItem(string.Format(Sitecore.Context.Language.CultureInfo, "{0:" + marker + "}", dateTime.AddMonths(i - 1)), i.ToString()));
            }
Unfortunately, the method is private, so to override it you need to roll out your own copy of the DateSelector class, and not just subclass the original.

Minimum/Maximum/Default date

there are three fields in the form designer to set dates:. Default, Minimum and Maximum. What these do is the following:
  • The Default date sets the date preselected when the page loads
  • The Minimum date sets a validation on the user selected date, as well as the minimum year in the relevant dropdown displayed in the form
  • The Maximum date sets a validation on the user selected date, as well as the maximum year in the relevant dropdown displayed in the form
Apart from an obscure "today" value to the default date, which I'm yet to find a way to pass using the form designer, there is absolutely no way to set these three dates in a rolling, dynamic fashion; you practically hard wire three fixed dates. However, most of the times I've had the pleasure (or torment, depending on your point of view) of creating a form with any kind of date selection, the limits and the default date were supposed to be fluid (e.g. from 2 years ago up until yesterday, with default value yesterday, or from the day after tomorrow until 3 months after today, with the default date being 3 days from now).

Unfortunately, the Sitecore UI for setting these fields (as well as the code supporting them) does not allow for such dynamic settings: the UI raises a calendar, while in the code, an ISO date string is expected. 

This is by far the worst of the quirks for the date selector field. This alone made me roll out my own class - effectively copying and then augmenting the existing class.

My custom class has the following new properties:
        [VisualCategory("Validation"), VisualProperty("Start Date expression:", 2001), DefaultValue("")]
        public string StartDateExp
        {
            get
            {
                return this.classAtributes["StartDateExp"];
            }
            set
            {
                if (!string.IsNullOrEmpty(value))
                {
                    if (value != "today" && !System.Text.RegularExpressions.Regex.IsMatch(value, @"^[-+]\d+[ymd](\s[-+]\d+[ymd])*$"))
                    {
                        throw new ArgumentException("The value is not a valid expression.", "value");
                    }
                }
                this.classAtributes["StartDateExp"] = value;
            }
        }
        [VisualCategory("Validation"), VisualProperty("End Date expression:", 2001), DefaultValue("")]
        public string EndDateExp
        {
            get
            {
                return this.classAtributes["EndDateExp"];
            }
            set
            {
                if (!string.IsNullOrEmpty(value))
                {
                    if (value != "today" && !System.Text.RegularExpressions.Regex.IsMatch(value, @"^[-+]\d+[ymd](\s[-+]\d+[ymd])*$"))
                    {
                        throw new ArgumentException("The value is not a valid expression.", "value");
                    }
                }
                this.classAtributes["EndDateExp"] = value;
            }
        }
        [VisualProperty("Selected Date expression:", 101), DefaultValue("")]
        public string SelectedDateExp
        {
            get
            {
                return this.classAtributes["SelectedDateExp"];
            }
            set
            {
                if (!string.IsNullOrEmpty(value))
                {
                    if (value != "today" && !System.Text.RegularExpressions.Regex.IsMatch(value, @"^[-+]\d+[ymd](\s[-+]\d+[ymd])*$"))
                    {
                        throw new ArgumentException("The value is not a valid expression.", "value");
                    }
                }
                this.classAtributes["SelectedDateExp"] = value;
            }
        }

Replaced the InitYear method with the following:
        private void InitYear(string marker)
        {
            if (!string.IsNullOrEmpty(StartDateExp))
            {
                StartDate = Sitecore.DateUtil.ToIsoDate(ApplyTransform(DateTime.Today, StartDateExp));
            }
            if (!string.IsNullOrEmpty(EndDateExp))
            {
                EndDate = Sitecore.DateUtil.ToIsoDate(ApplyTransform(DateTime.Today, EndDateExp));
            }

            DateTime dateTime = new DateTime(this.StartDateTime.Year - 1, 1, 1);
            this.year.Items.Clear();
            for (int i = this.StartDateTime.Year; i <= this.EndDateTime.Year; i++)
            {
                dateTime = dateTime.AddYears(1);
                this.year.Items.Add(new ListItem(string.Format("{0:" + marker + "}", dateTime), i.ToString()));
            }
            this.year.SelectedValue = this.CurrentDate.Year.ToString();
        }

The fact that this particular method was marked as private in the original Date Selector class, made me copy the entire code from the decompiler and not just subclass the original.

I also replaced the OnInit with the following:
        protected override void OnInit(EventArgs e)
        {
            this.day.CssClass = "scfDateSelectorDay";
            this.month.CssClass = "scfDateSelectorMonth";
            this.year.CssClass = "scfDateSelectorYear";
            this.help.CssClass = "scfDateSelectorUsefulInfo";
            this.generalPanel.CssClass = "scfDateSelectorGeneralPanel";
            this.title.CssClass = "scfDateSelectorLabel";
            this.Controls.AddAt(0, this.generalPanel);
            this.Controls.AddAt(0, this.title);
            this.generalPanel.Controls.AddAt(0, this.help);
            List<string> list = new List<string>(this.DateFormat.Split(new char[]
            {
                '-'
            }));
            list.Reverse();
            if (!string.IsNullOrEmpty(this.SelectedDateExp))
            {
                SelectedDate =Sitecore.DateUtil.ToIsoDate(ApplyTransform(DateTime.Today, SelectedDateExp));
            }
            list.ForEach(new Action<string>(this.InsertDateList));
            list.ForEach(new Action<string>(this.InsertLabelForDateList));
            this.RegisterCommonScript();
            this.month.Attributes["onclick"] = "javascript:return $scw.webform.controls.updateDateSelector(this);";
            this.year.Attributes["onclick"] = "javascript:return $scw.webform.controls.updateDateSelector(this);";
            this.day.Attributes["onclick"] = "javascript:return $scw.webform.controls.updateDateSelector(this);";
        }

And added this:
        private DateTime ApplyTransform(DateTime d, string t)
        {
            var parts = t.Trim().Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
            if (parts.Length>1)
            {
                foreach(var part in parts)
                {
                    d = ApplyTransform(d, part);
                }
                return d;
            }
            if (t=="today")
            {
                return DateTime.Now;
            }
            var num = int.Parse(t.Substring(1, t.Length - 2));
            if (t[0] == '-')
            {
                num = -num;
            }
            switch (t[t.Length-1])
            {
                case 'd':
                    return d.AddDays(num);
                case 'm':
                    return d.AddMonths(num);
                case 'y':
                    return d.AddYears(num);
                default:
                    return d;
            }
        }
This is the method that actually does things. First It reads the input from the "Start/End/Default date Expression", which is a string literal of space separated terms of the form (+/-)(number)(marker) e.g.
+2d -3m +1y

The above means "9 months and 2 days from today". The notation is taken from the similar properties of the jQuery UI DatePicker widget.

Then, it splits the input into terms, and applies the terms sequentially on today's date. Full code for the class at the end of this post.

But wait, that's not all! We've dealt with the display, but not the validation of the user input. you see, the dates the user can select using the dropdowns span from January 1st of the minimum year to December 31st of the maximum year. The minimum and maximum dates are more often than not different. There is a validator that handles this, however it also needs to be aware of the newly added "Expression" properties.

I followed the same logic for the validator, simply copied the code from the decompiler into a new class and added the following:
        public string EndDateExp
        {
            get
            {
                return this.classAttributes["enddateexp"];
            }
            set
            {
                this.classAttributes["enddateexp"] = value;
            }
        }

        public string StartDateExp
        {
            get
            {
                return this.classAttributes["startdateexp"];
            }
            set
            {
                this.classAttributes["startdateexp"] = value;
            }
        }
        private string ApplyTransform(string Modifier)
        {
            return Sitecore.DateUtil.ToIsoDate(ApplyTransform(DateTime.Today, Modifier));
        }

        private DateTime ApplyTransform(DateTime date, string Modifier)
        {
            var parts = Modifier.Trim().Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
            if (parts.Length > 1)
            {
                foreach (var part in parts)
                {
                    date = ApplyTransform(date, part);
                }
                return date;
            }
            if (Modifier == "today")
            {
                return DateTime.Today;
            }
            var num = int.Parse(Modifier.Substring(1, Modifier.Length - 2));
            if (Modifier[0] == '-')
            {
                num = -num;
            }
            switch (Modifier[Modifier.Length - 1])
            {
                case 'd':
                    return date.AddDays(num);
                case 'm':
                    return date.AddMonths(num);
                case 'y':
                    return date.AddYears(num);
                default:
                    return date;
            }
        }

And changed the OnLoad to the following:
        protected override void OnLoad(EventArgs e)
        {
            if (!string.IsNullOrEmpty(StartDateExp))
            {
                StartDate = ApplyTransform(StartDateExp);
            }
            if (!string.IsNullOrEmpty(EndDateExp))
            {
                EndDate = ApplyTransform(EndDateExp);
            }
            base.ErrorMessage = string.Format(base.ErrorMessage, "{0}", this.StartDateTime.ToString(Constants.LongDateFormat), this.EndDateTime.ToString(Constants.LongDateFormat));
            this.Text = string.Format(this.Text, "{0}", this.StartDateTime.ToString(Constants.LongDateFormat), this.EndDateTime.ToString(Constants.LongDateFormat));
            base.OnLoad(e);
        }
Full code for the validator class at the end of the post.

Sitecore-wise, I opted to create a new field and a new validator, rather than overwrite the existing.

That's it! And it works like a charm!

As promised, code for the DateSelector field:
    [Adapter(typeof(DateAdapter)), ValidationProperty("Value")]
    public class DateSelector : ValidateControl, IHasTitle
    {
        private static readonly string baseCssClassName = "scfDateSelectorBorder";

        protected DropDownList day = new DropDownList();

        protected Panel generalPanel = new Panel();

        protected DropDownList month = new DropDownList();

        protected System.Web.UI.WebControls.Label title = new System.Web.UI.WebControls.Label();

        protected DropDownList year = new DropDownList();

        private DateTime CurrentDate
        {
            get
            {
                DateTime result;
                if (string.IsNullOrEmpty(this.SelectedDate))
                {
                    result = Sitecore.DateUtil.ToServerTime(DateTime.UtcNow);
                }
                else
                {
                    result = Sitecore.DateUtil.IsoDateToDateTime(Sitecore.DateUtil.IsoDateToServerTimeIsoDate(this.SelectedDate));
                }
                return result;
            }
        }

        private DateTime StartDateTime
        {
            get
            {
                return Sitecore.DateUtil.IsoDateToDateTime(this.StartDate);
            }
        }

        private DateTime EndDateTime
        {
            get
            {
                return Sitecore.DateUtil.IsoDateToDateTime(this.EndDate);
            }
        }

        public string Value
        {
            get
            {
                DateTime dateTime = new DateTime(this.StartDateTime.Year + ((this.year.SelectedIndex > -1) ? this.year.SelectedIndex : 0), ((this.month.SelectedIndex > -1) ? this.month.SelectedIndex : 0) + 1, ((this.day.SelectedIndex > -1) ? this.day.SelectedIndex : 0) + 1);
                return Sitecore.DateUtil.ToIsoDate(dateTime.Date);
            }
        }

        [VisualFieldType(typeof(SelectDateField)), VisualProperty("Selected Date:", 100), DefaultValue("today")]
        public string SelectedDate
        {
            get;
            set;
        }
        [VisualProperty("Selected Date expression:", 101), DefaultValue("")]
        public string SelectedDateExp
        {
            get
            {
                return this.classAtributes["SelectedDateExp"];
            }
            set
            {
                if (!string.IsNullOrEmpty(value))
                {
                    if (value != "today" && !System.Text.RegularExpressions.Regex.IsMatch(value, @"^[-+]\d+[ymd](\s[-+]\d+[ymd])*$"))
                    {
                        throw new ArgumentException("The value is not a valid expression.", "value");
                    }
                }
                this.classAtributes["SelectedDateExp"] = value;
            }
        }

        public override string DefaultValue
        {
            set
            {
                this.SelectedDate = value;
            }
        }

        [VisualFieldType(typeof(DateFormatField)), VisualProperty("Display Format:", 100), DefaultValue("yyyy-MMMM-dd")]
        public string DateFormat
        {
            get
            {
                return this.classAtributes["DateFormat"];
            }
            set
            {
                this.classAtributes["DateFormat"] = value;
            }
        }

        [VisualCategory("Validation"), VisualFieldType(typeof(SelectDateField)), VisualProperty("Start Date:", 2000), DefaultValue("20000101T000000")]
        public string StartDate
        {
            get
            {
                return this.classAtributes["startDate"];
            }
            set
            {
                if (!Sitecore.DateUtil.IsIsoDate(value))
                {
                    throw new ArgumentException("The value is not an iso date.", "value");
                }
                if (!string.IsNullOrEmpty(StartDateExp))
                {
                    this.classAtributes["startDate"] = Sitecore.DateUtil.ToIsoDate(ApplyTransform(DateTime.Today, StartDateExp));
                }
                else
                {
                    this.classAtributes["startDate"] = value;
                }
            }
        }

        [VisualCategory("Validation"), VisualProperty("Start Date expression:", 2001), DefaultValue("")]
        public string StartDateExp
        {
            get
            {
                return this.classAtributes["StartDateExp"];
            }
            set
            {
                if (!string.IsNullOrEmpty(value))
                {
                    if (value != "today" && !System.Text.RegularExpressions.Regex.IsMatch(value, @"^[-+]\d+[ymd](\s[-+]\d+[ymd])*$"))
                    {
                        throw new ArgumentException("The value is not a valid expression.", "value");
                    }
                }
                this.classAtributes["StartDateExp"] = value;
            }
        }

        [VisualCategory("Validation"), VisualFieldType(typeof(SelectDateField)), VisualProperty("End Date:", 2000)]
        public string EndDate
        {
            get
            {
                return this.classAtributes["endDate"];
            }
            set
            {
                if (!Sitecore.DateUtil.IsIsoDate(value))
                {
                    throw new ArgumentException("The value is not an iso date.", "value");
                }
                if (!string.IsNullOrEmpty(EndDateExp))
                {
                    this.classAtributes["endDate"] = Sitecore.DateUtil.ToIsoDate(ApplyTransform(DateTime.Today, EndDateExp));
                }
                else
                {
                    this.classAtributes["endDate"] = value;
                }
            }
        }
        [VisualCategory("Validation"), VisualProperty("End Date expression:", 2001), DefaultValue("")]
        public string EndDateExp
        {
            get
            {
                return this.classAtributes["EndDateExp"];
            }
            set
            {
                if (!string.IsNullOrEmpty(value))
                {
                    if (value != "today" && !System.Text.RegularExpressions.Regex.IsMatch(value, @"^[-+]\d+[ymd](\s[-+]\d+[ymd])*$"))
                    {
                        throw new ArgumentException("The value is not a valid expression.", "value");
                    }
                }
                this.classAtributes["EndDateExp"] = value;
            }
        }

        public override string ID
        {
            get
            {
                return base.ID;
            }
            set
            {
                this.title.ID = value + "_text";
                this.day.ID = value + "_day";
                this.month.ID = value + "_month";
                this.year.ID = value + "_year";
                base.ID = value;
            }
        }

        public override ControlResult Result
        {
            get
            {
                return new ControlResult(this.ControlName, this.Value, null);
            }
            set
            {
            }
        }

        [VisualFieldType(typeof(CssClassField)), VisualProperty("CSS Class:", 600), DefaultValue("scfDateSelectorBorder")]
        public new string CssClass
        {
            get
            {
                return base.CssClass;
            }
            set
            {
                base.CssClass = value;
            }
        }

        protected override Control InnerValidatorContainer
        {
            get
            {
                return this.generalPanel;
            }
        }

        protected override Control ValidatorContainer
        {
            get
            {
                return this;
            }
        }

        [Bindable(true), Description("Title")]
        public string Title
        {
            get
            {
                return this.title.Text;
            }
            set
            {
                this.title.Text = value;
            }
        }

        public string DayLabel
        {
            get;
            set;
        }

        public string MonthLabel
        {
            get;
            set;
        }

        public string YearLabel
        {
            get;
            set;
        }

        public DateSelector(HtmlTextWriterTag tag) : base(tag)
        {
            this.EnableViewState = false;
            this.CssClass = DateSelector.baseCssClassName;
            this.classAtributes["DateFormat"] = "yyyy-MMMM-dd";
            this.classAtributes["startDate"] = Sitecore.DateUtil.IsoDateToServerTimeIsoDate("20000101T120000");
            this.classAtributes["endDate"] = Sitecore.DateUtil.ToIsoDate(Sitecore.DateUtil.ToServerTime(DateTime.UtcNow.AddYears(1)));
            this.SelectedDate = Sitecore.DateUtil.ToIsoDate(DateTime.UtcNow);
            this.DayLabel = "Day";
            this.MonthLabel = "Month";
            this.YearLabel = "Year";
            Utils.SetUserCulture();
        }

        public DateSelector() : this(HtmlTextWriterTag.Div)
        {
        }

        public override void RenderControl(HtmlTextWriter writer)
        {
            this.DoRender(writer);
        }

        protected virtual void DoRender(HtmlTextWriter writer)
        {
            base.RenderControl(writer);
        }

        protected override void OnInit(EventArgs e)
        {
            this.day.CssClass = "scfDateSelectorDay";
            this.month.CssClass = "scfDateSelectorMonth";
            this.year.CssClass = "scfDateSelectorYear";
            this.help.CssClass = "scfDateSelectorUsefulInfo";
            this.generalPanel.CssClass = "scfDateSelectorGeneralPanel";
            this.title.CssClass = "scfDateSelectorLabel";
            this.Controls.AddAt(0, this.generalPanel);
            this.Controls.AddAt(0, this.title);
            this.generalPanel.Controls.AddAt(0, this.help);
            List<string> list = new List<string>(this.DateFormat.Split(new char[]
            {
                '-'
            }));
            list.Reverse();
            if (!string.IsNullOrEmpty(this.SelectedDateExp))
            {
                SelectedDate =Sitecore.DateUtil.ToIsoDate(ApplyTransform(DateTime.Today, SelectedDateExp));
            }
            list.ForEach(new Action<string>(this.InsertDateList));
            list.ForEach(new Action<string>(this.InsertLabelForDateList));
            this.RegisterCommonScript();
            this.month.Attributes["onclick"] = "javascript:return $scw.webform.controls.updateDateSelector(this);";
            this.year.Attributes["onclick"] = "javascript:return $scw.webform.controls.updateDateSelector(this);";
            this.day.Attributes["onclick"] = "javascript:return $scw.webform.controls.updateDateSelector(this);";
        }

        protected override void Render(HtmlTextWriter writer)
        {
            int i = this.day.Items.Count;
            while (i <= 31)
            {
                i++;
                this.Page.ClientScript.RegisterForEventValidation(this.day.UniqueID, i.ToString());
            }
            base.Render(writer);
        }

        protected override void OnPreRender(EventArgs e)
        {
            int num = this.CurrentDate.Year;
            if (string.IsNullOrEmpty(this.year.SelectedValue) || !int.TryParse(this.year.SelectedValue, out num))
            {
                num = this.CurrentDate.Year;
            }
            while (this.day.Items.Count > CultureInfo.InvariantCulture.Calendar.GetDaysInMonth(num, this.month.SelectedIndex + 1))
            {
                this.day.Items.RemoveAt(this.day.Items.Count - 1);
            }
            HiddenField hiddenField = new HiddenField
            {
                ID = (this.ID ?? string.Empty) + "_complexvalue",
                Value = this.Value
            };
            if (this.FindControl(hiddenField.ID) == null)
            {
                this.generalPanel.Controls.AddAt(0, hiddenField);
            }
            base.OnPreRender(e);
            this.title.AssociatedControlID = null;
        }

        private void InsertLabelForDateList(string marker)
        {
            char c = marker.ToLower()[0];
            if (c == 'd')
            {
                this.generalPanel.Controls.AddAt(0, new System.Web.UI.WebControls.Label
                {
                    AssociatedControlID = this.day.ID,
                    Text = Translate.Text(this.DayLabel ?? string.Empty),
                    CssClass = "scfDateSelectorShortLabelDay"
                });
                return;
            }
            if (c == 'm')
            {
                this.generalPanel.Controls.AddAt(0, new System.Web.UI.WebControls.Label
                {
                    AssociatedControlID = this.month.ID,
                    Text = Translate.Text(this.MonthLabel ?? string.Empty),
                    CssClass = "scfDateSelectorShortLabelMonth"
                });
                return;
            }
            if (c != 'y')
            {
                return;
            }
            this.generalPanel.Controls.AddAt(0, new System.Web.UI.WebControls.Label
            {
                AssociatedControlID = this.year.ID,
                Text = Translate.Text(this.YearLabel ?? string.Empty),
                CssClass = "scfDateSelectorShortLabelYear"
            });
        }

        private void InsertDateList(string marker)
        {
            char c = marker.ToLower()[0];
            if (c == 'd')
            {
                this.InitDay(marker);
                this.generalPanel.Controls.AddAt(0, this.day);
                return;
            }
            if (c == 'm')
            {
                this.InitMonth(marker);
                this.generalPanel.Controls.AddAt(0, this.month);
                return;
            }
            if (c != 'y')
            {
                return;
            }
            this.InitYear(marker);
            this.generalPanel.Controls.AddAt(0, this.year);
        }

        private void InitDay(string marker)
        {
            this.day.Items.Clear();
            for (int i = 1; i <= 31; i++)
            {
                this.day.Items.Add(new ListItem(i.ToString(), i.ToString()));
            }
            this.day.SelectedIndex = this.CurrentDate.Day - 1;
        }

        private void InitMonth(string marker)
        {
            DateTime dateTime = default(DateTime);
            this.month.Items.Clear();
            for (int i = 1; i <= 12; i++)
            {
                this.month.Items.Add(new ListItem(string.Format(Sitecore.Context.Language.CultureInfo, "{0:" + marker + "}", dateTime.AddMonths(i - 1)), i.ToString()));
            }
            this.month.SelectedIndex = this.CurrentDate.Month - 1;
        }

        private void InitYear(string marker)
        {
            if (!string.IsNullOrEmpty(StartDateExp))
            {
                StartDate = Sitecore.DateUtil.ToIsoDate(ApplyTransform(DateTime.Today, StartDateExp));
            }
            if (!string.IsNullOrEmpty(EndDateExp))
            {
                EndDate = Sitecore.DateUtil.ToIsoDate(ApplyTransform(DateTime.Today, EndDateExp));
            }

            DateTime dateTime = new DateTime(this.StartDateTime.Year - 1, 1, 1);
            this.year.Items.Clear();
            for (int i = this.StartDateTime.Year; i <= this.EndDateTime.Year; i++)
            {
                dateTime = dateTime.AddYears(1);
                this.year.Items.Add(new ListItem(string.Format("{0:" + marker + "}", dateTime), i.ToString()));
            }
            this.year.SelectedValue = this.CurrentDate.Year.ToString();
        }

        protected void RegisterCommonScript()
        {
        }

        private DateTime ApplyTransform(DateTime d, string t)
        {
            var parts = t.Trim().Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
            if (parts.Length>1)
            {
                foreach(var part in parts)
                {
                    d = ApplyTransform(d, part);
                }
                return d;
            }
            if (t=="today")
            {
                return DateTime.Now;
            }
            var num = int.Parse(t.Substring(1, t.Length - 2));
            if (t[0] == '-')
            {
                num = -num;
            }
            switch (t[t.Length-1])
            {
                case 'd':
                    return d.AddDays(num);
                case 'm':
                    return d.AddMonths(num);
                case 'y':
                    return d.AddYears(num);
                default:
                    return d;
            }
        }
    }
And also for the validator
    public class DateValidator : FormCustomValidator
    {
        public string EndDate
        {
            get
            {
                return this.classAttributes["enddate"];
            }
            set
            {
                this.classAttributes["enddate"] = value;
            }
        }

        public string StartDate
        {
            get
            {
                return this.classAttributes["startdate"];
            }
            set
            {
                this.classAttributes["startdate"] = value;
            }
        }

        public string EndDateExp
        {
            get
            {
                return this.classAttributes["enddateexp"];
            }
            set
            {
                this.classAttributes["enddateexp"] = value;
            }
        }

        public string StartDateExp
        {
            get
            {
                return this.classAttributes["startdateexp"];
            }
            set
            {
                this.classAttributes["startdateexp"] = value;
            }
        }

        protected DateTime EndDateTime
        {
            get
            {
                return Sitecore.DateUtil.IsoDateToDateTime(this.EndDate).Date;
            }
        }

        protected DateTime StartDateTime
        {
            get
            {
                return Sitecore.DateUtil.IsoDateToDateTime(this.StartDate).Date;
            }
        }

        public DateValidator()
        {
            base.ServerValidate += new System.Web.UI.WebControls.ServerValidateEventHandler(this.OnDateValidate);
            this.StartDate = "20000101T000000";
            this.EndDate = Sitecore.DateUtil.ToIsoDate(DateTime.UtcNow.AddYears(1));
            Utils.SetUserCulture();
        }

        protected override bool EvaluateIsValid()
        {
            bool result;
            try
            {
                if (!string.IsNullOrEmpty(base.ControlToValidate))
                {
                    result = base.EvaluateIsValid();
                }
                else
                {
                    result = true;
                }
            }
            catch (ArgumentOutOfRangeException)
            {
                result = false;
            }
            return result;
        }

        protected override void OnLoad(EventArgs e)
        {
            if (!string.IsNullOrEmpty(StartDateExp))
            {
                StartDate = ApplyTransform(StartDateExp);
            }
            if (!string.IsNullOrEmpty(EndDateExp))
            {
                EndDate = ApplyTransform(EndDateExp);
            }
            base.ErrorMessage = string.Format(base.ErrorMessage, "{0}", this.StartDateTime.ToString(Constants.LongDateFormat), this.EndDateTime.ToString(Constants.LongDateFormat));
            this.Text = string.Format(this.Text, "{0}", this.StartDateTime.ToString(Constants.LongDateFormat), this.EndDateTime.ToString(Constants.LongDateFormat));
            base.OnLoad(e);
        }

        private void OnDateValidate(object source, System.Web.UI.WebControls.ServerValidateEventArgs args)
        {
            DateTime date = Sitecore.DateUtil.IsoDateToDateTime(args.Value).Date;
            if (this.StartDateTime <= date && date <= this.EndDateTime)
            {
                args.IsValid = true;
                return;
            }
            args.IsValid = false;
        }

        private string ApplyTransform(string Modifier)
        {
            return Sitecore.DateUtil.ToIsoDate(ApplyTransform(DateTime.Today, Modifier));
        }

        private DateTime ApplyTransform(DateTime date, string Modifier)
        {
            var parts = Modifier.Trim().Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
            if (parts.Length > 1)
            {
                foreach (var part in parts)
                {
                    date = ApplyTransform(date, part);
                }
                return date;
            }
            if (Modifier == "today")
            {
                return DateTime.Today;
            }
            var num = int.Parse(Modifier.Substring(1, Modifier.Length - 2));
            if (Modifier[0] == '-')
            {
                num = -num;
            }
            switch (Modifier[Modifier.Length - 1])
            {
                case 'd':
                    return date.AddDays(num);
                case 'm':
                    return date.AddMonths(num);
                case 'y':
                    return date.AddYears(num);
                default:
                    return date;
            }
        }
    }
Happy Coding!

Fun with WFFM Part 1

WFFM is a great tool. Don't get me wrong. It really is. 

It's just that sometimes it drives me nuts.

The other day, I set out to populate a contact us form, in a multilingual site, on a 8.1 update 2 Sitecore, with its relevant WFFM module installed. I set the form up in English first, added the stock "Send message" action, jotted down a simple email message with the submission data, and tested it. All was well.

Then I started tweaking it. First of all, the form had a dropdown, that did various things on change (via custom javascript of course), so I had to have different display texts and values. No problem there, I just went into Sitecore and did what had to be done in the form editor. But that broke the email message, because now, instead of the nice display text, I got the Sitecore ID of the selected option. Not good.

So I set out and used this amazing solution, ILSpy-ed the "send message" save action and rolled out my own. No biggie, in a matter of an hour I was up and running, with my custom javascript onchange event handlers on the WFFM droplist, and my custom made email sending save action with properly "shoehorned" values. I need to stress out here, that part of the solution is accessing the submitted values (the result set) by name, where name is the Item name of the WFFM fields of the form. Here comes the fun stuff:

Hidden Item Renaming

Then I went on to add the form in the other languages of the site. I had the translations handy, so it was a simple matter of data entry. Right? WRONG! After publishing, much to my horror, my form stopped functioning. I got the dreaded "your data may not have been saved" warning message I so well know and loathe.

So I went into the log, and lo and behold, a null reference exception on a line that accessed a result by name. Wondering what the issue may be, since I had already tested the thing before, I went into Sitecore to check the Item name - perhaps a capitalization issue. Imagine my surprise when I realized that Sitecore had changed the form field item names to the translated values of their titles. So the action could not locate the form field, and it all broke down in tears. Of course I changed the names back, but why would Sitecore rename the items in the first place? I only wanted a translation, not an item rename!

No Multilingual Email Messages

Then I went on to add a html template for the email message sent, with some images, and some fancy text apart from the submission data. I copy pasted the carefully crafted html, that had proper full url's for the images and tried it. All was ok. then I went on to change the language and add its translation, but NO! the Save Actions field, where the entire HTML is stored as html-encoded value of a XML fragment, is in fact a shared field! So no translations for you, mail message! 

Granted, more often than not, that's what you want. But when it comes to emails, which are client-facing things, Sitecore should have provisioned some kind of multilingual support. I found a post saying "just simply unshare the field". THAT'S A HACK IF I EVER SAW ONE! Of course I went on and unshared the field. It's working, but with a HUGE caveat. When you create a new "form" item, the standard values have a specific xml string for the "Save actions" field. You guessed it, when creating a new language version for the form, this field is now empty, thus breaking the sitecore client. My workaround is EVEN HACKIER, as I first switch to raw values, copy the xml from the first language, then create the version in the other language (with the "raw values" on, sitecore doesn't break) then paste the value in the field, save and switch back to normal display. Talk about additional administrative burden!

Eating away the server url from links and image src

Having done that, I went on to check the email sent and OH NO! All url's had their server part stripped! Apparently, that's something the DHTML Editor does. But then, the ProcessMessage pipeline (the one that parses the email, replaces tokens and sends the mail) does not restore them back, if they are not Sitecore links

So all links had to be converted to sitecore links. Fair enough, but with a lot of teeth clenching.

No images can be added via design mode

Fair enough, only Sitecore links. But what about images? The DHTML editor for the email message does not have a "add image" button! Cheap hack: add it via html, then right click on it and open the image properties. You can then go on and replace your dummy (or not so dummy) image with one from the Media Library. The src is now a propersitecore link, so it should be picked up by Sitecore and replaced with a fully qualified url with the servar part in. Only to find out that...

Sitecore links in Image Src attributes are not expanded

Because WFFM wants to throw yet another curveball. Apparently, the AddHostToMediaItem method only expands media item links that are in anchors (i.e. "<a href>"  stuff, not "<img src>" stuff). I had to roll out an additional custom pipeline processor for this (mostly ripped from the stock sitecore one though):

    public class ProcessMessage
    {
        private readonly string shortSrcMediaReplacer;

        public IItemRepository ItemRepository { get; set; }

        public IFieldProvider FieldProvider { get; set; }

        public ProcessMessage() : this(DependenciesManager.WebUtil) { }

        public ProcessMessage(IWebUtil webUtil)
        {
            Sitecore.Diagnostics.Assert.IsNotNull(webUtil, "webUtil");
            this.shortSrcMediaReplacer = string.Join(string.Empty, new string[]
			{
				"src=\"",
				webUtil.GetServerUrl(),
				"/-/"
			});
        }

        public void AddHostToMediaItem(ProcessMessageArgs args)
        {
            args.Mail.Replace("src=\"-/", this.shortSrcMediaReplacer);
            args.Mail.Replace("src=\"/-/", this.shortSrcMediaReplacer);
        }
    }
And then add it in a config patch:
<?xml version="1.0"?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
     <pipelines>
      <processMessage>
        <processor patch:after="processor[@method='AddHostToMediaItem']" 
                type="MyAssembly.Pipelines.Forms.ProcessMessage, MyAssembly" 
                method="AddHostToMediaItem" />
      </processMessage>
    </pipelines>
  </sitecore>
</configuration>


Finally, I had the form and the action the way I wanted it!

WFFM is fun to work with, but darned if it isn't quirky as can be!

Multi upload from filesystem into Sitecore

There comes a time when you need to perform some heavy duty data entry into Sitecore, and that usually involves a lot of images.

Sitecore does offer a very nifty feature, where you can zip up your media, upload the zip and let Sitecore unpack and create the entire folder and file structure within the Media Library. However, this sometimes falls short, due to web server limitations, e.g. what if the zip file ends up being many hundreds of Mb, with a complex folder and file structure? It will take forever to upload, and also forever to import into Sitecore - if it ever makes it into it.

I've had this issue lately, where I had toy perform a lot of data entry into Sitecore, and I had to import some 300Mb worth of images, in a very complex file structure. Breaking up the zip into smaller zips was not really an option, as even the smaller parts were practically impossible to import.

A bit of inventing googling and some glue code got me the solution to this.

Media item creation

First of all, I got a nifty function to add a file to the Media Library, given I have programmatic access to the file location, from Brian Pedersen, at https://briancaos.wordpress.com/2009/07/09/adding-a-file-to-the-sitecore-media-library-programatically/.

I changed his function just slightly to this:

        public MediaItem AddFile(string filepath, string sitecorePath, string mediaItemName)
        {
            // Create the options
            Sitecore.Resources.Media.MediaCreatorOptions options = new Sitecore.Resources.Media.MediaCreatorOptions();
            // Store the file in the database, not as a file
            options.FileBased = false;
            // Remove file extension from item name
            options.IncludeExtensionInItemName = false;
            // Overwrite any existing file with the same name
            options.OverwriteExisting = true;
            // Do not make a versioned template
            options.Versioned = false;
            // set the path
            options.Destination = sitecorePath + "/" + mediaItemName;
            // Set the database
            options.Database = Sitecore.Configuration.Factory.GetDatabase("master");

            // Now create the file
            Sitecore.Resources.Media.MediaCreator creator = new Sitecore.Resources.Media.MediaCreator();
            MediaItem mediaItem = creator.CreateFromFile(filepath, options);
            return mediaItem;
        }
This is, if you have the sitecore folder path, the new media item name and the file path, the above function will create the media item.

Media item name


To get the media item name, I opted to use the file name without the extension - i.e. what Sitecore does anyway. So I needed to take the filename and remove the last part (the extension). Just splitting on the "." would not cut it, because a name such as "image.date.jpg" is a valid filename, and I would get a "image" instead of "image.date". if I just split on the "." and took the first non empty element.

So I found a function that, given an IEnumerable, returns an IEnumerable with the last N elements removed. Here it is:

        public IEnumerable<T> SkipLastN<T>(IEnumerable<T> source, int n)
        {
            var it = source.GetEnumerator();
            bool hasRemainingItems = false;
            var cache = new Queue<T>(n + 1);

            do
            {
                if (hasRemainingItems = it.MoveNext())
                {
                    cache.Enqueue(it.Current);
                    if (cache.Count > n)
                        yield return cache.Dequeue();
                }
            } while (hasRemainingItems);
        }

Then, to get the Item name, it's just a matter of implementation of the above:
            var itemName=string.Join(".",SkipLastN(file.Name.Split(new[] {'.'}, StringSplitOptions.RemoveEmptyEntries), 1));
Where file is the FileInfo object for the file I want to upload.

File Path


That's just the "FullName" property of the FileInfo object for the file I want to upload.

Sitecore folder path

This was the trickiest one for me. I wanted to maintain the directory structure intact. So what I thought was: 
1. All files to be uploaded must ultimately exist under a single folder. This means my import mechanism would import a folder, its subfolders and files recursively. So I needed a "root" folder path for the source.

2. I might not want to put the uploaded structure directly under the Media Library folder, but under a subfolder in Sitecore. So I needed a "root" folder path for the destination in sitecore.

The end result should be that the contents of the destination root folder in sitecore should reflect the folder and file structure of the source folder one-to-one.

DirectoryInfo to the rescue! I jotted down the following:

        public void ProcessFolder(DirectoryInfo folder, string destination, HttpContext context)
        {
            context.Response.Write(string.Format("Entering {0}...{1}", destination, Environment.NewLine));
            context.Response.Flush();
            foreach(var file in folder.GetFiles())
            {
                processFile(file, destination, context);
            }
            foreach(var subfolder in folder.GetDirectories())
            {
                var sub = subfolder.FullName.Replace(folder.FullName, "").Trim('\\').Trim('/');
                ProcessFolder(subfolder, destination + "/" + sub, context);
            }
        }
The destination string is the Sitecore folder path that corresponds to the folder being processed. As you can see, the function recursively builds the destination folder path, given an initial path and an initial DirectoryInfo object. HttpContext is ther just for reporting reasons (i.e. context.Response.Write).

As for the ProcessFile function:

        public void processFile(FileInfo file, string sitecoreFolder, HttpContext context)
        {
            var itemName=string.Join(".",SkipLastN(file.Name.Split(new[] {'.'}, StringSplitOptions.RemoveEmptyEntries), 1));
            if (string.IsNullOrEmpty(itemName))
            {
                itemName=file.Name.Trim('.');
            }
            var mi = AddFile(file.FullName, sitecoreFolder, itemName);
            context.Response.Write(string.Format("created {0}{1}",mi.Path ,Environment.NewLine));
        }
You can see the IEnumerable popping mthod in action there, as well as the Addfile method.

Putting it all together


Finally, the calling code:
        public void ProcessRequest(HttpContext context)
        {
            context.Response.ContentType = "text/plain";
            var source=context.Request.QueryString["source"].Trim('/');
            if (string.IsNullOrEmpty(source))
            {
                return;
            }
            var directory=context.Server.MapPath(Sitecore.IO.TempFolder.Folder + "/"+source);
            var destination=context.Request.QueryString["destination"].Trim('/');
            if (string.IsNullOrEmpty(destination))
            {
                return;
            }
            var dest = Sitecore.Data.Database.GetDatabase("master").GetItem(Sitecore.ItemIDs.MediaLibraryRoot).Paths.Path + "/" + destination;
            ProcessFolder(new DirectoryInfo(directory), dest, context);
        }
This is just the "ProcessRequest" method of a Generic handler (ashx). I uploaded all the files, in their original directory/file structure, to the temp folder in the Sitecore installation. I then created the "root" folder in Media Library, and ran the above handler, providing source and destination as querystring parameters. So for images uploaded under /temp/MyImages, and destination in /Sitecore/Media Library/SomeSite/Images, and name of handler "ImageImporter.ashx", the url would be
ImageImporter.ashx?source=MyImages&destination=/SomeSite/Images
And that's it! Running the importer, I went through 300Mb worth of images , in about 100 folders and subfolders in less than 20 seconds.

In the link below, you may find the full code for the ImageImporter handler.

Happy coding!

ImageImporter.ashx.cs (4,1KB)

CSS Headaches: IE9 crashes unexpectedly without prompt :/

Have you ever experienced IE9 crashing without prompt while visiting your newly created web site? 
Or even worse, your newly created website is perfectly ok; except one day your client is complaining after incidentally visiting it with IE9?

Your blood becomes cold as ice and wondered what in the heck just happened. What kind of wizard needs to de-compile the entire site in order to find what when wrong...

I have just experienced all this staff, instantly heartlessly and rapidly!

My first response was to start testing the web page by removing whole blocks of staff, such as css or js. I was lucky enough and started removing css files. I quickly discover that a single css file was responsible for this headache. My next move was to remove half css contents (bulk remove), and again half of half content until finally after narrowing my search I end up with 10 lines of css "matter" and therefore I discovered a css property that made the whole headache:

A background property with calc() method:  background-position:calc(100% - 15px) 0;

I just removed it and IE9 smiled again!

So I realized once again: don't mess with IE or your head is cut off. LOL.

For reference:
Calc():  is a very nice and neat method that 
mathematically calculates mixed units such as pixes with percents or ems with percents, etc. This is a real powerful "code ammunition" that makes your life a lot easier. Don't use it unless you are sure you don't care for IE9 users though...

Cheerios,
Nick M.

Design Athens II. From print to digital. Ένα workshop από τη Linakis digital για designers.

 

To εργαστήριο από τη Linakis digital στα πλαίσια του Design Athens II στα μέσα Ιανουαρίου είχε σαν σκοπό να παρέχει τη θεωρία και κυρίως, τη σχεδιαστική πράξη, δημιουργίας ενός web site.

"Ας ξεκινήσουμε από την αρχή. Ας καταρρίψουμε ένα μύθο. Η digital επικοινωνία δεν είναι προϊόν παρθενογένεσης. Δεν είναι μια νέα τέχνη ή μια νέα εμπειρία που ξεπήδησε από ένα κενό. Τα web sites δεν δημιουργήθηκαν σε ένα αφηρημένο χώρο από άϋλα pixels κάποιο σωτήριο έτος. Δημιουργήθηκαν ως φυσική ανάγκη μίας κοινωνίας όπου πραγματικοί άνθρωποι, και όχι μηχανές, θέλησαν να επικοινωνήσουν ολοένα και περισσότερο αναμεταξύ τους". 

DesignAthensII_FromPrintToDigital.pdf (833.4KB)

ASP.NET MVC 4 Mobile Views

We recently needed to develop a mobile version for one of our Umbraco websites: www.easy972.gr. (Umbraco version 7.2.8)

Initially, we rejected the approach of using Umbraco's alternative templates for the same document type, because it was required to preserve common URLs as well as a common content tree for both the desktop and the mobile version.

We based our implementation on the following ASP.NET MVC 4 Mobile feature: 

"A significant new feature in ASP.NET MVC 4 is a simple mechanism that lets you override any view (including layouts and partial views) for mobile browsers in general, for an individual mobile browser, or for any specific browser. To provide a mobile-specific view, you can copy a view file and add .Mobile to the file name. For example, to create a mobile Index view, copy Views\Home\Index.cshtml to Views\Home\Index.Mobile.cshtml.

Source: http://www.asp.net/mvc/overview/older-versions/aspnet-mvc-4-mobile-features

Therefore, in order to implement the mobile version of the website, we created a copy of every required view/partial view with filename:  ‘[original-view-name].Mobile.cshtml’, and used the mobile html markup in those.

Having done that, we observed that when the client device was a desktop browser, the desktop version of the website was being loaded. Also when the client device was a mobile phone or tablet or the mobile browser emulator, then the mobile version of the website was displayed.

The problem was that the mobile version of the website shouldn't be displayed in tablet devices. Our client's requirement was to display the desktop version for tablets.

Solution 1:

A possible solution was to create another copy of every mobile view with filename ‘[original-view-name].Tablet.cshtml’. 

This is a fully functional solution, but with a significant drawback as far as website maintenance is concerned, because e.g. for the Homepage, we could end up with 3 files to maintain:

  • Homepage.cshtml
  • Homepage.Mobile.cshtml
  • Homepage.Tablet.cshtml

The ‘Homepage.cshtml’ would be identical to 'Homepage.Tablet.cshtml’ (code duplication), which would make it very easy to forget applying a modification made at the desktop site, to the tablet view as well.

Solution 2:

Another solution was to try and interfere with MVC 4 out of the box device detection, in order to display the desktop version to tablet clients.

This was achieved by making use of MVC 4 Display Modes, and you can refer to paragraph ‘Browser-Specific Views’ at: http://www.asp.net/mvc/overview/older-versions/aspnet-mvc-4-mobile-features. Another useful link on Display Modes: https://msdn.microsoft.com/en-us/magazine/dn296507.aspx


Here is the final C# Code that solved the problem:   

DisplayModeProvider.Instance.Modes.Insert(0, new DefaultDisplayMode("")
{
	ContextCondition = (context => this.DetectTablet(context.GetOverriddenUserAgent()) &&
        	!this.DetectMobile(context.GetOverriddenUserAgent())
                )
});

DisplayModeProvider.Instance.Modes.Insert(0, new DefaultDisplayMode("Mobile")
{
	ContextCondition = (context => this.DetectMobile(context.GetOverriddenUserAgent()) &&
        	!this.DetectTablet(context.GetOverriddenUserAgent())
                )
});

The functions DetectMobile και DetectTablet are custom device detection functions: 

protected bool DetectTablet(string userAgent)
{
	var tablets = new[] {
                    "ipad",
                    "android 3",
                    "xoom",
                    "sch-i800",
                    "tablet",
                    "kindle",
                    "playbook"
                };
        return (
        	tablets.Any(userAgent.ToLower().Contains) ||
                (userAgent.ToLower().Contains("android") && !userAgent.ToLower().Contains("mobile")));
}

 
protected bool DetectMobile(string userAgent)
{
   var mobiles = new[] { 
	"midp", "j2me", "avant", "docomo", "novarra", "palmos", "palmsource", "240x320", 
	"opwv", "chtml", "pda", "windows ce", "mmp/", "blackberry", "mib/", "symbian",
	"wireless", "nokia", "hand", "mobi", "phone", "cdm", "up.b", "audio", "sie-", "sec-",
        "samsung", "htc", "mot-", "mitsu", "sagem", "sony" , "alcatel", "lg", "eric", "vx",
        "NEC", "philips", "mmm", "xx", "panasonic", "sharp", "wap", "sch", "rover", "pocket",
        "benq", "java", "pt", "pg", "vox", "amoi", "bird", "compal", "kg", "voda",
        "sany", "kdd", "dbt", "sendo", "sgh", "gradi", "jb", "dddi", "moto", "iphone",
        "Opera Mini"
        };
 
   return (mobiles.Any(userAgent.Contains));
}



Webforms CompareValidator small gotcha

The logic behind webforms validation will never cease to amaze me.

First we have the checkbox, which is not a "validatable" control. You either have to roll out your own Validator subclass to validate a checkbox (e.g. "you must agree to the terms and conditions to proceed"), or you have to use a custom validator, without ControlToValidate field, and roll your own server AND client side logic for the validation. And let's not forget that you must explicitly tell the CustomValidator that you want to validate empty values...

I've just encountered an issue with a pair of textboxes: email and email confirmation. The page had a RequiredFieldValidator on the Email field and a CompareValidator on the EmailConfirmation field, with ControlToCompare being the Email field and operator being "equal".

The validator does not trigger if the ControlToValidate (in our case, the email confirmation field) does not contain a value...

An easy solution for my case was to switch the ControlToValidate and ControlToCompare. So both validators now validate the Email field. If you do not type any value in the Email field, the RequiredFieldValidator kicks in. If you do type a value and leave the confirmation field empty, then the CompareValidator kicks in and since the email field DOES have a value, it triggers the comparison (and returns as invalid, correctly).

Took me about an hour to figure this out...

Multilist With Search change in datasource syntax in 7.2 compared to 7.0

This is a small gotcha in multilist with search field type introduced in Sitecore 7.0.

There are two ways to pass a pre-filter (i.e. the datasource) in the field definition:

1. Regular querystring parameters
2. Lucene query

There are certain rules that apply for the querystring passed as datasource:

a. the querystring parameter StartSearchLocation must be present, and its value must be a valid ID, e.g.


StartSearchLocation={9D0686A2-5BFF-4DA0-B926-9276169D1707}

b. The lucene query part  must go into a querystring parameter called "Filter"
c. The lucene query part cannot contain spaces. Instead a pipe character ("|") is used to split the various parts, e.g.

StartSearchLocation={9D0686A2-5BFF-4DA0-B926-9276169D1707}&Filter=+_path:9D0686A25BFF4DA0B9269276169D1707|_template:47485A7EAB1A424DB6FCA1B6947B6BB6|+parsedlanguage:english

d. As per this blog post, the _templatename lucenw query parameter must NOT be preceded by a "+" sign

What I've found out, is that you should not have duplication between the "querystring" part and the Lucene query part, as of 7.2. That is, the following would function "normally" in 7.0:

StartSearchLocation={9D0686A2-5BFF-4DA0-B926-9276169D1707}&TemplateFilter={47485A7E-AB1A-424D-B6FC-A1B6947B6BB6}&Language=english&Filter=+_path:9D0686A25BFF4DA0B9269276169D1707|_template:47485A7EAB1A424DB6FCA1B6947B6BB6|+parsedlanguage:english

Notice that the querystring parameters correspond 1-1 to the lucene query parts. The actual query has 3 parameters:
a. _path (StartSearchLocation)
b. _template (TemplateFilter)
c. _parsedlanguage (Language)

However, in Sitecore 7.2, the above datasource string does not work properly. When the field first loads, it shows up properly, with all the relevant items displayed on the left ("All") pane. However, if you try a search, the ajax requests that run to fetch the filtered data return an empty result without reporting any error.

To amend this, I chose to keep only the "querystring" part, discarding the "Filter" part altogether, since I could, based on the actual query. If the query was more involved, I might have to have kept the "Filter" parameter and made sure that the two parts don't overlap at all. So, the actual working query is:

StartSearchLocation={9D0686A2-5BFF-4DA0-B926-9276169D1707}&TemplateFilter={47485A7E-AB1A-424D-B6FC-A1B6947B6BB6}&Language=english

It is rather strange though, I'd expect that if you have a list, and you  filter it, and then you re-apply the filter on the same list, you'd get the same filtered list again, not an empty set.



Umbraco MVC website for Easy FM 97.2

Introduction

This blog post is about the website of a Greek radio station: Easy 97.2 FM, that we recently delivered. Right from the start there were a couple of major requirements that we needed to take into consideration: 
  1. The playback of the Live audio stream should not be interrupted while the visitor is navigating from page to page, or is using the website e.g. submitting the contact form. 
  2. There was an area in the homepage, which was required to display the continuously updated playlist information/feed from an external data source, even when the browser was being left idle. Frequently changing information included the current song, the previous song just played, and the next few songs that will be played next. An external system is responsible to send the update to be consumed by our website in arbitrary/unknown time intervals.
You can view the final website live at: www.easy972.gr

Architectural Considerations

The website was implemented on the Open Source .NET CMS Umbraco v7.2.8 with MVC 4.

In order to satisfy the major requirement for continuous audio playback, we had to build a mechanism where page reload is prevented and the contents of the website were being sent to the server/received by the browser via AJAX calls. The plugin jQuery Address 1.6.1 was the tool used to achieve this. The jQuery Address plugin provides a 'change' event, where we could write custom extension code to make AJAX calls. 

Note that we rejected the idea of creating an IFrame for the audio player, because this would lead to poor SEO for the website. Similarly we rejected the option of opening the audio player in a pop-up window, because of the possibility to have active pop-up blockers, which would prevent the 'Auto-Play' feature, requested by Easy 97.2.

The selection of jQuery Address plugin and AJAX calls, contributed towards selecting the MVC flavour of Umbraco over Umbraco Web Forms. Using MVC we wanted to avoid the unnecessary complexities caused by ASP.NET Web Forms Page Lifecycle, when interfering with the normal communication between the server and the browser. In fact MVC has no ViewState and PostBack events. In addition we wanted to benefit from more standard MVC advantages such as Separation of Concern (SoC), full control over the rendered HTML, stateless design, easy to integrate with JavaScript frameworks, RESTful urls. Moreover, the Umbraco CMS itself is becoming more and more MVC oriented, and there exists official documentation to start with, plus various other resources online.

The second major requirement was to build a solution where the connected clients/browsers receive real-time information about the current playlist. The current playlist changes every time an external system updates a specific XML file on our web server. 

There were various ways to implement this. With ‘Interval Polling’ for example, there would be a standard interval of e.g. 5 seconds (or less to simulate online communication), to request new playlist data from the server. If the server had new playlist data to return, then this would be the time to consume it. In all other cases, we would have huge bandwidth drain and wasted cycles. This is similar to 'Long Polling' except that with Long Polling the request remains open until the server decides to return data or until the connection times out which may result in bad resource management. Another problem was that we would have to write code to handle having multiple clients requesting new playlist data.

However we decided to go forward by using ASP.NET SignalR, based on the following advantages:
  • it a library that simplifies the process of adding real-time web functionality to applications, and reduces the development effort significantly.
  • No unnecessary cycles and bandwidth drain
  • Better resource management
  • Multiple simultaneous clients
  • works cross-browser and supports multiple server-platforms, by providing fallback mechanisms.
Note: If you are not familiar with SignalR at all, feel free to get a quick hands-on picture of SignalR by watching this 5-minute demo of a real-time chatting web application, which I found online.

ISSUE 1: Achieving Continuous Audio Playback

Within our MVC website we created the first Umbraco Template (Views/BasePage.cshtml). We needed it to be the common layout cshtml file, inherited by all other pages. The BasePage.cshtml contains the audio player responsible for the playback of the radio station's audio stream, and this way we managed to have the audio player visible at the bottom part of every different page of the website.

At this point, when the visitor navigated to another page from the menu, the audio was being shortly interrupted. To solve this:

A) we modified BasePage.cshtml and added the content-section (HTML element), to be the wrapper of "updatable" areas: 

 

<head></head>

<body class="…">

 

<section class="content">

 <!-- header elements e.g. main navigation etc -->

@RenderBody()

<section class="…"> <!-- contact form etc --> </section>

</section>

 

<div class="socialWdgtBar"> <!-- feeds etc --> </div>

<section class="playlist">  </section>

<section class="businness"> </section>

<section class="footerMenu"> <!-- bottom menu, copyright etc --> </section>

<div class="bottom_area"></div>

<footer class="footer"> <!-- audio player --> </footer>

<!-- CSS, scripts -->

</body>

 


B) Added the following javascript:

i) in a central script (Functions.js):

var historyApi = !!(window.history && history.pushState);  // history API feature detection
if ($("#myRadio").length > 0) {
        // we have a player so enable jquery.address deep-linking for all internal links:
        ContentInA();
    }

ii) ContentInA function (addons.js).

Adds a 'rel' attribute to all internal links/anchors, as this is needed by the jQuery Address plugin. In addition, it processes all form submits towards the server, by using an AJAX POST request. This prevented page reload and thus audio playback interruption.

function ContentInA()
{
    if (historyApi) {
        $('a:not(.nav-trigger):not(.ui-tabs-anchor)').each(function () {
        // [...]
        // adds a rel attribute for the jquery address plugin, only for internal links:
$(this).attr("rel", "address:" + this.href.split("//")[1].replace(host, ""))
.address()
.on("click", function () {
$.address.value($(this).attr('rel').split(':')[1]);
});
        }); $("form").off("submit"); $("form").on("submit", function (e) { var thisForm = $(this); e.preventDefault(); var theQueryString = $(this).serialize(); if (thisForm.attr("method").toLowerCase() == "post") { initialPath = thisForm.attr("action"); $.post(thisForm.attr("action"), theQueryString, function (data) { $.address.value(thisForm.attr("action")); ContentInA(); var elm = $('.content')[0]; var htmlToInsert = data.replace(/^[\s\S]*<body.*?>|<div class="socialWdgtBar">[\s\S]*$/g, ''); htmlToInsert = htmlToInsert.replace('<section class="content">', ''); htmlToInsert = htmlToInsert.substring(0, htmlToInsert.lastIndexOf("</section>")) + ' '; if (elm) { elm.innerHTML = htmlToInsert; iniAll(); // then run all document ready code $.validator.unobtrusive.parse($("section.content")); } }, "html"); } else { $.address.value(thisForm.attr("action") + "?" + theQueryString); } return false; }); }


iii) To refresh just the "content" section, we included the jQuery Address plugin and added custom code for its change event. It basically fetches the new page content from the server, by using a HTTP GET request. It then locates the content section of BasePage.cshtml, by using regular expressions, and then replaces the old contents, with new page's contents:


$.address.change(function (e) {
$.get(e.value, null, function (data) {
var elm = $('.content')[0]; // find the 'content' element
var htmlToInsert = data.replace(/^[\s\S]*<body.*?>|<div class="socialWdgtBar">[\s\S]*$/g, '');
htmlToInsert = htmlToInsert.replace('<section class="content">', '');
htmlToInsert = htmlToInsert.substring(0, htmlToInsert.lastIndexOf("</section>")) + ' ';
if (elm) {
elm.innerHTML = htmlToInsert;
iniAll(); // then run all document ready code
}
ContentInA();
}, "html");
});

ISSUE 2: Updating the current playlist using SignalR

The current playlist is displayed in a dedicated area of the homepage (mainly) and is required to display real-time current playlist information from an external data source, about:
  • The Current song (displayed within the audio player area at the bottom of the site. Visible always)
  • The Previous song (displayed only in the home page, in the section 'Just Played')
  • The next six songs that will follow later (displayed only in the homepage in the section 'Playing Later')

The current playlist changes every time an external system updates a specific XML file on our web server, i.e. the external digital radio automation software i.e. ‘RCS Zetta’ for this project, was configured to update a physical xml file on our web server (nowplaying.xml), on various time intervals, depending on criteria which our website was unaware.

The website was required to detect every update of the specific XML file, parse it, and display the updated playlist in the homepage, for every connected client/browser immediately.

As we mentioned above, Microsoft ASP.NET SignalR was examined and chosen among other implementation methods.

The Approach

We used Microsoft’s SignalR framework (installation via nuget Microsoft.AspNet.SignalR), which enables real time communication between the browser and the application.


SignalR server-side / client-side overview:

The hub C# class was created at the server-side. 

  • At application start it attaches a FileSystemWatcher to monitor our special folder that contains the XML file. 
  • Code is also added to the FileSystemWatcher's OnFileChanged event, to trigger parsing of the XML file, and converting the result data to an object of type 'ListenLiveClipboard'.
  • This then triggers the update of all connected clients via the 'UpdateLiveClipboard' method.
  • The current playlist (currentClipboard) is kept within application cache
  • The server-side 'UpdateLiveClipboard' method also has a twin client-side method 'updateLiveClipboard', which works like a 'listener' for changes in the XML triggered by the FileSystemWatcher of the hub.
  • a boolean variable 'updateAllClients' is used on events: OnConnected , OnReconnected. This comes very useful when a new visitor is connected to the website. In this case, only this one client needs to receive the current playlist/currentClipboard. On the contrary, when the XML file is modified, then all the clients need to receive the new and updated playlist.
For more details, find the full code below:

Server-side code for the hub 'ListenLiveHub.cs’:

public class ListenLiveHub : Hub
{
	static ListenLiveClipboard currentClipboard
	{
		get { /* retrieve it from application cache */ }
		set { /* store it in application cache  */ }
		}
	}
	static FileSystemWatcher fsw;
	static ListenLiveHub()
	{
			fsw = new FileSystemWatcher
			{
				Filter = "ZettaXMLFileName".ToWebsiteSetting(), 
				Path = System.Web.Hosting.HostingEnvironment.MapPath("ZettaXMLFolderPath"), 
				EnableRaisingEvents = true,
				IncludeSubdirectories = false
			};
			fsw.Changed += new FileSystemEventHandler(OnFileChanged);
	}
	//Fill currentClipboard with data, the first time
	public ListenLiveHub()
	{
		   string standardFilePath = "…";
		   
			if (currentClipboard == null)
			{
				// parse xml file:
				ListenLiveClipboard newClipb = ZettaManager.GetLiveClipboardFromXmlFile(standardFilePath);
				currentClipboard = newClipb;
			}
	}
   
	// Define the event handlers: specify what is done when the file is changed.
	private static void OnFileChanged(object source, FileSystemEventArgs e)
	{
		// parse xml file and send new content to all connected clients
		this.UpdateLiveClipboard(true); 
	}
	public void UpdateLiveClipboard(bool updateAllClients)
	{
		var context = GlobalHost.ConnectionManager.GetHubContext<ListenLiveHub>();
		// serialize the current clipboard to JSON for JS transfer
		var currClipboardJson = JsonConvert.SerializeObject(currentClipboard);

		if (updateAllClients && currentClipboard != null && currentClipboard.CurrentSong != null)
		{
			context.Clients.All.updateLiveClipboard(currClipboardJson);
		}
		else if (!updateAllClients && currentClipboard != null && currentClipboard.CurrentSong != null)
		{
			context.Clients.Client(Context.ConnectionId).updateLiveClipboard(currClipboardJson);
		}
	}
	public override Task OnConnected()
	{
		this.UpdateLiveClipboard(false);
		return base.OnConnected();
	}
	public override Task OnReconnected()
	{
		this.UpdateLiveClipboard(false);
		return base.OnReconnected();
	}
}


Client-side library for SignalR, listenLive.js:

(function () {
    var listenLiveHub = $.connection.listenLiveHub;
    $.connection.hub.start();

    listenLiveHub.client.updateLiveClipboard = function (currClipboardJson) {

            var clip = jQuery.parseJSON(currClipboardJson);
            var nextSongs = clip.LaterSongList;

            var rawdata = {
                Id: clip.CurrentSong.Id,
                SongTitle: clip.CurrentSong.SongTitle,
                ArtistName: clip.CurrentSong.ArtistName,
                AlbumCoverImageUrl: clip.CurrentSong.AlbumCoverImageUrl,
                ITunesUrl: clip.CurrentSong.ITunesUrl
            }
            var previousRawdata;
            if (clip.PreviousSong != null) {
                previousRawdata = {
                    SongTitle: clip.PreviousSong.SongTitle,
                    ArtistName: clip.PreviousSong.ArtistName,
                    AlbumCoverImageUrl: clip.PreviousSong.AlbumCoverImageUrl,
                    ITunesUrl: clip.PreviousSong.ITunesUrl
                }
            }
            else {
                previousRawdata = {
                    SongTitle: "",
                    ArtistName: "",
                    AlbumCoverImageUrl: "",
                    ITunesUrl: ""
                }
            }


            var laterRawdata = [];
            for (var i = 0; i < nextSongs.length; i++) {
                laterRawdata.push({
                    SongTitle: nextSongs[i].SongTitle,
                    ArtistName: nextSongs[i].ArtistName,
                    AlbumCoverImageUrl: nextSongs[i].AlbumCoverImageUrl,
                    ITunesUrl: nextSongs[i].ITunesUrl
                });
            }

            $('#footerPlayer').livePlayer('getStreamInfo', rawdata, previousRawdata, laterRawdata);
        
    };
}());


ISSUE 3: Contact form - Client side validation


Built-in client side validation was not working even after adding references to libraries:
1. Jquery.unobtrusive-ajax.min.js
2. Jquery.validate.min.js
3. Jquery.validate.unobtrusive.min.js

It was not working because of coexistence with jQuery address plugin, which is used in the following way in our website:
Every time a url is loaded/changed in this website (even the first time), the jquery.address plugin replaces a part of the rendered result with new content, but leaves other rendered parts, such as the audio player, intact so that audio playback is never interrupted.

Solution:

We had to add code to the Jquery.address change event, so that:
When the new content is added to the “slot” (content-section in BasePage.cshtml), the client-side validators must be re-added to the “slot” by the following statement:
$.validator.unobtrusive.parse($("section.content"));


ISSUE 4: Localized validation messages (using Umbraco dictionary)


In order to localize the validation messages used by the MVC Contact form, we made use of the GitHub project https://github.com/warrenbuckley/Umbraco-Validation-Attributes.