Microsoft Search – work with audience targeted content

The launch of Viva Connections has once again put Microsoft SharePoint in the center of attention when it comes to building intranets or digital workplaces in Microsoft 365, especially for smaller businesses. One key success factor when creating a modern intranet is to provide users with relevant content that provides value to them in their day-to-day work.

Modern SharePoint Online contains functionality for targeting different types of content to different users. You target content by selecting one or more Azure AD groups and the content is then displayed only to members of those groups.

You can target many different types of content, including documents, quick links, calendar events and perhaps most importantly: News articles.

It is very easy to activate audience targeting, apply it to content, and configure e.g. the News webpart in SharePoint to adhere to the targeting when listing news articles. But what about the search experience?

The default “News” vertical in Microsoft Search lists all news articles the user has permissions to read. This might not be the desired behavior if you are looking at giving your users a relevant search experience. Luckily we can fix this.

In the following sections we will setup two custom search verticals: My News and Other News to make sure that it is easy for users to find news that are relevant to them.

Creating the My News search vertical

To create a new search vertical in Microsoft Search you must be a Search Administrator or Global Administrator in the Microsoft 365 tenant. Navigate to https://admin.microsoft.com/Adminportal/Home#/MicrosoftSearch/verticals. This is the interface where you can modify existing search verticals or create new ones.

Screenshot of the Search verticals section in Microsoft search

Click on “Add” to start creating a new vertical.

The first step is to give the vertical a name, let’s call it “My News”.

Screenshot of the Vertical name step

Click Next to go to the Content source selection step.

Make sure “SharePoint” is selected and click Next.

Screenshot of the Content source step

Now we have arrived at the most important step, the Query section. It is here we have the chance to enter a KQL query to define which types of search results we want to show in our search vertical. Enter the following text in the text box:

PromotedState:2 AND 
(ModernAudienceAadObjectIds:{User.Audiences} OR NOT IsAudienceTargeted:true)
Screenshot of the Query step

Let’s break this query down, bit by bit:

  • PromotedState:2 tells the search engine to only return modern SharePoint news articles.
  • ModernAudienceAadObjectIds:{User.Audiences} is the magic part, telling the search engine to only fetch articles that are tageted to the current user.
  • OR NOT IsAudienceTageted:true , makes sure that articles without targeted is also fetched.

Click Next to go to the Filter section. This gives you the option to add a list of filter that users can use from to further narrow the search results.

Click Next again to go to the Review step. Make sure the information is correct, and then click on Add Vertical.

Screen shot of the review step

Finally, the vertical needs to be enabled. Do this by clicking on the Enable vertical button.

Screenshot of the success screen

Creating the Other News search verticals

To create a search vertical that include only news articles that are not targeted to the current user (the news articles that we do not show in the My News vertical), follow the steps outlined in the section above but change the Query to look like this:

PromotedState:2 AND IsAudienceTargeted:true AND NOT ModernAudienceAadObjectIds:{User.Audiences}

Testing the new verticals

It can take some time (hours) for new or updated search verticals to show up to end users.

If you want to see them right away, navigate to the search results page and append cacheClear=true to the URL, e.g. https://m365xcontoso.sharepoint.com/_layouts/15/search.aspx?q=*&cacheClear=true

Now you are ready to perform a search and try out the two new verticals!

Fix broken Managed metadata service and more after CU update

After installing the September CU for SharePoint 2013 Server I have several problems.

The two most annoying things:

  • The Managed Metadata Service went down, giving me no managed navigation and no access to term store.
  • The User Profile Synchronization Service stopped and refused to start.

After trying numerous things (including recreation of the Managed Metadata Service application…) I found this message in the ULS logs:

w3wp.exe (0x1D78) 0x21A0 SharePoint Server Taxonomy ca3r Monitorable Error encountered in background cache check System.Security.SecurityException: Requested registry access is not allowed.     at Microsoft.Win32.RegistryKey.OpenSubKey(String name, Boolean writable)

Googling this error gave me some input! There is a command in psconfig.exe with the purpose of setting correct permissions on SharePoint registry keys.

Running

psconfig -cmd secureresources

resolved all my issues.

