Camlex.Net 3.1: динамическое создание ViewFields для CAML запросов в Sharepoint

На этой неделе была выпущена новая версия 3.1 проекта Camlex.Net. В этом релизе была добавлена возможность динамически формировать ViewFields для CAML запросов. Недавно в разделе discussions на сайте проекта был задан вопрос о такой фиче. Это не первый случай, когда мы добавляем функциональность в проект на основе фидбека, который мы получаем от разработчиков, и по мере возможностей и наличия свободного времени развиваем наш open source проект. Кроме этого мы сами интенсивно используем его в работе, поэтому стараемся добавлять функциональность по мере поступления новых запросов, чтобы проект был все более полезен в разработке для Sharepoint.

В версии 3.1 были добавлены следующие новые методы:

public interface IQueryEx : IQuery
{
    ...
    string ViewFields(IEnumerable<string> titles);
    string ViewFields(IEnumerable<string> titles, bool includeViewFieldsTag);
    string ViewFields(IEnumerable<Guid> ids);
    string ViewFields(IEnumerable<Guid> ids, bool includeViewFieldsTag);
}

Таким образом, теперь вы можете динамически сформировать список имен полей или идентификаторов (типа Guid) и передать его в Camlex. На основе этого списка Camlex сформирует CAML для ViewFields. Например, используя следующий код:

var items = new [] { "Title", "FileRef" };

string caml = Camlex.Query().ViewFields(items);

вы получите следующий CAML:

<FieldRef Name=\"Title\" />
<FieldRef Name=\"FileRef\" />

В приведенном выше примере список имен полей предопределен. Но в реальном приложении вы можете сформировать его динамически на основе каких-либо условий.

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

До новой версии в интерфейсе IQueryEx были следующие методы (они остались и в новой версии):

public interface IQueryEx : IQuery
{
    string ViewFields(Expression<Func<SPListItem, object>> expr);
    string ViewFields(Expression<Func<SPListItem, object>> expr, bool includeViewFieldsTag);
    string ViewFields(Expression<Func<SPListItem, object[]>> expr);
    string ViewFields(Expression<Func<SPListItem, object[]>> expr, bool includeViewFieldsTag);
}

С их помощью ViewFields можно сформировать с помощью следующего кода:

string caml =
    Camlex.Query().ViewFields(x => new [] { x["Title"], x["FileRef"] });

Чтобы создать список динамически вам самим пришлось бы написать код, который создает выражение (NewArrayInit) на основе списка имен полей (“Title” и “FileRef” в этом примере). Это не то, чем надо заниматься при работе над бизнес задачей. Библиотека должна делать это за нас. Когда я думал над реализацией, я хотел использовать существующие методы, чтобы не писать много нового кода. Чтобы это сделать надо было ответить на вопрос: как, имея массив строк или Guid-ов (напр. {“Title”, “FileRef”}), создать лямбда выражение x => new [] { x[“Title”], x[“FileRef”] }? Решение получилось довольно элегантным:

public string ViewFields(IEnumerable<string> titles, bool includeViewFieldsTag)
{
    ...
    return this.ViewFields(this.createExpressionFromArray(titles), includeViewFieldsTag);
}

public string ViewFields(IEnumerable<Guid> ids, bool includeViewFieldsTag)
{
    ...
    return this.ViewFields(this.createExpressionFromArray(ids), includeViewFieldsTag);
}

private Expression<Func<SPListItem, object[]>>
createExpressionFromArray<T>(IEnumerable<T> items)
{
    return Expression.Lambda<Func<SPListItem, object[]>>(
        Expression.NewArrayInit(typeof(object),
        (IEnumerable<Expression>)items.Select(
            t => Expression.Call(Expression.Parameter(typeof(SPListItem), "x"),
                typeof(SPListItem).GetMethod("get_Item", new[] { typeof(T) }),
                new[] { Expression.Constant(t) })).ToArray()),
        Expression.Parameter(typeof(SPListItem), "x"));
}

Т.е. для каждого элемента элемента списка items создается выражение – доступ к индексеру x[item], которое затем добавляется в список инициализации NewArrayInit. Обратите внимание на вызов ToArray() – без него ленивое LINQ выражение не будет развернуто и инициализация NewArrayInit упадет с исключением.

Это все, что я хотел рассказать о новом релизе. В данный момент у нас уже есть достаточно внушительный todo list, но мы всегда открыты новым предложениям и идеям. Пишете о них на сайте проекта на codeplex.

Рубрика: CAML, Camlex.NET, Sharepoint | Оставить комментарий

Использование класса ContentIterator в разработке для Sharepoint

В этом посте я хочу рассказать о ContentIterator — полезном классе, который вы можете использовать в повседневной разработке для Sharepoint. Он определен в сборке Microsoft.Office.Server.dll и помогает работать с такими общими задачами, как итерация по списку элементов списка Sharepoint (SPListItem), файлов (SPFile), веб сайтов (SPWeb) и др. Итерация по элементам и файлам реализована через SPQuery, что значит он имеет очень хорошую производительность (близкую к максимальной из того, что предлагает Sharepoint object model). ContentIterator – новый класс, который появился в Sharepoint 2010. Как сказано в документации к классу:

Sharepoint сервер предоставляет новый API, ContentIterator, для помощи при работе с большими списками, содержащими более 5000 элементов, позволяющий обойти throttling-предел и генерации SPQueryThrottleException.

Например, представим, что у нас есть ссылка на объект типа SPFolder, представляющий папку в Sharepoint списке, и мы хотим пройтись в цикле по всем элементам внутри этой папки. Для этого мы можем использовать метод ContentIterator.ProcessFilesInFolder:

SPFolder folder = web.GetFolder(...);
SPList doclib = web.Lists[...];
ContentIterator contentIterator = new ContentIterator();

bool isFound = false;
contentIterator.ProcessFilesInFolder(doclib, folder, false,
    f =>
        {
            // this is item iteration handler
            ...
        },
    (f, e) =>
        {
            // error handler
            ...
        });

Работа с ContentIterator основана на использовании делегатов. В параметрах метода мы передаем ссылку на родительскую библиотеку документов (либо список) и папку в ней, в которой нужно проитерировать элементы. Третий параметр указывает, нужно ли делать обход рекурсивным если внутри нашей папки есть подпапки. Четвертый параметр – это делегат, который вызывается из ContentIterator-а и который получает непосредственно каждый файл (объект типа SPFile). Последний параметр – тоже делегат, представляющий обработчик ошибок.

Давайте посмотрим как реализован этот метод. Внутри он использует другой метод ContentIterator.ProcessListItems:

public void ProcessListItems(SPList list, string strQuery, uint rowLimit,
    bool fRecursive, SPFolder folder, ItemsProcessor itemsProcessor,
    ItemsProcessorErrorCallout errorCallout)
{
    ...
    SPQuery query = new SPQuery();
    if (!string.IsNullOrEmpty(strQuery))
    {
        query.Query = strQuery;
    }
    query.RowLimit = rowLimit;
    if (folder != null)
    {
        query.Folder = folder;
    }
    if (fRecursive)
    {
        query.ViewAttributes = "Scope=\"RecursiveAll\"";
    }
    this.ProcessListItems(list, query, itemsProcessor, errorCallout);
}

Для начала создается объект SPQuery. Непосредственный CAML-запрос формируется выше по стеку:

public static string ItemEnumerationOrderByPath
{
    get
    {
        return "<OrderBy Override='TRUE'><FieldRef Name='FileDirRef' /><FieldRef Name='FileLeafRef' /></OrderBy>";
    }
}

И наиболее интересная реализация перегруженного метода ProcessListItems:

