Wednesday, 23 August 2017

How to Give Editors Access to Categories in Optimizely CMS 11

This approach applies to legacy Optimizely CMS / EPiServer solutions that use the classic admin interface and web.config-based access rules. It does not apply to CMS 12 or CMS 13.

Overview

In legacy EPiServer/Optimizely CMS implementations, editor and administrator features are separated into two interfaces: Edit and Admin.

Edit mode gives you fine-grained control over content permissions, but sometimes teams need to expose a specific admin feature, such as Categories, without granting full access to the entire admin interface.

A common requirement is to let editors manage categories while keeping the rest of the admin area restricted to administrator roles.

Grant Access to Categories Only

In the classic admin UI, access is controlled through web.config. By default, admin pages are usually limited to roles such as WebAdmins and Administrators.

If you want editor roles such as CmsEditors or WebEditors to access only the Categories screen, add the following configuration:

<location path="EPiServer/CMS/admin/Categories.aspx">
  <system.web>
    <authorization>
      <allow roles="CmsEditors, WebEditors, WebAdmins, Administrators" />
      <deny users="*" />
    </authorization>
  </system.web>
</location>

With this in place, users in the WebEditors role can open /EPiServer/CMS/admin/Categories.aspx directly without receiving access to other admin functionality.

Add a Navigation Link

Direct access works, but it is not very convenient for editors. A better experience is to surface Categories in the CMS navigation.

The simplest approach is to add a menu item in web.config:

<episerver.shell>
  <navigation>
    <add menupath="/global/Categories"
         sortindex="1000"
         text="Categories"
         url="/EPiServer/CMS/admin/Categories.aspx" />
  </navigation>
</episerver.shell>

This works, but there is one downside: the menu item is visible to all users, even if they do not have permission to access the Categories page.

Use a Role-Aware MenuProvider

If you want the Categories menu item to appear only for users who are actually allowed to use it, the better approach is to use a custom MenuProvider.

This lets you control both visibility and access in code, which keeps the navigation cleaner and avoids exposing irrelevant menu items to other users.

The following example adds a Categories submenu item under the CMS section with role-aware access control:

If you want Categories to appear as a top-level global item instead, change /global/cms/categories to /global/categories.

Why This Approach Is Useful

  • Editors get access to exactly one admin feature without broader admin permissions.
  • The navigation is more intuitive for content teams.
  • Role-aware menu items reduce confusion and keep the UI cleaner.

Learn More

If you are new to menu providers, the legacy Optimizely CMS 11 documentation is a good place to start: Extend the CMS navigation.

This is a useful pattern for older Optimizely CMS 11 projects where editors need access to selected admin tools without opening up the full admin interface.

August 23, 2017 →

How to Manually Clear Site and Page Cache in Optimizely CMS

This post focuses on content and object cache invalidation inside Optimizely CMS. It does not cover CDN cache, browser cache, or output cache.

Background

Most of the time, Optimizely handles cache invalidation automatically and very well. In normal publishing workflows, you usually do not need to clear cache manually.

That said, manual cache invalidation can still be useful in a few special cases: diagnostics, custom integrations, load-balanced proof-of-concepts, or controlled troubleshooting where you want to force content or object cache to refresh.

Below I have reordered the examples so the latest Optimizely CMS 12 and CMS 13 approach comes first, followed by older EPiServer examples for legacy projects.

Optimizely CMS 12 and CMS 13

For modern Optimizely CMS solutions, the recommended APIs are:

  • IContentCacheRemover for content cache invalidation
  • ISynchronizedObjectInstanceCache for general object cache invalidation

Clear the full content cache

using EPiServer;

public class CacheService
{
    private readonly IContentCacheRemover _contentCacheRemover;

    public CacheService(IContentCacheRemover contentCacheRemover)
    {
        _contentCacheRemover = contentCacheRemover;
    }

    public void ClearContentCache()
    {
        _contentCacheRemover.Clear();
    }
}

Clear the cache for a specific page or content item

using EPiServer;
using EPiServer.Core;

public class CacheService
{
    private readonly IContentCacheRemover _contentCacheRemover;

    public CacheService(IContentCacheRemover contentCacheRemover)
    {
        _contentCacheRemover = contentCacheRemover;
    }

    public void ClearSpecificContentCache()
    {
        _contentCacheRemover.Remove(ContentReference.StartPage);
    }
}

Clear a specific language branch

_contentCacheRemover.RemoveLanguage(ContentReference.StartPage, "en");

Clear the general object cache

If you previously used CacheManager, the newer approach is to use ISynchronizedObjectInstanceCache.

using EPiServer.Framework.Cache;

public class CacheService
{
    private readonly ISynchronizedObjectInstanceCache _synchronizedCache;

    public CacheService(ISynchronizedObjectInstanceCache synchronizedCache)
    {
        _synchronizedCache = synchronizedCache;
    }

    public void ClearObjectCache()
    {
        _synchronizedCache.Clear();
    }
}

Example endpoint for internal diagnostics

using EPiServer;
using EPiServer.Core;
using EPiServer.Framework.Cache;
using Microsoft.AspNetCore.Mvc;

[Route("util/cache")]
public class CacheController : Controller
{
    private readonly IContentCacheRemover _contentCacheRemover;
    private readonly ISynchronizedObjectInstanceCache _synchronizedCache;

    public CacheController(
        IContentCacheRemover contentCacheRemover,
        ISynchronizedObjectInstanceCache synchronizedCache)
    {
        _contentCacheRemover = contentCacheRemover;
        _synchronizedCache = synchronizedCache;
    }

    [HttpPost("content/clear")]
    public IActionResult ClearContentCache()
    {
        _contentCacheRemover.Clear();
        return Content("Ok, content cache cleared.");
    }

    [HttpPost("content/{id:int}")]
    public IActionResult ClearContent(int id)
    {
        _contentCacheRemover.Remove(new ContentReference(id));
        return Content("Ok, content cache cleared.");
    }

    [HttpPost("object/clear")]
    public IActionResult ClearObjectCache()
    {
        _synchronizedCache.Clear();
        return Content("Ok, object cache cleared.");
    }
}

Legacy EPiServer Examples

The examples below are for older EPiServer projects and are mainly useful when working with legacy CMS 6 to CMS 11 solutions.

Invalidate the cache for a specific EPiServer page

EPiServer 9.9+

var contentCacheRemover = ServiceLocator.Current.GetInstance<EPiServer.IContentCacheRemover>();
contentCacheRemover.Remove(ContentReference.StartPage);

EPiServer 6

DataFactoryCache.RemovePage(ContentReference.StartPage);

Invalidate the cache for an EPiServer site on a server

EPiServer 7

EPiServer.CacheManager.Clear();

EPiServer 6

EPiServer.DataFactoryCache.Clear();

A small web service to invalidate site cache:

public void ProcessRequest(HttpContext context)
{
    EPiServer.CacheManager.Clear();
    context.Response.ContentType = "text/plain";
    context.Response.Write("Ok, site cache cleared.");
}

Final Note

For most projects, it is best to let Optimizely manage cache invalidation automatically. Manual cache clearing should be reserved for troubleshooting, controlled utilities, and special integration scenarios.

