Posted in: Comments

Alright, so it turns out EPiServer 7 (and later versions) already has a route for permanent links. It was slightly different from the one I created, so here’s an updated version of the extension method:

public static string PermanentLink(this PageData page)
{
    var url = new UriBuilder(Settings.Instance.SiteUrl);

    var languageBranch = ServiceLocator.Current.GetInstance<ILanguageBranchRepository>()
        .Load(page.Language);

    url.Path = string.Format("/{0}/link/{1}",
        languageBranch.CurrentUrlSegment,
        page.ContentGuid.ToString("N"));

    return url.Uri.AbsoluteUri;
}

So no need to register your own route.

Posted in: Comments

Update: If you’re using EPiServer 7 (or later version), please see my updated post!

If you move a page in the page tree, the url to the page will change. There are numerous available solutions to handle this, e.g. by saving the old url and set up a re-direction to the new url.

In some cases we’re sending emails with the url in the message, but what happens if we change the url to the page after the email is sent? In this case we could send a permanent link instead, that never change, even when the page is moved. This is also useful when using commenting systems from 3rd parties, like Facebook, where the comments are tied to the url of the page.

First we need a way to create the urls, in this case we’re just using an extension method:

public static string PermanentLink(this PageData page)
{
    var url = new UriBuilder(Settings.Instance.SiteUrl);

    url.Path = string.Format("/permanentlink/{0}", page.ContentGuid);

    return url.Uri.AbsoluteUri;
}

The generated link will look like http://dodavinkeln.se/permanentlink/263581a1-6575-4938-b558-e871a1864f66.

Then we need a handler that maps this url to the page again. In this case we’re going to create a Http Handler, unfortunately you can’t create a route to a Http Handler, so we also need to create a Route Handler and then return our Http Handler.

The Http Handler gets the incoming page guid from the Route Data, then we map the guid to a Page Reference and finally fetch the page and do a re-direct. ExternalUrl() is an extension method that returns the friendly url to the page, which is not covered in this post.

namespace DV.HttpHandlers
{
    using EPiServer;
    using EPiServer.Core;
    using EPiServer.ServiceLocation;
    using EPiServer.Web;
    using System;
    using System.Web;
    using System.Web.Routing;

    public class PermanentLinkRouteHandler : IRouteHandler
    {
        public IHttpHandler GetHttpHandler(RequestContext requestContext)
        {
            return new PermanentLinkHttpHandler();
        }
    }

    public class PermanentLinkHttpHandler : IHttpHandler
    {
        public void ProcessRequest(HttpContext context)
        {
            Guid guid;

            if (Guid.TryParse((string)context.Request.RequestContext.RouteData.Values["guid"], out guid))
            {
                var linkMap = PermanentLinkMapStore.Find(guid, PermanentLinkMapStore.StorePreference.Page) as PermanentContentLinkMap;

                if (linkMap != null)
                {
                    var repo = ServiceLocator.Current.GetInstance<IContentRepository>();

                    try
                    {
                        var page = repo.Get<PageData>(linkMap.ContentReference);

                        context.Response.RedirectPermanent(page.ExternalUrl(false));
                    }
                    catch (Exception)
                    {
                    }
                }
            }

            context.Response.Status = "404 Not Found";
            context.Response.StatusCode = 404;
        }

        public bool IsReusable
        {
            get
            {
                return false;
            }
        }
    }
}

We also need to register the route, we can do this in an Initializable Module or on application start in Global. In this case we’re doing it in the application start event:

namespace DV
{
    using System;
    using System.Web.Routing;
    using DV.HttpHandlers;

    public class Global : EPiServer.Global
    {
        protected void Application_Start(object sender, EventArgs e)
        {
            RouteTable.Routes.Add(new Route("permanentlink/{guid}", new PermanentLinkRouteHandler()));
        }
    }
}

We could extend the links to handle language and other parameters as well. But I leave that to you.

Posted in: Comments

Sometimes you want a small variation of a block. E.g. in one content area you want the teaser blocks to have headings and in another area no headings. You basically have two options to achieve this; use tags to select another renderer or the new layout feature in EPiServer 7.5 (which basically works as tags). Both options involves using a new renderer and you have to repeat most of the functionality in the alternative renderer. This is often unnecessary and doesn’t comply with the DRY principle.

Instead we could just pass a custom rendering setting to the rendering control:

<EPiServer:Property PropertyName="SidebarTeasers" runat="server">
	<RenderSettings ShowHeadings="true" />
</EPiServer:Property>

