понедельник, 20 сентября 2010 г.

On-fly локализация меню в SharePoint Foundation 2010

Как вы, наверняка, знаете, в SharePoint 2010 добавлена возможность смены локализации сайта "на лету". Однако, контент, который был внесен пользователем вручную, или создан при развертывании шаблона сайта, или создан при активации фич, не подлежит локализации "на лету". Казалось бы, довольно логично. Но что делать, если хочется?...

В SharePoint Server есть Variation Labels, есть Publishing Pages, а вот в Foundation ничего этого нет.

Я начал проработку локализации меню с двух неудачных попыток. Потратил огромную кучу времени (хотя как оказалось, всё можно сделать за полчаса), но с третьей попытки всё-таки мне удалось сделать всё как я хотел. А хотел я так:
  1. Пункты меню развертываются фичей. И удаляются тоже ею же, при деактивации. Фича развертывается в пределах веба.
  2. При переключении текущего языка на сайте, пункты меню сразу же локализуются. Т.е. сайт может смотреть одновременно несколько пользователей, и все будут видеть меню именно на выбранном ими языке.
Надеюсь, представленный ниже код сэкономит кому-нибудь кучу времени.

Предыстория

Сначала я пытался подменить класс SPNavigationProvider. Но, есть два минуса:
  1. Некрасиво. В этом способе подразумевается, что вместо названий пунктов меню мы вбиваем строки "$Resources:File,Key", и соответственно эта хрень отображается в стандартном редакторе меню.
  2. При вызове /_layouts/viewlsts.aspx w3wp.exe зависал намертво, при этом на 100% забивая проц. И в логах - ни одной ошибки :( Причем, вся админка в то же самое время работала нормально.
Так и не разобрался, в чем там была проблема...

Потом я пытался использовать другое решение, описанное на CodePlex'е. Вкратце, идея там состоит в том, чтобы определять в веб.конфиге экземпляр XmlSiteMapProvider, указывать ему в качестве источника sitemap-файл, и в masterpage добавлять делегат. Но это решение мне тоже не понравилось:
  1. У нас 4 мастер-страницы, на каждый вариант дизайна. В будущем будет больше. Никогда не любил ковыряться в этом чудовищном говнокоде.
  2. Самое главное, при использовании этого способа меню получается нередактируемым стандартными средствами SharePoint, т.к. прицеплено строго к sitemap-файлу. Ребята даже специально скрывают соответствующий пункт меню в админке через кастомэкшн.
  3. Более того, верхнее меню в админке, и на том же злополучном /_layouts/viewlsts.aspx оставались неизмененными.
Потом я еще думал над тем, чтобы использовать SqlSiteMapProvider и прикрутить к нему интерфейс для редактирования. И еще был вариант, по той же тематике, но лучше. SPListSiteMapProvider! Т.е. меню хранится в обычном списке шарепойнта, там можно всё очень красивенько сделать с помощью Custom Field Types.

При этом пропадала вторая проблема, но третий пункт оставался. Впрочем, его наверное тоже можно было решить, вытащив наше меню из плейсхолдера, а сам плэйсхолдер скрыв (Visible="False"). Хотя тоже ведь, не очень правильный вариант :(

Во всех этих вариантах еще добавлялись трудозатраты немаленькие, и риски, соответственно, тоже. А сроки, они как всегда - подпирают.

Так что я уж было отчаялся, и начал писать код для статического развертывания. Т.е., при активации фичи, в ресивере кодом добавляем нужные нам пункты в меню, сразу же локализуя их в локаль сайта. И on-fly локализация в этом случае, естественно, не работает. Но тут я заметил две вещи:
  • Наличие поля TitleResource в классе SPNavigationNode
  • Потом я еще заметил, что на простом сайте, там где после создания один единственный пункт меню ("Домашняя"), если переключиться на другой язык - этот пункт локализуется! Опытным путем было выяснено, что редактирование разных трансляций тоже происходит при переключении текущего языка.
В общем, покрыл я себя матами как следует, и сел за реализацию. Между прочим, тоже не сразу всё пошло гладко, сначала пытался использовать XmlSiteMapProvider, затем собственный CustomSiteMapProvider, отнаследованный от StaticSiteMapProvider - всё это, сразу предупреждаю, совершенно без толку, потому что SiteMapProvider подразумевает использование HttpContext'ов, а при развертывании фичи через stsadm или из студии, никакого HttpContext'а, естественно, недоступно. В итоге плюнул я на эти sitemap-файлы, захардкодил коллекцию пунктов меню в класс-хелпер, и всё наконец-то заработало.

Вот такой вот получился тернистый путь к звездам, блин :)

Рабочий вариант

Основной код лежит в Feature Receiver. На всякий случай, на пальцах, нужно добавить в проект фичу, нажать на неё правой мышой, и выбрать Add Event Receiver, добавится класс с несколькими закомментированными методами.

Дальше вставляем в этот класс вот такой код:
public override void FeatureActivated(SPFeatureReceiverProperties properties)
{
    SPWeb web = properties.Feature.Parent as SPWeb;

    // Добавляем наши пункты в верхнее меню

    AddSiteMenuNodeRecursive(LocalizedSiteMenu.Instance.TopNavMenu, web.Navigation.TopNavigationBar);

    // Добавляем наши пункты в quicklaunch
    AddSiteMenuNodeRecursive(LocalizedSiteMenu.Instance.QuickLaunchMenu, web.Navigation.QuickLaunch);

    web.Update();
}

private void AddSiteMenuNodeRecursive(IEnumerable<SiteMenuNode> nodes, SPNavigationNodeCollection menu)
{
    foreach (SiteMenuNode node in nodes)
    {
        SPNavigationNode navigationNode = new SPNavigationNode(node.Title, node.Url);
        menu.AddAsLast(navigationNode);
        navigationNode.Update();
        foreach (int lcid in LocalizationHelper.SupportedLocales)
        {
            navigationNode.TitleResource.SetValueForUICulture(new CultureInfo(lcid), SPUtility.GetLocalizedString(node.Title, "ResourceFile", (uint)lcid));
        }
        if (node.HasChildNodes)
        {
            AddSiteMenuNodeRecursive(node.ChildNodes, navigationNode.Children);
        }
        navigationNode.Update();
    }
}
При деактивации фичи надо заметать следы. Для этого, ищем наши пункты меню по их Url'у, и если находим - то удаляем.
public override void FeatureDeactivating(SPFeatureReceiverProperties properties)
{
    SPWeb web = properties.Feature.Parent as SPWeb;

    // Удаляем наши пункты из верхнего меню
    foreach (SiteMenuNode node in LocalizedSiteMenu.Instance.TopNavMenu)
    {
        SPNavigationNode found = web.Navigation.TopNavigationBar.Navigation.GetNodeByUrl(node.Url);
        if (found != null)
            web.Navigation.TopNavigationBar.Delete(found);
    }

    // Удаляем наши пункты из quicklaunch
    foreach (SiteMenuNode node in LocalizedSiteMenu.Instance.QuickLaunchMenu)
    {
        SPNavigationNode found = web.Navigation.QuickLaunch.Navigation.GetNodeByUrl(node.Url);
        if (found != null)
            web.Navigation.QuickLaunch.Delete(found);
    }

    web.Update();
}
Плюс к тому, еще нужно создать файлик с классом SiteMenuNode, который служит для хранения пунктов меню. Это простенький класс с тремя свойствами:
namespace SharePointTestProject.Localization
{
    public class SiteMenuNode
    {
        public string Url { get; set; }
        public string Title { get; set; }
        public IEnumerable<SiteMenuNode> ChildNodes { get; set; }
        public bool HasChildNodes { get { return (ChildNodes != null && ChildNodes.Count() > 0); } }
    }
}
Наконец, надо еще определить собственно коллекцию пунктов меню, которая уже упоминалась выше. Для этого я создал синглтон и захардкодил эту самую коллекцию:
namespace SharePointTestProject.Localization
{
    public class LocalizedSiteMenu
    {
        private static LocalizedSiteMenu instance = null;
        public static LocalizedSiteMenu Instance
        {
            get
            {
                if (instance == null)
                    instance = new LocalizedSiteMenu();
                
                return instance;
            }
        }

        public IEnumerable<SiteMenuNode> TopNavMenu =
            new SiteMenuNode[]
            {
                new SiteMenuNode()
                {
                    Url = "/Pages/Page1.aspx",
                    Title = "$Resources:ResourceFile,Page1Title"
                },
                new SiteMenuNode()
                {
                    Url = "/Pages/Page2.aspx",
                    Title = "$Resources:ResourceFile,Page2Title"
                }
            };


        public IEnumerable<SiteMenuNode> QuickLaunchMenu =
            new SiteMenuNode[]
            {
                new SiteMenuNode()
                {
                    Url = "/Products/default.aspx",
                    Title = "$Resources:ResourceFile,ProductsTitle",
                    ChildNodes = new SiteMenuNode[]
                    {
                        new SiteMenuNode()
                        {
                            Url = "/Products/Product1.aspx",
                            Title = "$Resources:ResourceFile,Product1Title"
                        },
                        new SiteMenuNode()
                        {
                            Url = "/Products/Product2.aspx",
                            Title = "$Resources:ResourceFile,Product2Title"
                        }
                    }
                },
                new SiteMenuNode()
                {
                    Url = "/Pages/Page3.aspx",
                    Title = "$Resources:ResourceFile,Page3Title"
                }
            };

    }
}
В этом примере задается верхнее меню из трех пунктов (оно будет добавлено в конец существующего), а также меню QuickLaunch из двух пунктов, один из которых является составным.

Вообще, Вы можете подгружать пункты откуда-нибудь динамически, к примеру, из какого-нибудь конфигурационного файла, хотя не уверен, что это нужно.

P.S. Код проверен, мин нет.

5 комментариев:

  1. Спасибо! Очень полезный пост... но LocalizationHelper.SupportedLocales - непонятно что такое, вместо этого можно заюзать web.SupportedUICultures, и еще, почему-то в ресурс подставляется точка с запятой. Например: вместо $Resources:ResourceFile,Product2Title в конечном счете получится $Resources:ResourceFile,Product2Title; , а такого ресурса нет. Пока так и не понял почему так и как с этим бороться

    ОтветитьУдалить
  2. Сори, просто оказалось, что ресурсы нужно в папку Resouces класть. (((

    ОтветитьУдалить
  3. Привет, Илья!

    LocalizationHelper - это специальный класс в нашем проекте, который предоставляет некоторые функции для локализации.

    Сейчас, кстати, перешли на использование кодогенерации (т.о. напрямую этот класс уже не используется).

    Что касается свойства этого класса SupportedLocales, то это просто список всех локалей, на которые наш портал переведен.

    Выглядит он примерно так:

    public string IEnumerable SupportedLocales
    {
    get
    {
    return new int[] { 1033, 1049 };
    }
    }

    ОтветитьУдалить
  4. Отличная статья. Только вопрос по поводу файла ресурсов. Его необходимо создавать самому или можно использовать стандартные?

    ОтветитьУдалить
  5. Отличная статья, от себя можем добавить, если нужен профессиональный перевод от носителей языка и тестирование, то можно использовать данный сервис http://alconost.com/services/website-translation

    ОтветитьУдалить

Внимание! Реклама и прочий спам будут беспощадно удаляться.