Thanks to Wałdis Iljuczonok for previously highlighting the newer public API approach using IContentCacheRemover instead of older cache APIs.

August 23, 2017 →

Episerver CMS Version Gadget Republish Breaks Download Option for Media Content

I came across an annoying issue with EPiServer media management related to versioning in EPiServer CMS from version 10 .0.1 onward.

Problem


Versioning in EPiserver for media content is supported and is a great feature. In CMS version 10 & 10.1 unfortunately due to a bug,  the media items become inaccessible if republished the older version of the content.

Here is the scenario to reproduce this problem

1- Upload a pdf or image file in media folder though EPiServer CMS edit.
2- Upload another version of the file, the EPiServer ask to Replace file or Skip .. choose to replace.
3- Now you can access the new version of the file. (So far so good)
4- Go back to/ select old version of the file and republish it.
and
5- the file is inaccessible.

As shown in the screenshot below taken from CMS version 10, the download option is disappeared and in 10.0.1 the download option becomes grayed out.




No matter what I do now, I can't bring the links and versions back to life unless I upload the file with different name.

As shown in the screenshot below the physical files exists in the Assets folder and I can open them without any problem.



I tested the above scenario on EPiServer CMS 9.1 and 9.7 sites and it works perfectly. So I came to the conclusion that it’s a bug in EPiServer CMS 10 and I reported to EPiServer support.

Solution


Episerver Support provided me with the following SQL script for the bug fix and it's been later fixed in the next released version of EPiServer. As always it is recommended to make a backup of the database before applying any scripts.

ALTER PROCEDURE [dbo].[editCreateContentVersion]
(
 @ContentID      INT,
 @WorkContentID  INT,
 @UserName  NVARCHAR(255),
 @MaxVersions    INT = NULL,
 @SavedDate  DATETIME,
 @LanguageBranch    NCHAR(17)
)
AS
BEGIN
 SET NOCOUNT ON
 SET XACT_ABORT ON
 
 DECLARE @NewWorkContentID  INT
 DECLARE @DeleteWorkContentID    INT
 DECLARE @ObsoleteVersions    INT
 DECLARE @retval    INT
 DECLARE @IsMasterLang  BIT
 DECLARE @LangBranchID  INT
 
 SELECT @LangBranchID = pkID FROM tblLanguageBranch WHERE LanguageID=@LanguageBranch
 IF @LangBranchID IS NULL
 BEGIN
  RAISERROR (N'editCreateContentVersion: LanguageBranchID is null, possibly empty table tblLanguageBranch', 16, 1, @WorkContentID)
  RETURN 0
 END

 IF (@WorkContentID IS NULL OR @WorkContentID=0 )
 BEGIN
  /* If we have a published version use it, else the latest saved version */
  IF EXISTS(SELECT * FROM tblContentLanguage WHERE Status = 4 AND fkContentID=@ContentID AND fkLanguageBranchID=@LangBranchID)
      SELECT @WorkContentID=[Version] FROM tblContentLanguage WHERE fkContentID=@ContentID AND fkLanguageBranchID=@LangBranchID
  ELSE
      SELECT TOP 1 @WorkContentID=pkID FROM tblWorkContent WHERE fkContentID=@ContentID AND fkLanguageBranchID=@LangBranchID ORDER BY Saved DESC
 END

 IF EXISTS( SELECT * FROM tblContent WHERE pkID=@ContentID AND fkMasterLanguageBranchID IS NULL )
  UPDATE tblContent SET fkMasterLanguageBranchID=@LangBranchID WHERE pkID=@ContentID
 
 SELECT @IsMasterLang = CASE WHEN @LangBranchID=fkMasterLanguageBranchID THEN 1 ELSE 0 END FROM tblContent WHERE pkID=@ContentID
 
  /* Create a new version of this content */
  INSERT INTO tblWorkContent
      (fkContentID,
      fkMasterVersionID,
      ChangedByName,
      ContentLinkGUID,
      fkFrameID,
      ArchiveContentGUID,
      Name,
      LinkURL,
      ExternalURL,
      VisibleInMenu,
      LinkType,
      Created,
      Saved,
      StartPublish,
      StopPublish,
      ChildOrderRule,
      PeerOrder,
      fkLanguageBranchID,
      URLSegment,
      ThumbnailUri,
      BlobUri)
  SELECT 
      fkContentID,
      @WorkContentID,
      @UserName,
      ContentLinkGUID,
      fkFrameID,
      ArchiveContentGUID,
      Name,
      LinkURL,
      ExternalURL,
      VisibleInMenu,
      LinkType,
      Created,
      @SavedDate,
      StartPublish,
      StopPublish,
      ChildOrderRule,
      PeerOrder,
      @LangBranchID,
      URLSegment,
      ThumbnailUri,
      BlobUri
  FROM 
      tblWorkContent 
  WHERE 
      pkID=@WorkContentID
 
  IF (@@ROWCOUNT = 1)
  BEGIN
      /* Remember version number */
      SET @NewWorkContentID= SCOPE_IDENTITY() 
      /* Copy all properties as well */
      INSERT INTO tblWorkContentProperty
    (fkPropertyDefinitionID,
    fkWorkContentID,
    ScopeName,
    Boolean,
    Number,
    FloatNumber,
    ContentType,
    ContentLink,
    Date,
    String,
    LongString,
                LinkGuid)          
      SELECT
    fkPropertyDefinitionID,
    @NewWorkContentID,
    ScopeName,
    Boolean,
    Number,
    FloatNumber,
    ContentType,
    ContentLink,
    Date,
    String,
    LongString,
                LinkGuid
      FROM
    tblWorkContentProperty
      INNER JOIN tblPropertyDefinition ON tblPropertyDefinition.pkID=tblWorkContentProperty.fkPropertyDefinitionID
      WHERE
    fkWorkContentID=@WorkContentID
    AND (tblPropertyDefinition.LanguageSpecific>2 OR @IsMasterLang=1)--Only lang specific on non-master 
 
      /* Finally take care of categories */
      INSERT INTO tblWorkContentCategory
    (fkWorkContentID,
    fkCategoryID,
    CategoryType,
    ScopeName)
      SELECT
    @NewWorkContentID,
    fkCategoryID,
    CategoryType,
    ScopeName
      FROM
    tblWorkContentCategory
      WHERE
    fkWorkContentID=@WorkContentID
    AND (CategoryType<>0 OR @IsMasterLang=1)--No content category on languages
  END
  ELSE
  BEGIN
      /* We did not have anything corresponding to the WorkContentID, create new work content from tblContent */
      INSERT INTO tblWorkContent
    (fkContentID,
    ChangedByName,
    ContentLinkGUID,
    fkFrameID,
    ArchiveContentGUID,
    Name,
    LinkURL,
    ExternalURL,
    VisibleInMenu,
    LinkType,
    Created,
    Saved,
    StartPublish,
    StopPublish,
    ChildOrderRule,
    PeerOrder,
    fkLanguageBranchID,
    URLSegment,
    ThumbnailUri,
    BlobUri)
      SELECT 
    @ContentID,
    COALESCE(@UserName, tblContentLanguage.CreatorName),
    tblContentLanguage.ContentLinkGUID,
    tblContentLanguage.fkFrameID,
    tblContent.ArchiveContentGUID,
    tblContentLanguage.Name,
    tblContentLanguage.LinkURL,
    tblContentLanguage.ExternalURL,
    tblContent.VisibleInMenu,
    CASE tblContentLanguage.AutomaticLink 
        WHEN 1 THEN 
      (CASE
          WHEN tblContentLanguage.ContentLinkGUID IS NULL THEN 0    /* EPnLinkNormal */
          WHEN tblContentLanguage.FetchData=1 THEN 4    /* EPnLinkFetchdata */
          ELSE 1        /* EPnLinkShortcut */
      END)
        ELSE
      (CASE 
          WHEN tblContentLanguage.LinkURL=N'#' THEN 3    /* EPnLinkInactive */
          ELSE 2        /* EPnLinkExternal */
      END)
    END AS LinkType ,
    tblContentLanguage.Created,
    @SavedDate,
    tblContentLanguage.StartPublish,
    tblContentLanguage.StopPublish,
    tblContent.ChildOrderRule,
    tblContent.PeerOrder,
    @LangBranchID,
    tblContentLanguage.URLSegment,
    ThumbnailUri,
    BlobUri
      FROM tblContentLanguage
      INNER JOIN tblContent ON tblContent.pkID=tblContentLanguage.fkContentID
      WHERE 
    tblContentLanguage.fkContentID=@ContentID AND tblContentLanguage.fkLanguageBranchID=@LangBranchID

      IF (@@ROWCOUNT = 1)
      BEGIN
    /* Remember version number */
    SET @NewWorkContentID= SCOPE_IDENTITY() 
    /* Copy all non-dynamic properties as well */
    INSERT INTO tblWorkContentProperty
        (fkPropertyDefinitionID,
        fkWorkContentID,
        ScopeName,
        Boolean,
        Number,
        FloatNumber,
        ContentType,
        ContentLink,
        Date,
        String,
        LongString,
                    LinkGuid)
    SELECT
        P.fkPropertyDefinitionID,
        @NewWorkContentID,
        P.ScopeName,
        P.Boolean,
        P.Number,
        P.FloatNumber,
        P.ContentType,
        P.ContentLink,
        P.Date,
        P.String,
        P.LongString,
                    P.LinkGuid
    FROM
        tblContentProperty AS P
    INNER JOIN
        tblPropertyDefinition AS PD ON P.fkPropertyDefinitionID=PD.pkID
    WHERE
        P.fkContentID=@ContentID AND (PD.fkContentTypeID IS NOT NULL)
        AND P.fkLanguageBranchID = @LangBranchID
        AND (PD.LanguageSpecific>2 OR @IsMasterLang=1)--Only lang specific on non-master 
 
    /* Finally take care of categories */
    INSERT INTO tblWorkContentCategory
        (fkWorkContentID,
        fkCategoryID,
        CategoryType)
    SELECT DISTINCT
        @NewWorkContentID,
        fkCategoryID,
        CategoryType
    FROM
        tblContentCategory
    LEFT JOIN
        tblPropertyDefinition AS PD ON tblContentCategory.CategoryType = PD.pkID
    WHERE
        tblContentCategory.fkContentID=@ContentID 
        AND (PD.fkContentTypeID IS NOT NULL OR tblContentCategory.CategoryType = 0) --Not dynamic properties
        AND (PD.LanguageSpecific=1 OR @IsMasterLang=1) --No content category on languages
      END
      ELSE
      BEGIN
    RAISERROR (N'Failed to create new version for content %d', 16, 1, @ContentID)
    RETURN 0
      END
  END

 /*If there is no version set for tblContentLanguage set it to this version*/
 UPDATE tblContentLanguage SET Version = @NewWorkContentID
 WHERE fkContentID = @ContentID AND fkLanguageBranchID = @LangBranchID AND Version IS NULL
 
 RETURN @NewWorkContentID
