mirror of https://github.com/abpframework/abp.git
committed by
GitHub
301 changed files with 6502 additions and 3871 deletions
@ -1,3 +1,85 @@ |
|||
# Caching |
|||
|
|||
ABP framework extends ASP.NET Core's distributed caching system. |
|||
|
|||
## IDistributedCache Interface |
|||
|
|||
ASP.NET Core defines the `IDistributedCache` interface to get/set cache values. But it has some difficulties: |
|||
|
|||
* It works with **byte arrays** rather than .NET objects. So, you need to **serialize/deserialize** the objects you need to cache. |
|||
* It provides a **single key pool** for all cache items, so; |
|||
* You need to care about the keys to distinguish **different type of objects**. |
|||
* You need to care about the cache items of **different tenants** (see [multi-tenancy](Multi-Tenancy.md)). |
|||
|
|||
> `IDistributedCache` is defined in the `Microsoft.Extensions.Caching.Abstractions` package. That means it is not only usable for ASP.NET Core applications, but also available to **any type of applications**. |
|||
|
|||
> Default implementation of the `IDistributedCache` interface is the `MemoryDistributedCache` which works **in-memory**. See [ASP.NET Core's documentation](https://docs.microsoft.com/en-us/aspnet/core/performance/caching/distributed) to see how to switch to **Redis** or another cache provider. |
|||
|
|||
See [ASP.NET Core's distributed caching document](https://docs.microsoft.com/en-us/aspnet/core/performance/caching/distributed) for more information. |
|||
|
|||
## IDistributedCache<TCacheItem> Interface |
|||
|
|||
ABP framework defines the generic `IDistributedCache<TCacheItem>` interface in the [Volo.Abp.Caching](https://www.nuget.org/packages/Volo.Abp.Caching/) package. `TCacheItem` is the type of the object stored in the cache. |
|||
|
|||
`IDistributedCache<TCacheItem>` solves the difficulties explained above; |
|||
|
|||
* It internally **serializes/deserializes** the cached objects. Uses **JSON** serialization by default, but can be overridden by replacing the `IDistributedCacheSerializer` service in the [dependency injection](Dependency-Injection.md) system. |
|||
* It automatically adds a **cache name** prefix to the cache keys based on the object type stored in the cache. Default cache name is the full name of the cache item class (`CacheItem` postfix is removed if your cache item class ends with it). You can use the `CacheName` attribute on the cache item class to set the cache name. |
|||
* It automatically adds the **current tenant id** to the cache key to distinguish cache items for different tenants (only works if your application is [multi-tenant](Multi-Tenancy.md)). Define `IgnoreMultiTenancy` attribute on the cache item class to disable this if you want to share the cached objects among all tenants in a multi-tenant application. |
|||
* Allows to define a **global cache key prefix** per application, so different applications can use their isolated key pools in a shared distributed cache source. |
|||
|
|||
### Usage |
|||
|
|||
An example class to store an item in the cache: |
|||
|
|||
````csharp |
|||
public class BookCacheItem |
|||
{ |
|||
public string Name { get; set; } |
|||
|
|||
public float Price { get; set; } |
|||
} |
|||
```` |
|||
|
|||
You can inject and use the `IDistributedCache<BookCacheItem>` service to get/set `BookCacheItem` objects. |
|||
|
|||
Example usage: |
|||
|
|||
````csharp |
|||
public class BookService : ITransientDependency |
|||
{ |
|||
private readonly IDistributedCache<BookCacheItem> _cache; |
|||
|
|||
public BookService(IDistributedCache<BookCacheItem> cache) |
|||
{ |
|||
_cache = cache; |
|||
} |
|||
|
|||
public async Task<BookCacheItem> GetAsync(Guid bookId) |
|||
{ |
|||
return await _cache.GetOrAddAsync( |
|||
bookId.ToString(), //Cache key |
|||
async () => await GetBookFromDatabaseAsync(bookId), |
|||
() => new DistributedCacheEntryOptions |
|||
{ |
|||
AbsoluteExpiration = DateTimeOffset.Now.AddHours(1) |
|||
} |
|||
); |
|||
} |
|||
|
|||
private Task<BookCacheItem> GetBookFromDatabaseAsync(Guid bookId) |
|||
{ |
|||
//TODO: get from database |
|||
} |
|||
} |
|||
```` |
|||
|
|||
* This sample service uses the `GetOrAddAsync()` method to get a book item from the cache. |
|||
* If the book was not found in the cache, it calls the factory method (`GetBookFromDatabaseAsync` in this case) to retrieve the book item from the original source. |
|||
* `GetOrAddAsync` optionally gets a `DistributedCacheEntryOptions` which can be used to set the lifetime of the cached item. |
|||
|
|||
Other methods of the `IDistributedCache<BookCacheItem>` are same as ASP.NET Core's `IDistributedCache` interface, so you can refer [it's documentation](https://docs.microsoft.com/en-us/aspnet/core/performance/caching/distributed). |
|||
|
|||
### DistributedCacheOptions |
|||
|
|||
TODO |
|||
@ -1,3 +1,256 @@ |
|||
## Object To Object Mapping |
|||
# Object To Object Mapping |
|||
|
|||
It's common to map an object to another similar object. It's also tedious and repetitive since generally both classes have the same or similar properties mapped to each other. Imagine a typical [application service](Application-Services.md) method below: |
|||
|
|||
```csharp |
|||
public class UserAppService : ApplicationService |
|||
{ |
|||
private readonly IRepository<User, Guid> _userRepository; |
|||
|
|||
public UserAppService(IRepository<User, Guid> userRepository) |
|||
{ |
|||
_userRepository = userRepository; |
|||
} |
|||
|
|||
public void CreateUser(CreateUserInput input) |
|||
{ |
|||
//Manually creating a User object from the CreateUserInput object |
|||
var user = new User |
|||
{ |
|||
Name = input.Name, |
|||
Surname = input.Surname, |
|||
EmailAddress = input.EmailAddress, |
|||
Password = input.Password |
|||
}; |
|||
|
|||
_userRepository.Insert(user); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
`CreateUserInput ` is a simple [DTO](Data-Transfer-Objects.md) class and the `User` is a simple [entity](Entities.md). The code above creates a `User` entity from the given input object. The `User` entity will have more properties in a real-world application and manually creating it will become tedious and error-prone. You also have to change the mapping code when you add new properties to `User` and `CreateUserInput` classes. |
|||
|
|||
We can use a library to automatically handle these kind of mappings. ABP provides abstractions for object to object mapping and has an integration package to use [AutoMapper](http://automapper.org/) as the object mapper. |
|||
|
|||
## IObjectMapper |
|||
|
|||
`IObjectMapper` interface (in the [Volo.Abp.ObjectMapping](https://www.nuget.org/packages/Volo.Abp.ObjectMapping) package) defines a simple `Map` method. The example code introduced before can be re-written as shown below: |
|||
|
|||
````csharp |
|||
public class UserAppService : ApplicationService |
|||
{ |
|||
private readonly IRepository<User, Guid> _userRepository; |
|||
|
|||
public UserAppService(IRepository<User, Guid> userRepository) |
|||
{ |
|||
_userRepository = userRepository; |
|||
} |
|||
|
|||
public void CreateUser(CreateUserInput input) |
|||
{ |
|||
//Automatically creating a new User object using the CreateUserInput object |
|||
var user = ObjectMapper.Map<CreateUserInput, User>(input); |
|||
|
|||
_userRepository.Insert(user); |
|||
} |
|||
} |
|||
```` |
|||
|
|||
> `ObjectMapper` is defined in the `ApplicationService` base class in this example. You can directly inject the `IObjectMapper` interface when you need it somewhere else. |
|||
|
|||
Map method has two generic argument: First one is the source object type while the second one is the destination object type. |
|||
|
|||
If you need to set properties of an existing object, you can use the second overload of the `Map` method: |
|||
|
|||
````csharp |
|||
public class UserAppService : ApplicationService |
|||
{ |
|||
private readonly IRepository<User, Guid> _userRepository; |
|||
|
|||
public UserAppService(IRepository<User, Guid> userRepository) |
|||
{ |
|||
_userRepository = userRepository; |
|||
} |
|||
|
|||
public async Task UpdateUserAsync(Guid id, UpdateUserInput input) |
|||
{ |
|||
var user = await _userRepository.GetAsync(id); |
|||
|
|||
//Automatically set properties of the user object using the UpdateUserInput |
|||
ObjectMapper.Map<UpdateUserInput, User>(input, user); |
|||
|
|||
await _userRepository.UpdateAsync(user); |
|||
} |
|||
} |
|||
```` |
|||
|
|||
You should have defined the mappings before to be able to map objects. See the AutoMapper integration section to learn how to define mappings. |
|||
|
|||
## AutoMapper Integration |
|||
|
|||
[AutoMapper](http://automapper.org/) is one of the most popular object to object mapping libraries. [Volo.Abp.AutoMapper](https://www.nuget.org/packages/Volo.Abp.AutoMapper) package defines the AutoMapper integration for the `IObjectMapper`. |
|||
|
|||
Once you define mappings described as below, you can use the `IObjectMapper` interface just like explained before. |
|||
|
|||
### Define Mappings |
|||
|
|||
AutoMapper provides multiple ways of defining mapping between classes. Refer to [its own documentation](https://docs.automapper.org) for all details. |
|||
|
|||
One way to define object mappings is creating a [Profile](https://docs.automapper.org/en/stable/Configuration.html#profile-instances) class. Example: |
|||
|
|||
````csharp |
|||
public class MyProfile : Profile |
|||
{ |
|||
public MyProfile() |
|||
{ |
|||
CreateMap<User, UserDto>(); |
|||
} |
|||
} |
|||
```` |
|||
|
|||
You should then register profiles using the `AbpAutoMapperOptions`: |
|||
|
|||
````csharp |
|||
[DependsOn(typeof(AbpAutoMapperModule))] |
|||
public class MyModule : AbpModule |
|||
{ |
|||
public override void ConfigureServices(ServiceConfigurationContext context) |
|||
{ |
|||
Configure<AbpAutoMapperOptions>(options => |
|||
{ |
|||
//Add all mappings defined in the assembly of the MyModule class |
|||
options.AddMaps<MyModule>(); |
|||
}); |
|||
} |
|||
} |
|||
```` |
|||
|
|||
`AddMaps` registers all profile classes defined in the assembly of the given class, typically your module class. It also registers for the [attribute mapping](https://docs.automapper.org/en/stable/Attribute-mapping.html). |
|||
|
|||
### Configuration Validation |
|||
|
|||
`AddMaps` optionally takes a `bool` parameter to control the [configuration validation](https://docs.automapper.org/en/stable/Configuration-validation.html) for your [module](Module-Development-Basics.md): |
|||
|
|||
````csharp |
|||
options.AddMaps<MyModule>(validate: true); |
|||
```` |
|||
|
|||
While this option is `false` by default, it is suggested to enable configuration validation as a best practice. |
|||
|
|||
Configuration validation can be controlled per profile class using `AddProfile` instead of `AddMaps`: |
|||
|
|||
````csharp |
|||
options.AddProfile<MyProfile>(validate: true); |
|||
```` |
|||
|
|||
> If you have multiple profiles and need to enable validation only for a few of them, first use `AddMaps` without validation, then use `AddProfile` for each profile you want to validate. |
|||
|
|||
## Advanced Topics |
|||
|
|||
### IObjectMapper<TContext> Interface |
|||
|
|||
Assume that you have created a **reusable module** which defines AutoMapper profiles and uses `IObjectMapper` when it needs to map objects. Your module then can be used in different applications, by nature of the [modularity](Module-Development-Basics.md). |
|||
|
|||
`IObjectMapper` is an abstraction and can be replaced by the final application to use another mapping library. The problem here that your reusable module is designed to use the AutoMapper library, because it only defines mappings for it. In such a case, you will want to guarantee that your module always uses AutoMapper even if the final application uses another default object mapping library. |
|||
|
|||
`IObjectMapper<TContext>` is used to contextualize the object mapper, so you can use different libraries for different modules/contexts. |
|||
|
|||
Example usage: |
|||
|
|||
````csharp |
|||
public class UserAppService : ApplicationService |
|||
{ |
|||
private readonly IRepository<User, Guid> _userRepository; |
|||
|
|||
private readonly IObjectMapper<MyModule> _objectMapper; |
|||
|
|||
public UserAppService( |
|||
IRepository<User, Guid> userRepository, |
|||
IObjectMapper<MyModule> objectMapper) //Inject module specific mapper |
|||
{ |
|||
_userRepository = userRepository; |
|||
_objectMapper = objectMapper; |
|||
} |
|||
|
|||
public async Task CreateUserAsync(CreateUserInput input) |
|||
{ |
|||
//Use the module specific mapper |
|||
var user = _objectMapper.Map<CreateUserInput, User>(input); |
|||
|
|||
await _userRepository.InsertAsync(user); |
|||
} |
|||
} |
|||
```` |
|||
|
|||
`UserAppService ` injects the `IObjectMapper<MyModule>`, the specific object mapper for this module. It's usage is exactly same of the `IObjectMapper`. |
|||
|
|||
The example code above don't use the `ObjectMapper` property defined in the `ApplicationService`, but injects the `IObjectMapper<MyModule>`. However, it is still possible to use the base property since the `ApplicationService` defines an `ObjectMapperContext` property that can be set in the class constructor. So, the example about can be re-written as like below: |
|||
|
|||
````csharp |
|||
public class UserAppService : ApplicationService |
|||
{ |
|||
private readonly IRepository<User, Guid> _userRepository; |
|||
|
|||
public UserAppService(IRepository<User, Guid> userRepository) |
|||
{ |
|||
_userRepository = userRepository; |
|||
//Set the object mapper context |
|||
ObjectMapperContext = typeof(MyModule); |
|||
} |
|||
|
|||
public async Task CreateUserAsync(CreateUserInput input) |
|||
{ |
|||
var user = ObjectMapper.Map<CreateUserInput, User>(input); |
|||
|
|||
await _userRepository.InsertAsync(user); |
|||
} |
|||
} |
|||
```` |
|||
|
|||
While using the contextualized object mapper is same as the normal object mapper, you should register the contextualized mapper in your module's `ConfigureServices` method: |
|||
|
|||
````csharp |
|||
[DependsOn(typeof(AbpAutoMapperModule))] |
|||
public class MyModule : AbpModule |
|||
{ |
|||
public override void ConfigureServices(ServiceConfigurationContext context) |
|||
{ |
|||
//Use AutoMapper for MyModule |
|||
context.Services.AddAutoMapperObjectMapper<MyModule>(); |
|||
|
|||
Configure<AbpAutoMapperOptions>(options => |
|||
{ |
|||
options.AddMaps<MyModule>(validate: true); |
|||
}); |
|||
} |
|||
} |
|||
```` |
|||
|
|||
`IObjectMapper<MyModule>` is an essential feature for a reusable module where it can be used in multiple applications each may use a different library for object to object mapping. All pre-built ABP modules are using it. But, for the final application, you can ignore this interface and always use the default `IObjectMapper` interface. |
|||
|
|||
### IObjectMapper<TSource, TDestination> Interface |
|||
|
|||
ABP allows you to customize the mapping code for specific classes. Assume that you want to create a custom class to map from `User` to `UserDto`. In this case, you can create a class that implements the `IObjectMapper<User, UserDto>`: |
|||
|
|||
````csharp |
|||
public class MyCustomUserMapper : IObjectMapper<User, UserDto>, ITransientDependency |
|||
{ |
|||
public UserDto Map(User source) |
|||
{ |
|||
//TODO: Create a new UserDto |
|||
} |
|||
|
|||
public UserDto Map(User source, UserDto destination) |
|||
{ |
|||
//TODO: Set properties of an existing UserDto |
|||
return destination; |
|||
} |
|||
} |
|||
```` |
|||
|
|||
ABP automatically discovers and registers the `MyCustomUserMapper` and it is automatically used whenever you use the `IObjectMapper` to map `User` to `UserDto`. |
|||
|
|||
A single class may implement more than one `IObjectMapper<TSource, TDestination>` each for a different object pairs. |
|||
|
|||
> This approach is powerful since `MyCustomUserMapper` can inject any other service and use in the `Map` methods. |
|||
|
|||
TODO |
|||
@ -0,0 +1,20 @@ |
|||
using Autofac; |
|||
using Volo.Abp.Autofac; |
|||
using Microsoft.Extensions.DependencyInjection; |
|||
|
|||
namespace Microsoft.Extensions.Hosting |
|||
{ |
|||
public static class AbpAutofacHostBuilderExtensions |
|||
{ |
|||
public static IHostBuilder UseAutofac(this IHostBuilder hostBuilder) |
|||
{ |
|||
var containerBuilder = new ContainerBuilder(); |
|||
|
|||
return hostBuilder.ConfigureServices((_, services) => |
|||
{ |
|||
services.AddObjectAccessor(containerBuilder); |
|||
}) |
|||
.UseServiceProviderFactory(new AbpAutofacServiceProviderFactory(containerBuilder)); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,169 @@ |
|||
using System; |
|||
using System.IO; |
|||
using System.Text; |
|||
using System.Threading.Tasks; |
|||
using ICSharpCode.SharpZipLib.Core; |
|||
using ICSharpCode.SharpZipLib.Zip; |
|||
using Microsoft.Extensions.Logging; |
|||
using Microsoft.Extensions.Logging.Abstractions; |
|||
using Volo.Abp.Cli.Args; |
|||
using Volo.Abp.Cli.ProjectBuilding; |
|||
using Volo.Abp.Cli.ProjectBuilding.Building; |
|||
using Volo.Abp.DependencyInjection; |
|||
|
|||
namespace Volo.Abp.Cli.Commands |
|||
{ |
|||
public class GetSourceCommand : IConsoleCommand, ITransientDependency |
|||
{ |
|||
public ModuleProjectBuilder ModuleProjectBuilder { get; } |
|||
|
|||
public ILogger<NewCommand> Logger { get; set; } |
|||
|
|||
public GetSourceCommand(ModuleProjectBuilder moduleProjectBuilder) |
|||
{ |
|||
ModuleProjectBuilder = moduleProjectBuilder; |
|||
Logger = NullLogger<NewCommand>.Instance; |
|||
} |
|||
|
|||
public async Task ExecuteAsync(CommandLineArgs commandLineArgs) |
|||
{ |
|||
if (commandLineArgs.Target == null) |
|||
{ |
|||
throw new CliUsageException( |
|||
"Module name is missing!" + |
|||
Environment.NewLine + Environment.NewLine + |
|||
GetUsageInfo() |
|||
); |
|||
} |
|||
|
|||
Logger.LogInformation("Downloading source code of " + commandLineArgs.Target); |
|||
|
|||
var version = commandLineArgs.Options.GetOrNull(Options.Version.Short, Options.Version.Long); |
|||
if (version != null) |
|||
{ |
|||
Logger.LogInformation("Version: " + version); |
|||
} |
|||
|
|||
var outputFolder = commandLineArgs.Options.GetOrNull(Options.OutputFolder.Short, Options.OutputFolder.Long); |
|||
if (outputFolder != null) |
|||
{ |
|||
if (!Directory.Exists(outputFolder)) |
|||
{ |
|||
Directory.CreateDirectory(outputFolder); |
|||
} |
|||
|
|||
outputFolder = Path.GetFullPath(outputFolder); |
|||
} |
|||
else |
|||
{ |
|||
outputFolder = Directory.GetCurrentDirectory(); |
|||
} |
|||
|
|||
Logger.LogInformation("Output folder: " + outputFolder); |
|||
|
|||
var gitHubLocalRepositoryPath = commandLineArgs.Options.GetOrNull(Options.GitHubLocalRepositoryPath.Long); |
|||
if (gitHubLocalRepositoryPath != null) |
|||
{ |
|||
Logger.LogInformation("GitHub Local Repository Path: " + gitHubLocalRepositoryPath); |
|||
} |
|||
|
|||
commandLineArgs.Options.Add(CliConsts.Command, commandLineArgs.Command); |
|||
|
|||
var result = await ModuleProjectBuilder.BuildAsync( |
|||
new ProjectBuildArgs( |
|||
SolutionName.Parse(commandLineArgs.Target), |
|||
commandLineArgs.Target, |
|||
version, |
|||
DatabaseProvider.NotSpecified, |
|||
UiFramework.NotSpecified, |
|||
gitHubLocalRepositoryPath, |
|||
commandLineArgs.Options |
|||
) |
|||
); |
|||
|
|||
using (var templateFileStream = new MemoryStream(result.ZipContent)) |
|||
{ |
|||
using (var zipInputStream = new ZipInputStream(templateFileStream)) |
|||
{ |
|||
var zipEntry = zipInputStream.GetNextEntry(); |
|||
while (zipEntry != null) |
|||
{ |
|||
var fullZipToPath = Path.Combine(outputFolder, zipEntry.Name); |
|||
var directoryName = Path.GetDirectoryName(fullZipToPath); |
|||
|
|||
if (!string.IsNullOrEmpty(directoryName)) |
|||
{ |
|||
Directory.CreateDirectory(directoryName); |
|||
} |
|||
|
|||
var fileName = Path.GetFileName(fullZipToPath); |
|||
if (fileName.Length == 0) |
|||
{ |
|||
zipEntry = zipInputStream.GetNextEntry(); |
|||
continue; |
|||
} |
|||
|
|||
var buffer = new byte[4096]; // 4K is optimum
|
|||
using (var streamWriter = File.Create(fullZipToPath)) |
|||
{ |
|||
StreamUtils.Copy(zipInputStream, streamWriter, buffer); |
|||
} |
|||
|
|||
zipEntry = zipInputStream.GetNextEntry(); |
|||
} |
|||
} |
|||
} |
|||
|
|||
Logger.LogInformation($"'{commandLineArgs.Target}' has been successfully downloaded to '{outputFolder}'"); |
|||
} |
|||
|
|||
public string GetUsageInfo() |
|||
{ |
|||
var sb = new StringBuilder(); |
|||
|
|||
sb.AppendLine(""); |
|||
sb.AppendLine("Usage:"); |
|||
sb.AppendLine(""); |
|||
sb.AppendLine(" abp get-source <module-name> [options]"); |
|||
sb.AppendLine(""); |
|||
sb.AppendLine("Options:"); |
|||
sb.AppendLine(""); |
|||
sb.AppendLine("-o|--output-folder <output-folder> (default: current folder)"); |
|||
sb.AppendLine("-v|--version <version> (default: latest version)"); |
|||
sb.AppendLine(""); |
|||
sb.AppendLine("Examples:"); |
|||
sb.AppendLine(""); |
|||
sb.AppendLine(" abp get-source Volo.Blogging"); |
|||
sb.AppendLine(" abp get-source Volo.Blogging -o d:\\my-project"); |
|||
sb.AppendLine(""); |
|||
sb.AppendLine("See the documentation for more info: https://docs.abp.io/en/abp/latest/CLI"); |
|||
|
|||
return sb.ToString(); |
|||
} |
|||
|
|||
public string GetShortDescription() |
|||
{ |
|||
return "Downloads the source code of the specified module."; |
|||
} |
|||
|
|||
public static class Options |
|||
{ |
|||
public static class OutputFolder |
|||
{ |
|||
public const string Short = "o"; |
|||
public const string Long = "output-folder"; |
|||
} |
|||
|
|||
public static class GitHubLocalRepositoryPath |
|||
{ |
|||
public const string Long = "abp-path"; |
|||
} |
|||
|
|||
public static class Version |
|||
{ |
|||
public const string Short = "v"; |
|||
public const string Long = "version"; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
namespace Volo.Abp.Cli.ProjectBuilding.Building |
|||
{ |
|||
public class ModuleInfo |
|||
{ |
|||
public string Name { get; set; } |
|||
|
|||
public string Namespace { get; set; } |
|||
|
|||
public string DocumentUrl { get; set; } |
|||
} |
|||
} |
|||
@ -0,0 +1,19 @@ |
|||
using Volo.Abp.Cli.ProjectBuilding.Building.Steps; |
|||
|
|||
namespace Volo.Abp.Cli.ProjectBuilding.Building |
|||
{ |
|||
public static class ModuleProjectBuildPipelineBuilder |
|||
{ |
|||
public static ProjectBuildPipeline Build(ProjectBuildContext context) |
|||
{ |
|||
var pipeline = new ProjectBuildPipeline(context); |
|||
|
|||
pipeline.Steps.Add(new FileEntryListReadStep()); |
|||
pipeline.Steps.Add(new ProjectReferenceReplaceStep()); |
|||
pipeline.Steps.Add(new ReplaceCommonPropsStep()); |
|||
pipeline.Steps.Add(new CreateProjectResultZipStep()); |
|||
|
|||
return pipeline; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,96 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.IO; |
|||
using System.Text.RegularExpressions; |
|||
using System.Xml; |
|||
using Volo.Abp.Cli.ProjectBuilding.Files; |
|||
|
|||
namespace Volo.Abp.Cli.ProjectBuilding.Building.Steps |
|||
{ |
|||
public class ReplaceCommonPropsStep : ProjectBuildPipelineStep |
|||
{ |
|||
public override void Execute(ProjectBuildContext context) |
|||
{ |
|||
new CommonPropsReplacer(context.Files).Run(); |
|||
} |
|||
|
|||
private class CommonPropsReplacer |
|||
{ |
|||
private readonly List<FileEntry> _entries; |
|||
|
|||
public CommonPropsReplacer( |
|||
List<FileEntry> entries) |
|||
{ |
|||
_entries = entries; |
|||
} |
|||
|
|||
public void Run() |
|||
{ |
|||
foreach (var fileEntry in _entries) |
|||
{ |
|||
if (fileEntry.Name.EndsWith(".csproj")) |
|||
{ |
|||
fileEntry.SetContent(ProcessFileContent(fileEntry.Content)); |
|||
} |
|||
} |
|||
} |
|||
|
|||
private string ProcessFileContent(string content) |
|||
{ |
|||
Check.NotNull(content, nameof(content)); |
|||
|
|||
var doc = new XmlDocument() { PreserveWhitespace = true }; |
|||
|
|||
doc.Load(GenerateStreamFromString(content)); |
|||
|
|||
return ProcessReferenceNodes(doc, content); |
|||
} |
|||
|
|||
private string ProcessReferenceNodes(XmlDocument doc, string content) |
|||
{ |
|||
Check.NotNull(content, nameof(content)); |
|||
|
|||
var importNodes = doc.SelectNodes("/Project/Import[@Project]"); |
|||
|
|||
if (importNodes == null) |
|||
{ |
|||
return doc.OuterXml; |
|||
} |
|||
|
|||
foreach (XmlNode node in importNodes) |
|||
{ |
|||
if (!(node.Attributes?["Project"]?.Value?.EndsWith("\\common.props") ?? false)) |
|||
{ |
|||
continue; |
|||
} |
|||
|
|||
node.ParentNode?.RemoveChild(node); |
|||
} |
|||
|
|||
var propertyGroupNodes = doc.SelectNodes("/Project/PropertyGroup"); |
|||
|
|||
if (propertyGroupNodes == null || propertyGroupNodes.Count < 1) |
|||
{ |
|||
return doc.OuterXml; |
|||
} |
|||
|
|||
var firstPropertyGroupNode = propertyGroupNodes.Item(0); |
|||
var langNode = doc.CreateElement("LangVersion"); |
|||
langNode.InnerText = "latest"; |
|||
firstPropertyGroupNode?.PrependChild(langNode); |
|||
|
|||
return doc.OuterXml; |
|||
} |
|||
|
|||
private static Stream GenerateStreamFromString(string s) |
|||
{ |
|||
var stream = new MemoryStream(); |
|||
var writer = new StreamWriter(stream); |
|||
writer.Write(s); |
|||
writer.Flush(); |
|||
stream.Position = 0; |
|||
return stream; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,10 @@ |
|||
using System.Threading.Tasks; |
|||
using Volo.Abp.Cli.ProjectBuilding.Building; |
|||
|
|||
namespace Volo.Abp.Cli.ProjectBuilding |
|||
{ |
|||
public interface IModuleInfoProvider |
|||
{ |
|||
Task<ModuleInfo> GetAsync(string name); |
|||
} |
|||
} |
|||
@ -0,0 +1,12 @@ |
|||
using System.Net.Http; |
|||
using System.Threading.Tasks; |
|||
|
|||
namespace Volo.Abp.Cli.ProjectBuilding |
|||
{ |
|||
public interface IRemoteServiceExceptionHandler |
|||
{ |
|||
Task EnsureSuccessfulHttpResponseAsync(HttpResponseMessage responseMessage); |
|||
|
|||
Task<string> GetAbpRemoteServiceErrorAsync(HttpResponseMessage responseMessage); |
|||
} |
|||
} |
|||
@ -0,0 +1,58 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using System.Threading.Tasks; |
|||
using Volo.Abp.Json; |
|||
using Volo.Abp.Cli.Http; |
|||
using Volo.Abp.Cli.ProjectBuilding.Building; |
|||
using Volo.Abp.DependencyInjection; |
|||
using Volo.Abp.Threading; |
|||
|
|||
namespace Volo.Abp.Cli.ProjectBuilding |
|||
{ |
|||
public class ModuleInfoProvider : IModuleInfoProvider, ITransientDependency |
|||
{ |
|||
public IJsonSerializer JsonSerializer { get; } |
|||
public ICancellationTokenProvider CancellationTokenProvider { get; } |
|||
public IRemoteServiceExceptionHandler RemoteServiceExceptionHandler { get; } |
|||
|
|||
public ModuleInfoProvider( |
|||
IJsonSerializer jsonSerializer, |
|||
ICancellationTokenProvider cancellationTokenProvider, |
|||
IRemoteServiceExceptionHandler remoteServiceExceptionHandler) |
|||
{ |
|||
JsonSerializer = jsonSerializer; |
|||
CancellationTokenProvider = cancellationTokenProvider; |
|||
RemoteServiceExceptionHandler = remoteServiceExceptionHandler; |
|||
} |
|||
|
|||
public async Task<ModuleInfo> GetAsync(string name) |
|||
{ |
|||
var moduleList = await GetModuleListAsync(); |
|||
|
|||
var module = moduleList.FirstOrDefault(m => m.Name == name); |
|||
|
|||
if (module == null) |
|||
{ |
|||
throw new Exception("Module not found!"); |
|||
} |
|||
|
|||
return module; |
|||
} |
|||
|
|||
private async Task<List<ModuleInfo>> GetModuleListAsync() |
|||
{ |
|||
using (var client = new CliHttpClient()) |
|||
{ |
|||
var responseMessage = await client.GetAsync( |
|||
$"{CliUrls.WwwAbpIo}api/download/modules/", |
|||
CancellationTokenProvider.Token |
|||
); |
|||
|
|||
await RemoteServiceExceptionHandler.EnsureSuccessfulHttpResponseAsync(responseMessage); |
|||
var result = await responseMessage.Content.ReadAsStringAsync(); |
|||
return JsonSerializer.Deserialize<List<ModuleInfo>>(result); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,109 @@ |
|||
using Microsoft.Extensions.Logging; |
|||
using Microsoft.Extensions.Logging.Abstractions; |
|||
using Microsoft.Extensions.Options; |
|||
using System; |
|||
using System.Linq; |
|||
using System.Threading.Tasks; |
|||
using Volo.Abp.Cli.Commands; |
|||
using Volo.Abp.Cli.Licensing; |
|||
using Volo.Abp.Cli.ProjectBuilding.Analyticses; |
|||
using Volo.Abp.Cli.ProjectBuilding.Building; |
|||
using Volo.Abp.DependencyInjection; |
|||
using Volo.Abp.Json; |
|||
|
|||
namespace Volo.Abp.Cli.ProjectBuilding |
|||
{ |
|||
public class ModuleProjectBuilder : IProjectBuilder, ITransientDependency |
|||
{ |
|||
public ILogger<ModuleProjectBuilder> Logger { get; set; } |
|||
|
|||
protected ISourceCodeStore SourceCodeStore { get; } |
|||
protected IModuleInfoProvider ModuleInfoProvider { get; } |
|||
protected ICliAnalyticsCollect CliAnalyticsCollect { get; } |
|||
protected CliOptions Options { get; } |
|||
protected IJsonSerializer JsonSerializer { get; } |
|||
protected IApiKeyService ApiKeyService { get; } |
|||
|
|||
public ModuleProjectBuilder(ISourceCodeStore sourceCodeStore, |
|||
IModuleInfoProvider moduleInfoProvider, |
|||
ICliAnalyticsCollect cliAnalyticsCollect, |
|||
IOptions<CliOptions> options, |
|||
IJsonSerializer jsonSerializer, |
|||
IApiKeyService apiKeyService) |
|||
{ |
|||
SourceCodeStore = sourceCodeStore; |
|||
ModuleInfoProvider = moduleInfoProvider; |
|||
CliAnalyticsCollect = cliAnalyticsCollect; |
|||
Options = options.Value; |
|||
JsonSerializer = jsonSerializer; |
|||
ApiKeyService = apiKeyService; |
|||
|
|||
Logger = NullLogger<ModuleProjectBuilder>.Instance; |
|||
} |
|||
|
|||
public async Task<ProjectBuildResult> BuildAsync(ProjectBuildArgs args) |
|||
{ |
|||
var moduleInfo = await GetModuleInfoAsync(args); |
|||
|
|||
var templateFile = await SourceCodeStore.GetAsync( |
|||
args.TemplateName, |
|||
SourceCodeTypes.Module, |
|||
args.Version |
|||
); |
|||
|
|||
var apiKeyResult = await ApiKeyService.GetApiKeyOrNullAsync(); |
|||
if (apiKeyResult?.ApiKey != null) |
|||
{ |
|||
args.ExtraProperties["api-key"] = apiKeyResult.ApiKey; |
|||
} |
|||
|
|||
if (apiKeyResult?.LicenseCode != null) |
|||
{ |
|||
args.ExtraProperties["license-code"] = apiKeyResult.LicenseCode; |
|||
} |
|||
|
|||
var context = new ProjectBuildContext( |
|||
null, |
|||
moduleInfo, |
|||
templateFile, |
|||
args |
|||
); |
|||
|
|||
ModuleProjectBuildPipelineBuilder.Build(context).Execute(); |
|||
|
|||
if (!moduleInfo.DocumentUrl.IsNullOrEmpty()) |
|||
{ |
|||
Logger.LogInformation("Check out the documents at " + moduleInfo.DocumentUrl); |
|||
} |
|||
|
|||
// Exclude unwanted or known options.
|
|||
var options = args.ExtraProperties |
|||
.Where(x => !x.Key.Equals(CliConsts.Command, StringComparison.InvariantCultureIgnoreCase)) |
|||
.Where(x => !x.Key.Equals(NewCommand.Options.OutputFolder.Long, StringComparison.InvariantCultureIgnoreCase) && |
|||
!x.Key.Equals(NewCommand.Options.OutputFolder.Short, StringComparison.InvariantCultureIgnoreCase)) |
|||
.Where(x => !x.Key.Equals(NewCommand.Options.Version.Long, StringComparison.InvariantCultureIgnoreCase) && |
|||
!x.Key.Equals(NewCommand.Options.Version.Short, StringComparison.InvariantCultureIgnoreCase)) |
|||
.Select(x => x.Key).ToList(); |
|||
|
|||
await CliAnalyticsCollect.CollectAsync(new CliAnalyticsCollectInputDto |
|||
{ |
|||
Tool = Options.ToolName, |
|||
Command = args.ExtraProperties.ContainsKey(CliConsts.Command) ? args.ExtraProperties[CliConsts.Command] : "", |
|||
DatabaseProvider = null, |
|||
IsTiered = null, |
|||
UiFramework = null, |
|||
Options = JsonSerializer.Serialize(options), |
|||
ProjectName = null, |
|||
TemplateName = args.TemplateName, |
|||
TemplateVersion = templateFile.Version |
|||
}); |
|||
|
|||
return new ProjectBuildResult(context.Result.ZipContent, args.TemplateName); |
|||
} |
|||
|
|||
private async Task<ModuleInfo> GetModuleInfoAsync(ProjectBuildArgs args) |
|||
{ |
|||
return await ModuleInfoProvider.GetAsync(args.TemplateName); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,92 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using System.Net.Http; |
|||
using System.Text; |
|||
using System.Threading.Tasks; |
|||
using Newtonsoft.Json; |
|||
using Volo.Abp.DependencyInjection; |
|||
using Volo.Abp.Http; |
|||
using Volo.Abp.Json; |
|||
|
|||
namespace Volo.Abp.Cli.ProjectBuilding |
|||
{ |
|||
public class RemoteServiceExceptionHandler : IRemoteServiceExceptionHandler, ITransientDependency |
|||
{ |
|||
private readonly IJsonSerializer _jsonSerializer; |
|||
|
|||
public RemoteServiceExceptionHandler(IJsonSerializer jsonSerializer) |
|||
{ |
|||
_jsonSerializer = jsonSerializer; |
|||
} |
|||
|
|||
public async Task EnsureSuccessfulHttpResponseAsync(HttpResponseMessage responseMessage) |
|||
{ |
|||
if (responseMessage == null) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
if (responseMessage.IsSuccessStatusCode) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
var exceptionMessage = "Remote server returns '" + (int)responseMessage.StatusCode + "-" + responseMessage.ReasonPhrase + "'. "; |
|||
|
|||
var remoteServiceErrorMessage = await GetAbpRemoteServiceErrorAsync(responseMessage); |
|||
if (remoteServiceErrorMessage != null) |
|||
{ |
|||
exceptionMessage += remoteServiceErrorMessage; |
|||
} |
|||
|
|||
throw new Exception(exceptionMessage); |
|||
} |
|||
|
|||
public async Task<string> GetAbpRemoteServiceErrorAsync(HttpResponseMessage responseMessage) |
|||
{ |
|||
var errorResult = _jsonSerializer.Deserialize<RemoteServiceErrorResponse> |
|||
( |
|||
await responseMessage.Content.ReadAsStringAsync() |
|||
); |
|||
|
|||
if (errorResult?.Error == null) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
var sbError = new StringBuilder(); |
|||
if (!string.IsNullOrWhiteSpace(errorResult.Error.Code)) |
|||
{ |
|||
sbError.Append("Code: " + errorResult.Error.Code); |
|||
} |
|||
|
|||
if (!string.IsNullOrWhiteSpace(errorResult.Error.Message)) |
|||
{ |
|||
if (sbError.Length > 0) |
|||
{ |
|||
sbError.Append(" - "); |
|||
} |
|||
|
|||
sbError.Append("Message: " + errorResult.Error.Message); |
|||
} |
|||
|
|||
if (errorResult.Error.ValidationErrors != null && errorResult.Error.ValidationErrors.Any()) |
|||
{ |
|||
if (sbError.Length > 0) |
|||
{ |
|||
sbError.Append(" - "); |
|||
} |
|||
|
|||
sbError.AppendLine("Validation Errors: "); |
|||
for (var i = 0; i < errorResult.Error.ValidationErrors.Length; i++) |
|||
{ |
|||
var validationError = errorResult.Error.ValidationErrors[i]; |
|||
sbError.AppendLine("Validation error #" + i + ": " + validationError.Message + " - Members: " + validationError.Members.JoinAsString(", ") + "."); |
|||
} |
|||
} |
|||
|
|||
return sbError.ToString(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,9 @@ |
|||
namespace Volo.Abp.Cli.ProjectBuilding |
|||
{ |
|||
public static class SourceCodeTypes |
|||
{ |
|||
public const string Template = "template"; |
|||
|
|||
public const string Module = "module"; |
|||
} |
|||
} |
|||
@ -0,0 +1,18 @@ |
|||
namespace Volo.Abp.Data |
|||
{ |
|||
public static class AbpCommonDbProperties |
|||
{ |
|||
/// <summary>
|
|||
/// This table prefix is shared by most of the ABP modules.
|
|||
/// You can change it to set table prefix for all modules using this.
|
|||
///
|
|||
/// Default value: "Abp".
|
|||
/// </summary>
|
|||
public static string DbTablePrefix { get; set; } = "Abp"; |
|||
|
|||
/// <summary>
|
|||
/// Default value: null.
|
|||
/// </summary>
|
|||
public static string DbSchema { get; set; } = null; |
|||
} |
|||
} |
|||
@ -1,12 +0,0 @@ |
|||
|
|||
namespace Volo.Abp.AuditLogging |
|||
{ |
|||
public static class AbpAuditLoggingConsts |
|||
{ |
|||
public const string DefaultDbTablePrefix = "Abp"; |
|||
|
|||
public const string DefaultDbSchema = null; |
|||
|
|||
public const string ConnectionStringName = "AbpAuditLogging"; |
|||
} |
|||
} |
|||
@ -0,0 +1,13 @@ |
|||
using Volo.Abp.Data; |
|||
|
|||
namespace Volo.Abp.AuditLogging |
|||
{ |
|||
public static class AbpAuditLoggingDbProperties |
|||
{ |
|||
public static string DbTablePrefix { get; set; } = AbpCommonDbProperties.DbTablePrefix; |
|||
|
|||
public static string DbSchema { get; set; } = AbpCommonDbProperties.DbSchema; |
|||
|
|||
public const string ConnectionStringName = "AbpAuditLogging"; |
|||
} |
|||
} |
|||
27
modules/audit-logging/src/Volo.Abp.AuditLogging.EntityFrameworkCore/Volo/Abp/AuditLogging/EntityFrameworkCore/AbpAuditLoggingtDbContextModelBuilderExtensions.cs → modules/audit-logging/src/Volo.Abp.AuditLogging.EntityFrameworkCore/Volo/Abp/AuditLogging/EntityFrameworkCore/AbpAuditLoggingDbContextModelBuilderExtensions.cs
27
modules/audit-logging/src/Volo.Abp.AuditLogging.EntityFrameworkCore/Volo/Abp/AuditLogging/EntityFrameworkCore/AbpAuditLoggingtDbContextModelBuilderExtensions.cs → modules/audit-logging/src/Volo.Abp.AuditLogging.EntityFrameworkCore/Volo/Abp/AuditLogging/EntityFrameworkCore/AbpAuditLoggingDbContextModelBuilderExtensions.cs
@ -0,0 +1,18 @@ |
|||
using JetBrains.Annotations; |
|||
using Volo.Abp.EntityFrameworkCore.Modeling; |
|||
|
|||
namespace Volo.Abp.Identity.EntityFrameworkCore |
|||
{ |
|||
public class AbpAuditLoggingModelBuilderConfigurationOptions : ModelBuilderConfigurationOptions |
|||
{ |
|||
public AbpAuditLoggingModelBuilderConfigurationOptions( |
|||
[NotNull] string tablePrefix, |
|||
[CanBeNull] string schema) |
|||
: base( |
|||
tablePrefix, |
|||
schema) |
|||
{ |
|||
|
|||
} |
|||
} |
|||
} |
|||
@ -1,11 +0,0 @@ |
|||
namespace Volo.Abp.BackgroundJobs |
|||
{ |
|||
public static class BackgroundJobsConsts |
|||
{ |
|||
public const string DefaultDbTablePrefix = "Abp"; |
|||
|
|||
public const string DefaultDbSchema = null; |
|||
|
|||
public const string ConnectionStringName = "AbpBackgroundJobs"; |
|||
} |
|||
} |
|||
@ -0,0 +1,13 @@ |
|||
using Volo.Abp.Data; |
|||
|
|||
namespace Volo.Abp.BackgroundJobs |
|||
{ |
|||
public static class BackgroundJobsDbProperties |
|||
{ |
|||
public static string DbTablePrefix { get; set; } = AbpCommonDbProperties.DbTablePrefix; |
|||
|
|||
public static string DbSchema { get; set; } = AbpCommonDbProperties.DbSchema; |
|||
|
|||
public const string ConnectionStringName = "AbpBackgroundJobs"; |
|||
} |
|||
} |
|||
@ -1,9 +0,0 @@ |
|||
namespace Volo.Blogging |
|||
{ |
|||
public static class BloggingConsts |
|||
{ |
|||
public const string DefaultDbTablePrefix = "Blg"; |
|||
public const string DefaultDbSchema = null; |
|||
public const string ConnectionStringName = "Blogging"; |
|||
} |
|||
} |
|||
@ -0,0 +1,20 @@ |
|||
namespace Volo.Blogging |
|||
{ |
|||
public static class BloggingDbProperties |
|||
{ |
|||
/// <summary>
|
|||
/// Default value: "Blg".
|
|||
/// </summary>
|
|||
public static string DbTablePrefix { get; set; } = "Blg"; |
|||
|
|||
/// <summary>
|
|||
/// Default value: "null".
|
|||
/// </summary>
|
|||
public static string DbSchema { get; set; } = null; |
|||
|
|||
/// <summary>
|
|||
/// "Blogging".
|
|||
/// </summary>
|
|||
public const string ConnectionStringName = "Blogging"; |
|||
} |
|||
} |
|||
@ -1,11 +1,14 @@ |
|||
using Volo.Abp.EntityFrameworkCore.Modeling; |
|||
using JetBrains.Annotations; |
|||
using Volo.Abp.EntityFrameworkCore.Modeling; |
|||
|
|||
namespace Volo.Blogging.EntityFrameworkCore |
|||
{ |
|||
public class BloggingModelBuilderConfigurationOptions : ModelBuilderConfigurationOptions |
|||
{ |
|||
public BloggingModelBuilderConfigurationOptions() |
|||
: base(BloggingConsts.DefaultDbTablePrefix, BloggingConsts.DefaultDbSchema) |
|||
public BloggingModelBuilderConfigurationOptions( |
|||
[NotNull] string tablePrefix = "", |
|||
[CanBeNull] string schema = null) |
|||
: base(tablePrefix, schema) |
|||
{ |
|||
} |
|||
} |
|||
|
|||
@ -1,11 +0,0 @@ |
|||
namespace Volo.Docs |
|||
{ |
|||
public static class DocsConsts |
|||
{ |
|||
public const string DefaultDbTablePrefix = "Docs"; |
|||
|
|||
public const string DefaultDbSchema = null; |
|||
|
|||
public const string ConnectionStringName = "Docs"; |
|||
} |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
namespace Volo.Docs |
|||
{ |
|||
public static class DocsDbProperties |
|||
{ |
|||
public static string DbTablePrefix { get; set; } = "Docs"; |
|||
|
|||
public static string DbSchema { get; set; } = null; |
|||
|
|||
public const string ConnectionStringName = "Docs"; |
|||
} |
|||
} |
|||
@ -1,11 +1,14 @@ |
|||
using Volo.Abp.EntityFrameworkCore.Modeling; |
|||
using JetBrains.Annotations; |
|||
using Volo.Abp.EntityFrameworkCore.Modeling; |
|||
|
|||
namespace Volo.Docs.EntityFrameworkCore |
|||
{ |
|||
public class DocsModelBuilderConfigurationOptions : ModelBuilderConfigurationOptions |
|||
{ |
|||
public DocsModelBuilderConfigurationOptions() |
|||
: base(DocsConsts.DefaultDbTablePrefix, DocsConsts.DefaultDbSchema) |
|||
public DocsModelBuilderConfigurationOptions( |
|||
[NotNull] string tablePrefix, |
|||
[CanBeNull] string schema) |
|||
: base(tablePrefix, schema) |
|||
{ |
|||
} |
|||
} |
|||
|
|||
@ -1,11 +0,0 @@ |
|||
namespace Volo.Abp.FeatureManagement |
|||
{ |
|||
public static class FeatureManagementConsts |
|||
{ |
|||
public const string DefaultDbTablePrefix = "Abp"; |
|||
|
|||
public const string DefaultDbSchema = null; |
|||
|
|||
public const string ConnectionStringName = "AbpFeatureManagement"; |
|||
} |
|||
} |
|||
@ -0,0 +1,13 @@ |
|||
using Volo.Abp.Data; |
|||
|
|||
namespace Volo.Abp.FeatureManagement |
|||
{ |
|||
public static class FeatureManagementDbProperties |
|||
{ |
|||
public static string DbTablePrefix { get; set; } = AbpCommonDbProperties.DbTablePrefix; |
|||
|
|||
public static string DbSchema { get; set; } = AbpCommonDbProperties.DbSchema; |
|||
|
|||
public const string ConnectionStringName = "AbpFeatureManagement"; |
|||
} |
|||
} |
|||
@ -1,6 +0,0 @@ |
|||
{ |
|||
"culture": "cs", |
|||
"texts": { |
|||
|
|||
} |
|||
} |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue