Home » Publishing

Category Archives: Publishing

Synchronize IDTable Entries Across Multiple Sitecore Databases Using a Custom publishItem Pipeline Processor

In a previous post I showed a solution that uses the Composite design pattern in an attempt to answer the following question by Sitecore MVP Kyle Heon:

Although I enjoyed building that solution, it isn’t ideal for synchronizing IDTable entries across multiple Sitecore databases — entries are added to all configured IDTables even when Items might not exist in all databases of those IDTables (e.g. the Sitecore Items have not been published to those databases).

I came up with another solution to avoid the aforementioned problem — one that synchronizes IDTable entries using a custom <publishItem> pipeline processor, and the following class contains code for that processor:

using System.Collections.Generic;
using System.Linq;

using Sitecore.Configuration;
using Sitecore.Data;
using Sitecore.Data.IDTables;
using Sitecore.Diagnostics;
using Sitecore.Publishing.Pipelines.PublishItem;

namespace Sitecore.Sandbox.Pipelines.Publishing
{
    public class SynchronizeIDTables : PublishItemProcessor
    {
        private IEnumerable<string> _IDTablePrefixes;
        private IEnumerable<string> IDTablePrefixes
        {
            get
            {
                if (_IDTablePrefixes == null)
                {
                    _IDTablePrefixes = GetIDTablePrefixes();
                }

                return _IDTablePrefixes;
            }
        }

        private string IDTablePrefixesConfigPath { get; set; }

        public override void Process(PublishItemContext context)
        {
            Assert.ArgumentNotNull(context, "context");
            Assert.ArgumentNotNull(context.PublishOptions, "context.PublishOptions");
            Assert.ArgumentNotNull(context.PublishOptions.SourceDatabase, "context.PublishOptions.SourceDatabase");
            Assert.ArgumentNotNull(context.PublishOptions.TargetDatabase, "context.PublishOptions.TargetDatabase");
            IDTableProvider sourceProvider = CreateNewIDTableProvider(context.PublishOptions.SourceDatabase);
            IDTableProvider targetProvider = CreateNewIDTableProvider(context.PublishOptions.TargetDatabase);
            RemoveEntries(targetProvider, GetAllEntries(targetProvider, context.ItemId));
            AddEntries(targetProvider, GetAllEntries(sourceProvider, context.ItemId));
        }

        protected virtual IDTableProvider CreateNewIDTableProvider(Database database)
        {
            Assert.ArgumentNotNull(database, "database");
            return Factory.CreateObject(string.Format("IDTable[@id='{0}']", database.Name), true) as IDTableProvider;
        }

        protected virtual IEnumerable<IDTableEntry> GetAllEntries(IDTableProvider provider, ID itemId)
        {
            Assert.ArgumentNotNull(provider, "provider");
            Assert.ArgumentCondition(!ID.IsNullOrEmpty(itemId), "itemId", "itemId cannot be null or empty!");
            List<IDTableEntry> entries = new List<IDTableEntry>();
            foreach(string prefix in IDTablePrefixes)
            {
                IEnumerable<IDTableEntry> entriesForPrefix = provider.GetKeys(prefix, itemId);
                if (entriesForPrefix.Any())
                {
                    entries.AddRange(entriesForPrefix);
                }
            }

            return entries;
        }

        private static void RemoveEntries(IDTableProvider provider, IEnumerable<IDTableEntry> entries)
        {
            Assert.ArgumentNotNull(provider, "provider");
            Assert.ArgumentNotNull(entries, "entries");
            foreach (IDTableEntry entry in entries)
            {
                provider.Remove(entry.Prefix, entry.Key);
            }
        }

        private static void AddEntries(IDTableProvider provider, IEnumerable<IDTableEntry> entries)
        {
            Assert.ArgumentNotNull(provider, "provider");
            Assert.ArgumentNotNull(entries, "entries");
            foreach (IDTableEntry entry in entries)
            {
                provider.Add(entry);
            }
        }

        protected virtual IEnumerable<string> GetIDTablePrefixes()
        {
            Assert.ArgumentNotNullOrEmpty(IDTablePrefixesConfigPath, "IDTablePrefixConfigPath");
            return Factory.GetStringSet(IDTablePrefixesConfigPath);
        }
    }
}

The Process method above grabs all IDTable entries for all defined IDTable prefixes — these are pulled from the configuration file that is shown later on in this post — from the source database for the Item being published, and pushes them all to the target database after deleting all preexisting entries from the target database for the Item (the code is doing a complete overwrite for the Item’s IDTable entries in the target database).

