Tuesday, July 24, 2018

Custom Sitecore Field: Google Maps Autocomplete

Maps are everywhere.  Most modern web designs often include some kind of map functionality whenever points of interest are involved.  If you've ever built a template that houses location data (stores, events, etc), you'd likely included latitude and longitude data points as Single-Line Text fields on the relevant Sitecore template.

However, this approach still forces the Content Author / Marketer to discover the latitude and longitude points based on an address themselves.

While this isn't too difficult to manage, we wanted to make eliminate this step - and make it easier for the author to ensure they're getting reliable latitude/longitude points every time without the need for any external lat/long discovery.

We've seen other examples (Google Map Location Picker Field or  this older module) exist where maps selectors are used (many of which are pretty old now!), but none that strictly use the Google Maps Autocomplete API.
If you're looking for some code on how to achieve this - you've come to the right place!

GOAL

Create a custom Sitecore Field Type integrated with Google Maps Places Autocomplete API that automatically retrieves and stores Latitude and Longitude points based on an inputted address.

Injecting Scripts to the Content Editor

In order to consume the Google Maps Autocomplete API, our page will need to render a script tag referencing the API in the Content Editor.  Luckily, Sitecore allows us to inject custom styles and scripts to the Content Editor as needed.  In this case, there are two script tags we'll need to add:
    1. The Google Maps Autocomplete API library
    2. A custom Javascript file with methods that initialize, geolocate, and fill in the Latitude and Longitude field

Our patched config entries include a new pipeline:

In our pipeline, we loop through the resource definitions set in the config and process new script tags to be added when the content editor loads:

New Template Field Type Item

Our Autocomplete Address Template Field Type is defined in the Core DB:


The Control field value is set to customfieldtypes:AutocompleteAddress. This means two things:
  1. We'll need to register customfieldtype as a new controlSource
  2. A new AutocompleteAddress class which inherits from the Sitecore.Web.UI.HtmlControls.Control class. 

Template Field Type Code and Config

The config entry to define a new control is pretty straightforward:


The corresponding control code:
In this approach, our Control code actually creates three text boxes on the fly (Address, Latitude, Longitude).  The Address text box control will act as the autocomplete which interacts with the API.

Once an address is selected from the autocomplete selections - the custom JS retrieves the address's latitude and longitude points.  These lat/long values will be mapped to the two additional text boxes, then stored as a NameValueCollection string in the raw value of the Autocomplete field type in the following string format:

Address=2104 North Clark Street, Chicago, IL, USA&Latitude=41.920385&Longitude=-87.637572

To hook the Address textbox to our custom script we're simply adding an onfocus attribute 'javascript:autoCompleteAddress.initAutocomplete' to the Address text box control.

The custom JS handles the rest of the interaction to call the API and populate the Latitude and Longitude textbox controls with the right values.


Utilizing the Field Value

When it's time to use the latitude/longitude points on the front-end, we utilize a StringExtension method to parse the necessary values.

Full source for this implementation can be found here:
https://github.com/strezag/GoogleMapsAutocompleteCustomSitecoreField

