mandag den 25. november 2013

To assert or not to assert, thats a good question

It's no secret among people that work with me, that I really don't like the way Sitecore is misusing the Assert word.

The problem is, that Sitecore's Assert class, and the standard .NET Debug.Assert works fundamentally different, which can become a big problem, if people starts to learn the way Sitecore uses it, and then later on gets to work with other things than Sitecore.

So, when you in Sitecore have a function, that takes a parameter, and validates it like this:

public string IsItemMediaItem(Item item)
{
    Assert.ArgumentNotNull(item);

    return item.Paths.IsMediaItem;
}

I know this is a bad example, but it illustrates the problem, which is, that now we are using Assert to validate input parameters, which works just fine... in Sitecore.

Now lets say that the same code was written without Sitecore (Ignore the classes is called the same):

public string IsItemMediaItem(Item item)
{
    Debug.Assert(item != null);

    return item.Paths.IsMediaItem;
}

Now the problem starts to show, since normal .NET Assert only gets executed in Debug builds, so when you build your code as a Release build, the Debug.Assert line is removed, which now means your code no longer validates it's input!

It gets worse, once you start using this with input from clients, since you should always validate this.

That's why I always recommend people to do input validation this way, since that doesn't give them and other people bad habits.

This, on the other hand, is the "right" way to it:

public string IsItemMediaItem(Item item)
{
    if (item == null)
    {
        throw new ArgumentNullException("item", "item must not be null");
    }

    return item.Paths.IsMediaItem;
}

This works every time, on every system.

So Sitecore, if you read this, please mark your entire Assert class as obsolete, and start teaching people how to do real input validation, instead of something that will make them write bad code everywhere else.

Ensuring a child item exists

Sometimes, we need to get an item if it exists, and if it doesn't, create it instead.

This normally happens when we have something that needs to be placed inside folders, to ensure there aren't too many items in the same folder.

Lets say, that we have a task, that imports employees from an external system, and for each of these create an item - this can quickly result in problems if there are more than a few hundred items.

The normal way to solve this, is to divide the users into folders based on the first character in their name (or what else is used to differentiate them).

If we take the employee "A1D2", we would create a structure like this, for the item: "Employees/A/1/A1D2" .

Here comes the problem - to create the item, we need to make sure the folder "1" exists, but to ensure that, we need to ensure that the folder "A" exists, and so forth..

To do this, we have created a helper function, that returns a child item if it exists, and if it doesn't, we create the item, and return the new item - here is the code for the function:

public static Item EnsureChildItem(Item parentItem, string childName, TemplateID templateID, Action<Item> createdAction)
{
    if (parentItem == null)
    {
        throw new ArgumentNullException("parentItem", "parentItem must not be null");
    }

    if (string.IsNullOrEmpty(childName))
    {
        throw new ArgumentException("childName must not be empty", "childName");
    }

    string itemNameError = ItemUtil.GetItemNameError(childName);

    if (!string.IsNullOrEmpty(itemNameError))
    {
        throw new ArgumentException("childName is invalid - message: " + itemNameError, "childName");
    }

    Item childItem = parentItem.Children[childName];

    if (childItem != null)
    {
        return childItem;
    }

    childItem = parentItem.Add(childName, templateID);

    if (createdAction != null)
    {
        createdAction(childItem);
    }

    return childItem;
}

First we check if the parameters is valid, and if they are, we check if the item exist - if it does, we just return it.

If it doesn't we create it, and run the passed Action if it is defined - this allows the called to pass a function to be called if the item was created.
This is pretty clever, if I should say so, since it allows you to do anything to the new item, such as publish it (which is what we would normally do), so you're always sure the item is published.

It's a pretty small function, but it really makes it easy, to be able to do this:

string employeeName = GetEmployeeName();
Item employeeFolder = GetEmployeeFolder();

// Get the first level folder.
employeeFolder = HelperFunctions.EnsureChildItem(employeeFolder, employeeName[0].ToString(), new TemplateID(TEMPLATE_ID), item => HandleNewItem(item));

// Get the second level folder.
employeeFolder = HelperFunctions.EnsureChildItem(employeeFolder, employeeName[1].ToString(), new TemplateID(TEMPLATE_ID), item => HandleNewItem(item));

This way, we know that employeeFolder is containing the folder where the new employee item is to be created, and we know for sure, that it exists.

Nice :-)