Unfortunately there is no way to retrieve these settings Out-of-the-Box in EPiServer, at least not that I know of. But this is quite easy to solve by just traversing the control tree and get the Property Data Control, at least in webforms.

First we need the Property Data Control:

private PropertyDataControl propertyDataControl;

protected PropertyDataControl PropertyDataControl
{
    get
    {
        if (this.propertyDataControl == null)
        {
            Control parent = this.Parent;

            while (this.propertyDataControl == null && parent != null)
            {
                this.propertyDataControl = parent as PropertyDataControl;

                parent = parent.Parent;
            }
        }

        return this.propertyDataControl;
    }
}

And from the Property Data Control we can get the rendering settings:

protected IDictionary<string, object> RenderSettings
{
    get
    {
        if (this.PropertyDataControl != null)
        {
            return this.PropertyDataControl.RenderSettings;
        }

        return new Dictionary<string, object>();
    }
}

The complete code-behind for the block control with the ShowHeadings property:

namespace DV.Views.Blocks
{
    using EPiServer.Core;
    using EPiServer.Web;
    using EPiServer.Web.PropertyControls;
    using System.Collections.Generic;
    using System.Web.UI;

    public partial class TeaserControl : BlockControlBase<TeaserBlock>
    {
        #region Fields

        private PropertyDataControl propertyDataControl;

        #endregion

        protected IDictionary<string, object> RenderSettings
        {
            get
            {
                if (this.PropertyDataControl != null)
                {
                    return this.PropertyDataControl.RenderSettings;
                }

                return new Dictionary<string, object>();
            }
        }

        protected PropertyDataControl PropertyDataControl
        {
            get
            {
                if (this.propertyDataControl == null)
                {
                    Control parent = this.Parent;

                    while (this.propertyDataControl == null && parent != null)
                    {
                        this.propertyDataControl = parent as PropertyDataControl;

                        parent = parent.Parent;
                    }
                }

                return this.propertyDataControl;
            }
        }

        protected bool ShowHeadings
        {
            get
            {
                bool showHeadings = true;

                if (this.RenderSettings.ContainsKey("ShowHeadings"))
                {
                    bool.TryParse(this.RenderSettings.ItemAs<string>("ShowHeadings"), out showHeadings);
                }

                return showHeadings;
            }
        }
    }
}

And the front-end:

<%@ Control Language="C#" AutoEventWireup="false" CodeBehind="TeaserControl.ascx.cs" Inherits="DV.Views.Blocks.TeaserControl" %>

<EPiServer:Property PropertyName="Heading" CustomTagName="h2" Visible="<%# this.ShowHeadings %>" runat="server" />

<EPiServer:Property PropertyName="Content" runat="server" />

Posted in: Comments

So you've read Joels blogpost about Unified Search and want to try it out, great! It's actually — what I think — one of the cooler features of Find when it comes to actual search and not just querying for data. Too bad it's just for EPiServer 7, luckily some of the parts are backported to EPiServer 6.

So what's missing? We have to do most of the plumbing and hook up Find correctly, so everything is indexed, and we also have to add our own filters that resembles EPiServer's FilterForVisitors. All of this could easily be done by EPiServer in a couple of days, but hey, we don't want to wait for that and it's not certain that this ever will be fully backported to EPiServer 6.

First you need to install Find, if you're going with nuget remember to specify the correct version so we don't get the EPiServer 7 Integration. You can find the instructions under My Services > Download .NET Api at find.episerver.com.

If you're using Page Type Builder you can implement the ISearchContent interface in a base class, some of the methods are implemented by default and some we want to override. In my example we're not actually implementing the interface but just overriding some of the properties by adding properties with the same name.

namespace DV.PageTypes
{
    using System;
    using PageTypeBuilder;

    public abstract class PageTypeBase : TypedPageData
    {
        public string SearchTitle
        {
            get { return this.Property.ExistsLocally("PageTitle") && this["PageTitle"] != null ? this["PageTitle"].ToString() : this.PageName; }
        }

        public string SearchHitUrl
        {
            get { return this.ExternalURL(); }
        }

        public string SearchSection
        {
            get
            {
                PageData sectionPage = this;

                while (!sectionPage.ParentLink.CompareToIgnoreWorkID(PageReference.EmptyReference) &&
                       !sectionPage.ParentLink.CompareToIgnoreWorkID(PageReference.StartPage) &&
                       !sectionPage.ParentLink.CompareToIgnoreWorkID(PageReference.RootPage))
                {
                    sectionPage = DataFactory.Instance.GetPage(sectionPage.ParentLink);
                }

                if (!sectionPage.PageLink.CompareToIgnoreWorkID(PageReference.StartPage))
                {
                    return sectionPage.PageName;
                }

                return string.Empty;
            }
        }