Here is the blog post that guided me to the correct solution: http://sharepointologic.blogspot.se/2014/02/managed-metadata-service-not-working.html

Managed metadata and KQL Search

Using metadata on your information is a great way of categorizing it. It also enables you to drill up and down in information by the means of the term hierarchy.

By leveraging search, you can gather information from all the site collections, even web applications in your farm (as long as they use the same Search Service Application at least).

Let’s take a look at a basic example of how to create a method that using KQL will gather all items tagged with a specific term.

First, here is the complete method:

public DataTable GetItemsByTag(Term term, string[] selectProperties, string filterManagedProperyName)
{
     KeywordQuery kq = new KeywordQuery(SPContext.Current.Site);
     kq.TrimDuplicates = false;
     kq.SelectProperties.AddRange(selectProperties);
     kq.QueryText = String.Format("{0}:\"GP0|#{1}\"", term.TermGuid, filterManagedProperyName)

     SearchExecutor se = new SearchExecutor();
     var result = se.ExecuteQuery(kq);
     return result.Filter("TableType", KnownTableTypes.RelevantResults).FirstOrDefault().Table;
}

So, a very basic method that just returns the relevant results based on the term passed in. Of course, to be able to render the results we later on would have to iterate the DataTable rows and perhaps generate some more easy to handle objects that we could pass to our view manager.

Some clarifications about the code:
“filterManagedProperyName” should the the name of the managed property created for the metadata column (the one with ows_taxId in it).

The “GP0|#” part of the QueryText makes sure we only get items tagged with the passed in term. If you do not add “GP0|#” in from of the term GUID, you will also get results for the parent terms (since in the managed property also parts of the term hierarchy is stored)

That was it for today!

Avoid duplicated webparts on Page Layout feature reactivation

We’ve all been there:
You have created some nice Page Layouts, added some webparts to the WebPartZones via the Elements.xml file, deployed the solution and activated the feature.

Now it is just a matter of time before the client asks for a new Page Layout, or for a change in one of the existing one.

“Fine”, you think – makes the changes, builds, uploads the .wsp to the server, runs Update-SPSolution and reactivates the feature.

Disaster! All your webparts now are added two times to all new pages created! The client gets furious and you can’t stop hitting yourself.

Don’t worry! There is a quite easy solution to this problem:

Add a FeatureReceiver to the feature containing your Page Layouts by “Right Click on the Feature in Visual Studio -> Add Event Receiver”

In the FeatureActivatedEvent write the following:

public override void FeatureActivated(SPFeatureReceiverProperties properties)
{
    var site = properties.Feature.Parent as SPSite;
    FixDuplicateWebParts(site, "YOURPAGELAYOUTPREFIX");
}

Replace YOURPAGELAYOUTPREFIX with the prefix you hopefully have used in the filename of your webparts.

Now all you have to do is implementing the method

        private void FixDuplicateWebParts(SPSite site, string prefix)
        {
            try
            {
                SPList list = site.GetCatalog(SPListTemplateType.MasterPageCatalog);
                SPListItemCollection items = list.Items;
                List<string> webParts = new List<string>();

                // find the right Page Layout
                for (var i = items.Count - 1; i >= 0; i--)
                {
                    var item = items[i];
                    if (item.Name.IndexOf(prefix,
                        StringComparison.CurrentCultureIgnoreCase) > -1 && item.Name.IndexOf(".aspx",
                        StringComparison.CurrentCultureIgnoreCase) > -1)
                    {
                        SPFile file = item.File;
                        file.CheckOut();
                        // get the Web Part Manager for the Page Layout
                        using (SPLimitedWebPartManager wpm =
                            file.GetLimitedWebPartManager(PersonalizationScope.Shared))
                        {
                            // iterate through all Web Parts and remove duplicates
                            int nbrOfWebParts = wpm.WebParts.Count - 1;
                            for (var j = nbrOfWebParts; j >= 0; j--)
                            {
                                using (var stringWriter = new StringWriter())
                                {
                                    using (var xw = new XmlTextWriter(stringWriter))
                                    {
                                        System.Web.UI.WebControls.WebParts.WebPart wp =
                                        wpm.WebParts[j];

                                        wpm.ExportWebPart(wp, xw);
                                        xw.Flush();

                                        string md5Hash = GetMD5(stringWriter.ToString());
                                        if (webParts.Contains(md5Hash))
                                        {
                                            wpm.DeleteWebPart(wp);
                                        }
                                        else
                                        {
                                            webParts.Add(md5Hash);
                                        }
                                    }
                                }
                            }
                        }
                    }
                }

                file.CheckIn(string.Empty);
                if ((file.Level == SPFileLevel.Draft) && (file.CheckOutType == SPFile.SPCheckOutType.None))
                {
                    file.Publish(string.Empty);

                    try
                    {
                        if (file.Item.ModerationInformation.Status == SPModerationStatusType.Pending)
                        {
                            file.Approve(string.Empty);
                        }
                    }
                    catch (System.Exception)
                    { //Suppress 
                    }
                }
            }
            catch (Exception e)
            {
                Logger.WriteLog(Logger.Category.Unexpected, this.ToString(), e.Message);
            }
        }