I also added the following code to serve as an item:deleted event handler (if you would like to learn more about events and their handlers, check out John West‘s post about them, and also take a look at this page on the
Sitecore Developer Network (SDN)) to remove entries for the Item when it’s being deleted:

using System;
using System.Collections.Generic;
using System.Linq;

using Sitecore.Configuration;
using Sitecore.Data;
using Sitecore.Data.Events;
using Sitecore.Data.IDTables;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;
using Sitecore.Events;

namespace Sitecore.Sandbox.Data.IDTables
{
    public class ItemEventHandler
    {
        private IEnumerable<string> _IDTablePrefixes;
        private IEnumerable<string> IDTablePrefixes
        {
            get
            {
                if (_IDTablePrefixes == null)
                {
                    _IDTablePrefixes = GetIDTablePrefixes();
                }

                return _IDTablePrefixes;
            }
        }

        private string IDTablePrefixesConfigPath { get; set; }

        protected void OnItemDeleted(object sender, EventArgs args)
        {
            if (args == null)
            {
                return;
            }

            Item item = Event.ExtractParameter(args, 0) as Item;
            if (item == null)
            {
                return;
            }

            DeleteItemEntries(item);
        }

        private void DeleteItemEntries(Item item)
        {
            Assert.ArgumentNotNull(item, "item");
            IDTableProvider provider = CreateNewIDTableProvider(item.Database.Name);
            foreach (IDTableEntry entry in GetAllEntries(provider, item.ID))
            {
                provider.Remove(entry.Prefix, entry.Key);
            }
        }

        protected virtual IEnumerable<IDTableEntry> GetAllEntries(IDTableProvider provider, ID itemId)
        {
            Assert.ArgumentNotNull(provider, "provider");
            Assert.ArgumentCondition(!ID.IsNullOrEmpty(itemId), "itemId", "itemId cannot be null or empty!");
            List<IDTableEntry> entries = new List<IDTableEntry>();
            foreach (string prefix in IDTablePrefixes)
            {
                IEnumerable<IDTableEntry> entriesForPrefix = provider.GetKeys(prefix, itemId);
                if (entriesForPrefix.Any())
                {
                    entries.AddRange(entriesForPrefix);
                }
            }

            return entries;
        }

        private static void RemoveEntries(IDTableProvider provider, IEnumerable<IDTableEntry> entries)
        {
            Assert.ArgumentNotNull(provider, "provider");
            Assert.ArgumentNotNull(entries, "entries");
            foreach (IDTableEntry entry in entries)
            {
                provider.Remove(entry.Prefix, entry.Key);
            }
        }

        protected virtual IDTableProvider CreateNewIDTableProvider(string databaseName)
        {
            return Factory.CreateObject(string.Format("IDTable[@id='{0}']", databaseName), true) as IDTableProvider;
        }

        protected virtual IEnumerable<string> GetIDTablePrefixes()
        {
            Assert.ArgumentNotNullOrEmpty(IDTablePrefixesConfigPath, "IDTablePrefixConfigPath");
            return Factory.GetStringSet(IDTablePrefixesConfigPath);
        }
    }
}

The above code retrieves all IDTable entries for the Item being deleted — filtered by the configuration defined IDTable prefixes — from its database’s IDTable, and calls the Remove method on the IDTableProvider instance that is created for the Item’s database for each entry.

I then registered all of the above in Sitecore using the following configuration file:

<?xml version="1.0" encoding="utf-8" ?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <events>
      <event name="item:deleted">
        <handler type="Sitecore.Sandbox.Data.IDTables.ItemEventHandler, Sitecore.Sandbox" method="OnItemDeleted">
          <IDTablePrefixesConfigPath>IDTablePrefixes/IDTablePrefix</IDTablePrefixesConfigPath>
        </handler>
      </event>
    </events>
    <IDTable type="Sitecore.Data.$(database).$(database)IDTable, Sitecore.Kernel" singleInstance="true">
      <patch:attribute name="id">master</patch:attribute>
      <param connectionStringName="master"/>
      <param desc="cacheSize">500KB</param>
    </IDTable>
    <IDTable id="web" type="Sitecore.Data.$(database).$(database)IDTable, Sitecore.Kernel" singleInstance="true">
      <param connectionStringName="web"/>
      <param desc="cacheSize">500KB</param>
    </IDTable>
    <IDTablePrefixes>
      <IDTablePrefix>IDTableTest</IDTablePrefix>
    </IDTablePrefixes>
    <pipelines>
      <publishItem>
        <processor type="Sitecore.Sandbox.Pipelines.Publishing.SynchronizeIDTables, Sitecore.Sandbox">
          <IDTablePrefixesConfigPath>IDTablePrefixes/IDTablePrefix</IDTablePrefixesConfigPath>
        </processor>
      </publishItem>
    </pipelines>
  </sitecore>
