Визуальный редактор для DataAnnotations ASP.NET MVC? - PullRequest
3 голосов
/ 01 мая 2011

Я пишу аннотации данных для моих моделей в моей программе MVC. После того, как я проделал то же самое для третьей модели, я не могу не задуматься, есть ли более простой способ сделать это?

    [MetadataType(typeof(ArticleMD))]
public partial class Article
{
    public class ArticleMD
    {
        [DisplayName("Created By:")]
        public string CreatedBy { get; set; }

        [DisplayName("Additional Information:")]
        [DataType(DataType.MultilineText)]
        public string AdditionalInformation { get; set; }

        [DisplayName("Title:")]
        [Required]
        public string Title { get; set; }
//...

Кто-нибудь знает какие-либо дополнения или плагины для Visual Studio, которые позволили бы вам выбрать Model или ViewModel и использовать GUI для определения всего этого (в отдельном классе метаданных)?

Ответы [ 2 ]

1 голос
/ 21 мая 2011

Я не знаю ни одного редактора графического интерфейса, но я использую шаблон T4, чтобы сгенерировать стартер для каждого класса метаданных.Я получил идею из сообщения от Дана Уолина .Вот шаблон, который я использую:

<#
//     Created by Dan Wahlin - http://www.thewahlingroup.com
//     Title: T4 Metadata and Data Annotations template
//     Usage: Generates the initial "buddy" classes to handle data validation across multiple frameworks
//     Description: I got tired of writing my initial "buddy" classes by hand once my EF model was created. This template
//                  handles generating all the classes and generates the primitive and navigation properties. It also
//                  decorates the properties with the [Required] and [StringLength] attributes as appropriate. Once the
//                  template generates the code you can copy it to a new file and make all the tweaks you want to support
//                  custom data annotations.
//     License: UIFAYW - Use it for anything you want (free or commercial)
//     Made enhancements? Please let me know at dwahlin at xmlforasp dot net
//     To use:
//              1. Add the file into your project (it needs a .tt extension)
//              2. Change the Source CsdlPath to point to your Entity Framework 4 .edmx file
//              3. Whatever you name the .tt file will also be the name given to the code file it generates
//              4. Don't make changes directly to the modified code. You'll need to copy it to a file 
//                 you control or else the template may overwrite changes you make.

#>
<#@ template language="C#" debug="false" hostspecific="true"#>
<#@ import namespace="System.Text.RegularExpressions" #>
<#@ include file="EF.Utility.CS.ttinclude"#><#@ output extension=".cs"#><#

UserSettings userSettings =
        new UserSettings
        {
            SourceCsdlPath = @"YourModel.edmx",
            ReferenceCsdlPaths = new string[] {},
            FullyQualifySystemTypes = true,
            CreateContextAddToMethods = true,
            CamelCaseFields = false,
        };

ApplyUserSettings(userSettings);
if(Errors.HasErrors)
{
    return String.Empty;
}

MetadataLoader loader = new MetadataLoader(this);
MetadataTools ef = new MetadataTools(this);
CodeRegion region = new CodeRegion(this);
CodeGenerationTools code = new CodeGenerationTools(this){FullyQualifySystemTypes = userSettings.FullyQualifySystemTypes, CamelCaseFields = userSettings.CamelCaseFields};

ItemCollection = loader.CreateEdmItemCollection(SourceCsdlPath, ReferenceCsdlPaths.ToArray());
ModelNamespace = loader.GetModelNamespace(SourceCsdlPath);
string namespaceName = code.VsNamespaceSuggestion();
UpdateObjectNamespaceMap(namespaceName);

#>

using System;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Data.Objects.DataClasses;
/*
namespace <#=namespaceName#>
{
    <#
        foreach (EntityType entity in GetSourceSchemaTypes<EntityType>().OrderBy(e => e.Name))
        {
    #>
[MetadataType(typeof(<#=code.Escape(entity)#>Metadata))]
    <#=Accessibility.ForType(entity)#> <#=code.SpaceAfter(code.AbstractOption(entity))#>partial class <#=code.Escape(entity)#>
    {
        internal sealed class <#=code.Escape(entity)#>Metadata
        {
        <#
            bool first = true;
            foreach (EdmProperty property in entity.Properties.Where(p => p.DeclaringType == entity && p.TypeUsage.EdmType is PrimitiveType))
            {
                if (first)
                {
                    WriteLine(string.Empty);
                    first = false;
                }
                WritePrimitiveTypeProperty(property, code);
            }
            foreach (NavigationProperty navProperty in entity.NavigationProperties.Where(n => n.DeclaringType == entity))
            {
                WriteNavigationTypeProperty(navProperty, code);
            }
        #>
        }
    }

    <#
        }
        WriteLine(string.Empty);
    #>
}
*/
<#+
    ////////
    ////////  Write PrimitiveType Properties.
    ////////
    private void WritePrimitiveTypeProperty(EdmProperty property, CodeGenerationTools code)
    {
        MetadataTools ef = new MetadataTools(this);
        var dataTypeAtt = GetDataTypeAttribute(property, ef);
        if (!property.Nullable)
        {
#>
            [Required(ErrorMessage="<#=FixName(code.Escape(property))#> is required")]
<#+ 
        }
        foreach (Facet facet in property.TypeUsage.Facets)
        {
            if (facet.Name == "MaxLength" && facet.Value != null && facet.IsUnbounded == false)
            {
#>
            [StringLength(<#=facet.Value#>)]
<#+
            }
        }
        if (dataTypeAtt != String.Empty)
        {
#>
            <#=dataTypeAtt#>
<#+
        }       
#>
            <#=code.SpaceAfter(NewModifier(property))#><#=Accessibility.ForProperty(property)#> <#=ef.ClrType(property.TypeUsage).Name#> <#=code.Escape(property)#> { get; set; }

<#+
    }

    ////////
    ////////  Write PrimitiveType Properties.
    ////////
    private void WriteNavigationTypeProperty(NavigationProperty navProperty, CodeGenerationTools code)
    {
#>
            <#=code.SpaceAfter(NewModifier(navProperty))#><#=Accessibility.ForProperty(navProperty)#> EntityCollection<<#=MultiSchemaEscape(navProperty.ToEndMember.GetEntityType(), code)#>> <#=code.Escape(navProperty)#> { get; set; }

<#+
    }

public string FixName(string propName)
{
    if (propName.ToLower() != "id" && propName.ToLower().EndsWith("id"))
    {
        propName = propName.Replace("Id","").Replace("ID","");
    }
    return Regex.Replace(propName,"([A-Z])"," $1",RegexOptions.Compiled).Trim();
}

public string GetDataTypeAttribute(EdmProperty property, MetadataTools ef)
{
    var propLower = property.Name.ToLower();
    if (ef.ClrType(property.TypeUsage) == typeof(System.DateTime))
    {
        return "[DataType(DataType.DateTime)]";
    }
    if (propLower.Contains("phone"))
    {
        return "[DataType(DataType.PhoneNumber)]";
    }
    if (propLower.Contains("html"))
    {
        return "[DataType(DataType.Html)]";
    }
    if (propLower.Contains("email"))
    {
        return "[DataType(DataType.EmailAddress)]";
    }
    if (propLower.Contains("url"))
    {
        return "[DataType(DataType.Url)]";
    }
    return String.Empty;
}

////////
////////  Declare Template Public Properties.
////////
public string SourceCsdlPath{ get; set; }
public string ModelNamespace{ get; set; }
public EdmItemCollection ItemCollection{ get; set; }
public IEnumerable<string> ReferenceCsdlPaths{ get; set; }
public Nullable<bool> CreateContextAddToMethods{ get; set; }
public Dictionary<string, string> EdmToObjectNamespaceMap
{
    get { return _edmToObjectNamespaceMap; }
    set { _edmToObjectNamespaceMap = value; }
}
public Dictionary<string, string> _edmToObjectNamespaceMap = new Dictionary<string, string>();
public Double SourceEdmVersion
{
    get
    {
        if (ItemCollection != null)
        {
            return ItemCollection.EdmVersion;
        }

        return 0.0;
    }
}

////////
////////  Declare Template Private Properties.
////////
static System.Resources.ResourceManager ResourceManager
{
    get
    {
        if (_resourceManager == null)
        {
            System.Resources.ResourceManager resourceManager = new System.Resources.ResourceManager("System.Data.Entity.Design", typeof(System.Data.Entity.Design.MetadataItemCollectionFactory).Assembly);
            System.Threading.Interlocked.CompareExchange(ref _resourceManager, resourceManager, null);
        }
        return _resourceManager;
    }
}
static System.Resources.ResourceManager _resourceManager;

#>
<#+


private static string GetResourceString(string resourceName)
{
    return ResourceManager.GetString(resourceName,
         null); //  Take default culture.
}



private void VerifyTypeUniqueness()
{
    HashSet<string> hash = new HashSet<string>();
    IEnumerable<GlobalItem> allTypes = GetSourceSchemaTypes<GlobalItem>().Where(i => i is StructuralType || i is EntityContainer);

    foreach (GlobalItem type in allTypes)
    {
        if (!hash.Add(GetGlobalItemName(type)))
        {
            //  6034 is the error number used by System.Data.Entity.Design EntityClassGenerator.
            Errors.Add(new System.CodeDom.Compiler.CompilerError(SourceCsdlPath, -1, -1, "6034",
             String.Format(CultureInfo.CurrentCulture,
                GetResourceString("Template_DuplicateTopLevelType"),
             GetGlobalItemName(type))));
        }
    }
}

protected string GetGlobalItemName(GlobalItem item)
{
    if (item is EdmType)
    {
        //  EntityType or ComplexType.
        return ((EdmType)item).Name;
    }
    else
    {
        //  Must be an EntityContainer.
        return ((EntityContainer)item).Name;
    }
}



void ApplyUserSettings(UserSettings userSettings)
{
    //  Setup template UserSettings.
    if (SourceCsdlPath == null)
    {
#if !PREPROCESSED_TEMPLATE
        if(userSettings.SourceCsdlPath == "$" + "edmxInputFile" + "$")
        {
            Errors.Add(new System.CodeDom.Compiler.CompilerError(Host.TemplateFile, 0, 0, "",
                GetResourceString("Template_ReplaceVsItemTemplateToken")));
            return;
        }

        SourceCsdlPath = Host.ResolvePath(userSettings.SourceCsdlPath);
#else
        SourceCsdlPath = userSettings.SourceCsdlPath;
#endif
    }

    // normalize the path, remove ..\ from it
    SourceCsdlPath = Path.GetFullPath(SourceCsdlPath);


    if (ReferenceCsdlPaths == null)
    {
        ReferenceCsdlPaths = userSettings.ReferenceCsdlPaths;
    }

    if (!CreateContextAddToMethods.HasValue)
    {
        CreateContextAddToMethods = userSettings.CreateContextAddToMethods;
    }

    DefaultSummaryComment = GetResourceString("Template_CommentNoDocumentation");
}


class UserSettings
{
    public string SourceCsdlPath{ get; set; }
    public string[] ReferenceCsdlPaths{ get; set; }
    public bool FullyQualifySystemTypes{ get; set; }
    public bool CreateContextAddToMethods{ get; set; }
    public bool CamelCaseFields{ get; set; }
}

string MultiSchemaEscape(TypeUsage usage, CodeGenerationTools code)
{
    StructuralType structural = usage.EdmType as StructuralType;
    if (structural != null)
    {
        return MultiSchemaEscape(structural, code);
    }
    return code.Escape(usage);
}

string MultiSchemaEscape(StructuralType type, CodeGenerationTools code)
{
    if (type.NamespaceName != ModelNamespace)
    {
        return code.CreateFullName(code.EscapeNamespace(GetObjectNamespace(type.NamespaceName)), code.Escape(type));
    }

    return code.Escape(type);
}

string NewModifier(NavigationProperty navigationProperty)
{
    Type baseType = typeof(EntityObject);
    return NewModifier(baseType, navigationProperty.Name);
}

string NewModifier(EdmFunction edmFunction)
{
    Type baseType = typeof(ObjectContext);
    return NewModifier(baseType, edmFunction.Name);
}

string NewModifier(EntitySet set)
{
    Type baseType = typeof(ObjectContext);
    return NewModifier(baseType, set.Name);
}

string NewModifier(EdmProperty property)
{
    Type baseType;
    if (property.DeclaringType.BuiltInTypeKind == BuiltInTypeKind.EntityType)
    {
        baseType = typeof(EntityObject);
    }
    else
    {
        baseType = typeof(ComplexObject);
    }
    return NewModifier(baseType, property.Name);
}

string NewModifier(Type type, string memberName)
{
    if (HasBaseMemberWithMatchingName(type, memberName))
    {
        return "new";
    }
    return string.Empty;
}

static bool HasBaseMemberWithMatchingName(Type type, string memberName)
{
    BindingFlags bindingFlags = BindingFlags.FlattenHierarchy | BindingFlags.NonPublic | BindingFlags.Public
                | BindingFlags.Instance | BindingFlags.Static;
    return type.GetMembers(bindingFlags).Where(m => IsVisibleMember(m)).Any(m => m.Name == memberName);
}

string ChangingMethodName(EdmMember member)
{
    return string.Format(CultureInfo.InvariantCulture, "On{0}Changing", member.Name);
}

string ChangedMethodName(EdmMember member)
{
    return string.Format(CultureInfo.InvariantCulture, "On{0}Changed", member.Name);
}

string InitializedTrackingField(EdmProperty property, CodeGenerationTools code)
{
    string namePart = property.Name + "Initialized";
    if (code.CamelCaseFields)
    {
        namePart = code.CamelCase(namePart);
    }
    return "_" + namePart;
}

string OptionalNullableParameterForSetValidValue(EdmMember member, CodeGenerationTools code)
{
    MetadataTools ef = new MetadataTools(this);
    string list = string.Empty;
    if (((PrimitiveType)member.TypeUsage.EdmType).ClrEquivalentType.IsClass)
    {
        MetadataProperty storeGeneratedPatternProperty = null;
        bool isNullable = ef.IsNullable(member.TypeUsage) ||
            (member.MetadataProperties.TryGetValue(MetadataConstants.EDM_ANNOTATION_09_02 + ":StoreGeneratedPattern", false, out storeGeneratedPatternProperty) &&
             Object.Equals(storeGeneratedPatternProperty.Value, "Computed"));
        list += ", " + code.CreateLiteral(isNullable);
    }
    return list;
}

static bool IsVisibleMember(MemberInfo memberInfo)
{
    if (memberInfo is EventInfo)
    {
        EventInfo ei = (EventInfo)memberInfo;
        MethodInfo add = ei.GetAddMethod();
        MethodInfo remove = ei.GetRemoveMethod();
        return IsVisibleMethod(add) || IsVisibleMethod(remove);
    }
    else if (memberInfo is FieldInfo)
    {
        FieldInfo fi = (FieldInfo)memberInfo;
        return !fi.IsPrivate && !fi.IsAssembly;
    }
    else if (memberInfo is MethodBase)
    {
        MethodBase mb = (MethodBase)memberInfo;
        if (mb.IsSpecialName)
            return false;
        return IsVisibleMethod(mb);
    }
    else if (memberInfo is PropertyInfo)
    {
        PropertyInfo pi = (PropertyInfo)memberInfo;
        MethodInfo get = pi.GetGetMethod();
        MethodInfo set = pi.GetSetMethod();
        return IsVisibleMethod(get) || IsVisibleMethod(set);
    }

    return false;
}

static bool IsVisibleMethod(MethodBase methodBase)
{
    if (methodBase == null)
        return false;

    return !methodBase.IsPrivate && !methodBase.IsAssembly;
}

IEnumerable<T> GetSourceSchemaTypes<T>() where T : GlobalItem
{
    if (Path.GetExtension(SourceCsdlPath) != ".edmx")
    {
        return ItemCollection.GetItems<T>().Where(e => e.MetadataProperties.Any(mp => mp.Name == "SchemaSource" && (string)mp.Value == SourceCsdlPath));
    }
    else
    {
        return ItemCollection.GetItems<T>();
    }
}

string EndName(AssociationType association, int index)
{
    return association.AssociationEndMembers[index].Name;
}

string EndMultiplicity(AssociationType association, int index, CodeGenerationTools code)
{
    return code.CreateLiteral(association.AssociationEndMembers[index].RelationshipMultiplicity);
}

string EscapeEndTypeName(AssociationType association, int index, CodeGenerationTools code)
{
    EntityType entity = association.AssociationEndMembers[index].GetEntityType();
    return code.CreateFullName(code.EscapeNamespace(GetObjectNamespace(entity.NamespaceName)), code.Escape(entity));
}

string GetObjectNamespace(string csdlNamespaceName)
{
    string objectNamespace;
    if (EdmToObjectNamespaceMap.TryGetValue(csdlNamespaceName, out objectNamespace))
    {
        return objectNamespace;
    }

    return csdlNamespaceName;
}

void UpdateObjectNamespaceMap(string objectNamespace)
{
    if(objectNamespace != ModelNamespace && !EdmToObjectNamespaceMap.ContainsKey(ModelNamespace))
    {
        EdmToObjectNamespaceMap.Add(ModelNamespace, objectNamespace);   
    }
}

static string FixParameterName(string name, CodeGenerationTools code)
{
    //  Change any property that is 'id' (case insensitive) to 'ID'
    //  since 'iD' is a violation of FxCop rules.
    if (StringComparer.OrdinalIgnoreCase.Equals(name, "id"))
    {
        //  Return all lower case since it is an abbreviation, not an acronym.
        return "id";
    }
    return code.CamelCase(name);
}

string BaseTypeName(EntityType entity, CodeGenerationTools code)
{
    return entity.BaseType == null ? "EntityObject" : MultiSchemaEscape((StructuralType)entity.BaseType, code);
}

bool IncludePropertyInFactoryMethod(StructuralType factoryType, EdmProperty edmProperty)
{
    if (edmProperty.Nullable)
    {
        return false;
    }

    if (edmProperty.DefaultValue != null)
    {
        return false;
    }

    if ((Accessibility.ForReadOnlyProperty(edmProperty) != "public" && Accessibility.ForWriteOnlyProperty(edmProperty) != "public") ||
        (factoryType != edmProperty.DeclaringType && Accessibility.ForWriteOnlyProperty(edmProperty) == "private")
       )
    {
        //  There is no public part to the property.
        return false;
    }

    return true;
}


class FactoryMethodParameter
{
    public EdmProperty Source;
    public string RawParameterName;
    public string ParameterName;
    public string ParameterType;
    public string ParameterComment;
    public bool IsComplexType;

    public static IEnumerable<FactoryMethodParameter> CreateParameters(IEnumerable<EdmProperty> properties, UniqueIdentifierService unique, Func<TypeUsage, CodeGenerationTools, string> multiSchemaEscape, CodeGenerationTools code)
    {
        List<FactoryMethodParameter> parameters = new List<FactoryMethodParameter>();
        foreach (EdmProperty property in properties)
        {
            FactoryMethodParameter parameter = new FactoryMethodParameter();
            parameter.Source = property;
            parameter.IsComplexType = property.TypeUsage.EdmType is ComplexType;
            parameter.RawParameterName = unique.AdjustIdentifier(FixParameterName(property.Name, code));
            parameter.ParameterName = code.Escape(parameter.RawParameterName);
            parameter.ParameterType = multiSchemaEscape(property.TypeUsage, code);
            parameter.ParameterComment = String.Format(CultureInfo.CurrentCulture, GetResourceString("Template_CommentFactoryMethodParam"), property.Name);
            parameters.Add(parameter);
        }

        return parameters;
    }
}

string DefaultSummaryComment{ get; set; }

string SummaryComment(MetadataItem item)
{
    if (item.Documentation != null && item.Documentation.Summary != null)
    {
        return PrefixLinesOfMultilineComment(XMLCOMMENT_START + " ", XmlEntityize(item.Documentation.Summary));
    }

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

    return string.Empty;
}

string LongDescriptionCommentElement(MetadataItem item, int indentLevel)
{
    if (item.Documentation != null && !String.IsNullOrEmpty(item.Documentation.LongDescription))
    {
        string comment = Environment.NewLine;
        string lineStart = CodeRegion.GetIndent(indentLevel) + XMLCOMMENT_START + " ";
        comment += lineStart + "<LongDescription>" + Environment.NewLine;
        comment += lineStart + PrefixLinesOfMultilineComment(lineStart, XmlEntityize(item.Documentation.LongDescription)) + Environment.NewLine;
        comment += lineStart + "</LongDescription>";
        return comment;
    }
    return string.Empty;
}

string PrefixLinesOfMultilineComment(string prefix, string comment)
{
    return comment.Replace(Environment.NewLine, Environment.NewLine + prefix);
}

string ParameterComments(IEnumerable<Tuple<string, string>> parameters, int indentLevel)
{
    System.Text.StringBuilder builder = new System.Text.StringBuilder();
    foreach (Tuple<string, string> parameter in parameters)
    {
        builder.AppendLine();
        builder.Append(CodeRegion.GetIndent(indentLevel));
        builder.Append(XMLCOMMENT_START);
        builder.Append(String.Format(CultureInfo.InvariantCulture, " <param name=\"{0}\">{1}</param>", parameter.Item1, parameter.Item2));
    }
    return builder.ToString();
}

string XmlEntityize(string text)
{
    if (string.IsNullOrEmpty(text))
    {
        return string.Empty;
    }

    text = text.Replace("&","&amp;");
    text = text.Replace("<","&lt;").Replace(">","&gt;");
    string id = Guid.NewGuid().ToString();
    text = text.Replace(Environment.NewLine, id);
    text = text.Replace("\r", "&#xD;").Replace("\n","&#xA;");
    text = text.Replace(id, Environment.NewLine);
    return text.Replace("\'","&apos;").Replace("\"","&quot;");
}

const string XMLCOMMENT_START = "///";
IEnumerable<EdmProperty> GetProperties(StructuralType type)
{
    if (type.BuiltInTypeKind == BuiltInTypeKind.EntityType)
    {
        return ((EntityType)type).Properties;
    }
    else
    {
        return ((ComplexType)type).Properties;
    }
}

protected void VerifyGetterAndSetterAccessibilityCompatability(EdmMember member)
{
    string rawGetterAccessibility = Accessibility.ForReadOnlyProperty(member);
    string rawSetterAccessibility = Accessibility.ForWriteOnlyProperty(member);

    if ((rawGetterAccessibility == "internal" && rawSetterAccessibility ==   "protected") ||
        (rawGetterAccessibility == "protected" && rawSetterAccessibility == "internal"))

    {
           Errors.Add(new System.CodeDom.Compiler.CompilerError(SourceCsdlPath, -1, -1, "6033", String.Format(CultureInfo.CurrentCulture,
                   GetResourceString("GeneratedPropertyAccessibilityConflict"),
                       member.Name, rawGetterAccessibility, rawSetterAccessibility)));
    }
}

private void VerifyEntityTypeAndSetAccessibilityCompatability(EntitySet set)
{
    string typeAccess = Accessibility.ForType(set.ElementType);
    string setAccess = Accessibility.ForReadOnlyProperty(set);

    if(typeAccess == "internal" && (setAccess == "public" || setAccess == "protected"))
    {
       Errors.Add(new System.CodeDom.Compiler.CompilerError(SourceCsdlPath, -1, -1, "6036", String.Format(CultureInfo.CurrentCulture,
               GetResourceString("EntityTypeAndSetAccessibilityConflict"),
                   set.ElementType.Name, typeAccess, set.Name, setAccess)));
    }
}

////////
////////  UniqueIdentifierService
////////
sealed class UniqueIdentifierService
{
    private readonly HashSet<string> _knownIdentifiers;

    public UniqueIdentifierService()
    {
        _knownIdentifiers = new HashSet<string>(StringComparer.Ordinal);
    }

    /// <summary>
    ///  Makes the supplied identifier  unique within the scope by adding
    ///  a suffix (1, 2, 3, ...), and returns the unique identifier.
    /// </summary>
    public string AdjustIdentifier(string identifier)
    {
        //  find a unique name by adding suffix as necessary
        var numberOfConflicts = 0;
        var adjustedIdentifier = identifier;

        while (!_knownIdentifiers.Add(adjustedIdentifier))
        {
            ++numberOfConflicts;
            adjustedIdentifier = identifier + numberOfConflicts.ToString(CultureInfo.InvariantCulture);
        }

        return adjustedIdentifier;
    }
}

#>

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

0 голосов
/ 06 мая 2011

Не думаю, что есть надстройки oss или сторонних разработчиков.

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...