Content events in Episerver
So you need to do something when content changes?
Knowing when content changes can be important in many use cases. You might need to update a search index with the new information, send an email to some editor or similar.
That is easy to support using content events in Episerver but there are a few gotyas. Let's start by listening to the most common content event, PublishedContent, that are raised in Episerver and then examine a few edge cases. You can do this by creating your own initialization module and attach some eventhandlers by using the IContentEvent interface like this:
[ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
public class ChangeEventInitialization : IInitializableModule
{
private ILogger _log = LogManager.GetLogger(typeof(ChangeEventInitialization));
public void Initialize(InitializationEngine context)
{
var events = ServiceLocator.Current.GetInstance<IContentEvents>();
events.PublishedContent += Events_PublishedContent;
}
private void Events_PublishedContent(object sender, EPiServer.ContentEventArgs e)
{
_log.Information($"Published content fired for content {e.ContentLink.ID}");
}
public void Uninitialize(InitializationEngine context)
{
var events = ServiceLocator.Current.GetInstance<IContentEvents>();
events.PublishedContent -= Events_PublishedContent;
}
}
Done!
Or, not really. You covered the most obvious change of content but there are a few others you need to be aware of.
So let's dig into some pitfalls you might have forgotten to handle.
- Wastebasket
Throwing things into the trash (or restoring) will cause a move event, not a delete event.
Makes sense really but easy to miss. So if you need to reindex an item that ends up in wastebasket this is a good thing to know. - Move event
Only the page that is being moved will trigger the move event, not the children.
If you cast the event args to the MoveContentEventArgs class you can see it also has a property called Descendents. This contains the affected child content. Remember that you need to handle the descendents if you have a move event.
private void Events_MovedContent(object sender, EPiServer.ContentEventArgs e) { var eventargs = e as MoveContentEventArgs; if(eventargs!=null) { // use eventargs.Descendents to get every content item that is affected... } }
- Delete event
The Deleted event will send you the id of the wastebasket as contentlink if the user empties the wastebasket.
Hmm, ok. It is the wastebasket that triggers the delete but you might have expected to get the contentlink to the deleted content here.
The actual deleted content you need to handle can be gotten by casting the ContentEventArgs to DeleteContentEventArgs class and then checking the DeletedDescendents property like.
private void Events_DeletedContent(object sender, EPiServer.DeleteContentEventArgs e) { var eventArgs = e as DeleteContentEventArgs; if(eventArgs!=null) { // use eventArgs.DeletedDescendents to get affected content... } }
- Url changes
Changing the url segment (called Name in Url in edit mode) on a page and publishing it will trigger a publish event. On that page. But not on the page descendents.
Problem is that the url segment is also used for the full urls of the children. So you might need to handle that in the published event. One way to get if url has been changed is to store the old url in the publishing event in the ContentEventArgs Items collection and then check it in the published event.
private void Events_PublishingContent(object sender, EPiServer.ContentEventArgs e) { var urlResolver = ServiceLocator.Current.GetInstance<IUrlResolver>(); var oldUrl = urlResolver.GetUrl(new ContentReference(e.Content.ContentLink.ID)); e.Items.Add("Url", oldUrl); }
private void Events_PublishedContent(object sender, EPiServer.ContentEventArgs e) { var urlResolver = ServiceLocator.Current.GetInstance<IUrlResolver>(); var url = urlResolver.GetUrl(e.ContentLink); if (e.Items["Url"]!=null) { var oldUrl = e.Items["Url"].ToString(); if(url!=oldUrl) { //Handle that url for all children has now been changed...reindex them etc... } } }
-
Access rights
Changing access rights on content is another thing that you might forget to handle. This creates an event too but you need to use the IContentSecurityRepository to handle it. The published event will not trigger in this case. Remember that changing access rights can also affect children since access rights are normally inherited. You will only get this event for the node that is change and then you need to handle all descendents yourself if you need to reindex etc.//In initialization init: var contentSecurityRepo = ServiceLocator.Current.GetInstance<IContentSecurityRepository>(); contentSecurityRepo.ContentSecuritySaved += ContentSecurityRepo_ContentSecuritySaved; //and then create event handler method private void ContentSecurityRepo_ContentSecuritySaved(object sender, ContentSecurityEventArg e) { _log.Information($"ContentSecuritySaved fired for content {e.ContentLink.ID}"); }
Summary and source code for a new more inclusive ContentChange event
The basic event handling for content in Episerver is easy to find but to handle all types of content changes is more difficult. Hopefully this post will help you find a few of the most common pitfalls. In a future version of Episerver I would hope that Episerver CMS can also have a simpler event to find out if a content item has been changed in any way (including access rights and children etc).
I'll finish this post with adding some example code to create the backbone of such a new event called ContentChanged that you can modify to fit your specific need in your project. This event will be triggered if the content have been changed either by being moved, deleted, published, url changed on parent etc and will include a property with AffectedContent that will include all descendents that may have been affected by the action. To save some space I've only use a single initialization module to hook up all events.
Happy coding!
Example code for new ContentChange event handling
//Initialization module to hook up all events and setup a new event type for ContentChanged
[ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
public class ChangeEventInitialization : IInitializableModule
{
private ILogger _log = LogManager.GetLogger(typeof(ChangeEventInitialization));
public void Initialize(InitializationEngine context)
{
var events = ServiceLocator.Current.GetInstance<IContentEvents>();
var contentSecurityRepo = ServiceLocator.Current.GetInstance<IContentSecurityRepository>();
contentSecurityRepo.ContentSecuritySaved += ContentSecurityRepo_ContentSecuritySaved;
events.MovedContent += Events_MovedContent;
events.PublishingContent += Events_PublishingContent;
events.PublishedContent += Events_PublishedContent;
events.DeletedContent += Events_DeletedContent;
ExtendedContentEvents.Instance.ContentChanged += Instance_ContentChanged;
}
private void Instance_ContentChanged(object sender, ContentChangedEventArgs e)
{
_log.Information($"Events ContentChanged fired for content {JsonConvert.SerializeObject(e)}");
}
private void Events_PublishingContent(object sender, EPiServer.ContentEventArgs e)
{
_log.Information($"Events_PublishingContent fired for content {e.Content.ContentLink.ID}");
var urlResolver = ServiceLocator.Current.GetInstance<IUrlResolver>();
var oldUrl = urlResolver.GetUrl(new ContentReference(e.Content.ContentLink.ID));
e.Items.Add("Url", oldUrl);
_log.Information($"Old url: {oldUrl}");
}
private void ContentSecurityRepo_ContentSecuritySaved(object sender, ContentSecurityEventArg e)
{
_log.Information($"ContentSecuritySaved fired for content {e.ContentLink.ID}");
var action = ContentAction.AccessRightsChanged;
var affectedContent = new List<ContentReference>();
var contentRepository = ServiceLocator.Current.GetInstance<IContentRepository>();
var descendants = contentRepository.GetDescendents(e.ContentLink);
affectedContent.AddRange(descendants);
affectedContent.Add(e.ContentLink);
ExtendedContentEvents.Instance.RaiseContentChangedEvent(new ContentChangedEventArgs(e.ContentLink, action, affectedContent));
}
private void Events_DeletedContent(object sender, EPiServer.DeleteContentEventArgs e)
{
_log.Information($"Deleted content fired for content {e.ContentLink.ID}");
var eventArgs = e as DeleteContentEventArgs;
if(eventArgs!=null)
{
var action = ContentAction.ContentDeleted;
var affectedContent = new List<ContentReference>();
affectedContent.AddRange(eventArgs.DeletedDescendents);
if(e.ContentLink.ID!=ContentReference.WasteBasket.ID)
{
affectedContent.Add(e.ContentLink);
}
ExtendedContentEvents.Instance.RaiseContentChangedEvent(new ContentChangedEventArgs(e.ContentLink, action, affectedContent));
}
}
private void Events_MovedContent(object sender, EPiServer.ContentEventArgs e)
{
_log.Information($"Moved content fired for content {e.ContentLink.ID}");
var eventargs = e as MoveContentEventArgs;
if(eventargs!=null)
{
var action = ContentAction.ContentMoved;
if(eventargs.TargetLink.ID == ContentReference.WasteBasket.ID)
{
action = ContentAction.ContentMovedToWastebasket;
}
if(eventargs.OriginalParent.ID==ContentReference.WasteBasket.ID)
{
action = ContentAction.ContentMovedFromWastebasket;
}
var affectedContent = new List<ContentReference>();
affectedContent.AddRange(eventargs.Descendents);
affectedContent.Add(e.ContentLink);
ExtendedContentEvents.Instance.RaiseContentChangedEvent(new ContentChangedEventArgs(e.ContentLink, action, affectedContent));
}
}
private void Events_PublishedContent(object sender, EPiServer.ContentEventArgs e)
{
_log.Information($"Published content fired for content {e.ContentLink.ID}");
var urlResolver = ServiceLocator.Current.GetInstance<IUrlResolver>();
var url = urlResolver.GetUrl(e.ContentLink);
_log.Information($"New url: {url}");
if (e.Items["Url"]!=null)
{
var oldUrl = e.Items["Url"].ToString();
if(url!=oldUrl)
{
_log.Information($"Url changed for {e.ContentLink.ID}");
var contentRepository = ServiceLocator.Current.GetInstance<IContentRepository>();
var descendants = contentRepository.GetDescendents(e.ContentLink);
var affectedContent = new List<ContentReference>();
affectedContent.AddRange(descendants);
affectedContent.Add(new ContentReference(e.ContentLink.ID));
ExtendedContentEvents.Instance.RaiseContentChangedEvent(new ContentChangedEventArgs(e.ContentLink, ContentAction.UrlChanged, affectedContent));
}
else
{
ExtendedContentEvents.Instance.RaiseContentChangedEvent(new ContentChangedEventArgs(e.ContentLink, ContentAction.ContentPublished, new List<ContentReference>()));
}
}
}
public void Uninitialize(InitializationEngine context)
{
var events = ServiceLocator.Current.GetInstance<IContentEvents>();
var contentSecurityRepo = ServiceLocator.Current.GetInstance<IContentSecurityRepository>();
contentSecurityRepo.ContentSecuritySaved -= ContentSecurityRepo_ContentSecuritySaved;
events.MovedContent -= Events_MovedContent;
events.PublishingContent -= Events_PublishingContent;
events.PublishedContent -= Events_PublishedContent;
events.DeletedContent -= Events_DeletedContent;
ExtendedContentEvents.Instance.ContentChanged -= Instance_ContentChanged;
}
public void Preload(string[] parameters)
{
}
}
//New event args class that can store a list of descendents that were affected
//and the type of source event
public class ContentChangedEventArgs : EventArgs
{
public ContentReference SourceContentLink { get; }
public ContentAction Action { get; }
public ContentChangedEventArgs(ContentReference sourceContentLink, ContentAction action, IEnumerable<ContentReference> affectedContent)
{
SourceContentLink = sourceContentLink;
Action = action;
AffectedContent = affectedContent;
}
/// <summary>
/// Includes references to all affected content including the content that triggered the event
/// </summary>
public IEnumerable<ContentReference> AffectedContent { get; }
}
//New enum to specify the original action that changed the content.
//Can be extended if needed to include the entire source event
public enum ContentAction
{
ContentPublished,
ContentDeleted,
ContentMoved,
AccessRightsChanged,
UrlChanged,
ContentMovedToWastebasket,
ContentMovedFromWastebasket
}
//Some infrastructure to make it possible to listen on the changeevent,
///raise a new event etc.
public class ExtendedContentEvents
{
public const string CreatingLanguageEventKey = "ContentChangedEvent";
private EventHandlerList Events
{
get
{
if (_events == null)
throw new ObjectDisposedException(this.GetType().FullName);
return _events;
}
}
private EventHandlerList _events = new EventHandlerList();
private static object _keyLock = new object();
private static ExtendedContentEvents _instance;
internal const string ChangedEvent = "ChangedEvent";
public static ExtendedContentEvents Instance
{
get
{
if (_instance == null)
{
lock (_keyLock)
{
if (_instance == null)
_instance = new ExtendedContentEvents();
}
}
return _instance;
}
}
private object GetEventKey(string stringKey)
{
object obj;
if (!_eventKeys.TryGetValue(stringKey, out obj))
{
lock (_keyLock)
{
if (!this._eventKeys.TryGetValue(stringKey, out obj))
{
obj = new object();
_eventKeys[stringKey] = obj;
}
}
}
return obj;
}
private Dictionary<string, object> _eventKeys = new Dictionary<string, object>();
public event EventHandler<ContentChangedEventArgs> ContentChanged
{
add
{
Events.AddHandler(this.GetEventKey("ContentChangedEvent"), (Delegate)value);
}
remove
{
Events.RemoveHandler(this.GetEventKey("ContentChangedEvent"), (Delegate)value);
}
}
public virtual void RaiseContentChangedEvent(ContentChangedEventArgs eventArgs)
{
var eventHandler = Events[GetEventKey(CreatingLanguageEventKey)] as EventHandler<ContentChangedEventArgs>;
if (eventHandler != null)
{
eventHandler((object)this, eventArgs);
}
}
public void Dispose()
{
this.Dispose(true);
GC.SuppressFinalize((object)this);
}
protected virtual void Dispose(bool disposing)
{
if (!disposing)
return;
if (_events != null)
{
_events.Dispose();
_events = (EventHandlerList)null;
}
if (this != _instance)
return;
_instance = null;
}
}
This is my first time see IContentSecurityRepository interface. Thanks for your sharing Daniel.
Very thorough Daniel! Thanks for this!
Really appreciate this writeup!
Worth noting that EPiServer.Web.InitializationModule has now moved to EPiServer.CMS.AspNet dll if you are missing it. Same namespace though.