Unified Search for different languages in Optimizely CMS for Ajax Requests

Unified Search for different languages in Optimizely CMS for Ajax Requests

In this blog post, I will explain a little bit how we handle ajax search queries that use unified search for a site with several languages. The main issue we are trying to address is that each site with its own language should only return results for the language the user is in and unfortunately if you are using ajax for these queries the current language in the API controller cannot be trusted. Therefore, the language should be sent directly in the ajax request. So without further due. Lets begin.

First, we are going to show you the view. In this case is only displaying a react component which renders the results, the important thing to take away from this code is the data endpoint for the search Api which includes the line ContentLanguage.PreferredCulture.Name which holds the current language for the user.

<div class="search-results container container--sm"
     data-module="searchResults"
     data-endpoint="@($"{Url.Action("Search", "SearchApi").ExternalApiUrl()}")?q=@Model.Query&lang=@ContentLanguage.PreferredCulture.Name.ToLower()"
     data-heading1="@Model.CurrentContent.HeadingLine1"
     data-heading2="@Html.Raw(Model.CurrentContent.HeadingLine2.Replace("{term}", "&lt;em&gt;" + Model.Query + "&lt;/em&gt;"))"
     data-no-results="@Model.CurrentContent.NoResultsMessage"
     data-labels='{
        "previous": "@Model.LabelSettings.PreviousLabel",
        "next": "@Model.LabelSettings.NextLabel"
      }'>
</div>

Second, we have the search Api controller which handles the view request and it sends the query and language to the search service. We could include the whole code inside the service in the controller, but is always better to keep the controller dumb. We are using HttpUtility.HtmlDecode to avoid issues when some pages use special characters but this can be improved if we use projections instead.

public class SearchApiController : Controller
    {
        private readonly ISearchService _searchService;

        public SearchApiController(ISearchService searchService)
        {
            _searchService = searchService;
        }

        [HttpGet]
        public ActionResult Search(string q, string lang, int page)
        {
            var filterOption = new FilterOptionViewModel
            {
                Q = q,
                Lang = lang,
                Page = page,
                PageSize = 9,
                HighlightTitle = false,
                HighlightExcerpt = true,
                IncludeImagesContent = false,
                SearchContent = true,
                TrackData = false
            };

            var results = _searchService.SearchContent(filterOption);

            var model = new
            {
                totalPages = results.FilterOption.TotalPages,
                items = results.Hits.Hits.Select(
                    x => new
                    {
                        url = x.Document.Url,
                        heading = HttpUtility.HtmlDecode(x.Document.Title.Replace("&amp;", "&")),
                        description = HttpUtility.HtmlDecode(Regex.Replace(x.Document.Excerpt, "{[\\w,<,>,/,\\s]*}", "", RegexOptions.IgnoreCase).Replace("_blank", "").Replace("&amp;", "&"))
                    })
            };

            return Json(model, JsonRequestBehavior.AllowGet);
        }
    }

The model FilterOptionViewModel class have several fields to be used as filters in the search service. The code is the following:

  public class FilterOptionViewModel
    {
        public int Page { get; set; }
        public string Q { get; set; }
        public string Lang { get; set; }
        public int TotalCount { get; set; }
        public int TotalPages { get; set; }
        public int PageSize { get; set; } = 15;
        public bool HighlightTitle { get; set; }
        public bool HighlightExcerpt { get; set; }
        public string SectionFilter { get; set; }
        public bool SearchContent { get; set; }
        public bool IncludeImagesContent { get; set; }
        public bool TrackData { get; set; } = true;
    }

We also need the model that the search service returns called ContentSearchViewModel.

  public class ContentSearchViewModel
    {
        public UnifiedSearchResults Hits { get; set; }
        public FilterOptionViewModel FilterOption { get; set; }
    }

The UnifiedSearchResults is a class from Optimizely CMS and FilterOptionViewModel class have data related to the results of the query like current page, total count and so on.

 public class FilterOptionViewModel
    {
        public int Page { get; set; }
        public string Q { get; set; }
        public string Lang { get; set; }
        public int TotalCount { get; set; }
        public int TotalPages { get; set; }
        public int PageSize { get; set; } = 15;
        public bool HighlightTitle { get; set; }
        public bool HighlightExcerpt { get; set; }
        public string SectionFilter { get; set; }
        public bool SearchContent { get; set; }
        public bool IncludeImagesContent { get; set; }
        public bool TrackData { get; set; } = true;
    }

Finally, the search service class which implements the interface ISearchService and uses the unified search api comes into place. There are two important pieces for making this work in a site with different languages. The first one, is getting the unified search language from what the view sent us, shown in line:

var mainLanguage = SearchHelper.GetCurrentLanguage(filterOptions.Lang); 

The second one, is to set that language in the unified search constructor so is aware that we only need the results for that specific language and that query, as shown in line:

_findClient.UnifiedSearchFor(filterOptions.Q, mainLanguage)

Everything else is a common unified search query including do not track functionality and some filters.

   public interface ISearchService
    {
        ContentSearchViewModel SearchContent(FilterOptionViewModel filterOptions);
    }

    public class SearchService : ISearchService
    {
        private readonly IClient _findClient;

        public SearchService(IClient findClient)
        {
            _findClient = findClient;
        }

        public ContentSearchViewModel SearchContent(FilterOptionViewModel filterOptions)
        {
            var model = new ContentSearchViewModel
            {
                FilterOption = filterOptions
            };

            if (!filterOptions.Q.IsNullOrEmpty())
            {
                _findClient.Conventions.UnifiedSearchRegistry
                    .ForInstanceOf<FoundationPageData>()
                    .ProjectTitleFrom(x => x.Heading);

                var siteId = SiteDefinition.Current.Id;
                var mainLanguage = SearchHelper.GetCurrentLanguage(filterOptions.Lang); // Get current language
                var query = _findClient.UnifiedSearchFor(filterOptions.Q, mainLanguage)
                    .UsingSynonyms()
                    .TermsFacetFor(x => x.SearchSection)
                    .FilterFacet("AllSections", x => x.SearchSection.Exists())
                    .Filter(x => (x.MatchTypeHierarchy(typeof(FoundationPageData)) & (((FoundationPageData)x).SiteId().Match(siteId.ToString())) | (x.MatchTypeHierarchy(typeof(PageData)) & x.MatchTypeHierarchy(typeof(MediaData)))))
                    .Skip((filterOptions.Page - 1) * filterOptions.PageSize)
                    .Take(filterOptions.PageSize)
                    .ApplyBestBets();

                //Include images in search results
                if (!filterOptions.IncludeImagesContent)
                {
                    query = query.Filter(x => !x.MatchType(typeof(ImageMediaData)));
                }

                //Exclude content from search
                query = query.Filter(x => !(x as FoundationPageData).ExcludeFromSearch.Exists() | (x as FoundationPageData).ExcludeFromSearch.Match(false));

                // obey DNT
                var doNotTrackHeader = System.Web.HttpContext.Current.Request.Headers.Get("DNT");
                if ((doNotTrackHeader == null || doNotTrackHeader.Equals("0")) && filterOptions.TrackData)
                {
                    query = query.Track();
                }

                if (!string.IsNullOrWhiteSpace(filterOptions.SectionFilter))
                {
                    query = query.FilterHits(x => x.SearchSection.Match(filterOptions.SectionFilter));
                }

                var hitSpec = new HitSpecification
                {
                    HighlightTitle = filterOptions.HighlightTitle,
                    HighlightExcerpt = filterOptions.HighlightExcerpt
                };

                model.Hits = query.StaticallyCacheFor(TimeSpan.FromMinutes(5)).GetResult(hitSpec);
                filterOptions.TotalCount = model.Hits.TotalMatching;
                filterOptions.TotalPages = (int)Math.Ceiling((double)model.Hits.TotalMatching / filterOptions.PageSize);
            }

            return model;
        }
    }

The search service uses the SearchHelper.GetCurrentLanguage method which only check for some specific cases we have identified are problematic. For instance, if your site has as master language en-gb, the language to use should be Language.None instead of Language.English which the method _findClient.Service.Settings.Languages.GetSupportedLanguage(lang) returns. “If you have any recommendations to avoid this issue you are more than welcome to add comments at the end of this blog”.

public static class SearchHelper
    {
        private static readonly Injected<IClient> _findClient;
        private static readonly Injected<ILanguageBranchRepository> _languageBranchRepository;

        public static CultureInfo GetCurrentLanguageCms(string lang)
        {
            var languages = _languageBranchRepository.Service.ListEnabled();
            return languages?.SingleOrDefault(x => string.Equals(x.LanguageID, lang, StringComparison.CurrentCultureIgnoreCase))?.Culture;
        }

        public static Language GetCurrentLanguage(string lang)
        {
            // Weird issue , en-gb does not return results properly unless set to Language.None
            if (lang == "en-gb")
            {
                return Language.None;
            }

            return _findClient.Service.Settings.Languages.GetSupportedLanguage(lang) ?? Language.None;
        }

        public static bool FilterLanguage(string lang)
        {
            // Weird issue , en-gb does not return results properly unless set to Language.None
            return !lang.Equals("en-gb", StringComparison.InvariantCultureIgnoreCase);
        }
    }

And that is it. Now, you can user ajax queries to search for content using unified search without losing the language of the user. If you have any question let me know. I hope it will help someone and as always keep learning !!!

Written by:

Jorge Cardenas

Developer with several years of experience who is passionate about technology and how to solve problems through it.

View All Posts

1 COMMENT

Leave a Reply