While this was built using the latest version of Sitecore (v9 Update-2 at the time of this post), we were able to verify this on a local copy of 8.2.  My guess is that this probably would work for even earlier versions (even if it's just a few tweaks).

Feel free to grab a copy and modify it to your own specs!

Thursday, April 12, 2018

Sitecore 9 Machine Prerequisites Check with PowerShell

Part of setting up Sitecore 9 for the first time requires you to install and confirm the machine's hardware, software, windows features, and SIF prerequisites are all met.

Going through the prerequisite checklist can be a bit daunting when planning to set up Sitecore 9 on an existing workstation (where you may already have some of the prerequisites met) or on a VM/new workstation (where you'll need to install new software) to ensure Sitecore 9 runs smoothly.

To make this process a little easier, I've written a PowerShell script that checks for the prerequisites as defined in the  Sitecore 9 Installation Guide and Sitecore Compatibility Table and reports if you're missing anything. 

As a bonus, it'll apply the IIS_IUSRS Modify permissions to folders defined in section 2.3.1 File System Permissions of the Sitecore 9 Installation Guide wherever applicable, ensure the JAVA_HOME path Environment Variable is set (helps with Solr), and register the Sitecore Powershell Gallery.

The IsThisSitecore9Ready.ps1 script helps verify:

  • - Hardware requirements (are there enough cores and RAM?)
  • - Operating system compatibility (Windows Server 2012 R2 (64-bit) / 2016 (32-bit/64-bit) / Windows 10 (32-bit/64-bit) / Windows 8.1 (32-bit/64-bit)
  • - System Folder Permissions (Sets IIS_IUSRS Modify permissions to folders defined in section 2.3.1 File System Permissions of the Sitecore 9 Installation Guide)
  • - IIS version (8.5+)
  • - NET Framework (4.6.2+).
  • - SQL Server 2014 SP2 or 2016 SP1
  • - JavaRuntime (and confirms JAVA_HOME path Windows EnvironmentVariable is set)
  • - Checks and registers SIF (SitecoreInstallFramework and SitecoreFundamentals)

When you run the script, it's run through a series of checks




Please note that this does not cover the following sections from the Sitecore 9 Installation Guide, so you'll want to ensure these loose ends are wrapped up after:

  • - 2.3.2 Prerequisites for the Sitecore Installation Framework
  • - 2.3.3 Enable Contained Database Authentication 
  • - 2.3.4 Installing Solr

You can grab a copy of the script here: https://github.com/strezag/IsThisSitecore9Ready


As always, feel free to use and modify the script to fit your needs.
Leave a comment if you have any suggestions or recommendations, too!


Good luck!

Friday, March 30, 2018

Quick Tip: xPath Builder Chrome Bookmark

Whenever I need to access Sitecore's xPath Builder, I typically do a Google search to find any random blog post that provides the URL path (no, I don't plan to memorize that URL path any time soon 😊).

However, in order to circumvent this, I now use a Google Chrome Bookmark that uses JavaScript to automatically take me to the xPath Builder.

Simply add this to the content of a new bookmark:

javascript:(rel=>{location=rel.startsWith('/')?`${location.protocol}//${location.host}${rel}`:`${location.protocol}//${location.host}${location.pathname}/${rel}`})('/sitecore/shell/default.aspx?xmlcontrol=IDE.XPath.Builder')
While on any Sitecore site - hit the bookmark and you're there!

Enjoy!

Monday, March 5, 2018

Sitecore Icon Search Web App

While the #SCHackathon was fully underway last weekend (holy cow, just following the Twitter diaries was intense), I was inspired to also stay up all night building something I hope will be useful for Sitecore developers moving forward.

I'll preface this with a quote from Phil Wicklund and Jason Wilkerson's book - Professional Sitecore 8 Development:

"It's often said that the hardest part about Sitecore projects is choosing the right icon."
As a best-practice, Sitecore recommends developers "use icons wherever applicable to facilitate visual differentiation."

But finding the right icon for your Sitecore templates ain't easy.
It unfortunately never has been.

Why?

  1. Opening the Icon selector for the first time takes a while

  2. There are 8,758 icons split across 25 categories - with no out-of-the-box search capabilities  -spotting the right icon can end up being time-consuming.

  3. We've gotten some great community traction in the Marketplace - however, the available modules often compatible with specific versions of Sitecore.  (Kudos to the following module developers - these are not irrelevant).


Introducing: Sitecore Icon Search


Sitecore Icon Search was built to help Sitecore developers quickly identify the right icon for their Sitecore templates - allowing them to focus on creating great visual experiences for Sitecore CMS users.  

There are no package installations or versions to keep up with - simply visit this tool from your web browser, find the best icon, then copy and paste the relative path into the template's Icon field.

demo

This tool isn't currently mapped to any custom domain as it's using a free Azure AppService tier.  Given a positive community response, I'd be happy to upgrade the tier and map a custom one for additional ease of access.

What do you think it should it be?

Feel free to leave your suggestions (or general thoughts) in the comments below!

Happy Sitecoring!



Thursday, February 22, 2018

Real-time, Filterable Trailing/Rolling Sitecore Logs with PowerShell

Sitecore troubleshooting usually goes something like this:

1) Do something on the site
2) Navigate to the /logs directory
3) Sort the files by date
4) Open the latest log file
5) Scroll to the bottom to find the most recent entries
6) Repeat as needed

Sometimes seeing what's being written to the logs in real-time makes a lot more sense during development and troubleshooting sessions.

While there are plenty of note editors out there that support log tailing (Notepad++, SnakeTail, or even just the Command Prompt) there are always caveats (lack of filtering, finding the latest log file) - I wanted a simpler experience, with slightly more advanced options that can be used across any of my local Sitecore instances - without it being too complicated to use.  More specifically, you may not always need all the INFO or WARN messages during a real-time monitoring session.

And since I've become a PowerShell addict throughout the last couple of years...

I ended up with a couple of pretty handy PowerShell script I've been using in my day-to-day development that'd I'd love to share with you.

Actually, there are two scripts!

RollingSitecoreLogs.ps1

The first script can be placed anywhere on your machine.  When you run it, you'll be prompted with a Directory Selection dialog.  Simply navigate to the /logs folder for any Sitecore instance.


Once you've selected the directory, the script prompts you with a second dialog form which allows you to filter in/out INFO, WARN, ERROR entries.


Hit OK, and the log starts rolling in!


RollingSitecoreLogs-RelativeDirectory.ps1

The second script is far less involved and runs from your /logs directory (just drop it right in).


It determines the current directory (based on where the script itself is located), finds the latest log.* file, gives you the option to choose which messages should filter in, and begins a rolling session.

The idea is - since you're probably navigating to your logs directory to pick up on the latest log anyway - once you have this script added to our /logs directory you can simply run the script and have it start a real-time log monitoring session.  Simple as that.

Please note - this only considers the primary log.* files - so it's not built for Crawling.log, Search.log, etc. Perhaps additional options can be added at some point in the future to chose or maybe even merge them somehow.

Feel free to grab whichever script suites your needs here:
https://github.com/strezag/sitecore-rolling-logs-powershell

As always, let me know what you think in the comments.

Happy log-sifting



Monday, January 22, 2018

Keep Performance Counters Enabled After Installing Sitecore 9 Using PowerShell

After figuring out the Sitecore Installation Framework and successfully installing Sitecore 9 - I was finally getting to enjoy that fresh CMS smell.