</configuration>

For testing, I quickly whipped up a web form to add a couple of IDTable entries using an IDTableProvider for the master database — I am omitting that code for brevity — and ran a query to verify the entries were added into the IDTable in my master database (I also ran another query for the IDTable in my web database to show that it contains no entries):

idtables-before-publish

I published both items, and queried the IDTable in the master and web databases:

idtables-after-publish-both-items

As you can see, both entries were inserted into the web database’s IDTable.

I then deleted one of the items from the master database via the Sitecore Content Editor:

idtables-deleted-from-master

It was removed from the IDTable in the master database.

I then published the deleted item’s parent with subitems:

idtables-published-deletion

As you can see, it was removed from the IDTable in the web database.

If you have any suggestions for making this code better, or have another solution for synchronizing IDTable entries across multiple Sitecore databases, please share in a comment.

Advertisement

Publish Items With the Sitecore Item Web API Using a Custom ResolveAction itemWebApiRequest Pipeline Processor

At the end of last week, when many people were probably thinking about what to do over the weekend, or were making plans with family and/or friends, I started thinking about what I might need to do next on the project I’m working on.

I realized I might need a way to publish Sitecore items I’ve touched via the Sitecore Item Web API — a feature that appears to be missing, or I just cannot figure out how to use from its documentation (if there is a way to do this “out of the box”, please share in a comment below).

After some digging around in Sitecore.ItemWebApi.dll and \App_Config\Include\Sitecore.ItemWebApi.config, I thought it would be a good idea to define a new action that employs a request method other than get, post, put, delete — these request methods are used by a vanilla install of the Sitecore Item Web API.

Where would one find a list of “standard” request methods? Of course Google knows all — I learned about the patch request method, and decided to use it.

According to Wikipedia — see this entry subsection discussing request methods — the patch request method is “used to apply partial modifications to a resource”, and one could argue that a publishing an item in Sitecore is a partial modification to that item — it’s being pushed to another database which is really an update on that item in the target database.

Now that our research is behind us — and we’ve learned about the patch request method — let’s get our hands dirty with some code.

Following the pipeline processor convention set forth in code for the Sitecore Item Web API for other request methods, I decide to box our new patch requests into a new pipeline, and doing this called for creating a new data transfer object for the new pipeline processor we will define below:

using Sitecore.Data.Items;

using Sitecore.ItemWebApi.Pipelines;

namespace Sitecore.Sandbox.ItemWebApi.Pipelines.Patch.DTO
{
    public class PatchArgs : OperationArgs
    {
        public PatchArgs(Item[] scope)
            : base(scope)
        {
        }
    }
}

Next, I created a base class for our new patch processor:

using Sitecore.ItemWebApi.Pipelines;

using Sitecore.Sandbox.ItemWebApi.Pipelines.Patch.DTO;

namespace Sitecore.Sandbox.ItemWebApi.Pipelines.Patch
{
    public abstract class PatchProcessor : OperationProcessor<PatchArgs>
    {
        protected PatchProcessor()
        {
        }
    }
}

I then created a new pipeline processor that will publish items passed to it:

using System;
using System.Collections.Generic;
using System.Linq;

using Sitecore.Configuration;
using Sitecore.Data;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;
using Sitecore.ItemWebApi;
using Sitecore.Publishing;
using Sitecore.Web;

using Sitecore.Sandbox.ItemWebApi.Pipelines.Patch.DTO;

namespace Sitecore.Sandbox.ItemWebApi.Pipelines.Patch
{
    public class PublishScope : PatchProcessor
    {
        private string DefaultTargetDatabase { get; set; }

        public override void Process(PatchArgs arguments)
        {
            Assert.ArgumentNotNull(arguments, "arguments");
            Assert.IsNotNull(arguments.Scope, "The scope is null!");
            PublishItems(arguments.Scope, GetTargetDatabase());
            arguments.Result = GetResult(arguments.Scope);
        }