tirsdag den 19. november 2013

Creating timebased tasks in Sitecore

From time to time, our customers would like to run taks at a certain time each day.

To be able to do this, we have created a new concept, called Timebased Tasks, which works like normal tasks, but instead of a frequency has a timestamp defining when the task should run.

To do this, you need 6 things, some "dummy" items to define the days, a template for the tasks, a folder for the tasks, two classes and an include file adding the agent.

Dummy items:

Lets start with the dummy items, create this folder in the master database: "/sitecore/system/Modules/Timebased Tasks".

Then create 7 items below it (based on the "Standard Template" template, since we don't need any special fields on them), and call them: "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" and "Sunday".

Template:

We need a template to define a Timebased Task - create a new template called "Timebased Task" somewhere where it makes sense to you, and add the following fields to it (all should be marked shared):

Name: Type: Source:
Command Droplink /sitecore/system/tasks/commands
Items Treelist /sitecore
Start Date Date  
End Date Date  
Frequency Checklist /sitecore/system/Modules/Timebased Tasks
Time Of Day Single-Line Text  
Asynchronous Checkbox  
Auto Remove Checkbox  
Last Run Datetime  

Folder:

Now we have to create the folder where the tasks is to be placed, so create this folder: "/sitecore/system/Tasks/Timebased Schedules"

Once the folder has been created, replace the insert options on the folder, so you can create items based on the template you just created.

The code:

Finally we get to the fun part - the code :-)

First we need a class called TimebasedScheduleItem - this class represents the schedule item, and extends the normal schedule item (to be allowed to be passed to a task, as if it was a normal ScheduleItem object):

using System;
using System.Globalization;
using System.Linq;

using Sitecore;
using Sitecore.Data.Fields;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;
using Sitecore.Jobs;
using Sitecore.SecurityModel;
using Sitecore.Tasks;

public class TimebasedScheduleItem : ScheduleItem
{
    public TimebasedScheduleItem(Item innerItem) : base(innerItem)
    {
    }

    public new bool Asynchronous
    {
        get
        {
            return InnerItem["Asynchronous"] == "1";
        }
    }

    public new bool AutoRemove
    {
        get
        {
            return InnerItem["Auto Remove"] == "1";
        }
    }

    public new CommandItem CommandItem
    {
        get
        {
            ReferenceField field = InnerItem.Fields["Command"];

            if (field == null || field.TargetItem == null)
            {
                return null;
            }

            return field.TargetItem;
        }
    }

    public new bool Expired
    {
        get
        {
            // First we check if the end date is before today (meaning that we are expired).
            if (EndDate < DateTime.Today)
            {
                // It is, so we return true.
                return true;
            }

            // If the end date is today.
            if (EndDate == DateTime.Today)
            {
                TimeSpan timeOfDay;

                // We try and parse the time of day.
                if (!TryParseTimeOfDay(out timeOfDay))
                {
                    // We failed, so we considder the task expired.
                    return true;
                }

                // Then we take the end date, and adds the time of day to it.
                // This is because the last possible time it can run would be the time of day on the end date.
                DateTime totalEndDate = EndDate.Add(timeOfDay);

                // Finally we check if the total end date is before now, and that the task has been run today.
                // This is to make sure that task gets run the last day if needed.
                return totalEndDate < DateTime.Now && LastRun.Date == DateTime.Today;
            }

            // If we gets here, end date is in the future, so we are not expired yet.
            return false;
        }
    }

    public new bool IsCompleted
    {
        get
        {
            // A task is completed (never going to run again) when the NextRun is the minimum possible DateTime value.
            return NextRun == DateTime.MinValue;
        }
    }

    public new bool IsDue
    {
        get
        {
            // If the task has been run today already, we return false.
            if (LastRun.Date == DateTime.Today)
            {
                return false;
            }

            // If the start date is in the future, or the task is expired, we return false.
            if (StartDate > DateTime.Today || Expired)
            {
                return false;
            }

            // If the day of the week it is today is not checked in the checklist, we return false;
            if (!Frequency.Contains(DateTime.Now.DayOfWeek.ToString()))
            {
                return false;
            }

            TimeSpan timeOfDay;

            // Then finally we try and parse the time of day field, and figures out if it is before the current time.
            // This makes sure that task does not get run too early - since if we get here, the task is supposed to run today.
            return TryParseTimeOfDay(out timeOfDay) && timeOfDay <= DateTime.Now.TimeOfDay;
        }
    }