        public string SearchHitTypeName
        {
            get { return "Web page"; }
        }

        public string SearchTypeName
        {
            get
            {
                if (this.PageTypeID == 1)
                {
                    return "News";
                }

                if (this.PageTypeID == 2)
                {
                    return "Contact persons";
                }

                return "Other";
            }
        }

        public DateTime? SearchPublishDate
        {
            get { return this.Changed; }
        }

        public DateTime? SearchUpdateDate
        {
            get { return this.StartPublish; }
        }
    }
}

We also have to tell Find which types we want in our Unified Search, in EPiServer 7 PageData and UnifiedFile is added automatically. This is preferredly done in an InitializableModule. Notice that we also adds a filter for each type, more on that later. For UnifiedFiles we can't obviously use a base class, so here we have to go with extension methods. For some reason a property with the name Attachment is added automatically, but Unified Search expects a property with the name SearchAttachment. So first we have to remove the default and add our own.

var fileTypesToIndex = new List<string>
{
    ".txt",
    ".pdf",
    ".doc",
    ".docx",
    ".rtf",
    ".htm",
    ".html",
    ".xls",
    ".xlsx"
};

SearchClient.Instance.Conventions.UnifiedSearchRegistry.Add<PageData>().PublicSearchFilter(c => c.BuildFilter<PageData>().FilterForVisitor<PageData>());
SearchClient.Instance.Conventions.UnifiedSearchRegistry.Add<UnifiedFile>().PublicSearchFilter(c => c.BuildFilter<UnifiedFile>().FilterOnUnifiedFileReadAccess());

FileIndexer.Instance.Conventions.ShouldIndexVPPConvention = new VisibleInFilemanagerVPPIndexingConvention();
FileIndexer.Instance.Conventions.ForInstancesOf<UnifiedFile>().ShouldIndex(x => fileTypesToIndex.Contains(x.Extension));

PageIndexer.Instance.Conventions.EnablePageFilesIndexing();

SearchClient.Instance.Conventions.ForInstancesOf<UnifiedFile>()
    .ExcludeField(file => file.Attachment()) // Exclude the default Attachment
    .IncludeField(file => file.SearchFilename())
    .IncludeField(file => file.SearchFileExtension())
    .IncludeField(file => file.SearchHitUrl())
    .IncludeField(file => file.SearchAttachment()) // Include our extened Attachment
    .IncludeField(file => file.SearchPublishDate())
    .IncludeField(file => file.SearchUpdateDate())
    .IncludeField(file => file.SearchHitTypeName())
    .IncludeField(file => file.SearchSubsection())
    .IncludeField(file => file.SearchTitle());

Here are some example extensions for UnifiedFile:

namespace DV.Extensions
{
    using EPiServer;
    using EPiServer.Find;
    using EPiServer.Find.Cms;
    using EPiServer.Web.Hosting;
    using System;
    using System.Text;

    public static class UnifiedFileExtensions
    {
        public static string SearchFilename(this UnifiedFile file)
        {
            return file.VirtualPath;
        }

        public static string SearchFileExtension(this UnifiedFile file)
        {
            return file.Extension;
        }

        public static Attachment SearchAttachment(this UnifiedFile file)
        {
            return file.Attachment();
        }

        public static string SearchTitle(this UnifiedFile file)
        {
            return file.Summary != null && !string.IsNullOrWhiteSpace(file.Summary.Title) ? file.Summary.Title : file.Name;
        }

        public static string SearchHitUrl(this UnifiedFile file)
        {
            var fileUrlBuilder = new UrlBuilder(file.PermanentLinkVirtualPath);

            Global.UrlRewriteProvider.ConvertToExternal(fileUrlBuilder, null, Encoding.UTF8);

            var url = new UriBuilder(EPiServer.Configuration.Settings.Instance.SiteUrl);

            url.Path = fileUrlBuilder.ToString();

            return url.Uri.AbsoluteUri;
        }

        public static DateTime? SearchPublishDate(this UnifiedFile file)
        {
            return file.Created;
        }

        public static DateTime? SearchUpdateDate(this UnifiedFile file)
        {
            return file.Changed;
        }