        private Database GetTargetDatabase()
        {
            return Factory.GetDatabase(GetTargetDatabaseName());
        }

        private string GetTargetDatabaseName()
        {
            string databaseName = WebUtil.GetQueryString("sc_target_database");
            if(!string.IsNullOrWhiteSpace(databaseName))
            {
                return databaseName;
            }

            Assert.IsNotNullOrEmpty(DefaultTargetDatabase, "DefaultTargetDatabase must be set!");
            return DefaultTargetDatabase;
        }

        private static void PublishItems(IEnumerable<Item> items, Database database)
        {
            foreach(Item item in items)
            {
                PublishItem(item, database);
            }
        }

        private static void PublishItem(Item item, Database database)
        {
           PublishOptions publishOptions = new PublishOptions
           (
               item.Database,
               database,
               Sitecore.Publishing.PublishMode.SingleItem,
               item.Language,
               DateTime.Now
           );

            Publisher publisher = new Publisher(publishOptions);
            publisher.Options.RootItem = item;
            publisher.Publish();
        }

        private static Dynamic GetResult(IEnumerable<Item> scope)
        {
            Assert.ArgumentNotNull(scope, "scope");
            Dynamic dynamic = new Dynamic();
            dynamic["statusCode"] = 200;
            dynamic["result"] = GetInnerResult(scope);
            return dynamic;
        }

        private static Dynamic GetInnerResult(IEnumerable<Item> scope)
        {
            Assert.ArgumentNotNull(scope, "scope");
            Dynamic dynamic = new Dynamic();
            dynamic["count"] = scope.Count();
            dynamic["itemIds"] = scope.Select(item => item.ID.ToString());
            return dynamic;
        }
    }
}

The above pipeline processor class serially publishes each item passed to it, and returns a Sitecore.ItemWebApi.Dynamic instance containing information on how many items were published; a collection of IDs of the items that were published; and an OK status code.

If the calling code does not supply a publishing target database via the sc_target_database query string parameter, the processor will use the value defined in DefaultTargetDatabase — this value is set in \App_Config\Include\Sitecore.ItemWebApi.config, which you will see later when I show changes I made to this file.

It had been awhile since I’ve had to publish items in code, so I searched for a refresher on how to do this.

In my quest for some Sitecore API code, I rediscovered this article by Sitecore MVP
Brian Pedersen showing how one can publish Sitecore items programmatically — many thanks to Brian for this article!

If you haven’t read Brian’s article, you should go check it out now. Don’t worry, I’ll wait. 🙂

I then created a new ResolveAction itemWebApiRequest pipeline processor:

using System;

using Sitecore.Diagnostics;
using Sitecore.Exceptions;
using Sitecore.ItemWebApi.Pipelines.Request;
using Sitecore.ItemWebApi.Security;
using Sitecore.Pipelines;

using Sitecore.Sandbox.ItemWebApi.Pipelines.Patch.DTO;

namespace Sitecore.Sandbox.ItemWebApi.Pipelines.Request
{
    public class ResolveAction : Sitecore.ItemWebApi.Pipelines.Request.ResolveAction
    {
        public override void Process(RequestArgs requestArgs)
        {
            Assert.ArgumentNotNull(requestArgs, "requestArgs");
            string method = GetMethod(requestArgs.Context);
            AssertOperation(requestArgs, method);
            
            if(IsCreateRequest(method))
            {
	            ExecuteCreateRequest(requestArgs);
	            return;
            }

            if(IsReadRequest(method))
            {
	            ExecuteReadRequest(requestArgs);
	            return;
            }

            if(IsUpdateRequest(method))
            {
               ExecuteUpdateRequest(requestArgs);
               return;
            }

            if(IsDeleteRequest(method))
            {
	            ExecuteDeleteRequest(requestArgs);
	            return;
            }

            if (IsPatchRequest(method))
            {
	            ExecutePatchRequest(requestArgs);
	            return;
            }
        }

        private static void AssertOperation(RequestArgs requestArgs, string method)
        {
            Assert.ArgumentNotNull(requestArgs, "requestArgs");
            if (requestArgs.Context.Settings.Access == AccessType.ReadOnly && !AreEqual(method, "get"))
            {
                throw new AccessDeniedException("The operation is not allowed.");
            }
        }

        private static bool IsCreateRequest(string method)
        {
            return AreEqual(method, "post");
        }

