diff --git a/services/Directory/FilterLists.Directory.Api/Endpoints.cs b/services/Directory/FilterLists.Directory.Api/Endpoints.cs new file mode 100644 index 0000000000..fb05def77b --- /dev/null +++ b/services/Directory/FilterLists.Directory.Api/Endpoints.cs @@ -0,0 +1,22 @@ +using FilterLists.Directory.Application.Queries; +using MediatR; + +namespace FilterLists.Directory.Api; + +internal static class Endpoints +{ + internal static void MapEndpoints(this WebApplication app) + { + var lists = app.MapGroup("/lists"); + + lists.MapGet("/", + (IMediator mediator, CancellationToken ct) => + mediator.CreateStream(new GetLists.Request(), ct) + ); + + lists.MapGet("/{id:int}", + async (int id, IMediator mediator, CancellationToken ct) => + await mediator.Send(new GetListDetails.Request(id), ct) + ); + } +} \ No newline at end of file diff --git a/services/Directory/FilterLists.Directory.Api/FilterLists.Directory.Api.csproj b/services/Directory/FilterLists.Directory.Api/FilterLists.Directory.Api.csproj index d2d4c4c760..924f81eb47 100644 --- a/services/Directory/FilterLists.Directory.Api/FilterLists.Directory.Api.csproj +++ b/services/Directory/FilterLists.Directory.Api/FilterLists.Directory.Api.csproj @@ -8,6 +8,7 @@ + diff --git a/services/Directory/FilterLists.Directory.Api/Program.cs b/services/Directory/FilterLists.Directory.Api/Program.cs index d945d7e8b8..aa0044990b 100644 --- a/services/Directory/FilterLists.Directory.Api/Program.cs +++ b/services/Directory/FilterLists.Directory.Api/Program.cs @@ -1,39 +1,16 @@ +using FilterLists.Directory.Api; +using FilterLists.Directory.Application; + var builder = WebApplication.CreateBuilder(args); -// Add service defaults & Aspire components. builder.AddServiceDefaults(); - -// Add services to the container. builder.Services.AddProblemDetails(); +builder.AddApplication(); var app = builder.Build(); -// Configure the HTTP request pipeline. app.UseExceptionHandler(); - -var summaries = new[] -{ - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" -}; - -app.MapGet("/weatherforecast", () => -{ - var forecast = Enumerable.Range(1, 5).Select(index => - new WeatherForecast - ( - DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - Random.Shared.Next(-20, 55), - summaries[Random.Shared.Next(summaries.Length)] - )) - .ToArray(); - return forecast; -}); - +app.MapEndpoints(); app.MapDefaultEndpoints(); -app.Run(); - -internal record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) -{ - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); -} \ No newline at end of file +app.Run(); \ No newline at end of file diff --git a/services/Directory/FilterLists.Directory.Api/Properties/launchSettings.json b/services/Directory/FilterLists.Directory.Api/Properties/launchSettings.json index 7abf85672f..c3363795da 100644 --- a/services/Directory/FilterLists.Directory.Api/Properties/launchSettings.json +++ b/services/Directory/FilterLists.Directory.Api/Properties/launchSettings.json @@ -5,7 +5,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "launchUrl": "weatherforecast", + "launchUrl": "lists", "applicationUrl": "http://localhost:5444", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -15,11 +15,11 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "launchUrl": "weatherforecast", + "launchUrl": "lists", "applicationUrl": "https://localhost:7490;http://localhost:5444", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } } } -} +} \ No newline at end of file diff --git a/services/Directory/FilterLists.Directory.Application/ConfigurationExtensions.cs b/services/Directory/FilterLists.Directory.Application/ConfigurationExtensions.cs new file mode 100644 index 0000000000..5a45794c04 --- /dev/null +++ b/services/Directory/FilterLists.Directory.Application/ConfigurationExtensions.cs @@ -0,0 +1,17 @@ +using FilterLists.Directory.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace FilterLists.Directory.Application; + +public static class ConfigurationExtensions +{ + public static void AddApplication(this IHostApplicationBuilder builder) + { + builder.AddInfrastructure(); + builder.Services.AddMediatR(cfg => + { + cfg.RegisterServicesFromAssembly(typeof(ConfigurationExtensions).Assembly); + }); + } +} \ No newline at end of file diff --git a/services/Directory/FilterLists.Directory.Application/FilterLists.Directory.Application.csproj b/services/Directory/FilterLists.Directory.Application/FilterLists.Directory.Application.csproj new file mode 100644 index 0000000000..489f452c38 --- /dev/null +++ b/services/Directory/FilterLists.Directory.Application/FilterLists.Directory.Application.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + diff --git a/services/Directory/FilterLists.Directory.Application/Queries/GetListDetails.cs b/services/Directory/FilterLists.Directory.Application/Queries/GetListDetails.cs new file mode 100644 index 0000000000..f9c2a80f02 --- /dev/null +++ b/services/Directory/FilterLists.Directory.Application/Queries/GetListDetails.cs @@ -0,0 +1,253 @@ +using FilterLists.Directory.Infrastructure.Persistence.Queries.Context; +using JetBrains.Annotations; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace FilterLists.Directory.Application.Queries; + +public static class GetListDetails +{ + private static readonly Func> Query = + EF.CompileAsyncQuery((QueryDbContext ctx, int id) => + ctx.FilterLists + .Select(f => new ListDetailsVm + { + Id = f.Id, + Name = f.Name, + Description = f.Description, + LicenseId = f.LicenseId, + SyntaxIds = f.FilterListSyntaxes + .OrderBy(fs => fs.SyntaxId) + .Select(fs => fs.SyntaxId), + LanguageIds = f.FilterListLanguages + .OrderBy(fl => fl.LanguageId) + .Select(fl => fl.LanguageId), + TagIds = f.FilterListTags + .OrderBy(ft => ft.TagId) + .Select(ft => ft.TagId), + ViewUrls = f.ViewUrls + .OrderBy(u => u.SegmentNumber) + .ThenBy(u => u.Primariness) + .Select(u => new ListDetailsVm.ViewUrlVm + { + SegmentNumber = u.SegmentNumber, + Primariness = u.Primariness, + Url = u.Url + }), + HomeUrl = f.HomeUrl, + OnionUrl = f.OnionUrl, + PolicyUrl = f.PolicyUrl, + SubmissionUrl = f.SubmissionUrl, + IssuesUrl = f.IssuesUrl, + ForumUrl = f.ForumUrl, + ChatUrl = f.ChatUrl, + EmailAddress = f.EmailAddress, + DonateUrl = f.DonateUrl, + MaintainerIds = f.FilterListMaintainers + .OrderBy(fm => fm.MaintainerId) + .Select(fm => fm.MaintainerId), + UpstreamFilterListIds = f.UpstreamFilterLists + .OrderBy(ff => ff.UpstreamFilterListId) + .Select(ff => ff.UpstreamFilterListId), + ForkFilterListIds = f.ForkFilterLists + .OrderBy(ff => ff.ForkFilterListId) + .Select(ff => ff.ForkFilterListId), + IncludedInFilterListIds = f.IncludedInFilterLists + .OrderBy(fm => fm.IncludedInFilterListId) + .Select(fm => fm.IncludedInFilterListId), + IncludesFilterListIds = f.IncludesFilterLists + .OrderBy(fm => fm.IncludesFilterListId) + .Select(fm => fm.IncludesFilterListId), + DependencyFilterListIds = f.DependencyFilterLists + .OrderBy(fd => fd.DependencyFilterListId) + .Select(fd => fd.DependencyFilterListId), + DependentFilterListIds = f.DependentFilterLists + .OrderBy(fd => fd.DependentFilterListId) + .Select(fd => fd.DependentFilterListId) + }) + .Single(f => f.Id == id)); + + public sealed record Request(int Id) : IRequest; + + internal sealed class Handler(QueryDbContext ctx) : IRequestHandler + { + public Task Handle(Request request, CancellationToken _) + { + // TODO: handle Single() exceptions + return Query(ctx, request.Id); + } + } + + [PublicAPI] + public record ListDetailsVm + { + /// + /// The identifier. + /// + /// 301 + public int Id { get; init; } + + /// + /// The unique name in title case. + /// + /// EasyList + public required string Name { get; init; } + + /// + /// The brief description in English (preferably quoted from the project). + /// + /// + /// EasyList is the primary filter list that removes most adverts from international web pages, including unwanted + /// frames, images, and objects. It is the most popular list used by many ad blockers and forms the basis of over a + /// dozen combination and supplementary filter lists. + /// + public string? Description { get; init; } + + /// + /// The identifier of the License under which this FilterList is released. + /// + /// 4 + public int LicenseId { get; init; } + + /// + /// The identifiers of the Syntaxes implemented by this FilterList. + /// + /// [ 3 ] + public IEnumerable SyntaxIds { get; init; } = []; + + /// + /// The identifiers of the Languages targeted by this FilterList. + /// + /// [ 37 ] + public IEnumerable LanguageIds { get; init; } = []; + + /// + /// The identifiers of the Tags applied to this FilterList. + /// + /// [ 2 ] + public IEnumerable TagIds { get; init; } = []; + + /// + /// The view URLs. + /// + public IEnumerable ViewUrls { get; init; } = []; + + /// + /// The URL of the home page. + /// + /// https://easylist.to/ + public Uri? HomeUrl { get; init; } + + /// + /// The URL of the Tor / Onion page. + /// + /// null + public Uri? OnionUrl { get; init; } + + /// + /// The URL of the policy/guidelines for the types of rules this FilterList includes. + /// + /// null + public Uri? PolicyUrl { get; init; } + + /// + /// The URL of the submission/contact form for adding rules to this FilterList. + /// + /// null + public Uri? SubmissionUrl { get; init; } + + /// + /// The URL of the GitHub Issues page. + /// + /// https://github.com/easylist/easylist/issues + public Uri? IssuesUrl { get; init; } + + /// + /// The URL of the forum page. + /// + /// https://forums.lanik.us/viewforum.php?f=23 + public Uri? ForumUrl { get; init; } + + /// + /// The URL of the chat room. + /// + /// null + public Uri? ChatUrl { get; init; } + + /// + /// The email address at which the project can be contacted. + /// + /// easylist@protonmail.com + public string? EmailAddress { get; init; } + + /// + /// The URL at which donations to the project can be made. + /// + /// null + public Uri? DonateUrl { get; init; } + + /// + /// The identifiers of the Maintainers of this FilterList. + /// + /// [ 7 ] + public IEnumerable MaintainerIds { get; init; } = []; + + /// + /// The identifiers of the FilterLists from which this FilterList was forked. + /// + /// [] + public IEnumerable UpstreamFilterListIds { get; init; } = []; + + /// + /// The identifiers of the FilterLists that have been forked from this FilterList. + /// + /// [ 166, 565 ] + public IEnumerable ForkFilterListIds { get; init; } = []; + + /// + /// The identifiers of the FilterLists that include this FilterList. + /// + /// [] + public IEnumerable IncludedInFilterListIds { get; init; } = []; + + /// + /// The identifiers of the FilterLists that this FilterList includes. + /// + /// [ 11, 13, 168 ] + public IEnumerable IncludesFilterListIds { get; init; } = []; + + /// + /// The identifiers of the FilterLists that this FilterList depends upon. + /// + /// [] + public IEnumerable DependencyFilterListIds { get; init; } = []; + + /// + /// The identifiers of the FilterLists dependent upon this FilterList. + /// + /// [] + public IEnumerable DependentFilterListIds { get; init; } = []; + + [PublicAPI] + public record ViewUrlVm + { + /// + /// The segment number of the URL for the FilterList (for multi-part lists). + /// + /// 1 + public short SegmentNumber { get; init; } + + /// + /// How primary the URL is for the FilterList segment (1 is original, 2+ is a mirror; unique per SegmentNumber) + /// + /// 1 + public short Primariness { get; init; } + + /// + /// The view URL. + /// + /// https://easylist.to/easylist/easylist.txt + public required Uri Url { get; init; } + } + } +} \ No newline at end of file diff --git a/services/Directory/FilterLists.Directory.Application/Queries/GetLists.cs b/services/Directory/FilterLists.Directory.Application/Queries/GetLists.cs new file mode 100644 index 0000000000..b913b37e0f --- /dev/null +++ b/services/Directory/FilterLists.Directory.Application/Queries/GetLists.cs @@ -0,0 +1,112 @@ +using System.Runtime.CompilerServices; +using FilterLists.Directory.Infrastructure.Persistence.Queries.Context; +using JetBrains.Annotations; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace FilterLists.Directory.Application.Queries; + +public static class GetLists +{ + private static readonly Func> Query = + EF.CompileAsyncQuery((QueryDbContext ctx) => + ctx.FilterLists + .OrderBy(fl => fl.Id) + .Select(f => new ListVm + { + Id = f.Id, + Name = f.Name, + Description = f.Description, + LicenseId = f.LicenseId, + SyntaxIds = f.FilterListSyntaxes + .OrderBy(fs => fs.SyntaxId) + .Select(fs => fs.SyntaxId), + LanguageIds = f.FilterListLanguages + .OrderBy(fl => fl.LanguageId) + .Select(fl => fl.LanguageId), + TagIds = f.FilterListTags + .OrderBy(ft => ft.TagId) + .Select(ft => ft.TagId), + PrimaryViewUrl = f.ViewUrls + .OrderBy(u => u.SegmentNumber) + .ThenBy(u => u.Primariness) + .Select(u => u.Url) + .FirstOrDefault(), + MaintainerIds = f.FilterListMaintainers + .OrderBy(fm => fm.MaintainerId) + .Select(fm => fm.MaintainerId) + }) + ); + + public sealed record Request : IStreamRequest; + + internal sealed class Handler(QueryDbContext ctx) : IStreamRequestHandler + { + public async IAsyncEnumerable Handle(Request request, [EnumeratorCancellation] CancellationToken ct) + { + await foreach (var list in Query(ctx).WithCancellation(ct)) yield return list; + } + } + + [PublicAPI] + public record ListVm + { + /// + /// The identifier. + /// + /// 301 + public int Id { get; init; } + + /// + /// The unique name in title case. + /// + /// EasyList + public required string Name { get; init; } + + /// + /// The brief description in English (preferably quoted from the project). + /// + /// + /// EasyList is the primary filter list that removes most adverts from international web pages, including unwanted + /// frames, images, and objects. It is the most popular list used by many ad blockers and forms the basis of over a + /// dozen combination and supplementary filter lists. + /// + public string? Description { get; init; } + + /// + /// The identifier of the License under which this FilterList is released. + /// + /// 4 + public int LicenseId { get; init; } + + /// + /// The identifiers of the Syntaxes implemented by this FilterList. + /// + /// [ 3 ] + public IEnumerable SyntaxIds { get; init; } = []; + + /// + /// The identifiers of the Languages targeted by this FilterList. + /// + /// [ 37 ] + public IEnumerable LanguageIds { get; init; } = []; + + /// + /// The identifiers of the Tags applied to this FilterList. + /// + /// [ 2 ] + public IEnumerable TagIds { get; init; } = []; + + /// + /// The primary view URL. + /// + /// https://easylist.to/easylist/easylist.txt + public Uri? PrimaryViewUrl { get; init; } + + /// + /// The identifiers of the Maintainers of this FilterList. + /// + /// [ 7 ] + public IEnumerable MaintainerIds { get; init; } = []; + } +} \ No newline at end of file diff --git a/services/Directory/FilterLists.Directory.Infrastructure.MigrationService/Program.cs b/services/Directory/FilterLists.Directory.Infrastructure.MigrationService/Program.cs index 23db377b24..310eafc069 100644 --- a/services/Directory/FilterLists.Directory.Infrastructure.MigrationService/Program.cs +++ b/services/Directory/FilterLists.Directory.Infrastructure.MigrationService/Program.cs @@ -2,7 +2,7 @@ using FilterLists.Directory.Infrastructure.MigrationService; var builder = Host.CreateApplicationBuilder(args); -builder.AddInfrastructureForDbMaintenance(); +builder.AddInfrastructure(); builder.Services.AddHostedService(); var host = builder.Build(); diff --git a/services/Directory/FilterLists.Directory.Infrastructure/ConfigurationExtensions.cs b/services/Directory/FilterLists.Directory.Infrastructure/ConfigurationExtensions.cs index d894067389..efa8258b92 100644 --- a/services/Directory/FilterLists.Directory.Infrastructure/ConfigurationExtensions.cs +++ b/services/Directory/FilterLists.Directory.Infrastructure/ConfigurationExtensions.cs @@ -6,11 +6,14 @@ namespace FilterLists.Directory.Infrastructure; public static class ConfigurationExtensions { - public static void AddInfrastructureForDbMaintenance(this IHostApplicationBuilder builder) + public static void AddInfrastructure(this IHostApplicationBuilder builder) { builder.AddSqlServerDbContext("directorydb", settings => { }, - o => o.UseSqlServer(so => so.MigrationsAssembly("FilterLists.Directory.Infrastructure.Migrations")) - ); + o => + { + o.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); + o.UseSqlServer(so => so.MigrationsAssembly("FilterLists.Directory.Infrastructure.Migrations")); + }); } } \ No newline at end of file diff --git a/services/Directory/FilterLists.Directory.Infrastructure/Persistence/Queries/Context/IQueryContext.cs b/services/Directory/FilterLists.Directory.Infrastructure/Persistence/Queries/Context/IQueryContext.cs deleted file mode 100644 index b5ac7501a8..0000000000 --- a/services/Directory/FilterLists.Directory.Infrastructure/Persistence/Queries/Context/IQueryContext.cs +++ /dev/null @@ -1,14 +0,0 @@ -using FilterLists.Directory.Infrastructure.Persistence.Queries.Entities; - -namespace FilterLists.Directory.Infrastructure.Persistence.Queries.Context; - -public interface IQueryContext -{ - IQueryable FilterLists { get; } - IQueryable Languages { get; } - IQueryable Licenses { get; } - IQueryable Maintainers { get; } - IQueryable Software { get; } - IQueryable Syntaxes { get; } - IQueryable Tags { get; } -} \ No newline at end of file diff --git a/services/Directory/FilterLists.Directory.Infrastructure/Persistence/Queries/Context/QueryContext.cs b/services/Directory/FilterLists.Directory.Infrastructure/Persistence/Queries/Context/QueryContext.cs deleted file mode 100644 index 8155835696..0000000000 --- a/services/Directory/FilterLists.Directory.Infrastructure/Persistence/Queries/Context/QueryContext.cs +++ /dev/null @@ -1,25 +0,0 @@ -using FilterLists.Directory.Infrastructure.Persistence.Queries.Entities; -using Microsoft.EntityFrameworkCore; - -namespace FilterLists.Directory.Infrastructure.Persistence.Queries.Context; - -internal sealed class QueryContext(QueryDbContext dbContext) : IQueryContext, IAsyncDisposable, IDisposable -{ - public ValueTask DisposeAsync() - { - return dbContext.DisposeAsync(); - } - - void IDisposable.Dispose() - { - dbContext.Dispose(); - } - - public IQueryable FilterLists => dbContext.FilterLists.AsNoTracking(); - public IQueryable Languages => dbContext.Languages.AsNoTracking(); - public IQueryable Licenses => dbContext.Licenses.AsNoTracking(); - public IQueryable Maintainers => dbContext.Maintainers.AsNoTracking(); - public IQueryable Software => dbContext.Software.AsNoTracking(); - public IQueryable Syntaxes => dbContext.Syntaxes.AsNoTracking(); - public IQueryable Tags => dbContext.Tags.AsNoTracking(); -} \ No newline at end of file diff --git a/services/Directory/FilterLists.Directory.Infrastructure/Persistence/Queries/Context/QueryDbContext.cs b/services/Directory/FilterLists.Directory.Infrastructure/Persistence/Queries/Context/QueryDbContext.cs index 7d49f6b680..bffc5b84ad 100644 --- a/services/Directory/FilterLists.Directory.Infrastructure/Persistence/Queries/Context/QueryDbContext.cs +++ b/services/Directory/FilterLists.Directory.Infrastructure/Persistence/Queries/Context/QueryDbContext.cs @@ -3,6 +3,7 @@ namespace FilterLists.Directory.Infrastructure.Persistence.Queries.Context; +// TODO: explicitly make more readonly-ish public class QueryDbContext(DbContextOptions options) : DbContext(options) { public DbSet FilterLists => Set(); @@ -13,18 +14,6 @@ public class QueryDbContext(DbContextOptions options) : DbContex public DbSet Syntaxes => Set(); public DbSet Tags => Set(); - public override int SaveChanges(bool acceptAllChangesOnSuccess) - { - throw new InvalidOperationException("This context is read-only."); - } - - public override Task SaveChangesAsync( - bool acceptAllChangesOnSuccess, - CancellationToken cancellationToken = default) - { - throw new InvalidOperationException("This context is read-only."); - } - protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.UseCollation("Latin1_General_100_CI_AS_SC"); diff --git a/services/FilterLists.AppHost/Program.cs b/services/FilterLists.AppHost/Program.cs index 0c555c25bc..45bd97fbfd 100644 --- a/services/FilterLists.AppHost/Program.cs +++ b/services/FilterLists.AppHost/Program.cs @@ -18,6 +18,7 @@ var directoryApi = builder.AddProject("directoryapi") .WithReference(directoryDb) - .WithReference(appInsights); + .WithReference(appInsights) + .WithExternalHttpEndpoints(); builder.Build().Run(); \ No newline at end of file diff --git a/services/FilterLists.sln b/services/FilterLists.sln index e1ae5da4ac..bf2424d2d6 100644 --- a/services/FilterLists.sln +++ b/services/FilterLists.sln @@ -18,6 +18,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FilterLists.Directory.Infra EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FilterLists.Directory.Infrastructure", "Directory\FilterLists.Directory.Infrastructure\FilterLists.Directory.Infrastructure.csproj", "{02B4FF36-8012-42CD-BE3E-92475C091A4C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FilterLists.Directory.Application", "Directory\FilterLists.Directory.Application\FilterLists.Directory.Application.csproj", "{0D4420C3-283B-4C0A-88B4-D39E2033B80F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -52,6 +54,10 @@ Global {02B4FF36-8012-42CD-BE3E-92475C091A4C}.Debug|Any CPU.Build.0 = Debug|Any CPU {02B4FF36-8012-42CD-BE3E-92475C091A4C}.Release|Any CPU.ActiveCfg = Release|Any CPU {02B4FF36-8012-42CD-BE3E-92475C091A4C}.Release|Any CPU.Build.0 = Release|Any CPU + {0D4420C3-283B-4C0A-88B4-D39E2033B80F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0D4420C3-283B-4C0A-88B4-D39E2033B80F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0D4420C3-283B-4C0A-88B4-D39E2033B80F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0D4420C3-283B-4C0A-88B4-D39E2033B80F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -64,5 +70,6 @@ Global {5DD434A8-D394-4178-9DF3-669EC85EA229} = {C4F6197D-9546-480E-AE92-1FC264D8E88F} {37170729-4BC9-412F-9ADF-97592A677E90} = {C4F6197D-9546-480E-AE92-1FC264D8E88F} {02B4FF36-8012-42CD-BE3E-92475C091A4C} = {C4F6197D-9546-480E-AE92-1FC264D8E88F} + {0D4420C3-283B-4C0A-88B4-D39E2033B80F} = {C4F6197D-9546-480E-AE92-1FC264D8E88F} EndGlobalSection EndGlobal