public void ProcessListItems(SPList list, SPQuery query, ItemsProcessor itemsProcessor,
    ItemsProcessorErrorCallout errorCallout)
{
    string str2;
    SPListItemCollection items;
    ...
    if (!list.get_HasExternalDataSource() && (list.ItemCount == 0))
    {
        return;
    }
    if (list.get_HasExternalDataSource() && (query.RowLimit == 0))
    {
        query.RowLimit = 0x7fffffff;
    }
    else if ((query.RowLimit == 0) || (query.RowLimit == 0x7fffffff))
    {
        query.RowLimit = string.IsNullOrEmpty(query.ViewFields) ? 200 : 0x7d0;
    }
    if (!list.get_HasExternalDataSource() && this.StrictQuerySemantics)
    {
        query.set_QueryThrottleMode(2);
    }
    string strListId = list.ID.ToString("B");
    this.ResumeProcessListItemsBatch(strListId, out str2);
    if (!string.IsNullOrEmpty(str2))
    {
        query.ListItemCollectionPosition = new SPListItemCollectionPosition(str2);
    }
    int batchNo = 0;
Label_012B:
    items = list.GetItems(query);
    int count = items.Count;
    batchNo++;
    try
    {
        itemsProcessor(items);
        this.OnProcessedListItemsBatch(strListId, items, batchNo, count);
    }
    catch (Exception exception)
    {
        if ((errorCallout == null) || errorCallout(items, exception))
        {
            throw;
        }
    }
    if (!this.ShouldCancel(IterationGranularity.Item))
    {
        query.ListItemCollectionPosition = items.ListItemCollectionPosition;
        if (query.ListItemCollectionPosition != null)
        {
            goto Label_012B;
        }
    }
}

Внутри метода проверяется свойство RowLimit и, если оно не установлено, устанавливается в значения по умолчанию (в 200 или 2000 в зависимости от того, установлено ли свойство ViewFields или нет). Затем свойство SPQuery.QueryThrottleMode устанавливается в Strict. Документация говорит про это следующее:

Throttling запросов будет определяться числом элементов, а также полями Lookup, Person/Group и Workflow, вне зависимости от прав пользователей.

Также используется SPQuery.ListItemCollectionPosition для выборки элементов за один запрос (число элементов, возвращаемых за один запрос определяется свойством RowLimit).

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

Рубрика: CAML, Sharepoint | 2 комментария

Меняем адрес страницы с комментариями в Sharepoint 2010 без установки SP1

Комментарии – одна из новых возможностей, добавленных как часть социальных вычислений Sharepoint 2010. Они позволяют пользователям сайта, построенном на Sharepoint, оставлять отзывы и комментарии к контенту. Однако при этом есть проблема — технически комментарии привязаны к странице с использованием абсолютного адреса данной страницы. Это означает, что если вы перенесете страницу с одного сайта на другой, или измените доменное имя сайта – комментарии не будут отображаться (но не потеряются – они по-прежнему будут храниться в базе данных, как мы увидим ниже).

В этом году Microsoft выпустили пакет обновлений (Service pack 1) для Sharepoint 2010. Вместе с ним появился новый метод MergeSocialNotes() в классе SocialDataManager. Также появился новый cmdlet для PowerShell Move-SPSocialComments – обертка над указанным выше методом. Следующий пост описывает другие улучшения и новые фичи: Changes to Social Computing features in SharePoint Server 2010 Service Pack 1. Все это полезно, но что если на ваших серверах SP1 не установлен, но вам нужно изменить адреса страниц комментариев? Такая ситуация вполне возможна, например если заказчик еще не принял решение устанавливать пакет обновлений или нет (кто работал с крупными заказчиками знает, сколько времени может потребоваться на принятие решения и исполнение). В этом посте я расскажу, как можно перенести страницы, не потеряв при этом комментарии.

Прежде всего, нужно знать, что комментарии хранятся в отдельной базе данных для социальных вычислений – в таблице SocialComments:

Как видно на рисунке данная таблица содержит UrlID – внешний ключ к таблице Urls, в которой хранятся абсолютные урлы:

Теперь давайте посмотрим, как реализован метод SocialDataManager.MergeSocialNotes():

internal static bool MergeSocialNotes(UserProfileApplication userProfileApplication,
Guid partitionID, string oldUrl, string newUrl)
{
    if (null == userProfileApplication)
    {
        throw new ArgumentNullException("userProfileApplication");
    }
    if (string.IsNullOrEmpty(oldUrl))
    {
        throw new ArgumentNullException("oldUrl");
    }
    if (string.IsNullOrEmpty(newUrl))
    {
        throw new ArgumentNullException("newUrl");
    }
    if (!userProfileApplication.CheckAdministrationAccess(
UserProfileApplicationAdminRights.ManageSocialData))
    {
        throw new UnauthorizedAccessException();
    }
    using (SqlCommand command = new SqlCommand("dbo.proc_SocialData_MergeSocialNotes"))
    {
        command.CommandType = CommandType.StoredProcedure;
        command.Parameters.Add("@partitionID", SqlDbType.UniqueIdentifier).Value = partitionID;
        command.Parameters.Add("@correlationId", SqlDbType.UniqueIdentifier).Value = ULS.CorrelationGet();
        command.Parameters.Add("@oldUrl", SqlDbType.VarChar).Value = oldUrl;
        command.Parameters.Add("@newUrl", SqlDbType.VarChar).Value = newUrl;
        SqlParameter parameter = new SqlParameter("@success", SqlDbType.Bit);
        parameter.Direction = ParameterDirection.Output;
        command.Parameters.Add(parameter);
        userProfileApplication.SocialDatabase.SqlSession.ExecuteNonQuery(command);
        if (!((bool) command.Parameters["@success"].Value))
        {
            return false;
        }
    }
    return true;
}

Реализация достаточно простая: он вызывает хранимую процедуру proc_SocialData_MergeSocialNotes с переданными параметрами. Эта процедура создается в скриптах socialsrp.sql и socialup.sql, расположенными в папке 14\Template\SQL\SPS (они обновляются в SP1, т.е. в предшествующей версии Sharepoint указанной хранимой процедуры в них нет). В обоих скриптах код хранимый процедуры одинаковый:

IF  EXISTS (SELECT * FROM dbo.sysobjects
WHERE id = OBJECT_ID(N'[dbo].[proc_SocialData_MergeSocialNotes]')
AND OBJECTPROPERTY(id,N'IsProcedure') = 1)
DROP PROCEDURE [dbo].[proc_SocialData_MergeSocialNotes] 
GO

CREATE PROCEDURE [dbo].[proc_SocialData_MergeSocialNotes] 
    @partitionID uniqueidentifier,
    @oldUrl varchar(2048),
    @newUrl varchar(2048),
    @success bit = null output,
    @correlationId uniqueidentifier = null
AS
BEGIN
    SET NOCOUNT ON;

    DECLARE @oldUrlID bigint
    DECLARE @newUrlID bigint

    exec proc_Social_GetUrlID @partitionID, @oldUrl, @oldUrlID output, @correlationId
    exec proc_Social_GetUrlID @partitionID, @newUrl, @newUrlID output, @correlationId

    IF (@oldUrlID is null)
    BEGIN
        -- nothing to merge
        set @success = 1
        return
    END

    IF (@newUrlID is null)
    BEGIN
        EXEC proc_Social_EnsureUrlID @partitionID = @partitionID, @url = @newUrl,
@urlID = @newUrlID output, @correlationId = @correlationId;
    END

    IF (@newUrlID is null)
    BEGIN
        -- not able to create new Url - error
        set @success = 0
        return
    END

    UPDATE SocialComments
    SET UrlID = @newUrlID
    WHERE UrlID = @oldUrlID

    -- we succeed, even if there is no data to migrate
    set @success = 1
END
GO

Как видно из примера, используется код двух других хранимых процедур: proc_Social_GetUrlID и proc_Social_EnsureUrlID (которые существовали до установки пакета обновлений). Код, приведенный выше тоже достаточно простой: он проверяет, что newUrl существует в таблице Urls (если нет, то добавляет), и обновляет внешний ключ UrlID в таблице SocialComments для того, чтобы связать комментарий с новым урлом.

Для того чтобы использовать этот код на серверах без установленного пакета обновлений, прежде всего нужно создать процедуру proc_SocialData_MergeSocialNotes в базе данных социальных вычислений (выполнить скрипт, показанный выше). После этого останется лишь написать SQL скрипт, который обновит адреса страниц в зависимости от ваших требований.

