What Sitecore standard copy functionality does:
- Copies the whole item tree under the selected item
- Copies all item versions (if you have more)
- All references (included the datasources in your components) remain the same
So in some cases it is good but especially when you have pages with local component datasources the standard copy function does not really for you.
To solve this problem implemented the following solution but it has some prerequisites:
- The Page item should have folder item named Content, the the item identicated as a Page
- The Content folder should contain all local datasource items for the actual Page
What this button does?
- Creates a new item from the selected item with the Content item recursively
- Creates new language versions from the last version of the selected Page item
- Sets the new item’s datasource references in the Final Layout and Shared Layout
- Sets the new item’s rule action datasource references in the Final Layout and Shared Layout
At first I have the following config which registers a command for the button in Content Editor and a processor which does the main job.
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/" xmlns:x="http://www.sitecore.net/xmlconfig/"> | |
<sitecore> | |
<commands> | |
<command name="skeleton:copypage" type="Helix.Skeleton.Foundation.EditorExtensions.Commands.CopyPage,Helix.Skeleton.Foundation.EditorExtensions"/> | |
</commands> | |
<processors> | |
<skeletonUiCopyPage> | |
<processor mode="on" type="Helix.Skeleton.Foundation.EditorExtensions.Pipelines.CopyPage.CopyPage,Helix.Skeleton.Foundation.EditorExtensions" method="GetDestination"/> | |
<processor mode="on" type="Helix.Skeleton.Foundation.EditorExtensions.Pipelines.CopyPage.CopyPage,Helix.Skeleton.Foundation.EditorExtensions" method="CheckDestination"/> | |
<processor mode="on" type="Helix.Skeleton.Foundation.EditorExtensions.Pipelines.CopyPage.CopyPage,Helix.Skeleton.Foundation.EditorExtensions" method="CheckLanguage"/> | |
<processor mode="on" type="Helix.Skeleton.Foundation.EditorExtensions.Pipelines.CopyPage.CopyPage,Helix.Skeleton.Foundation.EditorExtensions" method="Execute"/> | |
</skeletonUiCopyPage> | |
</processors> | |
</sitecore> | |
</configuration> |
Here we have the command which calls the pipeline.
namespace Helix.Skeleton.Foundation.EditorExtensions.Commands | |
{ | |
using System.Collections.Specialized; | |
using System.Linq; | |
using System.Web.Mvc; | |
using Helix.Skeleton.Foundation.EditorExtensions.Consts.CopyPage; | |
using Helix.Skeleton.Foundation.EditorExtensions.Services; | |
using Sitecore; | |
using Sitecore.Data.Items; | |
using Sitecore.Shell.Framework.Commands; | |
using Sitecore.Shell.Framework.Pipelines; | |
public class CopyPage : Command | |
{ | |
private const string CopyPagePipelineName = "skeletonUiCopyPage"; | |
private readonly ICopyPageService duplicatePageService; | |
public CopyPage() | |
{ | |
this.duplicatePageService = DependencyResolver.Current.GetService<ICopyPageService>(); | |
} | |
public override void Execute(CommandContext context) | |
{ | |
NameValueCollection parameters = new NameValueCollection | |
{ | |
{ ParameterKeys.DatabaseKey, context.Items.First().Database.Name }, | |
{ ParameterKeys.ItemsKey, string.Join("|", context.Items.Select(i => i.ID)) }, | |
{ ParameterKeys.LanguageKey, context.Items.First().Language.Name } | |
}; | |
Context.ClientPage.Start(CopyPagePipelineName, new CopyItemsArgs { Parameters = parameters }); | |
} | |
public override CommandState QueryState(CommandContext context) | |
{ | |
var currentItem = this.GetCurrentItem(context); | |
if (currentItem == null) | |
{ | |
return CommandState.Disabled; | |
} | |
var contentFolder = this.duplicatePageService.GetContentFolder(currentItem.Paths.FullPath, currentItem.Database.Name); | |
if (contentFolder == null) | |
{ | |
return CommandState.Disabled; | |
} | |
return base.QueryState(context); | |
} | |
private Item GetCurrentItem(CommandContext context) | |
{ | |
if (context.Items == null || context.Items.Count() != 1) | |
{ | |
return null; | |
} | |
return context.Items.First(); | |
} | |
} | |
} |
Here is the referenced pipeline.
namespace Helix.Skeleton.Foundation.EditorExtensions.Pipelines.CopyPage | |
{ | |
using System.Collections.Specialized; | |
using System.Web.Mvc; | |
using Helix.Skeleton.Foundation.EditorExtensions.Consts.CopyPage; | |
using Helix.Skeleton.Foundation.EditorExtensions.Models.CopyPage; | |
using Helix.Skeleton.Foundation.EditorExtensions.Services; | |
using Sitecore; | |
using Sitecore.Configuration; | |
using Sitecore.Data; | |
using Sitecore.Data.Items; | |
using Sitecore.Data.Managers; | |
using Sitecore.Globalization; | |
using Sitecore.Shell.Framework.Pipelines; | |
using Sitecore.Web.UI.Sheer; | |
public class CopyPage : CopyItems | |
{ | |
private readonly ICopyPageService copyPageService; | |
public CopyPage() | |
{ | |
this.copyPageService = DependencyResolver.Current.GetService<ICopyPageService>(); | |
} | |
public override void Execute(CopyItemsArgs args) | |
{ | |
if (args == null) | |
{ | |
return; | |
} | |
var parameters = this.MapParameters(args.Parameters); | |
if (!this.AreParametersValid(parameters)) | |
{ | |
SheerResponse.Alert("Parameters are not valid."); | |
return; | |
} | |
var database = Factory.GetDatabase(parameters.Database); | |
if (database == null) | |
{ | |
SheerResponse.Alert("Database is null."); | |
return; | |
} | |
var oldItem = database.GetItem(ID.Parse(parameters.ItemId), Language.Parse(parameters.Language)); | |
if (oldItem == null) | |
{ | |
SheerResponse.Alert("Current item is null."); | |
return; | |
} | |
var targetItem = database.GetItem(ID.Parse(parameters.DestinationId)); | |
if (targetItem == null) | |
{ | |
SheerResponse.Alert("Target item is null."); | |
return; | |
} | |
var oldContentFolder = this.copyPageService.GetContentFolder(oldItem.Paths.FullPath, parameters.Database); | |
if (oldContentFolder == null) | |
{ | |
SheerResponse.Alert("Current item is not a page."); | |
return; | |
} | |
var newItemName = ItemUtil.GetCopyOfName(targetItem, oldItem.Name); | |
// Create new page item without version and language | |
var newItemWithoutVersion = ItemManager.CreateItem(newItemName, oldItem.Parent, oldItem.TemplateID); | |
// Create new content folder item | |
oldContentFolder.CopyTo(newItemWithoutVersion, oldContentFolder.Name); | |
foreach (var language in oldItem.Languages) | |
{ | |
var languageSpecificNewItem = this.copyPageService.CopyLastVersion(database, language, oldItem, newItemWithoutVersion); | |
if (languageSpecificNewItem == null) | |
{ | |
continue; | |
} | |
this.copyPageService.AdjustLayout(database, languageSpecificNewItem, oldItem, FieldIDs.LayoutField); | |
this.copyPageService.AdjustLayout(database, languageSpecificNewItem, oldItem, FieldIDs.FinalLayoutField); | |
} | |
} | |
private IParameters MapParameters(NameValueCollection parameters) | |
{ | |
return new Parameters | |
{ | |
Database = parameters.Get(ParameterKeys.DatabaseKey), | |
ItemId = parameters.Get(ParameterKeys.ItemsKey), | |
Language = parameters.Get(ParameterKeys.LanguageKey), | |
DestinationId = parameters.Get(ParameterKeys.DestinationKey) | |
}; | |
} | |
private bool AreParametersValid(IParameters parameters) | |
{ | |
return parameters != null | |
&& !string.IsNullOrEmpty(parameters.DestinationId) | |
&& !string.IsNullOrEmpty(parameters.Database) | |
&& !string.IsNullOrEmpty(parameters.ItemId) | |
&& !string.IsNullOrEmpty(parameters.Language); | |
} | |
} | |
} |
As you can see it calls the another service class where I have implemented the main logic.
namespace Helix.Skeleton.Foundation.EditorExtensions.Services | |
{ | |
using System; | |
using System.Collections.Generic; | |
using System.Linq; | |
using System.Xml.Linq; | |
using Helix.Skeleton.Foundation.EditorExtensions.Consts.CopyPage; | |
using Helix.Skeleton.Foundation.EditorExtensions.Models.CopyPage; | |
using Sitecore; | |
using Sitecore.Configuration; | |
using Sitecore.Data; | |
using Sitecore.Data.Fields; | |
using Sitecore.Data.Items; | |
using Sitecore.Globalization; | |
using Sitecore.Layouts; | |
using Sitecore.Rules.ConditionalRenderings; | |
public class CopyPageService : ICopyPageService | |
{ | |
private const string ContentFolderName = "Content"; | |
private const string GetItemsByNameAndTemplateQueryFormat = "{0}/*[@@name='{1}' and @@templateid='{2}']"; | |
public Item GetContentFolder(string currentItemPath, string databaseName) | |
{ | |
var foundContentItems = Factory | |
.GetDatabase(databaseName) | |
.SelectItems(string.Format(GetItemsByNameAndTemplateQueryFormat, currentItemPath.Replace("-", "#-#").Replace("$", "#$#"), ContentFolderName, TemplateIDs.Folder.ToString())); | |
if (foundContentItems.Length > 1) | |
{ | |
return null; | |
} | |
return foundContentItems.FirstOrDefault(); | |
} | |
public Item CopyLastVersion(Database database, Language language, Item source, Item destinationWithoutVersion) | |
{ | |
var languageSpecificSource = database.GetItem(source.ID, language); | |
if (languageSpecificSource.Versions.Count == 0) | |
{ | |
return null; | |
} | |
var languageSpecificDestination = database.GetItem(destinationWithoutVersion.ID, language); | |
languageSpecificDestination = languageSpecificDestination.Versions.AddVersion(); | |
languageSpecificDestination.Editing.BeginEdit(); | |
languageSpecificSource.Fields.ReadAll(); | |
foreach (Field field in languageSpecificSource.Fields) | |
{ | |
languageSpecificDestination.Fields[field.ID].SetValue(field.Value, true); | |
} | |
languageSpecificDestination.Editing.EndEdit(); | |
languageSpecificDestination.Editing.AcceptChanges(); | |
return languageSpecificDestination; | |
} | |
public void AdjustLayout(Database database, Item newItem, Item oldItem, ID layoutFieldId) | |
{ | |
var allDevices = database.Resources.Devices.GetAll(); | |
var newLayoutField = new LayoutField(newItem.Fields[layoutFieldId]); | |
var oldDatasources = new List<IOldDatasourceItem>(); | |
foreach (var device in allDevices) | |
{ | |
var renderingReferences = newLayoutField.GetReferences(device); | |
if (renderingReferences == null) | |
{ | |
continue; | |
} | |
// Collect datasource references | |
oldDatasources.AddRange(this.GetOldReferences(database, renderingReferences.Where(r => r != null), r => r.Settings.DataSource)); | |
// Collect personalization references | |
var conditionalRenderings = renderingReferences | |
.Where(r => r?.Settings?.Rules?.Rules != null) | |
.SelectMany(r => r.Settings.Rules.Rules | |
.SelectMany(rule => rule?.Actions | |
.Select(a => a as SetDataSourceAction<ConditionalRenderingsRuleContext>).Where(a => a != null))); | |
oldDatasources.AddRange(this.GetOldReferences(database, conditionalRenderings, c => c.DataSource)); | |
} | |
var newLayoutDefinition = LayoutDefinition.Parse(newLayoutField.Value); | |
foreach (var device in newLayoutDefinition.Devices.Cast<DeviceDefinition>()) | |
{ | |
var renderings = device.Renderings.Cast<RenderingDefinition>(); | |
// Set datasource referneces | |
this.SetNewReferences( | |
new SetNewReferencesParameters<RenderingDefinition> | |
{ | |
Database = database, | |
NewItem = newItem, | |
OldItem = oldItem, | |
NewLayout = newLayoutField, | |
NewLayoutDefinition = newLayoutDefinition, | |
OldDatasources = oldDatasources, | |
SourceList = renderings.Where(r => r.Datasource != null), | |
GetIdValue = r => r.Datasource, | |
SetIdValue = (r, d) => r.Datasource = d | |
}); | |
// Set personalization references | |
var actions = renderings | |
.Where(r => r?.Rules?.Descendants(RuleAttributeKeys.ActionKey) != null) | |
.SelectMany(r => r.Rules.Descendants(RuleAttributeKeys.ActionKey) | |
.Select(a => a.Attribute(RuleAttributeKeys.DatasourceKey)) | |
.Where(a => a != null)); | |
this.SetNewReferences( | |
new SetNewReferencesParameters<XAttribute> | |
{ | |
Database = database, | |
NewItem = newItem, | |
OldItem = oldItem, | |
NewLayout = newLayoutField, | |
NewLayoutDefinition = newLayoutDefinition, | |
OldDatasources = oldDatasources, | |
SourceList = actions, | |
GetIdValue = r => r.Value, | |
SetIdValue = (r, d) => r.SetValue(d) | |
}); | |
} | |
} | |
private IEnumerable<IOldDatasourceItem> GetOldReferences<T>(Database database, IEnumerable<T> sourceList, Func<T, string> getDatasourceId) | |
{ | |
var oldDatasources = new List<IOldDatasourceItem>(); | |
if (sourceList == null) | |
{ | |
return oldDatasources; | |
} | |
foreach (var rendering in sourceList) | |
{ | |
ID oldDatasourceId; | |
if (!ID.TryParse(getDatasourceId.Invoke(rendering), out oldDatasourceId)) | |
{ | |
continue; | |
} | |
var oldDatasource = database.GetItem(oldDatasourceId); | |
if (oldDatasource == null) | |
{ | |
continue; | |
} | |
// Handle duplicated paths | |
var oldDatasourcesWithSamePath = database.SelectItems(oldDatasource.Paths.FullPath).ToList(); | |
oldDatasources.Add( | |
new OldDatasourceItem | |
{ | |
Id = oldDatasourceId, | |
Path = oldDatasource.Paths.FullPath, | |
Index = oldDatasourcesWithSamePath.IndexOf(oldDatasourcesWithSamePath.FirstOrDefault(d => d.ID == oldDatasourceId)) | |
}); | |
} | |
return oldDatasources; | |
} | |
private void SetNewReferences<T>(ISetNewReferencesParameters<T> parameters) | |
{ | |
foreach (var rendering in parameters.SourceList) | |
{ | |
var datasource = parameters.GetIdValue.Invoke(rendering); | |
var matchedOldDatasource = parameters.OldDatasources.FirstOrDefault(d => d.Id.ToString() == datasource); | |
if (matchedOldDatasource == null) | |
{ | |
continue; | |
} | |
string newDatasourcePath = matchedOldDatasource.Path.ToString().Replace(parameters.OldItem.Paths.FullPath, parameters.NewItem.Paths.FullPath); | |
var newDatasource = parameters.Database.SelectItems(newDatasourcePath)[matchedOldDatasource.Index]; | |
parameters.SetIdValue.Invoke(rendering, newDatasource.ID.ToString()); | |
parameters.NewItem.Editing.BeginEdit(); | |
parameters.NewLayout.Value = parameters.NewLayoutDefinition.ToXml(); | |
parameters.NewItem.Editing.EndEdit(); | |
} | |
} | |
} | |
} |
namespace Helix.Skeleton.Foundation.EditorExtensions.Services | |
{ | |
using Sitecore.Data; | |
using Sitecore.Data.Items; | |
using Sitecore.Globalization; | |
public interface ICopyPageService | |
{ | |
Item GetContentFolder(string currentItemPath, string databaseName); | |
Item CopyLastVersion(Database database, Language language, Item source, Item destinationWithoutVersion); | |
void AdjustLayout(Database database, Item newItem, Item oldItem, ID layoutFieldId); | |
} | |
} |
I have some extra class which are the model and constant classes.
namespace Helix.Skeleton.Foundation.EditorExtensions.Consts.CopyPage | |
{ | |
public class ParameterKeys | |
{ | |
public const string DatabaseKey = "database"; | |
public const string ItemsKey = "items"; | |
public const string LanguageKey = "language"; | |
public const string DestinationKey = "destination"; | |
} | |
} |
namespace Helix.Skeleton.Foundation.EditorExtensions.Consts.CopyPage | |
{ | |
public static class RuleAttributeKeys | |
{ | |
public const string DatasourceKey = "DataSource"; | |
public const string ActionKey = "action"; | |
} | |
} |
namespace Helix.Skeleton.Foundation.EditorExtensions.Models.CopyPage | |
{ | |
using Sitecore.Data; | |
public interface IOldDatasourceItem | |
{ | |
ID Id { get; set; } | |
string Path { get; set; } | |
int Index { get; set; } | |
} | |
} |
namespace Helix.Skeleton.Foundation.EditorExtensions.Models.CopyPage | |
{ | |
using Sitecore.Data; | |
public class OldDatasourceItem : IOldDatasourceItem | |
{ | |
public ID Id { get; set; } | |
public string Path { get; set; } | |
public int Index { get; set; } | |
} | |
} |
namespace Helix.Skeleton.Foundation.EditorExtensions.Models.CopyPage | |
{ | |
using System; | |
using System.Collections.Generic; | |
using Sitecore.Data; | |
using Sitecore.Data.Fields; | |
using Sitecore.Data.Items; | |
using Sitecore.Layouts; | |
public interface ISetNewReferencesParameters<T> | |
{ | |
Database Database { get; set; } | |
Item NewItem { get; set; } | |
Item OldItem { get; set; } | |
LayoutField NewLayout { get; set; } | |
LayoutDefinition NewLayoutDefinition { get; set; } | |
IEnumerable<IOldDatasourceItem> OldDatasources { get; set; } | |
IEnumerable<T> SourceList { get; set; } | |
Func<T, string> GetIdValue { get; set; } | |
Action<T, string> SetIdValue { get; set; } | |
} | |
} |
namespace Helix.Skeleton.Foundation.EditorExtensions.Models.CopyPage | |
{ | |
using System; | |
using System.Collections.Generic; | |
using Sitecore.Data; | |
using Sitecore.Data.Fields; | |
using Sitecore.Data.Items; | |
using Sitecore.Layouts; | |
public class SetNewReferencesParameters<T> : ISetNewReferencesParameters<T> | |
{ | |
public Database Database { get; set; } | |
public Item NewItem { get; set; } | |
public Item OldItem { get; set; } | |
public LayoutField NewLayout { get; set; } | |
public LayoutDefinition NewLayoutDefinition { get; set; } | |
public IEnumerable<IOldDatasourceItem> OldDatasources { get; set; } | |
public IEnumerable<T> SourceList { get; set; } | |
public Func<T, string> GetIdValue { get; set; } | |
public Action<T, string> SetIdValue { get; set; } | |
} | |
} |
namespace Helix.Skeleton.Foundation.EditorExtensions.Models.CopyPage | |
{ | |
public interface IParameters | |
{ | |
string Database { get; set; } | |
string ItemId { get; set; } | |
string Language { get; set; } | |
string DestinationId { get; set; } | |
} | |
} |
namespace Helix.Skeleton.Foundation.EditorExtensions.Models.CopyPage | |
{ | |
public class Parameters : IParameters | |
{ | |
public string Database { get; set; } | |
public string ItemId { get; set; } | |
public string Language { get; set; } | |
public string DestinationId { get; set; } | |
} | |
} |
At the end create a button in the Core database.
- Path: /sitecore/content/Applications/Content Editor/Ribbons/Chunks/Operations
- Name: Copy Page To
- Click field: skeleton:copypage
Aaaand here is the new button!
Happy copying!