I decided to take a peek at the logs (which to my surprise were now located inside the Website root's App_Data folder) and was greeted an error:


 ManagedPoolThread #0 22:19:56 WARN Failed to create counter 'Sitecore.System\Events | Events Raised / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #1 22:19:56 WARN Failed to create counter 'Sitecore.System\IO | File Watcher Events / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #2 22:19:56 WARN Failed to create counter 'Sitecore.System\Logging | Errors Logged / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #3 22:19:56 WARN Failed to create counter 'Sitecore.System\Logging | Fatals Logged / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #4 22:19:56 WARN Failed to create counter 'Sitecore.System\Logging | Informations Logged / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #5 22:19:57 WARN Failed to create counter 'Sitecore.System\Logging | Warnings Logged / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #6 22:19:57 WARN Failed to create counter 'Sitecore.System\Logging | Audits Logged / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #7 22:19:57 WARN Failed to create counter 'Sitecore.System\Reflection | Methods Invoked / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #8 22:19:57 WARN Failed to create counter 'Sitecore.System\Reflection | Objects Created / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #9 22:19:57 WARN Failed to create counter 'Sitecore.System\Reflection | Objects Not Created / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #10 22:19:57 WARN Failed to create counter 'Sitecore.System\Reflection | Types Resolved / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #11 22:19:57 WARN Failed to create counter 'Sitecore.System\Reflection | Types Not Resolved / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #12 22:19:58 WARN Failed to create counter 'Sitecore.System\Threading | Background Threads Started / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #13 22:19:58 WARN Failed to create counter 'Sitecore.System\Xml | Packets Created / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 3692 22:19:58 INFO HttpModule is being initialized  
 ManagedPoolThread #13 22:19:58 WARN Failed to create counter 'Sitecore.Jobs\Jobs | Jobs Executed / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #12 22:19:58 WARN Failed to create counter 'Sitecore.Jobs\Pipelines | Pipelines Aborted / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #11 22:19:58 WARN Failed to create counter 'Sitecore.Jobs\Pipelines | Pipelines Executed / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #10 22:19:58 WARN Failed to create counter 'Sitecore.Jobs\Pipelines | Processors Executed / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #9 22:19:58 WARN Failed to create counter 'Sitecore.Jobs\Publishing | Items Queued / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #8 22:19:58 WARN Failed to create counter 'Sitecore.Jobs\Publishing | Replacements / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #7 22:19:58 WARN Failed to create counter 'Sitecore.Jobs\Tasks | File Cleanups / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #6 22:19:58 WARN Failed to create counter 'Sitecore.Jobs\Tasks | Html Cache Clearings / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #5 22:19:58 WARN Failed to create counter 'Sitecore.Jobs\Tasks | Publishings / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #4 22:19:58 WARN Failed to create counter 'Sitecore.Jobs\Tasks | Reminders Sent / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #3 22:19:58 WARN Failed to create counter 'Sitecore.Jobs\Tasks | Tasks Executed / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  

Given that I was installing Sitecore 9 on a Windows 10 VM, I must have missed a requirement/prerequisite somewhere along the line - but this looked really familiar.

I've seen this before.

Oh yeah...

From https://kb.sitecore.net/articles/404548:
Possible solution:   A Sitecore application pool user has to be a member of the system “Performance Monitor Users” group to have access to the performance counters.
Adding the user to this group and restarting IIS should resolve the problem.

That's right - Windows Performance Counters!


Turns out, this is actually mentioned in the appendix of the Sitecore Experience Platform 9.0 Sitecore Experience Platform Installation Guide under Windows Performance Counters section, too (1 page before the end of the document!):


Sitecore XP contains a built-in functionality that reads and updates the Windows performance counters that you can use to monitor and troubleshoot the Sitecore application. This functionality requires access to Windows registry keys. 

So the obvious two options -

1) Set Counters.Enabled setting to false in \App_Config\Sitecore.config
Booorrring

2) Grant access by making the application pool identity a member of the built-in Performance Monitor Users group.

Instead of simply just disabling the counters, I wanted to see exactly what it takes to keep them on.

This should be as easy as adding the Application Pool Identity of my Sitecore 9 instance as a Performance Monitor Users group.  Unfortunately for me and my VM - the Local Users and Groups option wasn't available for me:
WHERE IS IT?!


Rather than figuring out why Windows 10 isn't showing that option - I figured, why can't this just be automated?

So in the spirit of scripting everything...

I put together the following post-installation script that assigns the environment's AppPoolIdentity account to the Performance Monitor Users group, then resets IIS for the changes to take effect.

All that needs to be configured is the $accountName variable - then simply run the script as in an elevated PowerShell window:



Write-Host 

# Configure this to the same value as the Sitecore Site Name (eg. sc90.local). 
# If you're using Network Service account, set to 'NT AUTHORITY\Network Service'
$accountName = "sc90.local"  
# Get the Performance Monitor Users group policy
$group = [ADSI]"WinNT://$Env:ComputerName/Performance Monitor Users,group"

# Create Account Object
$ntAccount = New-Object System.Security.Principal.NTAccount($accountName)
Write-Host "Account to add to Performance Monitor Users: $ntAccount"

# Translates the account name represented by the NTAccount object into another IdentityReference-derived type.
$strSID = $ntAccount.Translate([System.Security.Principal.SecurityIdentifier])

#Create the user
$user = [ADSI]"WinNT://$strSID"

try
{
        # Set user to group policy
 $group.Add($user.Path)
 Write-Host -ForegroundColor Green "$accountName has been successfully added as a member of the Performance Monitor Users group."
 
 # Reset IIS for changes to take effect
 iisreset
}
catch
{
     Write-Host -ForegroundColor Green "$accountName is already a member of Performance Monitor Users."
}


Write-Host "Process complete."
Write-Host 


After running this script, the logs no longer contained "Access to the registry key 'Global' is denied. / Sitecore has no necessary permissions for reading/creating counters" errors - indicating that Sitecore could now read the permissions.

Feel free to include this in your own set of post-installation steps and modify to fit your needs.

Happy scripting!

Wednesday, January 3, 2018

Sitecore Powershell: Valid Page URLs Report

Preface: I love the Sitecore Powershell Extensions Module, and I opt to use it every chance I get.


One of my clients had a simple request:
"Please provide a list in Excel of every single valid URL on the live global site, please." 