Например, предположим, что нам нужно переместить новости с подсайта http://example.com/Departments/IT на родительский сайт http://example.com/Departments. Во время миграции новостей, комментарии должны быть тоже обновлены так, чтобы они по-прежнему были видны на новом сайте. Это можно сделать с помощью следующего скрипта:

declare @comment nvarchar(4000), @url nvarchar(2048), @newUrl nvarchar(2048)
declare @partitionId uniqueidentifier
declare @b bit

set @partitionId = CAST('...' as uniqueidentifier)

declare CommentsCursor cursor for
select sc.Comment, u.Url from dbo.SocialComments sc
    inner join dbo.Urls u on sc.UrlID = u.UrlID
where Url like '%/Departmens/IT/%'

open CommentsCursor

FETCH NEXT FROM CommentsCursor 
INTO @comment, @url

while @@FETCH_STATUS = 0
begin
    print @url + '        ' + @comment
    
    set @newUrl = replace(@url, '/Departmens/IT/', '/Departmens/')
    if @newUrl <> @url
    begin
        print 'Change url ' + @url + ' -> ' + @newUrl
        exec proc_SocialData_MergeSocialNotes @partitionId, @url, @newUrl, @b output
        print 'Result: ' + str(@b)
    end
    
    FETCH NEXT FROM CommentsCursor 
    INTO @comment, @url
end

close CommentsCursor
deallocate CommentsCursor

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

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

Последний вопрос – это где взять значение для @partitionId. Простейший способ найти его – это выполнить следующий запрос в базе данных социальных вычислений:

select sc.Comment, sc.PartitionID, u.Url, u.PartitionID from dbo.SocialComments sc
    inner join dbo.Urls u on sc.UrlID = u.UrlID

и посмотреть какой PartitionID используется в вашем веб-приложении.

После выполнения скрипта, комментарии будут привязаны к страницам, перемещенным на родительский сайт.

Понятно, что указанный метод является наполовину хэком: он манипулирует данными в базе данных социальных вычислений напрямую в обход объектной модели. Однако, как мы видели, объектная модель – это всего лишь тонкая оболочка над хранимой процедурой. Также этот подход не требует установки пакета обновлений на production-сервера, что может быть важным в реальной жизни. В любом случае, надеюсь что эта информация окажется полезной для вас.

Рубрика: Migration, Sharepoint, Social computing | Оставить комментарий

Программное создание иерархических таксономий в обработчике событий компонента

Скажу сразу что под обработчиком событий компонента имеется ввиду feature receiver. Но так как этот блог на русском языке и я не знаю более менее удачного перевода – для заголовка статьи я решил использовать перевод из Msdn. Дальше по тексту я буду использовать термин фича, понятный всем Sharepoint разработчикам.

Провиженинг управляемых метаданных (managed metadata) – достаточно частое требование в проектах на Sharepoint 2010. Часто вместе с созданием сайт коллекции необходимо добавить начальные данные для упрощения задачи сотрудникам, ответственным за контент (напр. страницы публикаций, данные в lookup листах и др.). Управляемые метаданные также могут быть созданы при активации фичи во время провиженинга. Например, заказчик может предоставить вам метаданные в виде xml файла (либо в любом другом формате, который можно распарсить программно) и вам нужно создать иерархию таксономий на основе этого файла. Одна из возможностей – использовать стандартный механизм импорта из csv файла, доступный в Term store. Но при этом во-первых у вам еще один шаг ручной настройки, а во-вторых стандартный импорт ограничен в кастомизации, напр. вы не сможете установить переводы ваших метаданных, синонимы, а также смержить метаданные с существующими наборами терминов (term sets). В этом посте я покажу как программно установить таксономии с любым уровнем вложенности на основе xml файла.

Прежде всего нужно создать фичу с областью действия – сайт коллекция. Это удобно, т.к. мы сможем добавить ее в onet.xml кастомного определения сайта (в секцию<SiteFeatures>) – и управляемые метаданные будут созданы при провиженинге сайт коллекции. Но при этом нужно иметь ввиду, что метаданные могут быть изменены после того, как они были запровиженены. Поэтому если планируется активировать фичу, устанавливающую метаданные, несколько раз в течение периода существования сайт коллекции, нужно позаботиться о том, чтобы по ошибке не перезаписать изменения, сделанные вручную администраторами метаданных. Простой способ – проверить что группа или набор терминов с текущим названием уже существуют и не перезаписывать их. Более сложный – мержить наборы терминов. Но последний сценарий вне скопа данной статьи.

Вернемся к нашей фиче и ее feature.xml файлу:

<?xml version="1.0" encoding="utf-8" ?>
<Feature
    Id="2FCC2C0B-A5D9-480B-B15B-189B5755185B"
    Title="Managed metadata example"
    Description=""
    Version="1.0.0.0"
    Scope="Site"
    Hidden="FALSE"
    ReceiverAssembly="ManagedMetadataProgrammaticalExample, Version=1.0.0.0, Culture=neutral, PublicKeyToken=d71c4ad7a0705ced"
    ReceiverClass="ManagedMetadataProgrammaticalExample.TermSetsFeatureReceiver"
    xmlns="http://schemas.microsoft.com/sharepoint/">
    <ElementManifests>
        <ElementFile Location="DefaultMetadata.xml"/>
    </ElementManifests>
</Feature>

Мы определили обработчик событий фичи, в котором будет выполняться непосредственная работы, т.е. парсинг xml файла и создание таксономии в Managed metadata service application. Также мы добавили ссылку на файл DefaultMetadata.xml, который содержит управляемые метаданные. Напр. так моэет выглядеть метаданные для интернет магазина (для простоты используем небольшую выборку):

<?xml version="1.0" encoding="utf-8" ?>
<TermSets>
  <TermSet Name="Categories">
    <Term Name="Books">
      <Term Name="IT">
        <Term Name="Sharepoint" />
        <Term Name="ASP.Net MVC" />
        <Term Name=".Net" />
      </Term>
      <Term Name="Math">
        <Term Name="Algebra" />
        <Term Name="Geometry" />
        <Term Name="Math analysis" />
      </Term>
    </Term>
    <Term Name="Films">
      <Term Name="The Big Bang Theory" />
      <Term Name="Haus MD" />
      <Term Name="Numb3rs" />
    </Term>
  </TermSet>
</TermSets>

Хотя в примере выше только один набор терминов, код приведенный в статье будет работать также для многих наборов (т.е. если xml будет содержать несколько наборов – все будут созданы). Провиженинг метаданных производится в 2 шага:

  1. Парсинг xml и представление данных в объектной модели
  2. Создание наборов терминов и терминов

Нам понадобятся 2 DTO класса:

public class TermSetDTO
{
    public string Name { get; set; }
    public List<TermDTO> Terms { get; set; }
}

public class TermDTO
{
    public string Name { get; set; }
    public List<TermDTO> Terms { get; set; }
}

Как видно, это очень простое представление стандартных сущностей TermSet и Term.

Теперь обратимся к коду обработчика событий:

public class TermSetsFeatureReceiver : SPFeatureReceiver
{
    public override void FeatureActivated(SPFeatureReceiverProperties properties)
    {
        var site = properties.Feature.Parent as SPSite;
        if (site == null)
        {
            return;
        }

        SPSecurity.RunWithElevatedPrivileges(
            () =>
                {
                    using (var elevatedSite = new SPSite(site.ID, site.Zone))
                    {
                        string defaultMetadataFilePath = Path.Combine(properties.Feature.Definition.RootDirectory, "DefaultMetadata.xml");
                        var termSetsService = new TermSetsService();
                        termSetsService.CreateTermSetsFromXml(elevatedSite, defaultMetadataFilePath);
                    }
                });
    }
}

Важный момент: для определенности код исполняется под elevated privileges. Строго говоря, сами elevated privileges не нужны – нам нужно знать под каким аккаунтом исполняется код, чтобы добавить его в группу администраторов банка терминов  (Term store administrators). Поэтому если сайт коллекция создается из Central Administration, то необходимо добавить аккаунт пула приложений в IIS в группу администраторов банка терминов (Term store administrators) в Приложении службы метаданных (Managed metadata service application).