END

or you can download the SQL script here

August 23, 2017 →

Thursday, 13 July 2017

How to Fix SQL72014 and SQL72045 When Importing a BACPAC

Problem

I was importing a BACPAC generated on another server into my local development environment using SQL Server Management Studio and ran into the following errors:

TITLE: Microsoft SQL Server Management Studio
------------------------------

Could not import package.
Warning SQL0: A project which specifies Microsoft Azure SQL Database v12 as the target platform may experience compatibility issues with SQL Server 2014.
Warning SQL72012: The object [databaseXYZ_Data] exists in the target, but it will not be dropped even though you selected the 'Generate drop statements for objects that are in the target database but that are not in the source check box.
Warning SQL72012: The object [databaseXYZ_Log] exists in the target, but it will not be dropped even though you selected the 'Generate drop statements for objects that are in the target database but that are not in the source check box.
Error SQL72014: .Net SqlClient Data Provider: Msg 12824, Level 16, State 1, Line 5 The sp_configure value 'contained database authentication' must be set to 1 in order to alter a contained database. You may need to use RECONFIGURE to set the value_in_use.
Error SQL72045: Script execution error. The executed script:
IF EXISTS (SELECT 1
FROM   [master].[dbo].[sysdatabases]
WHERE  [name] = N'$(DatabaseName)')
BEGIN
ALTER DATABASE [$(DatabaseName)]
SET CONTAINMENT = PARTIAL
WITH ROLLBACK IMMEDIATE;
END

Error SQL72014: .Net SqlClient Data Provider: Msg 5069, Level 16, State 1, Line 5 ALTER DATABASE statement failed.
Error SQL72045: Script execution error. The executed script:
IF EXISTS (SELECT 1
FROM   [master].[dbo].[sysdatabases]
WHERE  [name] = N'$(DatabaseName)')
BEGIN
ALTER DATABASE [$(DatabaseName)]
SET CONTAINMENT = PARTIAL
WITH ROLLBACK IMMEDIATE;
END

(Microsoft.SqlServer.Dac)

Fix

Run the following T-SQL on the target SQL Server instance before importing the BACPAC:

EXEC sp_configure 'contained database authentication', 1;
GO
RECONFIGURE;
GO

Once that setting is enabled, rerun the import.

Why This Happens

The import process is trying to set the target database to partial containment:

ALTER DATABASE [YourDatabaseName]
SET CONTAINMENT = PARTIAL;

If contained database authentication is disabled on the SQL Server instance, that step fails and the import stops with SQL72014 and SQL72045.

This often happens when the BACPAC comes from Azure SQL Database and is being imported into a local SQL Server environment, where contained database authentication may be turned off by default.

Explanation

At first I suspected the issue might be caused by a corrupt BACPAC or Transparent Data Encryption (TDE), but the real cause was much simpler: the import required support for a contained database.

A partially contained database reduces dependencies on the master database and allows authentication and configuration to live more at the database level rather than relying entirely on server-level logins.

Because this has security implications, SQL Server does not always enable it by default on local or on-premises instances.

Notes

  • SQL72014 and SQL72045 are the main errors causing the import failure.
  • The SQL72012 warnings about data and log objects are not the root cause here.
  • For most local development environments, enabling contained database authentication is enough to complete the import successfully.

Further Reading

July 13, 2017 →