    public new Item[] Items
    {
        get
        {
            MultilistField field = InnerItem.Fields["Items"];

            return (from id in field.TargetIDs
                    select InnerItem.Database.GetItem(id)).ToArray();
        }
    }

    public new DateTime LastRun
    {
        get
        {
            DateField field = InnerItem.Fields["Last Run"];

            return field.Value.Length == 0 ? DateTime.MinValue : field.DateTime;
        }

        set
        {
            using (new SecurityDisabler())
            {
                InnerItem.Editing.BeginEdit();
                InnerItem["Last Run"] = DateUtil.ToIsoDate(value);
                InnerItem.Editing.EndEdit();
            }
        }
    }

    public new DateTime NextRun
    {
        get
        {
            TimeSpan timeOfDay;

            // First we test if the task is due, in which case the next run is now, so we return DateTime.Now .
            if (IsDue)
            {
                return DateTime.Now;
            }

            // If the task is expired, no days is selected in the checklist or we are unable to parse the time of day field.
            // We return DateTime.MinValue, which indicates that there is no next run.
            if (Expired || Frequency.Length == 0 || !TryParseTimeOfDay(out timeOfDay))
            {
                return DateTime.MinValue;
            }

            // If the checklist contains the day of the week that today is, and the time of day is before now, we take today,
            // and add the time of day to it, and return it.
            if (Frequency.Contains(DateTime.Now.DayOfWeek.ToString()) && timeOfDay >= DateTime.Now.TimeOfDay)
            {
                return DateTime.Today.Add(timeOfDay);
            }

            DateTime tmpDate = DateTime.Today.Add(timeOfDay);

            // If we get here, the task is not expired, there is at least one day selected in the checklist, so we try adding a day to the
            // temporary date variable above, untill the day of the week that it represents is checked in the checklist - thats the next run :-)
            // The reason for 7 is because there is 7 days in a week, so we should in theory never hit the 7th run, since that would be back to
            // the weekday of today.
            for (int i = 0; i < 7; i++)
            {
                tmpDate = tmpDate.AddDays(1);

                if (Frequency.Contains(tmpDate.DayOfWeek.ToString()) && tmpDate.Date <= EndDate)
                {
                    return tmpDate;
                }
            }

            // If we get here, the task is not able to be run, so we just return DateTime.MinValue to make sure it never gets run.
            return DateTime.MinValue;
        }
    }

    public new ScheduleField Schedule
    {
        get
        {
            // We don't use the ScheduleField here, since we have our own field for this, so we overwrite it and return null instead.
            // This also prevents the normal DatabaseAgent from working with the TimebasedScheduleItem objects (like it is ever to get its hands on one).
            return null;
        }
    }

    private DateTime StartDate
    {
        get
        {
            DateField field = InnerItem.Fields["Start Date"];

            if (field == null)
            {
                return DateTime.MinValue;
            }

            return field.Value.Length == 0 ? DateTime.MinValue : field.DateTime.Date;
        }
    }

    private DateTime EndDate
    {
        get
        {
            DateField field = InnerItem.Fields["End Date"];

            if (field == null)
            {
                return DateTime.MinValue;
            }

            return field.Value.Length == 0 ? DateTime.MinValue : field.DateTime.Date;
        }
    }

    private string[] Frequency
    {
        get
        {
            MultilistField field = InnerItem.Fields["Frequency"];

            if (field == null)
            {
                return new string[0];
            }

            // We take all the selected weekdays, and return the item names of those that are selected.
            return (from id in field.TargetIDs
                    select InnerItem.Database.GetItem(id).Name).ToArray();
        }
    }

    public new void Execute()
    {
        try
        {
            // If the commanditem is null, we cannot run the task.
            if (CommandItem == null)
            {
                return;
            }

            // We execute the task (async or sync depending on what is checked.)
            if (Asynchronous)
            {
                ExecuteAsynchronously(CommandItem);
            }
            else
            {
                ExecuteSynchronously(CommandItem);
            }

            // Then we set the LastRun to now.
            LastRun = DateTime.Now;

            // And if the task is complete (not to ever run again), and is set to be auto removed, we remove it.
            if (IsCompleted && AutoRemove)
            {
                Log.Info("Schedule is completed. Auto removing schedule item: " + Name, this);
                InnerItem.Delete();
            }
        }
        catch (Exception e)
        {
            Log.Error("Error executing schedule item: " + Name, e, this);
            throw;
        }
    }