Код обработчика достаточно простой – он передает путь к DefaultMetadata.xml в экземпляр TermSetsService, который выполняет непосредственную работу (это полезный паттерн – по возможности отвязывать функциональность от Sharepoint артефактов):

public class TermSetsService
{
    private const string TERM_SET_TAG = "TermSet";
    private const string TERM_TAG = "Term";
    private const string NAME_ATTR = "Name";
    private const string GROUP_NAME = "Shop";
    private const int ENGLISH_LOCALE_ID = 1033;

    public void CreateTermSetsFromXml(SPSite site, string fileName)
    {
        TermStore termStore = null;
        try
        {
            var taxonomySession = new TaxonomySession(site);
            termStore = taxonomySession.DefaultKeywordsTermStore;

            if (termStore == null)
            {
                return;
            }

            if (!termStore.Groups.IsNullOrEmpty() && termStore.Groups.Any(g => g.Name == GROUP_NAME))
            {
                return;
            }

            var taxonomyGroup = termStore.CreateGroup(GROUP_NAME);
            if (taxonomyGroup == null)
            {
                return;
            }

            // load terms sets from xml file
            var termSets = this.load(fileName);
            if (termSets.IsNullOrEmpty())
            {
                return;
            }

            createTermSets(taxonomyGroup, termSets);

            termStore.CommitAll();
        }
        catch (Exception x)
        {
            if (termStore != null)
            {
                termStore.RollbackAll();
            }
            throw;
        }
    }

    private List<TermSetDTO> load(string fileName)
    {
        try
        {
            var result = new List<TermSetDTO>();

            XDocument doc = XDocument.Load(fileName);
            foreach (var termSetElement in doc.Descendants(TERM_SET_TAG))
            {
                var nameAttr = termSetElement.Attributes().FirstOrDefault(a => a.Name == NAME_ATTR);
                if (nameAttr == null)
                {
                    continue;
                }

                string termSetName = nameAttr.Value;

                var termSet = new TermSetDTO { Name = termSetName };
                termSet.Terms = this.getTerms(termSetElement);
                result.Add(termSet);
            }

            return result;
        }
        catch (Exception x)
        {
            return new List<TermSetDTO>();
        }
    }

    private List<TermDTO> getTerms(XElement e)
    {
        try
        {
            if (e == null)
            {
                return null;
            }

            var terms = new List<TermDTO>();
            foreach (var termElement in e.Elements(TERM_TAG))
            {
                var nameAttr = termElement.Attributes().FirstOrDefault(a => a.Name == NAME_ATTR);
                if (nameAttr == null)
                {
                    continue;
                }

                var term = new TermDTO {Name = nameAttr.Value};
                term.Terms = this.getTerms(termElement);
                terms.Add(term);
            }
            return terms;
        }
        catch (Exception x)
        {
            return new List<TermDTO>();
        }
    }

    private void createTermSets(Group taxonomyGroup, List<TermSetDTO> termSets)
    {
        if (taxonomyGroup == null)
        {
            return;
        }

        if (termSets == null)
        {
            return;
        }

        // create managed metadata term set for each term set dto
        foreach (var ts in termSets)
        {
            if (taxonomyGroup.TermSets.Any(t => t.Name == ts.Name))
            {
                continue;
            }

            var termSet = taxonomyGroup.CreateTermSet(ts.Name);
            if (termSet == null)
            {
                continue;
            }

            this.createTerms(termSet, ts.Terms);
        }
    }

    private void createTerms(TermSet termSet, List<TermDTO> terms)
    {
        if (termSet == null)
        {
            return;
        }

        if (terms.IsNullOrEmpty())
        {
            return;
        }

        foreach (var term in terms)
        {
            if (termSet.Terms.Any(t => t.Name == term.Name))
            {
                continue;
            }
            var parentTerm = termSet.CreateTerm(term.Name, ENGLISH_LOCALE_ID);
            this.createTerms(parentTerm, term.Terms);
        }
    }

    private void createTerms(Term parentTerm, List<TermDTO> terms)
    {
        if (parentTerm == null)
        {
            return;
        }

        if (terms.IsNullOrEmpty())
        {
            return;
        }

        foreach (var term in terms)
        {
            if (parentTerm.Terms.Any(t => t.Name == term.Name))
            {
                continue;
            }
            var subTerm = parentTerm.CreateTerm(term.Name, ENGLISH_LOCALE_ID);
            this.createTerms(subTerm, term.Terms);
        }
    }
}

Код достаточно очевидный: во-первых он рекурсивно парсит xml файл и загружает результат в DTO объекты, определенные выше (см. методы load() и getTerms()). Затем он создает таксономии в банке терминов из объектной модели (см. методы createTermSets() и createTerms()) – также рекусрсивно. Для компиляции вам потребуется добавить ссылку на сборку Micosoft.SharePoint.Taxonomy.dll.

Также для компиляции нужно добавить метод-расширение IsNullOrEmpty() для типа IEnumerable<T>, описанный Филом Хааком в его блоге.

Код, приведенный выше, использует английскую локаль (lcid = 1033) для терминов. Вы можете расширить его и добавить поддержку переводов и синонимов.

После того, как вы установите wsp-пакет и активируете фичу в вашей сайт коллекции, будет создана иерархия таксономии в банке терминов:

Managed metadata

Таким образом вы можете создавать таксономии во время провиженинга сайт коллекции.

Рубрика: Uncategorized | Оставить комментарий

Динамическая установка режима read only для веб части SocialCommentWebPart

Веб часть SocialCommentWebPart относится к новым возможностям, которые появились в Sharepoint 2010. Она позволяет пользователям добавлять комментарии на страницы, где она установлена. Данная веб часть делает сайт, созданный на Sharepoint, более социальным и улучшает взаимодействие с пользователями. Одно из распространенных требований к такой функциональности – ограничение возможности оставлять комментарии, т.е. не все пользователи сайта должны иметь возможность комментировать контент. Например, вы можете решить, что только зарегистрированные пользователи должны иметь возможность оставлять комментарии (в случае если вы разрабатываете self-service систему), или вы можете использовать авторизацию на основе членства в определенной группе. В обоих случаях вам надо установить веб часть SocialCommentWebPart в режим только для чтения (read only) динамически во время исполнения. У данной веб части есть свойство SocialCommentWebPart.WebPartPropertyAllowNewComment. Но как и остальные свойства веб частей, предполагается что администратор или ответственный за контент установит ее один раз, и эта установка будет влиять на всех пользователей. Это не то, что нам нужно. Наша цель – дать возможность определенным пользователям оставлять комментарии, а другим – запретить.

Если вы изучите код веб части SocialCommentWebPart в reflector-е, то увидите, что вся функциональность реализована в отдельном user control-е SocialCommentControl (это неплохой паттерн при проектировании веб частей – выносить функциональность в user control, т.к. ваши дизайнеры по-прежнему будут иметь возможность править разметку без изменений кода). У этого элемента управления есть свойство AllowNewComment и веб часть устанавливает это свойство при добавлении элемента управления SocialCommentControl в свою коллекцию контролов. Это свойство – то, что нам нужно. К сожалению, оно объявлено как internal, так что мы не сможем использовать его напрямую в нашем коде. Нам поможет reflection. Предположим, что мы добавили веб часть непосредственно на page layout (т.е. она не добавлена через UI на publishing page, созданную на основе нашего page layout-а. В последнем случае, подход будет таким же. Единственное, что нужно будет сделать – это определить идентификатор веб части):

