sitecore 8

modifying sitecore itemlinks

I recently had to modify an archive functionality in which you could archive an item or one of it’s children. If you archived child A then it’s parent would be cloned or copied (depending on the situation) to the archive and the child would be moved. But we also have other items that were referencing this item and it would also be possible to archive just one language version. I won’t go into the rather complex rules of the archiving command, but basically I needed to duplicate all references to the child item and link them to the archived item.

I poked around the web a bit and the sitecore experts blog sent me in the right direction.

I used their GetReferrers method to retrieve an IEnumerable of ItemLinks to the original item.

1
2
3
4
5
6
7
8
private IEnumerable<ItemLink> GetReferrers(Item item, ID sourceFieldId = null)
{

ItemLink[] referrers = sourceFieldId != null as ID
? Globals.LinkDatabase.GetReferrers(item, sourceFieldId)
: Globals.LinkDatabase.GetReferrers(item);

return referrers;
}

After that it is just a matter of looping through the item links and adding a new link to the source item. In case of unarchiving I needed the opposite method to delete some item links, so I added that as well.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
private void AddLinks(Item source, Item target)
{

IEnumerable<ItemLink> referencingItems = GetReferrers(source, Constants.FieldId1);
referencingItems.Union(GetReferrers(source, Constants.FieldId2));
List<ItemLink> links = referencingItems.ToList();

if (links == null || !links.Any())
{
return;
}

foreach (ItemLink itemLink in links)
{
if (itemLink.SourceFieldID != Guid.Empty.ToID())
{
Item linkSource = itemLink.GetSourceItem();
linkSource.Editing.BeginEdit();
string fieldName = linkSource.Fields.FirstOrDefault(f => f.ID == itemLink.SourceFieldID).Name;
((MultilistField)linkSource.Fields[fieldName]).Add(target.ID.ToString());
linkSource.Editing.AcceptChanges();
linkSource.Editing.EndEdit();
}
}
}

private void DeleteLinks(Item item)
{

IEnumerable<ItemLink> referencingItems = GetReferrers(item, Constants.FieldId1);
referencingItems.Union(GetReferrers(item, Constants.FieldId2));
List<ItemLink> links = referencingItems.ToList();

if (links == null || !links.Any())
{
return;
}

foreach (ItemLink itemLink in links)
{
Item source = itemLink.GetSourceItem();
source.Editing.BeginEdit();
MultilistField field = source.Fields.FirstOrDefault(f => f.ID == itemLink.SourceFieldID);
if (field.Contains(item.ID.ToString()))
{
field.Remove(item.ID.ToString());
}
source.Editing.AcceptChanges();
source.Editing.EndEdit();
}
}

Seeing as both these methods are almost identical, some refactoring is required. It turns out this pattern has a name, the “hole in the middle pattern”. I personally would have called it the donut (doughnut) pattern.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
private void ModifyLinks(Action<MultilistField, Item> action, Item sourceItem, Item targetItem = null)
{

IEnumerable<ItemLink> referencingItems = GetReferrers(sourceItem, IDownloadConstants.ProductsFieldId);
referencingItems.Union(GetReferrers(sourceItem, ISoftware_InformationConstants.ProductsFieldId));
List<ItemLink> links = referencingItems.ToList();

if (links == null || !links.Any())
{
return;
}

foreach (ItemLink itemLink in links)
{
if (itemLink.SourceFieldID != Guid.Empty.ToID())
{
Item source = itemLink.GetSourceItem();
source.Editing.BeginEdit();
MultilistField field = source.Fields.FirstOrDefault(f => f.ID == itemLink.SourceFieldID);

action(field, targetItem);

source.Editing.AcceptChanges();
source.Editing.EndEdit();
}
}
}

Delete links like in the following snippet, where you pass the source item twice.

1
2
3
4
5
6
ModifyLinks((field, target) => {
if (field.Contains(target.ID.ToString()))
{
field.Remove(target.ID.ToString());
}
}, item, item);