After running ScreamingFrog and obtaining a report with missing URLs (the final list returned was faulty – likely due to the software’s inability to hit specific links only available via AJAX rendered components) - we had a couple options on the table:

  1. Similar to functionality found in a Sitemap component, we need a simple ASPX page that loops through the content that filters out everything but the global English version, generate the URLs, trigger a web request to determine the URL's web status, and display it on the page (or create a Download button to get the list).  Code this, deploy it, etc.
  2. Do all of the above - but with Powershell - which happened to already be installed on the CMS

GUESS which I opted for? :)
Yeah!...you guessed it!

Let's get right into it.

Using the Get-ChildItem command and targetting a specific part of the content tree (explicitly using the Web DB), we get the initial list of English versioned items.
 $itemsWithMatchingCondition = Get-ChildItem 
          -Path web:'/sitecore/content/WebsiteName/Home' 
          -Language 'en' 
          -Version * 
          -Recurse 

With this specific implementation, I was lucky enough to have a stable template naming convention where all items using a template that ended with "Page" were always going to be...well...pages.
(Without this luck, I may have had to check if the item contained at least a main layout within the renderings).

To filter this, we'll use a simple IF statement with a LIKE operator against the initial item list's item:
 iif ($item.Template.Name -like $script:pageString)

Now that we have a list of page items we want to process, we need to generate the item's URL.

This handy function that sets the site context, configures the UrlOptions, and gets the URL via the LinkManager does just that:
function Get-ItemUrl($itemToProcess){
     [Sitecore.Context]::SetActiveSite("website")
     $urlop = New-Object ([Sitecore.Links.UrlOptions]::DefaultOptions)
     $urlop.AddAspxExtension = $false
     $urlop.AlwaysIncludeServerUrl = $true
     $linkUrl = [Sitecore.Links.LinkManager]::GetItemUrl($itemToProcess,$urlop)
     $linkUrl
}

Here's the fun part!

Per the requirement, we'll need to validate that the URLs Sitecore was generating were actually functioning.  Any non-functioning URLs (if any) shouldn't be included in the final report (only status code 200).

Powershell lets us make web requests - which we could then check the status of.
All we need to do here is pass in the URL we generated and expect a true or false value in return:

function IsValidPageStatus($urStr){
    $return = $false;
    $HTTP_Request = [System.Net.WebRequest]::Create($urStr)
    $HTTP_Response = $HTTP_Request.GetResponse()
    $HTTP_Status = [int]$HTTP_Response.StatusCode
    if ($HTTP_Status -eq 200) {
        $return = $true
    }
    else {
        Write-Host $urStr
        Write-Host "Response: " $HTTP_Status
        $return = $false
    }
    $HTTP_Response.Close()
    return $return
}

(Note: Any page URL that fails will be listed in the console after the script completes.)

After every URL goes through this check, we add the item to the array list:

if($isValidUrl){
      $script:itemIDsWithPassedCriteria.Add($item) > $null 
}

Finally, build out the report - which can then be exported via the Powershell ISE in CSV/Excel format:

if ($script:itemIDsWithPassedCriteria.Count -eq 0)
{
    Write-Warning "No page items found."
}else{
$props = @{
 InfoTitle = "Live Page Urls"
 InfoDescription = "Provides a list of all valid page URLs "
 PageSize = 100
}
    $script:itemIDsWithPassedCriteria|Show-ListView @props -Property 
       @{ Label = "Url"; Expression = { Get-ItemUrl ($_) } }
    Close-Window 
}


Here's the full script:

<#
.SYNOPSIS
  Provides a list report of all valid page URLs  
.AUTHOR
Written by Gabe Streza
#>
# Variables
$script:pageString = "* Page" #page string
function GetItemsWhichUsePageTemplate()
{
    $itemsWithMatchingCondition = Get-ChildItem -Path web:'/sitecore/content/WebsiteName/Home' 
                                                        -Language 'en' -Version * -Recurse 
    { 
        if ($item.Template.Name -like $script:pageString)
        {
            $linkUrl = Get-ItemUrl($item)
            $isValidUrl = IsValidPageStatus($linkUrl)
            if($isValidUrl){
                $script:itemIDsWithPassedCriteria.Add($item) > $null # The output of the Add is ignored
            }
        }
    }
}
function Get-ItemUrl($itemToProcess){
     [Sitecore.Context]::SetActiveSite("website")
     $urlop = New-Object ([Sitecore.Links.UrlOptions]::DefaultOptions)
     $urlop.AddAspxExtension = $false
     $urlop.AlwaysIncludeServerUrl = $true
     $linkUrl = [Sitecore.Links.LinkManager]::GetItemUrl($itemToProcess,$urlop)
     $linkUrl
}
function IsValidPageStatus($urStr){
    $return = $false;
    $HTTP_Request = [System.Net.WebRequest]::Create($urStr)
    $HTTP_Response = $HTTP_Request.GetResponse()
    $HTTP_Status = [int]$HTTP_Response.StatusCode
    if ($HTTP_Status -eq 200) {
        $return = $true
    }
    else {
        Write-Host $urStr
        Write-Host "Response: " $HTTP_Status
        $return = $false
    }
    $HTTP_Response.Close()
    return $return
}

$script:itemIDsWithPassedCriteria = New-Object System.Collections.ArrayList
GetItemsWhichUsePageTemplate

if ($script:itemIDsWithPassedCriteria.Count -eq 0)
{
    Write-Warning "No page items found."
}else{
$props = @{
 InfoTitle = "Live Page Urls"
 InfoDescription = "Provides a list of all valid page URLs "
 PageSize = 100
}
    $script:itemIDsWithPassedCriteria|Show-ListView @props 
                                                            -Property @{ Label = "Url"; Expression = { Get-ItemUrl ($_) } }
    Close-Window 
}
Write-Host "Done."