<SPSWC:SocialCommentWebPart 
    runat="server" 
    Title="Comments" 
    AllowEdit="True" 
    AllowConnect="True" 
    ConnectionID="00000000-0000-0000-0000-000000000000" 
    IsIncluded="True" 
    Dir="Default" 
    PartImageLarge="" 
    IsVisible="True" 
    AllowMinimize="True" 
    ZoneID="" 
    ID="g_61df80f5_8c13_4355_aeee_a52b497e81c6" 
    FrameState="Normal" 
    ExportMode="All" 
    AllowHide="True" 
    SuppressWebPartChrome="False" 
    DetailLink="" 
    ChromeType="None"
    DescriptionLocId="Null" 
    MissingAssembly="Cannot import this Web Part." 
    PartImageSmall="" 
    AllowRemove="True" 
    HelpMode="Modeless" 
    FrameType="TitleBarOnly" 
    AllowZoneChange="True" 
    PartOrder="1" 
    Description="Enable users to leave short, publicly-viewable notes about this page." 
    HelpLink="" 
    DescriptionLocIdNum="0" 
    ExportControlledProperties="True" 
    IsIncludedFilter="" 
    __MarkupType="vsattributemarkup" 
    __WebPartId="{61df80f5-8c13-4355-aeee-a52b497e81c6}" 
    WebPart="true" 
    Height="" 
    Width="">
</SPSWC:SocialCommentWebPart>

Для того чтобы установить веб часть в режим только для чтения, используем следующий код, который можно добавить непосредственно на page layout как серверный скрипт:

<script runat="server" type="text/C#">
    protected override void OnLoad(EventArgs e)
    {
        base.OnLoad(e);
        if (yourOwnLogic())
        {
            SocialCommentControl c =
                ControlFinder.FindChildControl<SocialCommentControl>(g_61df80f5_8c13_4355_aeee_a52b497e81c6);
            if (c != null)
            {
                ReflectionHelper.CallMethod(c, "set_AllowNewComment", false);
            }
        }
    }
</script>

Здесь функция yourOwnLogic() – любая функция, которую вы можете использовать для определения того, имеет текущий пользователь право оставлять комментарии или нет (как я уже говорил, например, вы можете проверить является ли пользователь членом определенной группы или нет). В приведенном коде были использованы 2 дополнительных компонента: ControlFinder и ReflectionHelper. Их реализация довольно простая. ControlFinder рекурсивно ищет элемент управления с указанным в generic-е типом в указанной коллекции:

public static class ControlFinder
{
    public static T FindChildControl<T>(Control startingControl) where T : Control
    {
        T found = null;
        foreach (Control c in startingControl.Controls)
        {
            found = c as T;
            if (found == null)
            {
                found = FindChildControl<T>(c);
            }
            if (found != null)
            {
                break;
            }
        }
        return found;
    }
}

ReflectionHelper – вспомогательный класс, который можно использовать, если вам надо поэкспериментировать с внутренним устройством Sharepoint (я понимаю, что в реальной жизни это может быть необходимым, но помните, что этот способ не является официальным способом работы с Sharepoint и не поддерживается):

public static class ReflectionHelper
{
    public static object CallMethod(object obj, string name, params object[] argv)
    {
        BindingFlags bf = 0 | BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
        MethodInfo mi = obj.GetType().FindMembers(MemberTypes.Method, bf, Type.FilterName, name)[0] as MethodInfo;
        return mi.Invoke(obj, argv);
    }

    public static object CallStaticMethod(Type t, string name, params object[] argv)
    {
        BindingFlags bf = 0 | BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
        MethodInfo mi = t.FindMembers(MemberTypes.Method, bf, Type.FilterName, name)[0] as MethodInfo;
        return mi.Invoke(null, argv);
    }

    public static object GetStaticData(Type t, string name)
    {
        BindingFlags bf = 0 | BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
        FieldInfo fi = t.FindMembers(MemberTypes.Field, bf, Type.FilterName, name)[0] as FieldInfo;
        return fi.GetValue(null);
    }
}

С указанным подходом веб часть SocialCommentWebPart будет работать в режиме только для чтения, если у пользователя нет достаточных прав, и будет позволять добавлять комментарии авторизованным пользователям.

Рубрика: Sharepoint, Social computing | 2 комментария

Программное изменение цвета фона заголовка веб части

Недавно я столкнулся со следующей проблемой: на одном из наших проектов было требование: заголовок одной из кастомных веб частей должен был иметь определенный цвет (оранжевый), который отличается от стандартного цвета других веб частей. Т.е. только одна эта веб часть должна была иметь этот цвет заголовка. Все остальные веб части должны были отображаться без изменений со стандартным цветом. Во время исследования я обнаружил, что в сети не так много решений указанной задачи, которые бы подходили под все наши требования. Относительно просто изменить цвет заголовков всех веб частей на сайте: для этого необходимо поработать со стандратным стилем “ms-WPHeader” так, как описано здесь. Но не так просто применить изменения для одной определенной веб части. Например, решение описанное здесь, предполагает что вы должны создать кастомный класс для зон веб частей – наследник WebPartZone (из ASP.Net, не из Sharepoint, т.к. класс WebPartZone из Sharepoint является изолированным), а также кастомный тип хрома (chrome type). Это интересное решение, но с ним вам придется использовать кастомные классы повсюду, где вы хотите иметь возможность изменять цвет заголовка. Также оно предполагает, что так как все веб части из одной зоны имеют одинаковый хром, все они будут иметь одинаковый цвет заголовка (что конечно уже прогресс по сравнению со случаем одинакового цвета для всех веб частей, но по-прежнему не идеально).

Еще одно решение показывает как использовать Content editor веб часть для того, чтобы добавить css стиль, который позволил бы уникально идентифицировать заголовок определенной веб части на DOM уровне (благодаря использованию идентификатора “MSOZoneCell_WebPartWPQ2” в стиле).

В идеале я бы хотел иметь контроль над видом заголовка веб части внутри самой веб части. И конечно, чтобы изменения, сделанные для этой одной веб части, не влияли бы на другие веб части.

Я попробовал несколько способов получить желаемый результат (как серверные так и клиентские с помощью javascript). Например, я пробовал использовать Control Adapter для класса WebPartZone. С помощью этого метода вы можете переопределить метод Render()  и добавить дополнительные стили в заголовок (с технической точки зрения заголовок веб части – это элемент <tr> как будет показано ниже). Это решение работает, но требует модификации .browser файлов и сложных манипуляций с регулярными выражениями. Также переопределение метода Render(), при котором вы рендерите контрол сначала в строку, а затем что-нибудь заменяете в этой строке, может негативно сказаться на производительности.

После экспериментов с решениями на клиентской стороне (с помощью jQuery), я решил объединить серверное решение с клиентским.

Прежде всего нам потребуется ссылка на библиотеку jQuery на нашей master странице:

<SharePoint:ScriptLink ID="ScriptLink4" language="javascript" name="/_layouts/Fiskars/scripts/jquery-1.4.4.min.js" OnDemand="False" LoadAfterUI="False" Localizable="False" runat="server"/>

Затем необходимо добавить следующий javascript код также на master страницу:

<script type="text/javascript">
    function changeTitleColor(id) {
        jQuery('#' + id).parentsUntil("td[id^='MSOZoneCell_']").find('tr.ms-WPHeader').css('background-color', 'Orange');
    }
</script>

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

image_thumb3

Веб части рендерится как таблица (класс s4-wpTopTable) с двумя строками: первая для заголовка и вторая для содержимого веб части. Проблема в том, что находясь внутри веб части, мы можем контролировать только ту часть, которая рендерится во второй строке (т.е. только содержимое – см. рисунок). Остальные части (типа заголовка и бордера) – контролируются хромом веб части, который извлекается из зоны веб части (WebPartZone). Хорошая новость в том, что внутри веб части мы имеем доступ к ее ClientID, который можно использовать для доступа к упомянутым компонентам с помощью jQuery (на рисунке это “ctl00_m_g_72570dc0_fe27_43ca_82b9_8f324b8370eb”). Теперь давайте вернемся к функции changeTitleColor() и посмотрим, что он делает. Прежде всего он проходит по элементам объектной модели до ближайшего родительского элемента с идентификатором, начинающимся со строки “MSOZoneCell_”:

.parentsUntil("td[id^='MSOZoneCell_']")

Как показано на рисунке он останавливается на следующем элементе:

<table class="s4-wpTopTable" border="0" cellpadding="0" cellspacing="0" width="100%">