This code in turn requires this method:

        public static string GetMD5(string Value)
        {
            if (Value == "") return "";
            MD5CryptoServiceProvider x = new MD5CryptoServiceProvider();
            byte[] bs = System.Text.Encoding.UTF8.GetBytes(Value);
            bs = x.ComputeHash(bs);
            x.Clear();  // dispose
            System.Text.StringBuilder s = new System.Text.StringBuilder();

            foreach (byte b in bs)
            {
                s.Append(b.ToString("x2").ToLower());
            }

            return s.ToString();
        }

That’s it! When you reactivate the feature it makes sure that not two identical WebParts will be present in a Page Layout.

Targeted Provisioning of Document Templates with XML

I had a request to provision Document Templates to Document Libraries in SharePoint 2013. The requirement included the ability to relatively easy add or update the Document Templates, as well as target a Document Template to all Site Collections, or only to Site Collections under a specific Managed path.

The solution is based on an XML file, that was added to the CONFIG folder in the 15 hive, template office documents added to the Layouts folder and an Event Receiver that parses the XML and creates appropriate Content Types for the Document Templates.

The XML looks like this:

<?xml version="1.0" encoding="utf-8" ?>
<DocumentTemplates Prefix="Baseic" ParentContentType="0x01010055DCB6CC4FB44A548DEE1113E5000917">
  <DocumentTemplate Target="collaboration">
    <ContentType Name="Collaboration Guideline" Group="Collaboration" Description="Create a Collaboration Guide" />
    <Template Location="/_layouts/15/Baseic.2013/DocumentTemplates/CollabGuide.docx" />
  </DocumentTemplate>
  <DocumentTemplate>
    <ContentType Name="Protocol" Group="Common" Description="Create a protocol" />
    <Template Location="/_layouts/15/Baseic.2013/DocumentTemplates/Protocol.docx" />
  </DocumentTemplate>
</DocumentTemplates>

As you can see, I specify a Parent Content Type that all the created ones will inherit from. I do this because in my scenario all documents in the solution must have a couple of fields added to them. I also specify a Prefix that will be added before the actual Content Type name.

For each Document Template we specify a Content Type and a Template.

  • The Content Type Node contains information about what the Content Type will be called, to which group it will be added and a description.
  • The Template Node specifies the location of the document to add as a template to the new Content Type

This file will be uploaded to a subfolder of the CONFIG directory (C:\Program Files\Common Files\microsoft shared\Web Server Extensions\15\CONFIG).

The template files will, as you can see in the XML be added to a subfolder of the Layouts folder (C:\Program Files\Common Files\microsoft shared\Web Server Extensions\15\TEMPLATE\LAYOUTS)

Now to the magic: The feature event receiver that makes it all happen!

The feature is Site Scoped, since we will really just want to add our Content Types to the root Web.

using System;
using System.Runtime.InteropServices;
using System.Security.Permissions;
using Microsoft.SharePoint;
using Microsoft.SharePoint.Utilities;
using System.IO;
using System.Linq;
using System.Xml.Linq;