    public new string GetJobName(CommandItem command)
    {
        if (command == null)
        {
            throw new ArgumentNullException("command", "command must not be null.");
        }

        // We return the combined name of the task, which is unique to the command
        return "TimebasedScheduleCommand '" + command.InnerItem.ID + "'";
    }

    private void ExecuteAsynchronously(CommandItem command)
    {
        // Here we run the task asynchronously, so we create a Job, and start it.
        string jobName = GetJobName(command);
        JobManager.Start(new JobOptions(
            jobName,
            "timebasedschedule",
            "scheduler",
            command,
            "Execute",
            new object[] { Items, this }) { AtomicExecution = true });
    }

    private void ExecuteSynchronously(CommandItem command)
    {
        // Here we run the task synchronously, so we just call execute on it.
        command.Execute(Items, this);
    }

    private bool TryParseTimeOfDay(out TimeSpan timeOfDay)
    {
        DateTime result;

        // We try and parse the time of day field - it is supposed to be in the format "HH:mm".
        if (!DateTime.TryParseExact(InnerItem["Time Of Day"], "HH:mm", null, DateTimeStyles.None, out result))
        {
            // We failed, so we set timeOfDay to an empty TimeSpan and return false.
            timeOfDay = new TimeSpan();
            return false;
        }

        // We succeded, so we set timeOfDay to the parsed value, and return true.
        timeOfDay = result.TimeOfDay;
        return true;
    }
}

Then we need another class, which is our database agent, which has the job of finding the tasks, and starting them when needed - the class is called TimebasedDatabaseAgent, and the code looks like this:

using System;
using System.Linq;

using Sitecore;
using Sitecore.Configuration;
using Sitecore.Data;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;
using Sitecore.Jobs;

public class TimebasedDatabaseAgent
{
    public TimebasedDatabaseAgent(string databaseName, string scheduleRoot)
    {
        Error.AssertString(databaseName, "databaseName", false);
        Error.AssertString(scheduleRoot, "scheduleRoot", false);

        Database = Factory.GetDatabaseNames().Contains(databaseName) ? Factory.GetDatabase(databaseName) : null;
        ScheduleRoot = scheduleRoot;
        LogActivity = true;

        if (Database == null)
        {
            LogInfo("TimebasedDatabaseAgent skipping, database is null");
        }
    }

    public Database Database { get; private set; }

    public bool LogActivity { get; set; }

    public string ScheduleRoot { get; private set; }

    public void Run()
    {
        // If we have no database (like running on a content delivery server without the master database), we return.
        if (Database == null)
        {
            return;
        }

        LogInfo("TimebasedDatabaseAgent started. Database: " + Database.Name);

        // First we find the current job, and the schedules.
        Job job = Context.Job;
        TimebasedScheduleItem[] schedules = GetSchedules();

        LogInfo("Examining schedules (count: " + schedules.Length + ")");

        // Then we test if the job is valid, and if it is, we set the status on it.
        if (IsValidJob(job))
        {
            job.Status.Total = schedules.Length;
        }

        // For each found task
        foreach (TimebasedScheduleItem scheduleItem in schedules)
        {
            try
            {
                // If the schedule is due, we execute it.
                if (scheduleItem.IsDue)
                {
                    LogInfo("Starting: " + scheduleItem.Name + (scheduleItem.Asynchronous ? " (asynchronously)" : string.Empty));
                    scheduleItem.Execute();
                    LogInfo("Ended: " + scheduleItem.Name);
                }
                else
                {
                    LogInfo("Not due: " + scheduleItem.Name);
                }

                // If the schedule is expired and set to be auto removed, we remove it.
                if (scheduleItem.AutoRemove && scheduleItem.Expired)
                {
                    LogInfo("Schedule is expired. Auto removing schedule item: " + scheduleItem.Name);
                    scheduleItem.Remove();
                }
            }
            catch (Exception e)
            {
                LogInfo("Failed to run tasks, error: " + e.Message);
            }

            // If the job is valid, we increase the processed counter.
            if (IsValidJob(job))
            {
                job.Status.Processed += 1;
            }
        }
    }