(функция parentUntil() не включает сам элемент, который удовлетворяет условию). Оставшаяся часть работы простая: мы находим потомок <tr> со стилем “ms-WPHeader” и меняем “background-color” свойство на оранжевый:

.find('tr.ms-WPHeader').css('background-color', 'Orange');

Последнее, что осталось – это вызвать нашу javascript функцию с идентификатором веб части:

protected override void CreateChildControls()
{
    ...
    this.Controls.Add(new LiteralControl(string.Format("<script type=\"text/javascript\">changeTitleColor('{0}');</script>", this.ClientID)));
    ...
}

Это все: после этих шагов у вас появится возможность определять цвет фона заголовка веб части в коде самой веб части.

Рубрика: Sharepoint, UI | Оставить комментарий

Cross-site навигация в Sharepoint. Часть 3

В предыдущей части я описал схему реализации кросс сайт навигации (навигация, которая сохраняется при переходе из сайта в сайт) для сайтов публикации (publishing sites). В этой заключительной части я опишу непосредственную реализацию компонентов описанной схемы.

Итак, у нас есть 2 сайта: http://example.com/site1 и http://example.com/site2, и мы хотим использовать навигацию сайта site1 на сайте site2. Для начала нам необходимо реализовать кастомный провайдер карты узлов навигации (custom site map provider) – наследник класса StaticSiteMapProvider, который будет вызывать веб метод нашего веб сервиса Navigation.asmx. Реализация провайдера показана ниже:

public class CustomNavigationProvider : StaticSiteMapProvider
{
    private const string SITE_MAP_SESSION_KEY = "CustomNavigationMap";

    private SPWeb getNavigationContextWebUrl()
    {
        // instead of hardcoding site url you can use your own logic here
        using (var web = SPContext.Current.Site.OpenWeb("/site1"))
        {
            return web.Url;
        }
    }

    private NavigationService initWebService(string contextWebUrl)
    {
        var proxy = new NavigationService();
        proxy.Url = SPUrlUtility.CombineUrl(contextWebUrl, "/_layouts/Custom/Navigation.asmx");
        // use another credentials if required instead of DefaultCredentials
        proxy.Credentials = CredentialCache.DefaultCredentials;
        return proxy;
    }

    public override void Initialize(string name, NameValueCollection attributes)
    {
        base.Initialize(name, attributes);
        // here you can add your initialization logic, e.g. initialize web service URL
    }
    
    public override SiteMapNode BuildSiteMap()
    {
        SiteMapNode node;
        if (HttpContext.Current.Session[SITE_MAP_SESSION_KEY] == null)
        {
            node = tryGetNavigationNodesFromContextWeb();
            HttpContext.Current.Session[SITE_MAP_SESSION_KEY] = node;
        }
        node = HttpContext.Current.Session[SITE_MAP_SESSION_KEY] as SiteMapNode;
        return node;
    }

    private SiteMapNode tryGetNavigationNodesFromContextWeb()
    {
        try
        {
            string webUrl = this.getNavigationContextWebUrl();
            var proxy = this.initWebService(webUrl);
            var doc = proxy.GetMenuItems();
            var collection = ConvertHelper.BuildNodesFromXml(this, doc);
            if (collection == null)
                return null;
            if (collection.Count == 0)
                return null;
            return collection[0];
        }
        catch(Exception x)
        {
            return new SiteMapNode(this, "/", "/", "");
        }
    }

    protected override SiteMapNode GetRootNodeCore()
    {
        SiteMapNode node = null;
        node = BuildSiteMap();
        return node;
    }
}

Я убрал некоторые методы, которые используются в реальном примере, но которые тем не менее не важны для данной статьи, и оставил лишь основные методы, раскрывающие суть идеи. Как и сказано в документации класса StaticSiteMapProvider, мы переопределили 3 метода: GetRootNodeCore(), Initialize() and BuildSiteMap(). Для того чтобы этот код скомпилировался, необходимо добавить ссылку на веб сервис Navigation.asmx для генерации прокси в Visual Studio (реализацию веб сервиса см. ниже). Т.к. мы не хотим вызывать веб метод при каждом обращении к сайту site2 (т.к. это сильно скажется на производительности – Sharepoint вызывает провайдер карты узлов навигации действительно часто при каждом запросе), я добавил простое кеширование, используя сессию (как вы знаете сессии в Sharepoint-е хранятся в SQL базе данных, поэтому такой подход будет работать в случае фермы с несколькими WFE). Как я и описывал в предыдущей части, провайдер вызывает метод веб сервиса на сайте site2 в контексте сайта site1.

Теперь рассмотрим детали реализации веб сервиса Navigation.asmx (его codebehind класс):

[WebService(Namespace = "http://example.com")]
[WebServiceBinding(ConformsTo = WsiProfiles.BasicProfile1_1)]
[ToolboxItem(false)]
public class NavigationService : WebService
{
    [WebMethod]
    public XmlDocument GetMenuItems()
    {
        PortalSiteMapDataSource ds = new PortalSiteMapDataSource();
        ds.SiteMapProvider = "CombinedNavSiteMapProvider";
        ds.EnableViewState = false;
        ds.StartFromCurrentNode = true;
        ds.StartingNodeOffset = 0;
        ds.ShowStartingNode = true;
        ds.TreatStartingNodeAsCurrent = true;
        ds.TrimNonCurrentTypes = NodeTypes.Heading;
        
        AspMenu m = new AspMenu();
        m.DataSource = ds;
        m.EnableViewState = false;
        m.Orientation = Orientation.Horizontal;
        m.StaticDisplayLevels = 2;
        m.MaximumDynamicDisplayLevels = 1;
        m.DynamicHorizontalOffset = 0;
        m.StaticPopOutImageTextFormatString = "";
        m.StaticSubMenuIndent = 0;
        m.DataBind();
        
        var doc = ConvertHelper.BuildXmlFromMenuItem(m.Items);
        return doc;
    }
}

Веб сервис содержит единственный веб метод GetMenuItems(), который вызывается в провайдере с использованием прокси (см. выше). Я использовал небольшой трюк: вместо использования PortalSiteMapProvider я использовал классы PortalSiteMapDataSource и AspMenu для того чтобы вернуть именно те узлы навигации, которые непосредственного отображаются на сайте site1 (я скопировал их настройки из master страницы, что не очень хорошо. Тем не менее, для примера этого достаточно). Есть разница между всеми узлами навигации для определенного сайта и теми узлами, которые на нем отображаются. Как я писал в другом своем посте The basics of navigation in Sharepoint отображение конфигурируется с помощью элементов управления site map data source и AspMenu (которые в большинстве случаев располагаются на master странице сайта). Конечно вы можете использовать непосредственно PortalSiteMapProvider и возвращать все узлы навигации.

Осталось описать вспомогательный класс ConverterHelper, который используется для двух целей:

  1. для того чтобы преобразовывать коллекцию узлов навигации, хранящуюся в памяти, в xml для передачи через веб сервис;
  2. и в обратном направлении: для преобразования из xml в коллекцию узлов в провайдере.

Реализация класса ConverterHelper показана ниже:

public static class ConvertHelper
{
    public const string TAG_ROOT = "root";
    public const string TAG_NODE = "node";
    public const string ATTR_PATH = "path";
    public const string ATTR_URL = "url";
    public const string ATTR_TITLE = "title";

    public static SiteMapNodeCollection BuildNodesFromXml(SiteMapProvider provider, XmlNode doc)
    {
        try
        {
            var collection = new SiteMapNodeCollection();
            if (doc.ChildNodes.Count == 1 && doc.ChildNodes[0].Name == TAG_ROOT)
            {
                doc = doc.ChildNodes[0];
            }

            buildNodesFromXml(provider, doc, collection);
            return collection;
        }
        catch (Exception x)
        {
            return null;
        }
    }