This took about 8 minutes to process a 2000 page site - which is good for a one-time run - but there are certainly some optimizations we should make if this was a report the client would use repeatedly in order to make it a bit snappier.  For this purpose, we're all set!

Feel free to grab this, tinker with it, and make it your own!

Let me know in the comments if this has helped - or if you have any additional recommendations.


Wednesday, December 6, 2017

Sitecore's Jobs.aspx Admin Page for Sitecore v6.6 to v7.1


Up until the release of Sitecore 7.2 Update-6 Sitecore was missing an out-of-the-box Jobs Viewer tool - which resulted in community developers rolling their own and sharing to the rest of us.

Here are links to some of the community-driven implementations:

https://www.geekhive.com/buzz/post/2015/04/sitecore-job-viewer/

https://briancaos.wordpress.com/2014/11/11/sitecore-job-viewer-see-what-sitecore-is-doing-behind-your-back/ 

https://marketplace.sitecore.net/en/Modules/V/View_Sitecore_Jobs.aspx 

https://sitecoreblog.marklowe.ch/2014/06/view-running-sitecore-background-jobs/ 

One feature that separated Sitecore's Jobs.aspx page from the community-driven implementations was its ability to auto-refresh and essentially view jobs running in real-time without the need for a refresh button. 

It also looked very nice! :)

I wanted to use THIS Jobs.aspx page on older versions of Sitecore I still maintained, so I started decompiling the Sitecore.Kernel, SitecoreClient, and Sitecore.ExperienceContentManagement.Administration binaries from v8.1 in an attempt to consolidate all the code into one Jobs.aspx page - no additional dll required.

Here's the result:

  <%@ Page language="c#" EnableEventValidation="false" AutoEventWireup="true" Inherits="Sitecore.sitecore.admin.AdminPage" %>   
  <script runat="server">   
   protected override void OnInit(EventArgs e)   
   {   
   base.CheckSecurity(true); //Required!   
   base.OnInit(e);   
   }   
  void Page_Load(object sender, System.EventArgs e)   
  {   
       Sitecore.Jobs.JobManager.GetJobs();   
     StringBuilder stringBuilder = new StringBuilder();   
     Type type = typeof(Sitecore.Jobs.JobManager);   
     this.ShowRefreshStatus(stringBuilder);   
     System.Reflection.FieldInfo field = type.GetField("_runningJobs", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic);   
     if (field != null)   
     {   
      this.ShowJobs(stringBuilder, "Running jobs", ((Sitecore.Collections.SafeDictionary<Sitecore.Handle, Sitecore.Jobs.Job>)field.GetValue(null)).Values.ToArray<Sitecore.Jobs.Job>());   
     }   
     System.Reflection.FieldInfo fieldInfo = type.GetField("_queuedJobs", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic);   
     if (fieldInfo != null)   
     {   
      this.ShowJobs(stringBuilder, "Queued jobs", ((Sitecore.Collections.JobCollection)fieldInfo.GetValue(null)).ToArray<Sitecore.Jobs.Job>());   
     }   
     System.Reflection.FieldInfo field1 = type.GetField("_finishedJobs", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic);   
     if (field1 != null)   
     {   
      this.ShowJobs(stringBuilder, "Finished jobs", ((Sitecore.Collections.JobCollection)field1.GetValue(null)).Reverse<Sitecore.Jobs.Job>().ToArray<Sitecore.Jobs.Job>());   
     }   
     this.lt.Text = stringBuilder.ToString();   
  }   
  protected virtual void ShowJobs(StringBuilder stringBuilder, string name, ICollection<Sitecore.Jobs.Job> enumerable)   
    {   
     stringBuilder.AppendLine(string.Concat("<h1>", name, ":</h1><br />"));   
     if (enumerable.Count <= 0)   
     {   
      stringBuilder.AppendLine("<b>No jobs</b><br />");   
     }   
     else   
     {   
      stringBuilder.AppendLine("<table class='jobs-table'>");   
      stringBuilder.AppendLine("<thead><tr><td class='counter'>No</td><td class='add-time'>Added</td><td class='title'>Title</td><td class='progress'>Progress</td><td class='priority'>Priority</td></tr></thead>");   
      int num = 1;   
      foreach (Sitecore.Jobs.Job job in enumerable)   
      {   
       long total = job.Status.Total;   
       TimeSpan localTime = TimeSpan.Zero;   
       string str = (localTime.Hours == 0 ? string.Empty : string.Concat(localTime.Hours, "h "));   
       string str1 = (localTime.Minutes == 0 ? string.Empty : string.Concat(localTime.Minutes, "m "));   
       stringBuilder.AppendLine("<tr>");   
       stringBuilder.AppendLine(string.Concat("<td class='counter'>", num, "</td>"));   
       object[] longTimeString = new object[] { "<td class='add-time'>", null, null, null, null, null, null };   
       DateTime dateTime = job.QueueTime.ToLocalTime();   
       longTimeString[1] = dateTime.ToLongTimeString();   
       longTimeString[2] = " (";   
       longTimeString[3] = str;   
       longTimeString[4] = str1;   
       longTimeString[5] = localTime.Seconds;   
       longTimeString[6] = "s ago)</td>";   
       stringBuilder.AppendLine(string.Concat(longTimeString));   
       stringBuilder.AppendLine(string.Concat("<td class='title'>", job.Name, "</td>"));   
       StringBuilder stringBuilder1 = stringBuilder;   
       object[] processed = new object[] { "<td class='progress'>", job.Status.Processed   
              , null, null };   
       processed[2] = (total > (long)0 ? string.Concat(" of ", total) : string.Empty);   
       processed[3] = "</td>";   
       stringBuilder1.AppendLine(string.Concat(processed));   
       stringBuilder.AppendLine(string.Concat("<td class='priority'>", job.Options.Priority, "</td>"));   
       stringBuilder.AppendLine("</tr>");   
       num++;   
      }   
      stringBuilder.AppendLine("</table>");   
     }   
     stringBuilder.AppendLine("<br /><hr />");   
    }   
    protected virtual void ShowRefreshStatus(StringBuilder stringBuilder)   
    {   
     int num;   
     string str;   
     string item = base.Request.QueryString["refresh"];   
     int.TryParse(item, out num);   
     object[] objArray = new object[1];   
     DateTime now = DateTime.Now;   
     objArray[0] = now.ToString(System.Globalization.CultureInfo.InvariantCulture);   
     stringBuilder.Append(Sitecore.StringExtensions.StringExtensions.FormatWith("Last updated: {0}. ", objArray));   
     int[] numArray = new int[] { 1, 2, 5, 10, 20, 30, 60 };   
     stringBuilder.Append(string.Format("Refresh each <a href='jobs.aspx' class='refresh-link {0}'>No Refresh</a>", (num == 0 ? "refresh-selected" : string.Empty)));   
     int[] numArray1 = numArray;   
     for (int i = 0; i < (int)numArray1.Length; i++)   
     {   
      int num1 = numArray1[i];   
      str = (num == num1 ? "refresh-selected" : string.Empty);   
      string str1 = string.Format(", <a href='jobs.aspx?refresh={0}' class='refresh-link {1}'>{0} sec</a>", num1, str);   
      stringBuilder.Append(str1);   
     }   
     stringBuilder.Append("<br /><br />");   
    }   
  </script>    
  <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" >   
  <html xmlns="http://www.w3.org/1999/xhtml">   
   <head runat="server">   
    <title>Jobs Viewer</title>   
    <link rel="Stylesheet" type="text/css" href="/sitecore/shell/themes/standard/default/WebFramework.css" />   
    <link rel="Stylesheet" type="text/css" href="./default.css" />   
    <style type="text/css">   
     .jobs-table {   
      border: solid 1px grey;    
      border-spacing: 2px;   
      border-collapse: separate;   
      width: 100%;   
     }   
     .jobs-table td {   
      padding: 2px;   
     }   
     .jobs-table thead {   
      font-weight: bold;   
     }   
     .jobs-table .counter {   
      width: 25px;   
      text-align: right;   
     }     
     .jobs-table .add-time {   
      width: 150px;   
     }     
     .jobs-table .title {   
      word-break: break-all;   
     }    
     .jobs-table .progress {   
      width: 50px;   
      text-align: center;   
     }    
     .jobs-table .priority {   
      width: 80px;   
     }        
    </style>   
   </head>   
   <body>   
    <form id="Form1" runat="server" class="wf-container">   
     <div class="wf-content">   
      <h1>   
       <a href="/sitecore/admin/">Administration Tools</a> - Jobs Viewer   
      </h1>   
      <br />   
      <asp:Literal runat="server" ID="lt"></asp:Literal>   
      <script type="text/javascript">   
       function getQueryString() {   
        var result = {}, queryString = location.search.substring(1), re = /([^&=]+)=([^&]*)/g, m;   
        while (m = re.exec(queryString)) {   
         result[decodeURIComponent(m[1])] = decodeURIComponent(m[2]);   
        }   
        return result;   
       }   
       var str = getQueryString()["refresh"];   
       if (str != undefined) {   
        c = parseInt(str) * 1000;   
        setTimeout("document.location.href = document.location.href;", c);   
       }   
      </script>   
     </div>   
    </form>   
   </body>   
  </html>   