Sunday, 14 February 2016

How to Upload Files and Folders to Amazon S3 from PowerShell

Overview

If you need to upload files or entire folder structures to Amazon S3 from PowerShell, you no longer need to write your own recursive upload function unless you have very specific custom logic.

Today, there are two practical approaches:

  • AWS CLI v2 – the simplest and most flexible option for most developers.
  • AWS Tools for PowerShell – a good choice if you want to stay fully inside PowerShell cmdlets.

For most cases, I recommend AWS CLI v2.

Important Note About “Folders” in S3

Amazon S3 does not store folders the same way a normal file system does. S3 stores objects by key, and folder-like paths are created through key prefixes such as images/logo.png or releases/v1/app.zip.

That means you do not need to create folders manually before uploading files. If you upload an object to a key like photos/abc.png, S3 will display that path as a folder structure in the console.

Option 1. Use AWS CLI v2 (Recommended)

The modern way to upload files and folders to S3 from PowerShell is to use AWS CLI version 2.

This approach is faster, easier to maintain, and much better than building your own recursive upload script.

Install AWS CLI

Install the latest AWS CLI v2 on your Windows machine before continuing.

Configure Credentials

The old approach of hardcoding AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in scripts is no longer a good default.

Instead, use a named profile:

aws configure --profile my-s3-profile

If your organisation uses AWS IAM Identity Center (SSO), use:

aws configure sso --profile my-sso-profile
aws sso login --profile my-sso-profile

This keeps credentials out of your script and makes your setup easier to reuse.

Upload a Single File

aws s3 cp "C:\Files\logo.png" "s3://my-bucket/assets/logo.png" --profile my-s3-profile

Upload an Entire Folder

For whole directories, sync is usually better than cp --recursive because it only uploads new or changed files.

aws s3 sync "C:\MyDirectory" "s3://my-bucket/releases/v1/" --profile my-s3-profile

Upload Only Certain File Types

aws s3 sync "C:\MyDirectory" "s3://my-bucket/images/" --exclude "*" --include "*.jpg" --include "*.png" --profile my-s3-profile

Preview Before Uploading

If you want to see what will happen before making changes, use --dryrun:

aws s3 sync "C:\MyDirectory" "s3://my-bucket/releases/v1/" --dryrun --profile my-s3-profile

Mirror a Folder Exactly

If you want S3 to match your local folder exactly, add --delete:

aws s3 sync "C:\MyDirectory" "s3://my-bucket/releases/v1/" --delete --profile my-s3-profile

Be careful: --delete removes files in the target that no longer exist in the source.

Use a Profile in PowerShell

If you do not want to pass --profile on every command, set it in your current PowerShell session:

$env:AWS_PROFILE = "my-s3-profile"
aws s3 sync "C:\MyDirectory" "s3://my-bucket/releases/v1/"

Option 2. Use AWS Tools for PowerShell

If you prefer native PowerShell cmdlets instead of the AWS CLI, you can use AWS Tools for PowerShell.

Install the S3 Module

Install-Module AWS.Tools.S3 -Scope CurrentUser

Use an Existing AWS Profile

Set-AWSCredential -ProfileName my-s3-profile

Upload a Single File

Write-S3Object -BucketName "my-bucket" -Key "assets/logo.png" -File "C:\Files\logo.png"

Upload a Folder Recursively

Write-S3Object -BucketName "my-bucket" -Folder "C:\MyDirectory" -KeyPrefix "releases/v1/" -Recurse

Which Option Should You Use?

  • Use AWS CLI v2 if you want the most common and portable approach.
  • Use AWS Tools for PowerShell if you prefer PowerShell cmdlets and are already working in PowerShell-heavy automation.

Final Thoughts

If your goal is simply to upload or sync files and folders to S3, the built-in AWS tools are much better than writing your own recursive function. They are faster, safer, easier to read, and easier to maintain.

My recommendation today would be simple: use aws s3 sync for folders, use aws s3 cp for single files, and avoid storing access keys directly inside scripts unless you have a very specific reason to do so.

February 14, 2016 →

Wednesday, 30 December 2015

How to Recursively List All Files and Subfolders Using PowerShell

Here is a simple recursive function to display all file-names with full path in folders and sub-folders. You can only run this command in Windows PowerShell available since the release of Windows 7.
$folder = "c:\\MyDirectory\\"

 Function Upload($item) {
    foreach ($i in Get-ChildItem $item)
    {
        Try
        {
            if((Get-Item $i.FullName) -is [System.IO.DirectoryInfo]){
                  Write-Output $i.FullName
                  Upload($i.FullName)

            }else{
              Write-Output $i.FullName
            }
        }catch{
            Write-Output $i.FullName
        }
    }   
}
 
Upload($folder)

Copy the above code and save in .ps1 file.

December 30, 2015 →

How to Batch Rename All Files in a Folder Using PowerShell

If you are not interested in external programs to renames all files in a folder to lowercase, there is a simple command for you.

- Open Command Prompt (cmd.exe) in Windows
- Go to the directory and run the following command

for /f "Tokens=*" %f in ('dir /l/b/a-d') do (rename "%f" "%f")

Note: This is not a recursive function, it will rename files to lowercase only on the directory where you will run the command.

December 30, 2015 →

Sunday, 8 November 2015

How to Create a Yozio SubLink Using C#

If you need to read more about Yozio Sublink API, here is the documentation http://docs.yozio.com/articles/sublink-apis
Below is a code to create Yozio Sublink.
using System;
using System.Net;
using Newtonsoft.Json;

namespace Yozio
{
    public class YozioResult
    {
        public string status { get; set; }
        public Body body { get; set; }
    }
 
    public class Body
    {
        public string sub_link { get; set; }
        public object link_alias { get; set; }
        public MetaData meta_data { get; set; }
        public long timestamp { get; set; }
    }
 
    public class MetaData
    {
        public string utm_source { get; set; }
        public string utm_medium { get; set; }
        public string utm_campaign { get; set; }
    }
 
    public static class YozioApi
    {
        public static YozioResult SubLink
        {
            get
            {
                const string apiKey = "YOUR-YOZIO-API-KEY";
                const string yozioSuperLink = "YOZIO-SUPER-LINK"; //e.gk7.k.cf

                const string urlString = "http://api.yozio.com/v2.0/?app_key={0}&amp;" +
                                         "method=sub.link.create&amp;" +
                                         "short_url={1}&amp;" +
                                         "reassign_old_alias_to_this_link=true&amp;" +
                                         "meta_data[utm_source]=web&amp;" +
                                         "meta_data[utm_medium]=link&amp;" +
                                         "meta_data[utm_campaign]=blog";

                var url = string.Format(urlString, apiKey, yozioSuperLink);

                var client = new WebClient();
                client.Headers.Add("user-agent", "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.2; .NET CLR 1.0.3705;)");
                var json = client.DownloadString(new Uri(url));
                var result = JsonConvert.DeserializeObject<yozioresult>(json);
                return result;
            }
        }
    }
}
Usage: Getting Sublink
Yozio.YozioApi.SubLink.body.sub_link;

/Adnan
November 08, 2015 →

Wednesday, 7 October 2015

Wednesday, 9 September 2015