    private static void buildNodesFromXml(SiteMapProvider provider, XmlNode parentNode, SiteMapNodeCollection collection)
    {
        foreach (XmlNode xmlNode in parentNode.ChildNodes)
        {
            if (xmlNode.Name == TAG_NODE)
            {
                var node = new SiteMapNode(provider, xmlNode.Attributes[ATTR_PATH].Value,
                                           xmlNode.Attributes[ATTR_URL].Value,
                                           xmlNode.Attributes[ATTR_TITLE].Value);

                if (xmlNode.HasChildNodes)
                {
                    var childNodes = new SiteMapNodeCollection();
                    buildNodesFromXml(provider, xmlNode, childNodes);
                    node.ChildNodes = childNodes;
                }

                collection.Add(node);
            }
        }
    }

    public static XmlDocument BuildXmlFromMenuItem(MenuItemCollection collection)
    {
        if (collection == null || collection.Count == 0)
        {
            return null;
        }

        var doc = new XmlDocument();

        var element = doc.CreateElement(TAG_ROOT);
        doc.AppendChild(element);

        foreach (MenuItem item in collection)
        {
            buildXmlFromMenuItem(item, doc, element);
        }

        return doc;
    }

    private static void buildXmlFromMenuItem(MenuItem item, XmlDocument doc, XmlNode xml)
    {
        if (item == null)
            return;

        XmlElement element = doc.CreateElement(TAG_NODE);
        element.SetAttribute(ATTR_PATH, item.DataPath);
        element.SetAttribute(ATTR_TITLE, item.Text);
        element.SetAttribute(ATTR_URL, item.NavigateUrl);

        xml.AppendChild(element);

        foreach (MenuItem childItem in item.ChildItems)
        {
            buildXmlFromMenuItem(childItem, doc, element);
        }
    }
}

Последнее действие, которое необходимо сделать – это сконфигурировать master страницу, чтобы использовать кастомный провайдер навигации. Я не буду повторяться о том, как это сделать – см. часть1.

В этой части я описал компоненты, которые необходимо реализовать для того чтобы использовать подход, основанный на вызовах веб сервиса. Одно из преимуществ этого подхода в том, что вы не ограничены пределами сайт коллекции или веб приложения (более того, вы не ограничены пределами одного веб сервера, т.к. можете вызывать веб сервис с другого сервера и показывать данные навигации с него – хотя в данный момент я не готов ответить, насколько эта возможность полезна ) ). Но с другой стороны с этим подходом нужно быть очень внимательным к вопросам производительности: нужно убедиться, что веб сервис не вызывается каждый раз при запросе к сайту (точнее к любой странице, которая использует master страницу с кастомным провайдером).

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

Рубрика: Navigation, Sharepoint | Оставить комментарий

Cross-site навигация в Sharepoint. Часть 2

В моей предыдущей статье (на английском) я написал как можно реализовать cross-site навигацию для сайтов, доступных в WSS (это не включает в себя сайты публикации – очень распространенный тип сайтов). Как показывает статистика указанный пост пользуется большой популярностью. Поэтому я пока решил отложить другие темы и закончить серию статей о навигации (раз эта тема настолько востребована). Прежде чем читать дальше я рекомендую ознакомиться с другим моим постом The basics of navigation in Sharepoint (тоже, к сожалению, только на английском), в котором я описал основные компоненты и архитектуру навигации в Sharepoint.

Итак перед нами стоит следующая задача: предположим у нас есть сайт коллекция (SPSite) с несколькими подсайтами (SPWeb). Мы хотим, чтобы при переходе между сайтами верхняя навигация сохранялась (как вы увидите ниже с описанным подходом можно также сохранять навигацию при переходе между сайт коллекциями. Более того узлы навигации можно показывать и из стороннего сайта, расположенного на другом веб сервере). При этом у нас есть один подсайт (SPWeb), который является источником карты узлов навигации (другими словами на всех других сайтах мы хотим показывать такую же верхнюю навигацию, как на этом “особенном” сайте).

Как я упоминал в предыдущей части, есть определенные проблемы с реализацией cross-site навигации для сайтов публикаций. Если вы смотрели предыдущую часть, то уже знаете, что в классе SPNavigationProvider есть публичное виртуальное свойство Web:

public class SPNavigationProvider : SiteMapProvider
{
    // ...
    protected virtual SPWeb Web { get; }
}

Также как вы видите класс SPNavigationProvider не является изолированным, т.е. мы можем создать кастомный провайдер навигации, наследуя SPNavigationProvider, и переопределить в нем свойство Web например так, чтобы оно всегда возвращало наш веб сайт – источник для навигации – см. часть1. К сожалению для сайтов публикаций (pubilshing sites) этот подход не может быть использован, потому что класс провайдера навигации PortalSiteMapProvider, который там используется, не имеет виртуальных свойств аналогичных свойству Web в классе SPNavigationProvider, который используется в WSS. Если посмотреть код PortalSiteMapProvider в рефлекторе, то можно обнаружить два свойства CurrentSite и CurrentWeb, очень близкие к тем, что нужны нам:

public class PortalSiteMapProvider : SiteMapProvider
{
    // ...
    public SPSite CurrentSite { get; set; }
    public SPWeb CurrentWeb { get; set; }
    // ...
}

Хотя PortalSiteMapProvider также не является изолированным, указанные свойства не виртуальные. Поэтому простой подход с наследованием PortalSiteMapProvider и переопределением свойств здесь не подходит (стандартная функциональность Sharepoint-а, которая работает с ссылками на базовый класс PortalSiteMapProvider будет использовать его реализацию вместо нашей). Можно попробовать использовать reflection, но я не уверен, что этот подход окажется рабочим. Существуют ли другие способы решения проблемы (без наследования и рефлекшена)? Да, существуют – и я хочу рассказать об одном из таких решений (впрочем, хотя оно и обходится без трюков с рефлекшеном, простым его назвать нельзя).

Идея в том, чтобы создать кастомный провайдер навигации. В качестве базового класса можно использовать стандартный SiteMapProvider из ASP.Net. Но в нашем случае лучшим выбором будет класс StaticSiteMapProvider, в котором уже реализованы некоторые методы, так что нам останется меньше работы (напр. StaticSiteMapProvider  используется в качестве базового класса для другого стандартного провайдера XmlSiteMapProvider). В документации этого класса говорится следующее:

Класс StaticSiteMapProvider представляет частичную реализацию абстрактного класса SiteMapProvider и предоставляет два дополнительных метода: AddNode и RemoveNode, а также абстрактный BuildSiteMap и защитный Clear методы.

Класс StaticSiteMapProvider поддерживает запись поставщика карты веб-узла (например, объекта XmlSiteMapProvider), который переводит карту веб-узла, хранящуюся в постоянном хранилище, в карту, хранящуюся в памяти. Класс StaticSiteMapProvider предоставляет основные реализации для сохранения и получения объектов SiteMapNode.

При расширении класса StaticSiteMapProvider используются три наиболее важных метода: GetRootNodeCore, Initialize и BuildSiteMap. Реализация методов Clear и FindSiteMapNode задана по умолчанию, и являются достаточной для большинства пользовательских реализаций поставщиков карт веб-узелов.

Это очень близко к нашему случаю – в качестве “постоянного хранилища” для карты узлов у нас выступает веб сайт в Sharepoint (точнее content база данных, т.к. данные навигации хранятся имеено в ней в конечно счете). Остается вопрос: как выбрать карту узлов (т.е. коллекцию объектов PortalSiteMapNode) для определенного сайта публикации? Очевидный ответ – используя PortalSiteMapProvider, но вызывать его следует в контексте сайта, из которого мы хотим получить карту узлов для навигации. Мы можем сделать это с помощью веб сервиса, используя следующую схему:

image_thumb2

Также нам нужно зарегистрировать наш кастомный провайдер навигации во всех веб приложениях, где мы хотим его использовать (в web.config-е – см. часть1). Наш провайдер вызовет метод кастомного веб сервиса Navigation.asmx (сам asmx файл может располагаться например в папке 12/Templates/Layouts/Custom на файловой системе веб сервера) в контексте сайта источника узлов навигации. Напр. если у нас есть 2 сайта http://example.com/site1 и http://example.com/site2, где site1 – источник, нам нужно вызвать метод веб сервиса Navigation.asmx, используя следующий URL: http://example.com/site1/_layout/Custom/Navigation.asmx. Как результат код веб сервиса будет исполняться в контексте сайта site1, так что мы сможем использовать стандартный PortalSiteMapProvider для того чтобы получить карту узлов сайта site1 для ее отображении на сайте site2.