Here is the Sitecore's Jobs Viewer admin tool working on a clean Sitecore 7.0 site:


Simply copy the code above into a new Jobs.aspx file and drop it into your site's /sitecore/admin folder.

I've confirmed this working on versions as far back as Sitecore versions 6.6.

Enjoy!

Friday, November 17, 2017

Quick Tip: Count Child Items of Sitecore Tree using Google Chrome Dev Console

There have been several requests among different clients our practice works with to provide a simple count of items under a part of a Sitecore tree.

While writing a Sitecore query and running it against the XPath builder is completely feasible, there is a quicker way if you're just trying to get a child count.

Open up the Sitecore Content Editor, activate the node you want to get the number of children under and expand it.

Open up your Google Chrome Dev Console and run the following line of Javascript:

 document.getElementsByClassName('scContentTreeNodeActive')[0].nextSibling.childNodes.length  

This one-liner finds the active Content Tree node and discovers the number of child items living under it.



Note: This doesn't account for all descendants and only seems to work with Google Chrome, however, I've found this useful in a few situations now across any Sitecore version.

Enjoy!


Tuesday, September 5, 2017

Paid Google Site Search is Going Away. What are my options in Sitecore?



I've seen this email a few times now - and as unfortunate as Google's decision is on removing a product that has powered hundreds of thousands of website's site search - this is real.

What does this mean for Sitecore site searches using GSS?

Depending on the original implementation, it could mean the search page stops working altogether - which could occur if your search results have been served via XML.  The XML results will no longer be served, and therefore your site won't have data to build the results page.

Alternatively, if the GSS implementation was a straight script and tag injection, the search page will still work, but you'll be met with 3-5 advertisements on the top results of your search results.

None of these options are ideal, of course.