How to Stop Visual Studio 2015 Creating Backup Folders During Project Migration

I was in the process of migrating over to Visual Studio 2015 from Visual Studio 2013. When I executed a local command line build, I received the following error.
Microsoft Visual Studio 2015 Version 14.0.23107.0.
Copyright (C) Microsoft Corp. All rights reserved.
Solution file 'xyz.sln' is from a previous version of this application and must 
be migrated in order to build in this version of the application. 
To migrate the solution, open the solution in this version of the application.
Migration completed successfully, but some warnings were detected during migration.
For more information, see the migration report:  UpgradeLog06.htm
I opened the solution using Visual Studio 2015, and got the migration report. I performed the solution clean followed by solution rebuild and everything works fine. On closing the solution, and performing a local command line build again I got the same error message.
Every time I open the solution using VS 2015 a new "Backup" folder is created with iteration and a new migration report is displayed.
The migration report shows 8 projects that have the following 5 warnings.

Visual Studio needs to make non-functional changes to this project in 
order to enable the project to open in Visual Studio 2015, Visual Studio 2013, 
Visual Studio 2012, and Visual Studio 2010 SP1 without impacting project behavior
The solution I was opening in Visual studio 2015 has around 35 projects and every-time a backup folder is created, it takes space in disk and the make changes in solution file. It was quite a hassle to check-in solution file to source control with backup folder information. So I started digging around how to stop this backup folder nonsense, and after some soul searching I finally fixed it.

Here is the solution:
- Firstly, open your project file (.csproj) in text editor.
- Find the following two lines as shown in the image and delete them.

- Then save the solution file.
Now, open your file in visual studio 2015 again. No more new backup folders and migration reports. It is safe to delete the old backup folders(s) and associated HTML file(s).

/Adnan
September 09, 2015 →

Saturday, 16 May 2015

How to Get Random Items from an Array or List in C#

The simple way to get random item from an Array is to use the return value from random.next(0, array.length) as index to get value from the array.

var randomIndex = random.Next(0, Array.Length);
Console.Write(Array[randomIndex]);
The downside of the above code is it might return you item multiple times (repetition) as we don't keep track of items that we are getting from the source Array.

The easy approach is to consider Array as a deck of cards. We want the items to be 'shuffled' similar to a deck of cards, meaning avoiding any repetition. So we will use  a List<> for the source items, grab them at random and push them to a Stack<> to create the deck of items.

You can create a Stack from anything that is IEnumerable
var stack = new Stack(myList);
See MSDN: http://msdn.microsoft.com/en-us/library/76atxd68.aspx

However, the stack constructor will be using a loop internally, you just don't see it. So for understanding the purpose I will use an example to create Stack with loop.