        public static string SearchHitTypeName(this UnifiedFile file)
        {
            if (!string.IsNullOrEmpty(file.Extension))
            {
                var extension = file.Extension.Replace(".", string.Empty).ToLower();

                switch (extension)
                {
                    case "asp":
                    case "aspx":
                    case "html":
                    case "htm":
                    case "jsp":
                    case "php":
                        return "Webpages";
                    case "docx":
                    case "doc":
                        return "Word";
                    case "pages":
                        return "Pages";
                    case "pdf":
                        return "PDF";
                    case "pps":
                    case "ppt":
                    case "ppsx":
                    case "pptx":
                        return "PowerPoint";
                    case "key":
                        return "Keynote";
                    case "xls":
                    case "xlsx":
                        return "Excel";
                    case "rtf":
                    case "txt":
                    case "text":
                        return "Text";
                    case "bmp":
                    case "dwg":
                    case "gif":
                    case "jpg":
                    case "jpeg":
                    case "png":
                    case "psd":
                    case "tif":
                        return "Images";
                    case "7z":
                    case "zip":
                    case "zipx":
                    case "rar":
                    case "sit":
                        return "Compressed files";
                    default:
                        return extension;
                }
            }

            return string.Empty;
        }

        public static string SearchTypeName(this UnifiedFile page)
        {
            return "Other";
        }
    }
}

If you're not using Page Type Builder you can create extension methods for PageData with the same name for every property in ISearchContent and add them to the indexing conventions. The implementations are quite easy and straight forward so I'm not gonna post example code for that. SearchText is already implemented in the EPiServer.Find.Cms namespace though, so add a using statement for that namespace.

SearchClient.Instance.Conventions.ForInstancesOf<PageData>()
    .IncludeField(page => page.SearchText())
    .IncludeField(page => page.SearchHitTypeName())
    .IncludeField(page => page.SearchHitUrl())
    .IncludeField(page => page.SearchPublishDate())
    .IncludeField(page => page.SearchSection())
    .IncludeField(page => page.SearchTitle())
    .IncludeField(page => page.SearchTypeName())
    .IncludeField(page => page.SearchUpdateDate());

As I mentioned before we added filters for each type. Those are added by default in the EPiServer 7 Integration, for EPiServer 6 they don't exists. So I've backported them by using Reflector and by verifying the outgoing requests' JSON. I've uploaded this code to EPiServer World because they are about about 200 lines of code, you can find them here.

There are some caveats using Unified Search with EPiServer 6, for instance pages are not re-indexed when the access rights are changed for pages and files. To be safe, enable the scheduled indexing job, so the ACLs are updated at least every night.

If you have any questions or found any bugs, please let me know.

Hope this helps and happy searching!

Posted in: Comments

Rewrites tags, dates and pages. Supports the format /Templates/Blog.aspx?id=3&epslanguage=en&year=2012&month=5&page=2&tag=episerver

namespace DV.UrlRewriters
{
    using System;
    using System.Collections.Specialized;
    using System.Globalization;
    using System.Text;
    using System.Text.RegularExpressions;
    using System.Web;
    using EPiServer;
    using EPiServer.Core;
    using EPiServer.DataAbstraction;
    using EPiServer.Globalization;
    using EPiServer.Web;

    public class CustomUrlRewriter : FriendlyUrlRewriteProvider
    {
        /// <summary>
        /// Check if the url needs to be rewritten with our provider to an internal url
        /// </summary>
        private static bool RewriteToInternal(UrlBuilder url)
        {
            return Regex.Match(url.Path, "/tag/|/((?:19|20)\\d\\d)/(0[1-9]|1[012])/|/((?:19|20)\\d\\d)/|/page/\\d*/", RegexOptions.Compiled | RegexOptions.IgnoreCase).Success;
        }

        /// <summary>
        /// Check if the url needs to be rewritten with our provider to an external url
        /// </summary>
        private static bool RewriteToExternal(UrlBuilder url)
        {
            return url.QueryCollection["tag"] != null
                || url.QueryCollection["year"] != null
                || url.QueryCollection["month"] != null
                || url.QueryCollection["page"] != null;
        }