In addition to one of those actions occurring on your site, you'll also lose any email support from Google, and be left to the mercy of capped query limits.

What are our options?

Luckily, there are a few.  Here are just some you can explore:

Keep what you can (if you can).

If you've confirmed that Google will still serve results and are okay with advertisements (potentially from competitors) slipped onto your website, then do nothing.  Just remember, if your monthly quota is reached, the search pages will stop showing results.

Leverage Sitecore's OOB Lucene Indexes

Sitecore comes with a fairly powerful search engine library and API, making it pretty straight forward to recreate what Google has been serving all built into your solution.  An experienced Sitecore developer should be able to whip up a straight site search based on a template restricted Lucene index in a reasonable timeline.

Odds are that all styles used in the GSS implementation can be reused for the Sitecore Search API implementation.  If you require a more complex solution such as facets / filtering / sorting, etc - this will obviously require more development time - but can be done in phases if the goal is to change over from GSA as soon as possible.  


If you're in the cloud, try Azure Search service

If you use Sitecore Azure PaaS and on Sitecore Experience Platform 8.2 Update-1 or above, you can use Azure Search are your primary search engine. It works similarly to the OOB Lucene implementation (with some exceptions) but is rather a service that lives in the Azure cloud and connected to your solution via a connection string.

Configuration is handled similarly to a Solr implementation and offers "extreme scalability, simplicity, and stability".  The Azure Search service also guarantees 99.95% uptime as a part of the Microsoft Azure service level agreement and can be easily scaled up or down on an as-needed basis.  
There are certainly some limitations with Azure search as of now, but it is worth exploring when choosing a replacement for GSA. 

More information:

Go with Coveo

Coveo offers an extremely powerful search platform that works on top of Sitecore and comes with all the bells and whistles of what you'd expect from a reputable search company.

Coveo features the ability to provide relevant search results, self-learning/self-tuning search, a secure and scalable cloud service, responsive design, and full integrated Sitecore components.   Coveo OOB allows Content Authors to build rich search pages with Sitecore Page Editor, tune search and promote content using Sitecore Rules Engine, and manage search indexes and crawl schedules via Sitecore Index Manager all without additional development resources.

With Coveo Cloud, you don't need to worry about any additional on premise hardware - but you should still be conscious of the additional license costs associated with this option.

If your goal is to take this opportunity to swap in a more elegant search solution - Coveo can help you upgrade the search experience, as opposed to simply replacing it.  

More information:

Searching with Solr

Solr has been a viable option for Sitecore search for years.  Comparable to OOB Lucene, Solr is an enterprise-level Lucene-based search platform making it easier to scale for larger implementation vs the OOB Lucene engine.    Ensuring compatibility for the version of Solr you should be using is critical in your decision here.

There are plenty of Sitecore provided resources and community driven blogs out there to help you get started.

More information:
Sitecore Docs - Walkthrough: Setting up Solr


Other Plug-and-Play Third Party Search Libraries

If development resources are strapped, you can explore an alternative like Bing Search or Cludo which would likely cut on internal development time, but also comes at an additional monthly cost to keep running.   I personally have little experience working with either of these, but both may be feasible in a pinch. 


Do you know of other options not mentioned here?
Feel free to comment below and describe your experience!

Monday, August 28, 2017

Sitecore Text Searcher - The Ultimate SQL Script

"Where can I edit this text in Sitecore?"
"Can you find out where this content can be changed in Sitecore? 
"I tried changing this content in Sitecore here, but it's not reflecting on the front-end after I publish." 

I hear that a lot.  

When maintaining multiple Sitecore solutions - knowing every template, rendering, and how the data ties in to what you see on the front-end - is nearly impossible.   Prior to Helix, developers were free to develop using various patterns. Some are built in MVC, others are WebForms.  Some components rely on datasource items, others rely on data from the items template. Regardless of architecture, we need to be able to help Content Authors change content if they can't find what they're looking to change.

There are typically a few options to obtain the information for the Content Author's request:

     - If you have access to the solution - inspect the page elements, grab a common class or id, do a Entire Solution search, find some files and follow the code to determine how it populates the component.

     - Start with the page item in Sitecore and determine whether the content is located on the item's template, or in within a data source item.

Those options are both fine - and eventually effective - but there is a faster way - IF YOU HAVE ACCESS TO SQL (which is normally the case if you have a recent DB locally or access to the DB server).

Simply start a new query in SQL MS on the Sitecore DB you want to search in (Web or Master),  modify the @SEARCHTERM variable (include the percent symbols eg. '%content goes here%'), and execute this handy script:



DECLARE @SITECOREROOTID NVARCHAR(MAX), @FULLPATHSEARCH NVARCHAR(MAX), @SEARCHTERM NVARCHAR(MAX)

SET @SITECOREROOTID = '11111111-1111-1111-1111-111111111111';
SET @FULLPATHSEARCH = 'sitecore/content/%';
SET @SEARCHTERM = '%search term%';

WITH FullPathItems (ID, NAME, ITEMPATH, TEMPLATEID)
    
AS (SELECT ID,
                
NAME,
                
CAST(NAME AS NVARCHAR(MAX)) AS itempath,
                
TEMPLATEID
        
FROM   [dbo].ITEMS
        
WHERE  ID = @SITECOREROOTID
        
UNION ALL
        
SELECT i.ID,
                
i.NAME,
                
CAST(( ITEMPATH + '/' + i.NAME ) AS NVARCHAR(MAX)) AS itempath,
                
i.TEMPLATEID
        
FROM   [dbo].ITEMS i
                
INNER JOIN FullPathItems a
                        
ON i.PARENTID = a.ID)
                        
SELECT fieldsX.ITEMID,
      