Add links in a similar fashion by passing in the source item and the target item. The first being the one you want to copy the links from and the latter being the one you want a new link to.

1
ModifyLinks((field, target) => {field.Add(target.ID.ToString());}, item, addedItem);

UCommerce Extensions

Over the course of my first Sitecore 8 and UCommerce integration project I’ve created some extension methods that might be useful in other projects. Most of these are straight forward so let’s dive into it.

Basket

the culture difference

In case you have different stores for different you need to keep your baskets separated. You cannot have a basket with product X with Euro as currency and product Y with Dollars as currency. So what we decided to do is delete the existing basket when the visitor browses a different Country website. Our site had a country/language selector which allowed the visitor to do that.

The following code does just that. It checks if the basket culture corresponds with the SiteContext culture.

1
2
3
4
5
6
7
8
9
public static void EnsureBasketCulture(this Basket basket)
{

if (!basket.PurchaseOrder.CultureCode.Equals(SiteContext.Current.CurrentCulture.Name))
{
SiteContext.Current.OrderContext.ClearBasketInformation();
basket = SiteContext.Current.OrderContext.GetBasket();
basket.PurchaseOrder.Save();
}
}

Now in case you may not want to loose any basket data, so we want to save that. The following code adds a summary of the existing basket in an audit trail in the new one. Now you might be thinking you want to add the order lines to the new basket and you can do that. However it was out of scope for our project and it could lead you down the rabbit hole since you would have to check if the product is sold in the new country, the prices might be different between countries, not to mention taxes and so on.

This code is just example and you might want to be more defensive in your coding, it’s merely for reference.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public static void EnsureBasketCulture(this Basket basket)
{

if (!basket.PurchaseOrder.CultureCode.Equals(SiteContext.Current.CurrentCulture.Name))
{
string summary = string.Empty;
if (basket.PurchaseOrder != null && basket.PurchaseOrder.OrderLines.Any())
{
summary = string.Join(" | ", basket.PurchaseOrder.OrderLines.Select(
l => string.Format("{0} - #{1}", l.ProductName, l.Quantity)));
}
SiteContext.Current.OrderContext.ClearBasketInformation();
basket = SiteContext.Current.OrderContext.GetBasket();
basket.PurchaseOrder.Save();

var auditline = new OrderStatusAudit();
var order = basket.PurchaseOrder;
auditline.CreatedOn = DateTime.Now;
auditline.Message = string.Format("Country changed, new basket created: {0}", summary);
auditline.PurchaseOrder = order;
auditline.NewOrderStatus = basket.PurchaseOrder.OrderStatus;
auditline.Save();
order.AddOrderStatusAudit(auditline);
}
}

Payment method

the delivery date modification

In the Adyen payment method properties you can set standard shipping time. To be able to show the estimated delivery date of the order, we added the following extension method. It simply retrieves the properties from the payment method and corrects the date (adding only business days).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private static DateTime GetDateAdjustment(PaymentMethod paymentMethod)
{

AdyenPaymentMethodProperties properties = new AdyenPaymentMethodProperties(paymentMethod.PaymentMethodProperties);
string shipDays = properties.ShipDays;
string shipHours = properties.ShipHours;
string shipMinutes = properties.ShipMinutes;

DateTime shipBefore = DateTime.Now;
if (!string.IsNullOrEmpty(shipDays))
{
shipBefore = shipBefore.AddBusinessDays(int.Parse(shipDays));
}

if (!string.IsNullOrEmpty(shipHours))
{
shipBefore = shipBefore.AddHours(int.Parse(shipHours));
}

if (!string.IsNullOrEmpty(shipMinutes))
{
shipBefore = shipBefore.AddMinutes(int.Parse(shipMinutes));
}

return shipBefore;
}

the property enquiry

The following extension method avoids duplicating the same code each time you need to access a payment method property. I also saves a lot of time trying to figure out how to do that again.