        public override bool TryConvertToInternal(UrlBuilder url, out CultureInfo preferredCulture, out object internalObject)
        {
            preferredCulture = null;
            internalObject = null;

            if (url == null)
            {
                return false;
            }

            if (RewriteToInternal(url))
            {
                bool isModified;

                if (base.IsVppPath(url, out isModified))
                {
                    return isModified;
                }

                if (!url.Path.EndsWith("/"))
                {
                    HttpContext.Current.Response.Redirect(url.Path + "/" + url.Query + url.Fragment);
                }

                // Get the address to the blog (remove all rewritten query parameters)
                string path = Regex.Replace(url.Path, "tag/[^/]*/|(?:19|20)\\d\\d/0[1-9]|1[012]/|(?:19|20)\\d\\d/|page/\\d*/", string.Empty, RegexOptions.Compiled | RegexOptions.IgnoreCase);
                string pathWithOutLanguage = string.Empty;

                LanguageBranch languageBranch = GetLanguageBranchAndPath(path, out pathWithOutLanguage);

                if (languageBranch == null)
                {
                    languageBranch = LanguageBranch.Load(ContentLanguage.PreferredCulture);
                }
                else
                {
                    ContentLanguage.PreferredCulture = languageBranch.Culture;
                }

                preferredCulture = languageBranch.Culture;

                // Get the blog page
                NameValueCollection queryCollection = url.QueryCollection;
                PageData blogPage = null;
                PageReference blogPageReference = null;

                if (PageReference.TryParse(queryCollection["id"], out blogPageReference))
                {
                    if ((blogPageReference.WorkID > 0) || !string.IsNullOrEmpty(blogPageReference.RemoteSite))
                    {
                        blogPage = DataFactory.Instance.GetPage(blogPageReference);
                    }

                    queryCollection.Remove("id");
                }

                if (blogPage == null)
                {
                    blogPage = GetPageFromStartByPath(pathWithOutLanguage, languageBranch);
                }

                // Add query parameters
                queryCollection.Add("id", blogPage.PageLink.ToString());
                queryCollection.Add("epslanguage", ContentLanguage.PreferredCulture.Name);

                Match tag = Regex.Match(url.Path, "/tag/([^/]*)/", RegexOptions.Compiled | RegexOptions.IgnoreCase);
                Match dates = Regex.Match(url.Path, "/((?:19|20)\\d\\d)/(0[1-9]|1[012])/|/((?:19|20)\\d\\d)/", RegexOptions.Compiled | RegexOptions.IgnoreCase);
                Match page = Regex.Match(url.Path, "/page/(\\d*)/", RegexOptions.Compiled | RegexOptions.IgnoreCase);

                if (tag.Success)
                {
                    queryCollection.Add("tag", tag.Groups[1].Value);
                }

                if (dates.Success)
                {
                    if (!string.IsNullOrEmpty(dates.Groups[1].Value) && !string.IsNullOrEmpty(dates.Groups[2].Value))
                    {
                        queryCollection.Add("year", dates.Groups[1].Value);
                        queryCollection.Add("month", dates.Groups[2].Value);
                    }
                    else if (!string.IsNullOrEmpty(dates.Groups[3].Value))
                    {
                        queryCollection.Add("year", dates.Groups[3].Value);
                    }
                }

                if (page.Success)
                {
                    queryCollection.Add("page", page.Groups[1].Value);
                }


                // Set the address to the internal address
                url.Path = new Url(blogPage.StaticLinkURL).Path;
                internalObject = blogPage.PageLink;

                return true;
            }
            else
            {
                return base.TryConvertToInternal(url, out preferredCulture, out internalObject);
            }
        }

        protected override bool ConvertToExternalInternal(UrlBuilder url, object internalObject, Encoding toEncoding)
        {
            try
            {
                if (RewriteToExternal(url))
                {
                    PageData blogPage = DataFactory.Instance.GetPage(internalObject as PageReference);
                    UrlBuilder urlToBlog = new UrlBuilder(blogPage.LinkURL);

                    Global.UrlRewriteProvider.ConvertToExternal(urlToBlog, blogPage.PageLink, UTF8Encoding.UTF8);

                    string friendlyUrl = urlToBlog.Path;

                    if (url.QueryCollection["tag"] != null)
                    {
                        friendlyUrl = string.Concat(friendlyUrl, "tag/", url.QueryCollection["tag"].ToLower(), "/");
                    }

                    if (url.QueryCollection["year"] != null)
                    {
                        friendlyUrl = string.Concat(friendlyUrl, url.QueryCollection["year"], "/");
                    }

                    if (url.QueryCollection["month"] != null)
                    {
                        friendlyUrl = string.Concat(friendlyUrl, url.QueryCollection["month"], "/");
                    }

                    // Allways check for paging
                    if (url.QueryCollection["page"] != null)
                    {
                        friendlyUrl = string.Concat(friendlyUrl, "page/", url.QueryCollection["page"], "/");
                    }

                    url.Path = friendlyUrl;
                    url.QueryCollection.Clear();

                    return true;
                }
                else
                {
                    return base.ConvertToExternalInternal(url, internalObject, toEncoding);
                }
            }
            catch (Exception)
            {
                return false;
            }
        }
    }
}