fpi.NAME AS ITEMNAME,
      
fieldsX.VALUE,
      
fpi.TEMPLATEID AS TEMPLATEID,
      
templatesX.NAME AS TEMPLATENAME,
      
ITEMPATH,
      
fieldsX.FIELDID,
      
itemsX.NAME AS FIELDNAME,
      
fieldsX.LANGUAGE  AS ITEMLANGUAGE 

       FROM   [dbo].[FIELDS] fieldsX
      
INNER JOIN FullPathItems fpi
              
ON fpi.ID = fieldsX.ITEMID
      
INNER JOIN [dbo].ITEMS itemsX
              
ON itemsX.ID = fieldsX.FIELDID
      
INNER JOIN [dbo].ITEMS templatesX
              
ON templatesX.ID = fpi.TEMPLATEID

WHERE fieldsX.VALUE LIKE @SEARCHTERM 
AND fpi.ITEMPATH LIKE @FULLPATHSEARCH

/*INCLUDE  THE FOLLOWING LINE TO INCLUDE ITEM ID CONDITION */
-- AND fieldsX.ItemId = 'B3F15B8C-BAE5-40F4-A139-BA7B3EC6E1ED'

/*INCLUDE THE FOLLOWING LINE TO INCLUDE ITEM NAME CONDITION */
-- AND fpi.Name LIKE '%My Item%'

/* INCLUDE THE FOLLOWING LINE TO INCLUDE TEMPLATE NAME CONDITION */
--AND templatesX.Name = 'My Template'

/* INCLUDE THE FOLLOWING LINE TO ADD LANGUAGE CONDITION */
-- AND fieldsX.Language = 'en-US'


ORDER  BY fpi.ITEMPATH



The result includes the following columns:

Sometimes, you'll be presented with several instances of a specific keyword, in which case you can uncomment various lines near the bottom of the script to drill down and filter the results even further.

I find myself using this script on a daily basis and hope that sharing this script helps others track down content quickly and effectively, too.


Friday, August 4, 2017

Sitecore Website Backup Powershell Script

Depending on the environment, infrastructure, and budget - each Sitecore project has their own backup and deployment process.  More recent solutions often include a file backup step during an automated build process - while others take on a more manual process.

Whatever your specific scenario, my rule of thumb for changing anything is: ALWAYS BACKUP BEFORE MAKING CHANGES.

With solutions using less complex deploy processes - I used to select, copy, and paste specific files and directories to a separate location in case something went completely awry.  To save time - and maintain peace of mind before making file changes to a staging or production environment - I now utilize a handy backup script using Powershell that backs up all necessary files (Robocopy) by simply running it.


To use it:

  1. Modify and save the Settings.xml file with your own source and destination paths.

  2. Double-click on RunBackup.bat file.  Once you accept the prompt to run the script as an administrator, you'll see the output of each file being transferred to the destination folder.

  3. Watch it run!

The script explicitly excludes the following Sitecore specific paths that often aren't changed from build to build - and don't need to take up more space than needed.  To add/remove an excluded entries,  open and modify the _sitecorebackupscript.ps1 file's '$exclude' variable.
"_DEV", "App_Data", "Content", "temp", "upload", "sitecore", "sitecore modules" 


Settings.xml - Configuration settings

 <?xml version="1.0" encoding="UTF-8"?>  
 <settings>  
      <!-- Date format for Destination folder -->  
      <DateFormat>MM-dd-yyyy hhmmss</DateFormat>  
      <!-- Source Website directory to backup (exclude trailing back-slash) -->  
      <SourceDirectory>C:\inetpub\wwwroot\Sitecore8\Website</SourceDirectory>  
      <!-- Destination directory where backup made (exclude trailing back-slash) -->  
      <BackupDirectory>C:\inetpub\wwwroot-backup\Sitecore8</BackupDirectory>  
 </settings>  

_sitecorebackupscript.ps1: The Powershell script

 <# Sitecore Backup Script - Gabriel Streza (sitecoregabe.com)   
 **************************************************************************  
 Powershell script that backups your Sitecore website while excluding unessesary files.  
   
 Modify Settings.xml to control Source and Directory paths.   
   
 **************************************************************************  
 #>  
   
 # Directory definitions  
 $scriptpath = $MyInvocation.MyCommand.Path  
 $directory = Split-Path $scriptpath  
 [xml]$ConfigFile = Get-Content "$directory\Settings.xml"  
   
 # Script Variable Defintions  
 $DateFormat = Get-Date -format $ConfigFile.Settings.DateFormat  
 $SourceDirectory = $ConfigFile.Settings.SourceDirectory  
 $BackupDirectory = $ConfigFile.Settings.BackupDirectory  
 $dest = $BackupDirectory + '_' + $DateFormat  
   
 # Folders to exclude  
 $exclude = "_DEV", "App_Data", "Content", "temp", "upload", "sitecore", "sitecore modules"  
   
 # Run as Administrator  
 if (!([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")) { Start-Process powershell.exe "-NoProfile -ExecutionPolicy Bypass -File `"$PSCommandPath`"" -Verb RunAs; exit }  
   
 # File copy  
 robocopy $SourceDirectory $dest /s /xj /xd $exclude  
   
 # Completed  
 Write-Host -NoNewLine 'DONE! Press any key to continue...';  
 $null = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown');  

RunBackup.bat: Execute the Powershell script

 Powershell.exe -executionpolicy remotesigned -File "_sitecorebackupscript.ps1"  


You can download, modify, and utilize this by heading to this GitHub repo and grabbing a copy:
https://github.com/strezag/Sitecore-Website-Backup-Script/


Happy backup...ing!