В этой части я описал идею. Непосредственную реализацию компонентов этой схемы я опишу в следующей части.

Рубрика: Navigation, Sharepoint | 1 комментарий

Использование C# 3.0 на layout страницах приложения в Sharepoint

В повседневной работе мы часто используем application layout страницы (aspx страницы, которые расположены в папке 12/Templates/Layouts на файловой системе веб сервера) для трассировки и отладки. Это достаточно полезная техника, т.к. вы можете написать серверный скрипт прямо на странице и посмотреть результаты его выполнения на той же странице в контексте запущенного сайта на production сервере. Допустим у нас есть подпапка 12/Templates/Layouts/Test, в которой мы создали простую aspx страницу:

<%@ Page Language="C#" %>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" >
<head id="Head1" runat="server">
  <title>Test</title>
</head>
<body>
  <form id="form1" runat="server">
    <%   1:  int i = 1;
       this.lbl.Text = i.ToString();
    
    <asp:Label ID="lbl" runat="server" />
  </form>
</body>
</html>

Обратите внимание на код внутри <%%>. Это очень простой пример, который показывает, как на стороне сервера мы присваиваем значение переменной “i” и отображаем его на странице. В реальности в блоке с серверным кодом будет более полезный код, который будет выводить результаты на страницу (напр. отображение identity текущего пользователя).

К сожалению в этой технике есть небольшой недостаток: по умолчанию мы ограничены использованием C# 2.0 и не можем использовать многие полезные возможности C# 3.0 (полный список см. здесь: Overview of C# 3.0). Так например, если вместо “int i = 1;” вы напишете “var i = 1;” (см. Implicitly Typed Local Variables), то вы получите следующую ошибку при обращении к вашей странице:

C:\Program Files\Common Files\Microsoft Shared\web server extensions\12\TEMPLATE\LAYOUTS\test\test.aspx(9): error CS0246: The type or namespace name ‘var’ could not be found (are you missing a using directive or an assembly reference?)
   at System.Web.Compilation.AssemblyBuilder.Compile()
   at System.Web.Compilation.BuildProvidersCompiler.PerformBuild() 
   … 
   at System.Web.HttpApplication.ExecuteStep(IExecutionStep step, Boolean& completedSynchronously)

Для того чтобы иметь возможность использовать С# 3.0 на layout-страницах необходимо добавить web.config в нашу поддиректорию (12/Templates/Layouts/Test) со следующим содержимым:

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<configuration>
<system.codedom>
  <compilers>
    <compiler
      language="c#;cs;csharp"
      extension=".cs"
      type="Microsoft.CSharp.CSharpCodeProvider, System, Version=2.0.0.0,
            Culture=neutral, PublicKeyToken=b77a5c561934e089"
      warningLevel="4">
      <providerOption name="CompilerVersion" value="v3.5"/>
      <providerOption name="WarnAsError" value="false"/>
    </compiler>
  </compilers>
</system.codedom>
</configuration>

В этом файле мы переопределили версию компилятора (см. <providerOption name=»CompilerVersion» value=»v3.5″/>), используемого ASP.Net. После этого вы сможете использовать C# 3.0 на ваших страницах. Эта техника помогла нам писать код для application layout страниц более эффективно. Надеюсь она окажется полезной и для вас.

Рубрика: Sharepoint | Оставить комментарий

Динамическое создание OrderBy выражений с помощью Camlex.NET

На этой неделе состоялся очередной релиз нашего проекта Camlex.NET, в который была добавлена новая функциональность – динамические OrderBy выражения. Camlex.NET был выпущен для Sharepoint разработчиков для упрощения создания CAML запросов. Как и много других фич, динамические OrderBy выражения были инициированы дискуссией на сайте проекта на Codeplex, т.е. является результатом обратной связи с разработчиками, использующими Camlex (спасибо пользователю с ником umike за его вопрос). Так что если вы думаете, что какая-либо функциональность будет полезной для Camlex-а, не стесняйтесь сообщать нам об этом через раздел дискуссий. На этом мое небольшое вступление заканчивается и я перехожу непосредственно к описанию новой функциональности в Camlex.NET.

Во многих сценариях, когда нам нужно создать CAML запрос программно на основе каких-либо параметров, на этапе компиляции мы не знаем, какие именно поля будут использоваться для сортировки. Например, предположим, что у нас есть grid view контрол, который отображает коллекцию item-ов, которая в свою очередь получена из списка Sharepoint с использованием CAML. Пользователи могут сортировать записи по нескольким полям по возрастанию и убыванию. Мы не знаем, какой CAML запрос будет использован в каждом случае. Для таких сценариев динамические OrderBy выражения были добавлены в Camlex. Теперь разработчики могут создавать OrderBy выражения в CAML запросах, используя параметры, полученные, к примеру, из UI или пользовательского ввода. Новый метод был добавлен в интерфейс IQuery, который является базовым интерфейсом Camlex-а. Этот метод принимает на вход список C# выражений для построения OrderBy части CAML запроса (каждое такое выражение имеет вид x => x[“Title”] или x => x[“Title”] as Camlex.Asc – см. http://camlex.codeplex.com – Scenario 6. Query with sorting (OrderBy)):

public interface IQuery
{
...
IQuery OrderBy(IEnumerable<Expression<Func<SPListItem, object>>> expressions);
}

Таким образом, теперь вы можете динамически создавать список выражений для OrderBy в runtime и передавать его непосредственно в Camlex, как показано в следующем примере:

var orderByList = new List<Expression<Func<SPListItem, object>>>();
orderByList.Add(x => x["Title"] as Camlex.Asc);
orderByList.Add(y => y["Date"] as Camlex.Desc);

var caml = Camlex.Query().OrderBy(orderByList).ToString();

В итоге мы получим следующий CAML запрос:

<OrderBy>
<FieldRef Name="Title" Ascending="True" />
<FieldRef Name="Date" Ascending="False" />
</OrderBy>

Также вы можете выполнять сортировку по одному полю путем добавления единственного элемента в список выражений.

Пример выше целиком взят из юнит тестов (которыми полностью покрыт Camlex. Если вам интересно посмотреть, как это сделано, можете скачать исходники здесь). Давайте рассмотрим более приближенный к реальности пример: в общем случае у нас есть некий список пар (имя поля, направление сортировки), и нам нужно преобразовать его к типу, который ожидает Camlex (IEnumerable<Expression<Func<SPListItem, object>>>). Задача решается одной строчкой кода:

var sortParams = new[]
                     {
                         new { Title = "Title", Direction = "Asc" },
                         new { Title = "Date", Direction = "Desc" },
                     };
var list =
    sortParams.Select(
        t => t.Direction == "Asc" ? (Expression<Func<SPListItem, object>>)(x => x[t.Title] as Camlex.Asc) : x => x[t.Title] as Camlex.Desc);

var query = Camlex.Query().OrderBy(list).ToString();
Console.WriteLine(query);

Для удобочитаемости я разбил преобразование на несколько строк, так что формально строчка не одна, тем не менее оно выполнено одним выражением :). Кроме этого я добавил приведение типов к первому выражению тринарного оператора (см. (Expression<Func<SPListItem, object>>)), т.к. без него компилятор выдает ошибку:

Type of conditional expression cannot be determined because there is no implicit conversion between ‘lambda expression’ and ‘lambda expression’

Это известная проблема (см. например здесь и здесь): компилятор не может вывести тип выражения, и нам необходимо ему немного помочь.

Как видите, если не считать досадной мелочи с приведением типов, преобразование делается достаточно просто. Надеюсь, что новая функциональность будет полезной в вашей повседневной работе.

Рубрика: CAML, Camlex.NET, Sharepoint | Оставить комментарий