namespace Baseic.2013.Features.ProvisionDocTemplates
{
    [Guid("9798f1d6-4e4a-407d-8989-ad91e9f04e03")]
    public class ProvisionDocTemplatesEventReceiver : SPFeatureReceiver
    {
        public override void FeatureActivated(SPFeatureReceiverProperties properties)
        {
            // Get Rootweb
            var site = new SPSite((properties.Feature.Parent as SPSite).ID);
            var web = site.OpenWeb();

            // Locate XML file with definitions
            var definitionsFile = SPUtility.GetVersionedGenericSetupPath("CONFIG\\Baseic\\DocTemplatesDefinition.xml", 15);
            
            // Parse file
            XDocument definitionsDocument;
            using (StreamReader oReader = new StreamReader(definitionsFile))
            {
                definitionsDocument = XDocument.Load(oReader);
            };

            var contentTypePrefix = (from c in definitionsDocument.Descendants("DocumentTemplates") select c).First().Attribute("Prefix").Value;
            var parentContentTypeId = (from c in definitionsDocument.Descendants("DocumentTemplates") select c).First().Attribute("ParentContentType").Value;
            var documentTemplates = from c in definitionsDocument.Descendants("DocumentTemplate") select c;

            // Get the parent content type from web
            var parentContentType = web.AvailableContentTypes[new SPContentTypeId(parentContentTypeId)];

            if (parentContentType == null)
            {
                // Logger.WriteError(this.ToString(), "Failed to apply content type: Failed to find parent content type");
                web.Dispose();
                site.Dispose();
                return;
            }

            // Call provisioning method for each CT to deploy 
            foreach (var docTemplate in documentTemplates)
            {
                var target = docTemplate.Attribute("Target");

                // Depending on the Target, provison or not
                if (target == null)
                {
                    CreateContentTypeAndAddDocTemplate(web, contentTypePrefix, parentContentType, docTemplate);
                }
                else if (site.ServerRelativeUrl.Split('/')[1].ToLowerInvariant().Equals(target.Value.ToLowerInvariant())) // the split gives us the managed path of the current sitecollection
                {
                    CreateContentTypeAndAddDocTemplate(web, contentTypePrefix, parentContentType, docTemplate);
                }
            }

            // Make sure to dispose
            web.Update();
            web.Dispose();
            site.Dispose();
        }

        private void CreateContentTypeAndAddDocTemplate(SPWeb web, string contentTypePrefix, SPContentType parentContentType, XElement docTemplate)
        {
            try
            {
                // Parse node data
                var contentTypeNode = (from c in docTemplate.Descendants("ContentType") select c).First();

                var newContentTypeName = string.Concat(contentTypePrefix, " ", contentTypeNode.Attribute("Name").Value);
                var newContentTypeGroup = contentTypeNode.Attribute("Group").Value;
                var newContentTypeDescription = contentTypeNode.Attribute("Description").Value;

                var docTemplateLocation = (from c in docTemplate.Descendants("Template") select c).First().Attribute("Location").Value;

                // Add or update Content Type
                var contentType = web.AvailableContentTypes[newContentTypeName]; 
  
                if (contentType == null) // Does not exist
                {
                    var newContentType = new SPContentType(parentContentType, web.ContentTypes, newContentTypeName);
                    web.ContentTypes.Add(newContentType);

                    newContentType.DocumentTemplate = docTemplateLocation;
                    newContentType.Group = newContentTypeGroup;
                    newContentType.Description = newContentTypeDescription;

                    newContentType.Update(true);
                }
                else // Does exists, update
                {
                    contentType = web.ContentTypes[contentType.Id];

                    contentType.DocumentTemplate = docTemplateLocation;
                    contentType.Group = newContentTypeGroup;
                    contentType.Description = newContentTypeDescription;

                    contentType.Update(true);
                }
            }
            catch (Exception e)
            {
                // Logger.WriteError(this.ToString(), e.Message);
            }
        }
    }
}

So basically, as you can see, it all comes down to using some nice Linq statements to get the correct stuff out of the XML file and then create or update Content Types on the root Web.

You can activate the feature through e.g. the WebTemplate’s ONET.XML or PowerShell.

A heads up though, this code requires that the specified Parent Content Type does already exist on the root Web.

For part two in this blog post I will show you how to bind these new Content Types to a Document Library, using the same XML file!

Best of luck
Robert

The First Post

In this blog I will write about findings, problems and solutions related to SharePoint development