public static Stack CreateShuffledDeck(IEnumerable values) {

var random = new Random();  var list = new List(values); var stack = new Stack();  while (list.Count > 0) {  // Get the next item at random. var randomIndex = random .Next(0, list.Count); var randomItem = list[randomIndex];  // Remove the item from the list and push it to the top of the deck. list.RemoveAt(randomIndex); stack.Push(randomItem ); }  return stack; } 
Now we have a solution to create a Shuffled Deck. We can now get random items out using Stack.Pop method . Popping something from the stack means "taking the top 'thing'" off the stack.

public static string[] RandomArrayEntries(string[] arrayItems, int count) {
var listToReturn = new List();

if (arrayItems.Length != count) {
var deck = CreateShuffledDeck(arrayItems);

for (var i = 0; i < count; i++) {
var arrayItems= deck.Pop();
listToReturn .Add(item);
}

return listToReturn .ToArray();
}

return arrayItems;
}
We can execute the above code as following:

var countriesArray = new string[] { "Sweden", "Pakistan", "United Kingdom", "Denmark", "Norway", "Finland" };

var newRandomAraay = RandomArrayEntries(countriesArray, 3);

/Adnan
May 16, 2015 →

Tuesday, 9 December 2014

How to Change the Default Font in Blogger

If you are tired of writing your blog posts in Times New Roman font and don't want to add bloated HTML by choosing the font and size from Blogger's post editor, this post is right place for you. The solution is simple if you know where to look. Below are two ways you can try.
Chrome Browser Settings
As Blogger is a part of Google family, changing the font is actually a Chrome setting, not a Blogger setting.

To change your font setting do the following
1. Chrome browser
2. Settings
3. Show Advanced Settings
4. Web Content: Customize Fonts
Here is a bonus part, though it does set your default font & size in Blogger, it also changes it all over Google.

CSS Way
Alternatively if you don't want to change chrome browser settings, you can make CSS work for you.

1. Sign in to your blogger account.
2. Select your blog.
3. On right hand menu click Templates and then Edit HTML.
4. Locate or search for <b:skin> and copy/paste the following CSS code inside.

* { font-family: Arial!important;}
or
body {
font-family: Arial!important;
}

5. Click Save template.

Now all the old and new blog posts in your blog will be in the font you specified through CSS.


/Adnan
December 09, 2014 →

Saturday, 6 December 2014

How to Fix HttpContextBase Errors in Facebook OAuth for ASP.NET

This fix applies to legacy ASP.NET Web Forms or ASP.NET MVC applications running on .NET Framework and using DotNetOpenAuth. If you are building a new application on ASP.NET Core, use the built-in external authentication providers instead of this older pattern.

Problem

I was helping a friend wire up Facebook OAuth login in an older ASP.NET application using the DotNetOpenAuth extensions installed from NuGet.

The code looked like this:

Uri ui = new Uri("~/Login.aspx", UriKind.Relative);

var fbClient = new DotNetOpenAuth.AspNet.Clients.FacebookClient("***", "***********");
fbClient.RequestAuthentication(context, ui);

The problem is that RequestAuthentication expects an instance of HttpContextBase.

If you try to pass HttpContext.Current directly, it fails because HttpContext.Current is a HttpContext, not a HttpContextBase.

Why This Happens

HttpContextBase was introduced as an abstraction over HttpContext. This makes ASP.NET code easier to test and easier to work with in components that should not depend directly on the concrete runtime context.

To bridge the gap between the two types, ASP.NET provides HttpContextWrapper.

Solution

Wrap HttpContext.Current in a HttpContextWrapper before calling RequestAuthentication:

var httpContextBase = new HttpContextWrapper(HttpContext.Current);
fbClient.RequestAuthentication(httpContextBase, ui);

Explanation

HttpContextWrapper acts as an adapter. It takes the current ASP.NET request context and exposes it as a HttpContextBase, which is exactly what the DotNetOpenAuth API expects.

So if you are maintaining a legacy ASP.NET application and run into a type mismatch between HttpContext and HttpContextBase, this wrapper is the correct fix.

Modern Note

For new applications, this is no longer the recommended approach. In modern ASP.NET Core applications, external login providers such as Facebook are configured through the built-in authentication middleware, and System.Web, HttpContextBase, and HttpContextWrapper are not part of that model.

December 06, 2014 →

Thursday, 27 November 2014

How to Render ASP.NET MVC Views to an HTML String

A common need I have in my ASP.NET MVC based projects is to render a complete "View" or "PartialView" to string instead of the HTTP response and then present it or embed it in another rendered view.

You can implement the following code in shared controller or preferably in base Controller so that you can access this function in all controllers across your project. You can access the ControllerContext within controller and pass it to the function. It will return the rendered view in HTML string, the usage is self-explanatory.
public static string RenderViewToString(string viewName, object model) 
{
 if (string.IsNullOrEmpty(viewName)) 
     viewName = ControllerContext.RouteData.GetRequiredString("action");

 ViewData.Model = model;
 using(StringWriter sw = new StringWriter()) 
 {
  ViewEngineResult viewResult = ViewEngines.Engines.FindPartialView(ControllerContext, viewName);
  ViewContext viewContext = new ViewContext(ControllerContext, viewResult.View, ViewData, TempData, sw);
  viewResult.View.Render(viewContext, sw);
  return sw.GetStringBuilder().ToString();
 }
}

ControllerContext can be access using following method.

ControllerContext.RouteData.GetRequiredString("action");

If you want to put the function in a helper class you have to pass the ControllerContext from controller to the function.

public static string RenderViewToString(ControllerContext context, string viewName, object model) 
{
 if (string.IsNullOrEmpty(viewName)) 
     viewName = context.RouteData.GetRequiredString("action");
 
 var viewData = new ViewDataDictionary(model);
 using(var sw = new StringWriter()) 
 {
  var viewResult = ViewEngines.Engines.FindPartialView(context, viewName);
  var viewContext = new ViewContext(context, viewResult.View, viewData, new TempDataDictionary(), sw);
  viewResult.View.Render(viewContext, sw);
  return sw.GetStringBuilder().ToString();
 }
}

Call the function in your Action Method

//Somewhere in HomeController
public ActionResult Index() 
{
 //Second parameter(model) can be null
 var context = ControllerContext.RouteData.GetRequiredString("action");
 var content = RenderViewToString(context, "profile", new ProfileModel());
 //var content = RenderViewToString("profile", new ProfileModel());

 
        //Do something with the content, e.g.get profile specific template and send it to e-mail


 //This does nothing to do with rendered string
 return View();
}


/Adnan

November 27, 2014 →

Friday, 21 November 2014

How to Copy Text to the Clipboard Using JavaScript

In a recent web project, I needed to create a button that would copy text from textbox onto the user's clipboard. The obvious approach is to use jQuery or JavaScript to trigger onclick() event of the button and copy text to clipboard. It sounds easy and convenient and should be achieved with few lines of code but it is not. 
During the code generation process, I found that JavaScript copy to clipboard was not available because of security which also meant that jQuery would not be able to copy the text to clipboard. Actually you can still use JavaScript, but it prompts the user to allow the application to copy text on clipboard, and hence voids the whole idea of providing user convenience. This means I had to find another way around, but I still wanted to use JavaScript.

After spending some time on Google search, luckily I found a jQuery library called ZeroClipboard. This library provides an easy way to copy text to the clipboard using a pinch of Invisible Adobe Flash movie, and touch of JavaScript. Flash can access your computer's clipboard because you have to install flash and agree to the security settings. We can use JavaScript as an interface to flash so we can start this off with a click event on a button.

Note: Before we continue to tutorial/demo there are some things to consider. Due to security issues, flash cannot access the clipboard unless the action originates from a click (or user interaction) with a flash object.
You cannot copy paste code in html file and open it with browser and expect ZeroClipboard to run. You will not able to click button. So you have to host the HTML page in your local IIS website e.g http://localhost:8080/zeroclipbaord.html to make it work.

How to Use ZeroClipboard?
You can download ZeroClipboard from http://zeroclipboard.org/ or use it the from the public content distribution network (CDN) cdnjs: http://cdnjs.com/libraries/zeroclipboard
To start using ZeroClipboard simply include following JavaScript file in your page.

<script src="//cdnjs.cloudflare.com/ajax/libs/zeroclipboard/2.1.6/ZeroClipboard.js" type="text/javascript"></script>

The following example shows you two common ways to copy text on clipboard
1 - Copy the text by Setting Target Area
2 - Copy the text with a HTML data-attribute

1- Copy the Text by Setting Target Area
This method allows you to define a HTML element that you can get the text from to copy. The value that it will use can either be the value of the element, the innerHTML or the textContent. This works off a data attribute of data-clipboard-target with a value of the ID of element you want to copy.

<button data-clipboard-target="clipboard-text" id="btn-To-Copy">Copy To Clipboard</button>

<textarea cols="20" id="clipboard-text" name="clipboard-text" onclick="this.select();" rows="20">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus mattis lacus nibh, ac sollicitudin sapien accumsan in. Mauris euismod posuere tellus luctus sodales.
Fusce a consectetur massa, non tincidunt mauris. Phasellus a rutrum libero. Praesent tempus urna et nisi aliquam convallis. Fusce porttitor justo condimentum orcieuismod, pulvinar congue magna vestibulum.
Sed gravida eleifend justo, id ultrices tellus porttitor nec. Nam porttitor gravida tempor. In libero ante, euismod ac fermentum nec, gravida ut dolor. Nullam a pulvinar ligula.
</textarea>

<div id="responsecopy" style="display: none; position: relative;">
</div>

We setup the ZeroClipboard client to be attached to the btn-To-Copy button. ZeroClipboard will search for the data-clipboard-target attribute and use this value to get the text to copy on clipboard.

2- Copy the Text with a HTML data-attribute
You provide button with the text and use Html data-attribute (data-clipboard-text) to tell ZeroClipboard to copy value from button to Clipboard.

<button data-clipboard-text="This text will be copied to Clipboard" id="btn-To-Copy" name="btn-To-Copy">Copy To Clipboard</button>
<div id="responsecopy" style="display: none; position: relative;">
</div>

Both above examples use the same following JavaScript

<script type="text/javascript">
        var client = new ZeroClipboard(document.getElementByIdid("btn-To-Copy"));

        client.on("ready", function (readyEvent) {
            // alert( "ZeroClipboard SWF is ready!" );

            client.on("aftercopy", function (event) {
                // `this` === `client`
                // `event.target` === the element that was clicked

                var msgBox = document.getElementById("responsecopy");
                msgBox.innerHTML = "Copied '" + event.data["text/plain"] + "' to clipboard";
                msgBox.style.display = 'block';

               //alert("Copied text to clipboard: " + event.data["text/plain"]);
           });
        });
    </script>

ZeroClipboard uses a Flash movie, and so your users obviously need to have Adobe Flash installed. You do need to handle the case where it is not present.

ZeroClipboard Documentation
Need to learn more how to Use ZeroClipboard? You can go though the latest version documentation of ZeroClipboard on their Github Project page.


/Adnan
November 21, 2014 →

Sunday, 12 October 2014

How to Get Combinations of Rows from Multiple SQL Tables

Getting combinations of rows from database tables is simple. First you have to understand the difference between Cartesian product and Permutation before we go any further.

I have manually printed out all the combinations in example containing three tables with respective values for the understanding.

Example
Table1     Table2     Table3
a1             b1           c1
a2             b2           c2
a3                            c3
                                c4
The results should be as follow

a1,b1,c1
a1,b1,c2
a1,b1,c3
a1,b1,c4

a1,b2,c1
a1,b2,c2
a1,b2,c3
a1,b2,c4

a2,b1,c1
a2,b1,c2
a2,b1,c3
a2,b1,c4

a2,b2,c1
a2,b2,c2
a2,b2,c3
a2,b2,c4

a3,b1,c1
a3,b1,c2
a3,b1,c3
a3,b1,c4

a3,b2,c1
a3,b2,c2
a3,b2,c3
a3,b2,c4
The above results are not permutation, because you need the combinations to always follow the unique format. So, in conclusion the Cartesian product concept is the right way to go.

Solution
We will use Cartesian Join or Cross Join for the solution of above example. Cross Join returns the Cartesian product of rows from tables in the join. Each row in the first table is matched with every row in the second table and so on.
select *
from
  table1
  cross join table2
  cross join table3
Same thing as implicit cross join:
select *
from
  table1, table2, table3



/Adnan

October 12, 2014 →

Thursday, 2 October 2014

How to Get the Current Page in a Block Controller or Action Filter in Optimizely CMS

If you need to get the current routed page inside a block controller, view component, or action filter in Optimizely CMS, the usual approach is to use IPageRouteHelper.

If you want the current routed content more generically, use IContentRouteHelper.

Modern Approach

In newer Optimizely CMS projects, it is better to use constructor injection instead of ServiceLocator.Current.

Get the Current Page

using EPiServer.Web.Routing;

public class MyService
{
    private readonly IPageRouteHelper _pageRouteHelper;

    public MyService(IPageRouteHelper pageRouteHelper)
    {
        _pageRouteHelper = pageRouteHelper;
    }

    public PageData GetCurrentPage()
    {
        return _pageRouteHelper.Page;
    }
}

Get the Current Page Reference

using EPiServer.Web.Routing;

public class MyService
{
    private readonly IPageRouteHelper _pageRouteHelper;

    public MyService(IPageRouteHelper pageRouteHelper)
    {
        _pageRouteHelper = pageRouteHelper;
    }

    public PageReference GetCurrentPageReference()
    {
        return _pageRouteHelper.PageLink;
    }
}

Get the Current Routed Content

If you do not specifically need a page, use IContentRouteHelper instead:

using EPiServer.Web.Routing;

public class MyService
{
    private readonly IContentRouteHelper _contentRouteHelper;

    public MyService(IContentRouteHelper contentRouteHelper)
    {
        _contentRouteHelper = contentRouteHelper;
    }

    public IContent GetCurrentContent()
    {
        return _contentRouteHelper.Content;
    }

    public ContentReference GetCurrentContentReference()
    {
        return _contentRouteHelper.ContentLink;
    }
}

Using It in an Action Filter

If you need the current page inside an action filter, you can resolve the helper from the request services:

using EPiServer.Web.Routing;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.DependencyInjection;

public class MyActionFilter : ActionFilterAttribute
{
    public override void OnActionExecuting(ActionExecutingContext context)
    {
        var pageRouteHelper = context.HttpContext.RequestServices.GetRequiredService<IPageRouteHelper>();
        var currentPage = pageRouteHelper.Page;
        var currentPageReference = pageRouteHelper.PageLink;

        base.OnActionExecuting(context);
    }
}

Legacy Example

If you are working with older code, you may still see ServiceLocator.Current being used:

using EPiServer.ServiceLocation;
using EPiServer.Web.Routing;

var pageRouteHelper = ServiceLocator.Current.GetInstance<IPageRouteHelper>();
var currentPage = pageRouteHelper.Page;
var pageReference = pageRouteHelper.PageLink;

Important Note

Page and PageLink are only available when the current request is actually routed to a page. In some non-page contexts, such as certain preview scenarios, they may be null. If you need a more general routed object, IContentRouteHelper is the safer choice.

Final Thought

If your code only needs the routed content, prefer IContentRouteHelper. If your code specifically depends on a page, use IPageRouteHelper.

October 02, 2014 →

Friday, 26 September 2014

How to Make a Conference or Group Call Using Rebtel

If you are a Rebtel user and want to make a conference / group call, then this post is the right place for you. Rebtel is a great, cheap way to make international calls to your loved ones with crystal clear voice quality. I love it and I'm sure that you do too. So when you get what you need without much effort you want more, that’s human nature and in Rebtel's case more is possible in the form of Conference / Group calls.

Rebtel don't officially offer and support conference calling as a service. This can be a useful feature if you want to call your friends and family at once over PSTN without caring about what device they have. For a receiver it is as simple as just picking up the call and talking. You do not have to bother getting online, downloading plugins or software to just engage in a simple call. This is basically a hustle free solution. Conference call over data is great, but there is one catch that everyone you want to be in group / conference call should be using it too, and be online on certain application like Skype. So how can you actually make Conference / Group call using your beloved Rebtel, It requires some work but it’s not difficult at all.

Before we start with the details, please note that there are some limitations of this process. This will only work if you are registered in one of Rebtel Countries. If you don't know what the Rebtel countries are? Read here. The reason to be in a Rebtel country is to use Rebtel famous and brilliant feature Local Numbers. Confused about Local Numbers? Have a quick peak here. 

We will use the phones native conference call functionality. You have to use a Smartphone, any smart phone with the Add Call button on the dialer while on call indicates the phone supports conference call natively (e.g. Android, iPhone or Windows Phone). As we will not use the Rebtel app you have to bare network charges for every call. So if you have an unlimited or free minutes on your price plan from your operator this process will be much cost effective.


Every number you add in My Local Numbers by login in http://my.rebtel.com, Rebtel assigns a Local Access Number against the contact so as an alternative of using Rebtel app, you can directly call Local Number and Rebtel automatically connects you with the friend you want to call. FYI, Rebtel Local Numbers are personal and cannot be shared.

The following steps are based on Android native dial pad:

1. Go to http://my.rebtel.com on a menu click "My Local Numbers", and create Local Numbers for the contacts you want to do a group call, and save in your save Rebtel generated local access number to your phone contacts. You will get a SMS and email of the number when you created the local access number so you can easily save it in your phone contacts.

2. Now all you have to do is make a regular call from your phone native dialer to the Local Number with whom you want to have conference / group call to initiate local minutes based Rebtel call.

3. Once the call is established, press the add people button on a dialer and select contact (with Rebtel local number) to initiate the second call. 


At this point the other party will be on hold automatically so inform them beforehand not to disconnect the call. You can manually put them on hold as well just to be on safe side.



4. Now you have two calls established one on hold and the second active, simply tap Merge Calls on dial pad to create a conference call.



Repeat step 3 to 4 for each additional personal whom you want in on the conference call.

You can add up to 5 people on conference call on Android and iPhone by repeating the same process.

Bonus Tip: If your friend calls you while you are on a call you can merge him in conference call as well. So instead you call people, they can call you and you add them in your conference call. Great for people who want to save network and Rebtel charges.

Go ahead and make conference / group call.

Happy Calling

/Adnan

September 26, 2014 →

Wednesday, 24 September 2014

How to Delete a Language Branch from All Pages in Optimizely CMS

Suppose your EPiServer CMS site contains a large number of pages and supports multiple languages, for example English (en), French (fr), and Italian (it).

You want to remove one language branch, such as French, from all pages across the site, but you do not know exactly how many pages currently use that language.

If you try to remove the language directly from the CMS admin UI, you will usually hit an error because the language is still being used by content or language settings.

Before You Start

  • Take a database backup first.
  • Test the process in a lower environment before running it in production.
  • Disable the target language in Admin/Settings > Manage Website Languages so editors cannot keep creating new versions while the cleanup is running.
  • This approach will not delete pages where the language you want to remove is the master language. Those must be handled separately.

Small Sites vs Large Sites

If only a handful of pages use the language, the built-in Versions gadget is usually enough.

But if the site is large, deleting language branches manually page by page quickly becomes painful. In that case, a scheduled job is the safer and more practical option.

Modern Approach

In newer EPiServer / Optimizely CMS solutions, the cleanest approach is to:

  • Loop through the site start pages and their descendants.
  • Check whether a page has the language branch you want to remove.
  • Skip pages where that language is the master language.
  • Delete the branch with IContentRepository.DeleteLanguageBranch.

Scheduled Job Example

using System;
using System.Collections.Generic;
using System.Linq;
using EPiServer;
using EPiServer.Core;
using EPiServer.PlugIn;
using EPiServer.Scheduler;
using EPiServer.Security;
using EPiServer.Web;

[ScheduledPlugIn(
    DisplayName = "Delete French Language Branch From All Pages",
    Description = "Deletes the fr branch from all pages where fr is not the master language.",
    SortIndex = 100)]
public class DeleteLanguageBranchFromAllPagesJob : ScheduledJobBase
{
    private readonly IContentLoader _contentLoader;
    private readonly IContentRepository _contentRepository;
    private readonly ISiteDefinitionRepository _siteDefinitionRepository;

    private bool _stopRequested;

    private const string LanguageToDelete = "fr";

    public DeleteLanguageBranchFromAllPagesJob(
        IContentLoader contentLoader,
        IContentRepository contentRepository,
        ISiteDefinitionRepository siteDefinitionRepository)
    {
        _contentLoader = contentLoader;
        _contentRepository = contentRepository;
        _siteDefinitionRepository = siteDefinitionRepository;

        IsStoppable = true;
    }

    public override string Execute()
    {
        var scanned = 0;
        var deleted = 0;
        var skippedMasterLanguage = 0;
        var failed = 0;
        var visited = new HashSet<int>();

        foreach (var site in _siteDefinitionRepository.List().Where(x => !ContentReference.IsNullOrEmpty(x.StartPage)))
        {
            var references = new[] { site.StartPage }.Concat(_contentLoader.GetDescendents(site.StartPage));

            foreach (var contentLink in references)
            {
                if (_stopRequested)
                {
                    return $"Stopped. Scanned: {scanned}, Deleted: {deleted}, Skipped master language: {skippedMasterLanguage}, Failed: {failed}";
                }

                if (!visited.Add(contentLink.ID))
                {
                    continue;
                }

                scanned++;

                try
                {
                    var languageBranches = _contentRepository
                        .GetLanguageBranches<IContent>(contentLink)
                        .OfType<PageData>()
                        .ToList();

                    if (!languageBranches.Any())
                    {
                        continue;
                    }

                    var branchToDelete = languageBranches.FirstOrDefault(x =>
                        string.Equals(x.Language.Name, LanguageToDelete, StringComparison.OrdinalIgnoreCase));

                    if (branchToDelete == null)
                    {
                        continue;
                    }

                    if (branchToDelete is ILocalizable localizable &&
                        localizable.MasterLanguage != null  &&
                        string.Equals(localizable.MasterLanguage.Name, LanguageToDelete, StringComparison.OrdinalIgnoreCase))
                    {
                        skippedMasterLanguage++;
                        continue;
                    }

                    _contentRepository.DeleteLanguageBranch(contentLink, LanguageToDelete, AccessLevel.Delete);
                    deleted++;
                }
                catch (Exception ex)
                {
                    failed++;
                    OnStatusChanged($"Failed for content ID {contentLink.ID}: {ex.Message}");
                }
            }
        }

        return $"Completed. Scanned: {scanned}, Deleted: {deleted}, Skipped master language: {skippedMasterLanguage}, Failed: {failed}";
    }

    public override void Stop()
    {
        _stopRequested = true;
    }
}

How It Works

The job goes through each site start page and all descendant pages beneath it. For every page, it checks whether the target language exists.

If the page contains the target branch and that branch is not the master language, the job removes it. If the target language is the master language, the page is skipped.

After the Job

  • Review the skipped pages. These are typically pages where the language you want to remove is the master language.
  • Try removing the language from Manage Website Languages again.
  • If the CMS still reports that the language is used in language settings, clear that language from the affected start pages or content language settings and retry.

Final Note

If you only need to remove language branches from a few pages, use the UI. If you need to clean up hundreds or thousands of pages, a scheduled job like the above is a much more realistic approach.

If you also want to remove the same language from blocks or media, the same idea can be extended beyond PageData to other localizable content types.

September 24, 2014 →

Thursday, 11 September 2014

How to Get All Page Types in Optimizely CMS

If you want to get all page types in Optimizely CMS, the recommended modern approach is to use IContentTypeRepository.

This is the cleaner replacement for older patterns such as PageTypeRepository or PageType.List().

Modern approach

In newer Optimizely CMS projects, use IContentTypeRepository and filter the result to PageType:

using System.Linq;
using EPiServer.DataAbstraction;

var contentTypeRepository = ServiceLocator.Current.GetInstance<IContentTypeRepository>();

var pageTypes = contentTypeRepository
    .List()
    .OfType<PageType>()
    .OrderBy(x => x.DisplayName ?? x.Name)
    .ToList();

This returns the page type definitions configured in the CMS.

Recommended approach in application code

If you are writing new code, constructor injection is better than using ServiceLocator.Current:

using System.Collections.Generic;
using System.Linq;
using EPiServer.DataAbstraction;

public class PageTypeService
{
    private readonly IContentTypeRepository _contentTypeRepository;

    public PageTypeService(IContentTypeRepository contentTypeRepository)
    {
        _contentTypeRepository = contentTypeRepository;
    }

    public IList<PageType> GetAllPageTypes()
    {
        return _contentTypeRepository
            .List()
            .OfType<PageType>()
            .OrderBy(x => x.DisplayName ?? x.Name)
            .ToList();
    }
}

About PageTypeRepository

You may still see older examples using PageTypeRepository:

using EPiServer.DataAbstraction;
using EPiServer.ServiceLocation;

var repository = ServiceLocator.Current.GetInstance<PageTypeRepository>();
var pageTypes = repository.List();

This still appears in older codebases, but the official Optimizely API marks PageTypeRepository as obsolete and recommends using IContentTypeRepository instead.

Legacy fallback

For older EPiServer projects, you may still find this approach:

var pageTypes = EPiServer.DataAbstraction.PageType.List();

This method is obsolete in later versions, but it can still be useful when maintaining legacy solutions.

Final note

Remember that this gives you the list of page type definitions, not the actual pages created from those types. If you need the pages themselves, you will need to query content separately.

September 11, 2014 →