    private static bool IsValidJob(Job job)
    {
        // We check if the job is in the right category
        return job != null && job.Category == "schedule";
    }

    private TimebasedScheduleItem[] GetSchedules()
    {
        // First we find the root folder.
        Item item = Database.Items[ScheduleRoot];

        // If we found it.
        if (item != null)
        {
            // Then we take all descendants from it, which is based on the template "Timebased Schedule", and return them as an array.
            return (from foundItem in item.Axes.GetDescendants()
                    where foundItem.TemplateName == "Timebased Schedule"
                    select new TimebasedScheduleItem(foundItem)).ToArray();
        }

        // We did not find the root folder, so we return an empty array.
        return new TimebasedScheduleItem[0];
    }

    private void LogInfo(string message)
    {
        // If logging is enabled, we log the message.
        if (LogActivity)
        {
            Log.Info(message, this);
        }
    }
}

Thats all the code needed, now we just need to create an include file, to configure Sitecore to use our new task system.

Include file:

The include file looks like this, and assumes you have placed the code in an assembly called TimebasedTasks:

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/" xmlns:x="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <scheduling>
      <frequency>00:00:30</frequency>
      <!-- Agent to process timebased schedules embedded as items in a database -->
      <agent type="TimebasedTasks.TimebasedDatabaseAgent, TimebasedTasks" method="Run" interval="00:00:30" patch:after="frequency">
        <param desc="database">master</param>
        <param desc="schedule root">/sitecore/system/Tasks/Timebased Schedules</param>
        <LogActivity>true</LogActivity>
      </agent>
    </scheduling>
  </sitecore>
</configuration>

Note that we have to change the frequency to 30 seconds to ensure, that our tasks gets executed the right minute (The value 30 is found by experimentation, anything higher have been causing tasks not to get run when they should - your milage may vary).

Summary:

That really all there is to it - now you are able to create tasks in the folder you created, and select the command to run, which days to run, and the time to run, in the format "HH:mm".

Simple, right?

tirsdag den 12. november 2013

A PublishQueue for publishing several different items in the background

From time to time, we all happen to have to create some import task in Sitecore, that imports alot of content.

The problem with this content is often, that you have to publish it once it has been imported, which can take quite some time of you are importing several hundred items (and maybe deleting some old items too).

That is why we have created a PublishQueue (not to be mistaken for the normal publish queue in Sitecore), that you can create, and fill with items - and then finally publish in one go.

Enough talking - here we go :-)

This solution consists of three classes: the PublishQueue class, which implements the queue, the QueueProcessor, which does the publishinh, and finally PublishingHelper, which has two helper functions the developer calls once he is ready to publish.

First, we have the PublishQueue class:

using System;
using System.Collections.Generic;

using Sitecore.Data;
using Sitecore.Data.Items;
using Sitecore.Publishing;

public sealed class PublishQueue
{
    private readonly List<PublishOptions> publishQueue = new List<PublishOptions>();
    private readonly Database targetDatabase;

    public PublishQueue(Database targetDatabase)
    {
        this.targetDatabase = targetDatabase;
    }

    internal int Count
    {
        get
        {
            return publishQueue.Count;
        }
    }

    internal PublishOptions[] QueueContent
    {
        get
        {
            return publishQueue.ToArray();
        }
    }

    public void Clear()
    {
        publishQueue.Clear();
    }

    public void Enqueue(Item item, bool includeChildren, bool allLanguages, PublishMode publishMode)
    {
        if (item == null)
        {
            throw new ArgumentNullException("item", "item must not be null");
        }

        switch (publishMode)
        {
            case PublishMode.Full:
            case PublishMode.Smart:
                break;
            case PublishMode.SingleItem:
            case PublishMode.Unknown:
            case PublishMode.Incremental:
                throw new ArgumentException("The passed PublishMode is invalid", "publishMode");
            default:
                throw new ArgumentOutOfRangeException("publishMode");
        }

        PublishOptions options = new PublishOptions(item.Database, targetDatabase, publishMode, item.Language, DateTime.Now)
        {
            Deep = includeChildren,
            RepublishAll = allLanguages,
            RootItem = item
        };

        publishQueue.Add(options);
    }
}

The next class we need, is the QueueProcessor class:
using System;

using Sitecore.Data;
using Sitecore.Globalization;
using Sitecore.Publishing;

private class QueueProcessor
{
    private readonly PublishQueue queue;