        private static bool IsReadRequest(string method)
        {
            return AreEqual(method, "get");
        }

        private static bool IsUpdateRequest(string method)
        {
            return AreEqual(method, "put");
        }

        private static bool IsDeleteRequest(string method)
        {
            return AreEqual(method, "delete");
        }

        private static bool IsPatchRequest(string method)
        {
            return AreEqual(method, "patch");
        }

        private static bool AreEqual(string one, string two)
        {
            return string.Equals(one, two, StringComparison.InvariantCultureIgnoreCase);
        }

        protected virtual void ExecutePatchRequest(RequestArgs requestArgs)
        {
            Assert.ArgumentNotNull(requestArgs, "requestArgs");
            PatchArgs patchArgsArgs = new PatchArgs(requestArgs.Scope);
            CorePipeline.Run("itemWebApiPatch", patchArgsArgs);
            requestArgs.Result = patchArgsArgs.Result;
        }

        private string GetMethod(Sitecore.ItemWebApi.Context context)
        {
            Assert.ArgumentNotNull(context, "context");
            return context.HttpContext.Request.HttpMethod.ToLower();
        }
    }
}

The class above contains the same logic as Sitecore.ItemWebApi.Pipelines.Request.ResolveAction, though I refactored it a bit — the nested conditional statements in the Process method were driving me bonkers, and I atomized logic by placing into new methods.

Plus, I added an additional check to see if the request we are to execute is a patch request — this is true when HttpContext.Request.HttpMethod.ToLower() in our Sitecore.ItemWebApi.Context instance is equal to “patch” — and call our new patch pipeline if this is the case.

I then added the new itemWebApiPatch pipeline with its new PublishScope processor, and replaced /configuration/sitecore/pipelines/itemWebApiRequest/processor[@type=”Sitecore.ItemWebApi.Pipelines.Request.ResolveAction, Sitecore.ItemWebApi”] with /configuration/sitecore/pipelines/itemWebApiRequest/processor[@type=”Sitecore.Sandbox.ItemWebApi.Pipelines.Request.ResolveAction, Sitecore.Sandbox”] in \App_Config\Include\Sitecore.ItemWebApi.config:

<?xml version="1.0" encoding="utf-8"?>
	<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
		<sitecore>
			<pipelines>
				<itemWebApiRequest>
					<!-- stuff is defined up here -->
					<processor type="Sitecore.Sandbox.ItemWebApi.Pipelines.Request.ResolveAction, Sitecore.Sandbox" />
					<!-- stuff is defined right here -->
				</itemWebApiRequest>
				<!-- more stuff is defined here -->
				<itemWebApiPatch>
					<processor type="Sitecore.Sandbox.ItemWebApi.Pipelines.Patch.PublishScope, Sitecore.Sandbox">
						<DefaultTargetDatabase>web</DefaultTargetDatabase>
					</processor>
				</itemWebApiPatch>
			</pipelines>
			<!-- there's even more stuff defined down here -->
		</sitecore>
	</configuration>

Let’s test this out, and see how we did.

We’ll be publishing these items:

master-items-to-publish

As you can see, they aren’t in the web database right now:

arent-there-yet-web

I had to modify code in my copy of the console application written by Kern Herskind Nightingale, Director of Technical Services at Sitecore UK, to use the patch request method for the ancestor home item shown above, and set a scope of self and recursive (scope=s|r) — checkout out this post where I added the recursive axe to the Sitecore Item Web API. I am excluding the console application code modification for the sake of brevity.

I then ran the console application above, and saw this:

after-publishing-output

All of these items now live in the web database:

all-in-web-after-publish

If you have any thoughts on this, or ideas on other useful actions for the Sitecore Item Web API, please drop a comment.

Who Just Published That? Log Publishing Statistics in the Sitecore Client

Time and time again, I keep hearing about content in Sitecore being published prematurely, and this usually occurs by accident — perhaps a cat walks across the malefactor’s keyboard — although I have a feeling something else might driving these erroneous publishes.

However, in some instances, the malefactor will not fess up to invoking said publish.

Seeking out who had published a Sitecore item is beyond the know how of most end users, and usually requires an advanced user or developer to fish around in the Sitecore log to ascertain who published what and when.

Here’s an example of publishing information in my local sandbox’s instance of a publish I had done earlier this evening:

publishing-log-file