1
2
3
4
public static string GetPaymentMethodPropertiesValue(this ICollection<PaymentMethodProperty> properties, string key)
{

return properties.FirstOrDefault(p => p.DefinitionField != null && p.DefinitionField.Name.Equals(key)).Value;
}

PuchaseOrder

the American way As we had quite a lot of divergent logic for the US, I added this extension method that inspects the culture to find out if the order is in place in/for the US.

1
2
3
4
public static bool IsAmerican(this PurchaseOrder order)
{

return order.CultureCode.Equals("en-US");
}

the pipeline verification

Sometimes something happens, some times it’s bad. let’s leave it at that. I needed to check if the Checkout pipeline was executed or not, so here’s the code.

1
2
3
4
5
6
7
8
9
10
public static bool CheckoutPipelineHasAlreadyBeenExecutedForPayment(this PurchaseOrder order)
{

if (order.OrderStatus.OrderStatusId == (int)OrderStatusCode.Basket ||
order.OrderStatus.OrderStatusId == (int)OrderStatusCode.Processing)
{
return false;
}

return true;
}

the order number assignment

In case you want to assign an order number at some random point in the order process, this extension method will let you do that. We used it to assign the order number when the customer reaches the confirmation page and the payment was authorised.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static void AssignOrdernumberIfEmpty(this PurchaseOrder order,string source = "PurchaseOrderExtension")
{
if (string.IsNullOrEmpty(order.OrderNumber))
{
try
{
var numberSeriesService = ObjectFactory.Instance.Resolve<INumberSeriesService>();
order.OrderNumber =
numberSeriesService.GetNumber(order.ProductCatalogGroup.OrderNumberSerie.OrderNumberName);
Log.Info(string.Format("Assigning order number to order. id:
{0}, number {1}", order.OrderId, order.OrderNumber),source);
order.Save();
}
catch (Exception ex)
{
Log.Error(string.Format("An error occured assingning the order ordernumber to order (ID): {0}"
,order.OrderId) ,ex);
}
}
}

Custom Model

the currency format issue

The UCommerce version we were using 6.x had a fixed currency format. So I wrote this class so I could make my own money. You can provide a currency format to the ToString method or use the default one. In our project we only displayed decimals when they’re not zero.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class MyMoney : Money
{
public MyMoney(decimal value, CultureInfo culture, Currency currency)
: base(value, culture, currency)
{

}

public MyMoney(decimal value, Currency currency) : base(value, currency)
{

}

public MyMoney(Money money) : base(money.Value, money.Culture, money.Currency)
{


}

//uCommerce ToString method has a fixed currency format
public string ToString(string format)
{

if (format.IsNullOrEmpty())
{
format = "C0";
}
string cultureSpecificStringValue = Value.ToString(format, Culture);
return cultureSpecificStringValue;
}


//should return the value with 2 decimals if it is not a whole number
public override string ToString()
{

string format = "C0";
if (Value - Math.Truncate(Value) > 0)
{
format = "c2";
}
return ToString(format);
}
}

the money maker

Here’s how you would use that. Let’s say you’re displaying the cost of a product in an orderline. Since the ultimate goal of e-commerce it to turn the product value into cash, we can do that in code. Let’s convert the product cost to MyMoney.

1
2
string displayValue = new MyMoney(orderline.Total.GetValueOrDefault(), 
orderline.PurchaseOrder.BillingCurrency).ToString();

Custom mediaRequest in Sitecore 8

For a new project in Sitecore 8, we needed an Image cropper. However, the images needed to be cropped dynamically. We started off by implementing the image cropper made by Anders Laub (found here). After adding the custom image processor, we could use a few querystring parameters to crop the image. the important ones are:

  • cw : crop width
  • ch : crop height
  • c : indicate that this cropper is to be used

However, when we got this working we noticed that the image was cached by sitecore, but the new querystring parameters were not. When an image is being processed, a cache is created in the app_data/mediacache folder. In a subfolder the transformed image is stored along with some info in a .ini file. The contents of the file will look something like this:

1
2
3
4
5
6
7
8
9
10
11
[key]
?as=False&bc=0&h=0&iar=False&mh=0&mw=0&sc=0&thn=False&w=0

[extension]
png

[headers]
Content-Type: image/png

[dataFile]
3f427bbc14fa4e4aa399266c96221b51.png

The key obviously contains the querystring parameters used in the url to link the image. Notice how all the default parameters are included, but none of the custom ones for the image cropper. To force sitecore to do that you need to write a custom mediarequest class that inherits from the MediaRequest class, found in the Sitecore.Resources.Media namespace. In your new class, you need to override the GetOptions() and Clone() methods of the MediaRequest class. I came up with a super original name for my new class, the CustomMediaRequest class.

1
2
3
4
5
6
7
public override MediaRequest Clone()
{

Assert.IsTrue((bool)(base.GetType() == typeof(CustomMediaRequest)),
"The Clone() method must be overridden to support prototyping.");
return new CustomMediaRequest { innerRequest = this.innerRequest, mediaUri = this.mediaUri,
options = this.options, mediaQueryString = this.mediaQueryString };
}

Since I didn’t need any fancy things done here I basically copied the existing code and changed the MediaRequest to CustomMediaRequest. The GetOptions() method is also mostly a copy of the original, but here I added some code to include the new querystring parameters. The parameters both custom and sitecore ones (KnownOptions) are stored in MediaOptions.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
this.ProcessCustomParameters(options);                          

if (!options.CustomOptions.ContainsKey("c"))
{
options.CustomOptions.Add("c", queryString.Get("c"));
}
else
{
options.CustomOptions["c"] = queryString.Get("c");
}

if (!options.CustomOptions.ContainsKey("cw"))
{
options.CustomOptions.Add("cw", queryString.Get("cw"));
}
else
{
options.CustomOptions["cw"] = queryString.Get("cw");
}

if (!options.CustomOptions.ContainsKey("ch"))
{
options.CustomOptions.Add("ch", queryString.Get("ch"));
}
else
{
options.CustomOptions["ch"] = queryString.Get("ch");
}

Finally, we need to add an extra config file in the app_config/include folder. I named mine Sitecore.Media.RequestParser.config since we are replacing the default requestparser with our own.

1
2
<requestParser type="Company.SC.Requests.CustomMediaRequest, Company.SC" 
patch:instead="processor[@type='Sitecore.Resources.Media.MediaRequest, Sitecore.Kernel']" />

Notice how we added the patch:instead. Patch:After won’t work as only one RequestParser is used. you also need to patch the Sitecore.Media.RequestProtection.config file, so that sitecore knows about these parameters.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<protectedMediaQueryParameters>
...
<parameter description="crop query key" name="c"/>
<parameter description="crop width query key" name="cw"/>
<parameter description="crop height query key" name="ch"/>
<parameter description="jpeg compression" name="jq"/>
</protectedMediaQueryParameters>

<customMediaQueryParameters>
<parameter description="image encoding" name="enc"/>
</customMediaQueryParameters>
You need to patch this before the renderWebEditing pipeline.

<pipelines>
<renderField>
<processor patch:before="processor[@type='Sitecore.Pipelines.RenderField.RenderWebEditing, Sitecore.Kernel']"
type="Sitecore.Pipelines.RenderField.ProtectedImageLinkRenderer, Sitecore.Kernel" />
</renderField>
</pipelines>

When this is done, the CustomOptions will be added to the querystring and the image cache key in the ini .file will look like this:

1
2
[key]
?as=False&bc=0&h=0&iar=False&mh=0&mw=0&sc=0&thn=False&w=0&c=1&ch=550&cw=180

Now, every time you render the image with different cropping (querystring) settings, a new image is stored in the mediacache (sub)folder and a new key is added to the .ini file. I hope this short tutorial can help someone looking to extend the image caching.

The complete CustomMediaRequest class:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
using System.Collections.Specialized;
using System.Web;
using Sitecore.Configuration;
using Sitecore.Diagnostics;
using Sitecore.Resources.Media;

namespace YourProject.SC.Requests
{
public class CustomMediaRequest : MediaRequest
{
private HttpRequest innerRequest;
private bool isRawUrlSafe;
private bool isRawUrlSafeInitialized;
private MediaUrlOptions mediaQueryString;
private Sitecore.Resources.Media.MediaUri mediaUri;
private MediaOptions options;

protected override MediaOptions GetOptions()
{
NameValueCollection queryString = this.InnerRequest.QueryString;
if ((queryString == null) || queryString.HasKeys() )
{
options = new MediaOptions();
}
else
{
MediaUrlOptions mediaQueryString = this.GetMediaQueryString();
options = new MediaOptions
{
AllowStretch = mediaQueryString.AllowStretch,
BackgroundColor = mediaQueryString.BackgroundColor,
IgnoreAspectRatio = mediaQueryString.IgnoreAspectRatio,
Scale = mediaQueryString.Scale,
Width = mediaQueryString.Width,
Height = mediaQueryString.Height,
MaxWidth = mediaQueryString.MaxWidth,
MaxHeight = mediaQueryString.MaxHeight,
Thumbnail = mediaQueryString.Thumbnail
};
if (mediaQueryString.DisableMediaCache)
{
options.UseMediaCache = false;
}
string[] strArray = queryString.AllKeys;
for (int i = 0; i < strArray.Length; i = (int)(i + 1))
{
string str = strArray[i];
if ((str != null) && (queryString.Get(str) != null))
{
options.CustomOptions[str] = queryString.Get(str);
}
}
}
if (!this.IsRawUrlSafe)
{
if (Settings.Media.RequestProtection.LoggingEnabled)
{
string urlReferrer = this.GetUrlReferrer();
Log.SingleError(string.Format("MediaRequestProtection: An invalid/missing hash value was encountered.
The expected hash value: {0}. Media URL: {1}, Referring URL: {2}"
, HashingUtils.GetAssetUrlHash(this.InnerRequest.Path), this.InnerRequest.Path, string.IsNullOrEmpty(urlReferrer)
? ((object)"(empty)") : ((object)urlReferrer)), this);
}
options = new MediaOptions();
}

this.ProcessCustomParameters(options);

if (!options.CustomOptions.ContainsKey("c"))
{
options.CustomOptions.Add("c", queryString.Get("c"));
}
else
{
options.CustomOptions["c"] = queryString.Get("c");
}

if (!options.CustomOptions.ContainsKey("cw"))
{
options.CustomOptions.Add("cw", queryString.Get("cw"));
}
else
{
options.CustomOptions["cw"] = queryString.Get("cw");
}

if (!options.CustomOptions.ContainsKey("ch"))
{
options.CustomOptions.Add("ch", queryString.Get("ch"));
}
else
{
options.CustomOptions["ch"] = queryString.Get("ch");
}

return options;
}

public override MediaRequest Clone()
{
Assert.IsTrue((bool)(base.GetType() == typeof(CustomMediaRequest)),
"The Clone() method must be overridden to support prototyping.");
return new CustomMediaRequest { innerRequest = this.innerRequest,
mediaUri = this.mediaUri, options = this.options, mediaQueryString = this.mediaQueryString };
}
}
}

The complete Sitecore.Media.RequestParser.config file:

1
2
3
4
5
6
7
8
9
<?xml version="1.0"?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
<sitecore>
<mediaLibrary>
<requestParser type="Company.SC.Requests.CustomMediaRequest, Company.SC"
patch:instead="processor[@type='Sitecore.Resources.Media.MediaRequest, Sitecore.Kernel']" />
</mediaLibrary>
</sitecore>
</configuration>

Finally I’d like to thank Anders Laub, for his excellent Sitecore blog and image cropper code.