    public QueueProcessor(PublishQueue queue)
    {
        if (queue == null)
        {
            throw new ArgumentNullException("queue", "queue must not be null");
        }

        this.queue = queue;
    }

    public void ProcessQueue()
    {
        foreach (PublishOptions options in queue.QueueContent)
        {
            Language[] languages = options.RepublishAll ? options.RootItem.Languages : new[] { options.RootItem.Language };
            Database[] databases = new[] { options.TargetDatabase };

            PublishManager.PublishItem(options.RootItem, databases, languages, options.Deep, options.Mode == PublishMode.Smart);
        }
    }
}

Finally we need these the helper class, called PublishingHelper:
using System;

using Sitecore;
using Sitecore.Jobs;

public static class PublishingHelper
{
    public static void ProcessPublishQueue(PublishQueue queue)
    {
        QueueProcessor processor = new QueueProcessor(queue);

        processor.ProcessQueue();
    }

    public static Job ProcessPublishQueueAsync(PublishQueue queue)
    {
        string jobName = GetJobName(queue);

        JobOptions jobOptions = new JobOptions(jobName, "publish", "publisher", new QueueProcessor(queue), "ProcessQueue")
        {
            ContextUser = Context.User,
            AfterLife = TimeSpan.FromMinutes(1.0),
            AtomicExecution = true
        };

        if (Context.Job != null)
        {
            jobOptions.ClientLanguage = Context.Job.Options.ClientLanguage;
        }

        return JobManager.Start(jobOptions);
    }

    private static string GetJobName(PublishQueue queue)
    {
        return string.Format("Publish queue containing {0} items.", queue.Count);
    }
}

Then we are done - the only thing needed is to use it - here is an example of how it can be used:
public static void ImportItems(IEnumerable<XElement> dataFromExternalSystem)
{
    PublishQueue queue = new PublishQueue(Factory.GetDatabase("web"));

    IEnumerable<Item> oldItems = GetOldItemsToBeRemoved();

    using (new SecurityDisabler())
    {
        foreach (Item oldItem in oldItems)
        {
            oldItem.Delete();
            queue.Enqueue(oldItem, false, true, PublishMode.Full);
        }
    }

    foreach (XElement element in dataFromExternalSystem)
    {
        Item item = CreateItemFromData(element);
        queue.Enqueue(item, false, true, PublishMode.Full);
    }

    PublishingHelper.ProcessPublishQueueAsync(queue);
}

First we create our queue, defining that the items in it, should be published to the web database.

Then we gets all the old items that we want to remove, and both remove them and add them to the queue, so they get removed from the web database too.
And then we create all the new items, which also gets put into the queue.

Finally we starts an async publish of the entire queue in the background - we don't save the return value, since we don't care about when it gets done, it should just run in the background untill it is done.

Thats really all there is to it - we have been using this for a while now, and it really both simplifies and increase the stability of our import tasks, so thats a win-win :-)

mandag den 4. november 2013

Links to media items on external sitecore sites and Sitecore 6.5

Just ran into an interresting problem a while ago, with a Sitecore 6.5 solution we made a while back.

It seems like there is a bug in Sitecore 6.5, that has been fixed in later versions of Sitecore 6.5

I'm writing here, so people having the same problem knows that an upgrade fixes it.

Now, lets image we have created the website http://www.mywebsite.org for a customer, and they want to link to latest price list from one of their suppliers, who is also running Sitecore.

The supplier has the website http://www.supplier.org , and their pricelist is located at http://www.supplier.org/~/media/Files/PriceList.pdf .

Our editor at mywebsite would like to link to this, so he creates a new article, and inserts a link to this file using the Rich Text editor in Sitecore.

Once he saves the link, it is saved correctly to the database - however, when the frontend renders the link, it tries to expand it, which results in the following link getting rendered: http://www.supplier.org/http://www.mywebsite.org/~/media/Files/PriceList.pdf , which ofcause does not work.

The good news is, that the link manager we rewritten late in Sitecore 6.5 , so upgrading to the newest version of Sitecore 6.5, or Sitecore 6.6 would solve this problem.

So, if you are creating a new solution, there is nothing to worry about, since you really should be using the newest version of Sitecore anyway - but if you have some old solutions, where people hits this bug, the easy fix is to upgrade. (There is a workaround which replaces parts of the link manager, which I'm not gonna go into details with).