Given that I keep hearing about this happening, I decided it would be a great exercise to develop a feature to bring publishing information into the Sitecore client. We can accomplish this by building a custom PublishItemProcessor pipeline to log statistics into publishing fields.

First, I created a template containing fields to hold publishing information.

publishing-statistics-template

These fields will only keep track of who published an item last, similar to how the fields in the Statistics section in the Standard Template.

Next, I set the title of my fields to not show underscores:

set-title-field-no-underscores

I then added my template to the Standard Template:

add-publishing-stats-standard-template

Now that my publishing fields are in Sitecore, It’s time to build a custom PublishItemProcessor (Sitecore.Publishing.Pipelines.PublishItem.PublishItemProcessor):

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

using Sitecore.Data;
using Sitecore.Data.Items;
using Sitecore.Data.Fields;
using Sitecore.Diagnostics;
using Sitecore.Publishing.Pipelines.PublishItem;
using Sitecore.SecurityModel;

namespace Sitecore.Sandbox.Pipelines.Publishing
{
    public class UpdatePublishingStatistics : PublishItemProcessor
    {
        private const string PublishedFieldName = "__Published";
        private const string PublishedByFieldName = "__Published By";

        public override void Process(PublishItemContext context)
        {
            SetPublishingStatisticsFields(context);
        }

        private void SetPublishingStatisticsFields(PublishItemContext context)
        {
            Assert.ArgumentNotNull(context, "context");
            Assert.ArgumentNotNull(context.PublishOptions, "context.PublishOptions");
            Assert.ArgumentNotNull(context.PublishOptions.SourceDatabase, "context.PublishOptions.SourceDatabase");
            Assert.ArgumentNotNull(context.PublishOptions.TargetDatabase, "context.PublishOptions.TargetDatabase");
            Assert.ArgumentCondition(!ID.IsNullOrEmpty(context.ItemId), "context.ItemId", "context.ItemId must be set!");
            Assert.ArgumentNotNull(context.User, "context.User");
            
            SetPublishingStatisticsFields(context.PublishOptions.SourceDatabase, context.ItemId, context.User.Name);
            SetPublishingStatisticsFields(context.PublishOptions.TargetDatabase, context.ItemId, context.User.Name);
        }

        private void SetPublishingStatisticsFields(Database database, ID itemId, string userName)
        {
            Assert.ArgumentNotNull(database, "database");
            Item item = TryGetItem(database, itemId);

            if (HasPublishingStatisticsFields(item))
            {
                SetPublishingStatisticsFields(item, DateUtil.IsoNow, userName);
            }
        }

        private void SetPublishingStatisticsFields(Item item, string isoDateTime, string userName)
        {
            Assert.ArgumentNotNull(item, "item");
            Assert.ArgumentNotNullOrEmpty(isoDateTime, "isoDateTime");
            Assert.ArgumentNotNullOrEmpty(userName, "userName");

            using (new SecurityDisabler())
            {
                item.Editing.BeginEdit();
                item.Fields[PublishedFieldName].Value = DateUtil.IsoNow;
                item.Fields[PublishedByFieldName].Value = userName;
                item.Editing.EndEdit();
            }
        }

        private Item TryGetItem(Database database, ID itemId)
        {
            try
            {
                return database.Items[itemId];
            }
            catch (Exception ex)
            {
                Log.Error(this.ToString(), ex, this);
            }

            return null;
        }

        private static bool HasPublishingStatisticsFields(Item item)
        {
            Assert.ArgumentNotNull(item, "item");
            return item.Fields[PublishedFieldName] != null
                    && item.Fields[PublishedByFieldName] != null;
        }
    }
}

My PublishItemProcessor sets the publishing statistics on my newly added fields on the item in both the source and target databases, and only when the publishing fields exist on the published item.

I then added my PublishItemProcessor after the UpdateStatistics PublishItemProcessor in a new patch config file:

<?xml version="1.0" encoding="utf-8"?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <pipelines>
      <publishItem>
        <processor type="Sitecore.Sandbox.Pipelines.Publishing.UpdatePublishingStatistics, Sitecore.Sandbox"
					patch:after="processor[@type='Sitecore.Publishing.Pipelines.PublishItem.UpdateStatistics, Sitecore.Kernel']" />
      </publishItem>
    </pipelines>
  </sitecore>
</configuration>

I created a new page for testing, put some dummy data in some fields and then published:

publishing-stats-after-publish

Beware — you can no longer hide after publishing when you shouldn’t! 🙂