From 1aa901bfe0895947cee8bad824dfd066b1caf34f Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Sun, 9 Jul 2017 19:46:48 +0200 Subject: [PATCH] First working version. --- Squidex.sln | 2 +- .../Schemas/Field_Generic.cs | 2 +- .../Assets/MongoAssetRepository.cs | 2 +- .../Contents/MongoContentRepository.cs | 8 +- .../Contents/GraphQL/CachingGraphQLInvoker.cs | 104 ++++++++ .../Contents/GraphQL/GraphQLModel.cs | 187 ++++++++++++++ .../Contents/GraphQL/GraphQLQuery.cs | 21 ++ .../GraphQL}/IGraphQLContext.cs | 10 +- .../Contents/GraphQL/IGraphQLInvoker.cs | 18 ++ .../Contents/GraphQL/QueryContext.cs | 183 ++++++++++++++ .../Contents/GraphQL/Types/AssetGraphType.cs | 62 +++++ .../GraphQL/Types/ContentDataGraphType.cs | 69 +++++ .../GraphQL/Types}/ContentGraphType.cs | 23 +- .../GraphQL/Types/ContentQueryType.cs | 192 ++++++++++++++ .../Repositories/IContentRepository.cs | 6 +- .../GraphQl/ContentDataGraphType.cs | 37 --- .../GraphQl/ContentFieldGraphType.cs | 36 --- .../GraphQl/GraphQLContext.cs | 94 ------- src/Squidex/Config/Domain/ReadModule.cs | 6 + .../Config/Domain/StoreMongoDbModule.cs | 2 + .../ContentApi/ContentsController.cs | 37 ++- src/Squidex/app/app.routes.ts | 4 + .../app/features/api/api-area.component.html | 33 +++ .../app/features/api/api-area.component.scss | 12 + .../app/features/api/api-area.component.ts | 26 ++ src/Squidex/app/features/api/declarations.ts | 10 + src/Squidex/app/features/api/index.ts | 9 + src/Squidex/app/features/api/module.ts | 50 ++++ .../pages/graphql/graphql-page.component.html | 5 + .../pages/graphql/graphql-page.component.scss | 12 + .../pages/graphql/graphql-page.component.ts | 55 ++++ .../angular/panel-container.directive.ts | 72 ++++-- .../app/framework/angular/panel.component.ts | 27 +- src/Squidex/app/framework/declarations.ts | 1 - src/Squidex/app/framework/module.ts | 2 - .../framework/services/panel.service.spec.ts | 84 ------- .../app/framework/services/panel.service.ts | 51 ---- .../shell/pages/app/left-menu.component.html | 5 + src/Squidex/app/theme/icomoon/demo.html | 44 +--- .../app/theme/icomoon/fonts/icomoon.eot | Bin 17676 -> 17496 bytes .../app/theme/icomoon/fonts/icomoon.svg | 4 +- .../app/theme/icomoon/fonts/icomoon.ttf | Bin 17512 -> 17332 bytes .../app/theme/icomoon/fonts/icomoon.woff | Bin 17588 -> 17408 bytes src/Squidex/app/theme/icomoon/selection.json | 237 +++++++----------- src/Squidex/app/theme/icomoon/style.css | 20 +- src/Squidex/app/theme/theme.scss | 6 +- src/Squidex/package.json | 7 +- 47 files changed, 1302 insertions(+), 575 deletions(-) create mode 100644 src/Squidex.Domain.Apps.Read/Contents/GraphQL/CachingGraphQLInvoker.cs create mode 100644 src/Squidex.Domain.Apps.Read/Contents/GraphQL/GraphQLModel.cs create mode 100644 src/Squidex.Domain.Apps.Read/Contents/GraphQL/GraphQLQuery.cs rename src/Squidex.Domain.Apps.Read/{GraphQl => Contents/GraphQL}/IGraphQLContext.cs (81%) create mode 100644 src/Squidex.Domain.Apps.Read/Contents/GraphQL/IGraphQLInvoker.cs create mode 100644 src/Squidex.Domain.Apps.Read/Contents/GraphQL/QueryContext.cs create mode 100644 src/Squidex.Domain.Apps.Read/Contents/GraphQL/Types/AssetGraphType.cs create mode 100644 src/Squidex.Domain.Apps.Read/Contents/GraphQL/Types/ContentDataGraphType.cs rename src/Squidex.Domain.Apps.Read/{GraphQl => Contents/GraphQL/Types}/ContentGraphType.cs (69%) create mode 100644 src/Squidex.Domain.Apps.Read/Contents/GraphQL/Types/ContentQueryType.cs delete mode 100644 src/Squidex.Domain.Apps.Read/GraphQl/ContentDataGraphType.cs delete mode 100644 src/Squidex.Domain.Apps.Read/GraphQl/ContentFieldGraphType.cs delete mode 100644 src/Squidex.Domain.Apps.Read/GraphQl/GraphQLContext.cs create mode 100644 src/Squidex/app/features/api/api-area.component.html create mode 100644 src/Squidex/app/features/api/api-area.component.scss create mode 100644 src/Squidex/app/features/api/api-area.component.ts create mode 100644 src/Squidex/app/features/api/declarations.ts create mode 100644 src/Squidex/app/features/api/index.ts create mode 100644 src/Squidex/app/features/api/module.ts create mode 100644 src/Squidex/app/features/api/pages/graphql/graphql-page.component.html create mode 100644 src/Squidex/app/features/api/pages/graphql/graphql-page.component.scss create mode 100644 src/Squidex/app/features/api/pages/graphql/graphql-page.component.ts delete mode 100644 src/Squidex/app/framework/services/panel.service.spec.ts delete mode 100644 src/Squidex/app/framework/services/panel.service.ts diff --git a/Squidex.sln b/Squidex.sln index d365dc2c8..8ba487e78 100644 --- a/Squidex.sln +++ b/Squidex.sln @@ -54,7 +54,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Squidex.Domain.Users", "src EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Squidex.Domain.Users.MongoDb", "src\Squidex.Domain.Users.MongoDb\Squidex.Domain.Users.MongoDb.csproj", "{27CF800D-890F-4882-BF05-44EC3233537D}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Squidex.Domain.Users.Tests", "tests\Squidex.Domain.Users.Tests\Squidex.Domain.Users.Tests.csproj", "{42184546-E3CB-4D4F-9495-43979B9C63B9}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Squidex.Domain.Users.Tests", "tests\Squidex.Domain.Users.Tests\Squidex.Domain.Users.Tests.csproj", "{42184546-E3CB-4D4F-9495-43979B9C63B9}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/src/Squidex.Domain.Apps.Core/Schemas/Field_Generic.cs b/src/Squidex.Domain.Apps.Core/Schemas/Field_Generic.cs index 0ba34c117..a227a7691 100644 --- a/src/Squidex.Domain.Apps.Core/Schemas/Field_Generic.cs +++ b/src/Squidex.Domain.Apps.Core/Schemas/Field_Generic.cs @@ -15,7 +15,7 @@ namespace Squidex.Domain.Apps.Core.Schemas { private T properties; - protected T Properties + public T Properties { get { return properties; } } diff --git a/src/Squidex.Domain.Apps.Read.MongoDb/Assets/MongoAssetRepository.cs b/src/Squidex.Domain.Apps.Read.MongoDb/Assets/MongoAssetRepository.cs index 02198b288..b8525b493 100644 --- a/src/Squidex.Domain.Apps.Read.MongoDb/Assets/MongoAssetRepository.cs +++ b/src/Squidex.Domain.Apps.Read.MongoDb/Assets/MongoAssetRepository.cs @@ -44,7 +44,7 @@ namespace Squidex.Domain.Apps.Read.MongoDb.Assets await Collection.Find(x => assetIds.Contains(x.Id) && x.AppId == appId).Project(Project.Include(x => x.Id)) .ToListAsync(); - return assetIds.Except(assetEntities.Select(x => x["Id"].AsGuid)).ToList(); + return assetIds.Except(assetEntities.Select(x => Guid.Parse(x["_id"].AsString))).ToList(); } public async Task> QueryAsync(Guid appId, HashSet mimeTypes = null, HashSet ids = null, string query = null, int take = 10, int skip = 0) diff --git a/src/Squidex.Domain.Apps.Read.MongoDb/Contents/MongoContentRepository.cs b/src/Squidex.Domain.Apps.Read.MongoDb/Contents/MongoContentRepository.cs index 2f06a7928..9c711ddfb 100644 --- a/src/Squidex.Domain.Apps.Read.MongoDb/Contents/MongoContentRepository.cs +++ b/src/Squidex.Domain.Apps.Read.MongoDb/Contents/MongoContentRepository.cs @@ -75,7 +75,7 @@ namespace Squidex.Domain.Apps.Read.MongoDb.Contents this.modelBuilder = modelBuilder; } - public async Task> QueryAsync(Guid schemaId, bool nonPublished, HashSet ids, string odataQuery, IAppEntity appEntity) + public async Task> QueryAsync(IAppEntity appEntity, Guid schemaId, bool nonPublished, HashSet ids, string odataQuery) { var contentEntities = (List)null; @@ -121,7 +121,7 @@ namespace Squidex.Domain.Apps.Read.MongoDb.Contents return contentEntities; } - public async Task CountAsync(Guid schemaId, bool nonPublished, HashSet ids, string odataQuery, IAppEntity appEntity) + public async Task CountAsync(IAppEntity appEntity, Guid schemaId, bool nonPublished, HashSet ids, string odataQuery) { var contentsCount = 0L; @@ -166,10 +166,10 @@ namespace Squidex.Domain.Apps.Read.MongoDb.Contents .ToListAsync(); }); - return contentIds.Except(contentEntities.Select(x => x["Id"].AsGuid)).ToList(); + return contentIds.Except(contentEntities.Select(x => Guid.Parse(x["_id"].AsString))).ToList(); } - public async Task FindContentAsync(Guid schemaId, Guid id, IAppEntity appEntity) + public async Task FindContentAsync(IAppEntity appEntity, Guid schemaId, Guid id) { var contentEntity = (MongoContentEntity)null; diff --git a/src/Squidex.Domain.Apps.Read/Contents/GraphQL/CachingGraphQLInvoker.cs b/src/Squidex.Domain.Apps.Read/Contents/GraphQL/CachingGraphQLInvoker.cs new file mode 100644 index 000000000..82f58575f --- /dev/null +++ b/src/Squidex.Domain.Apps.Read/Contents/GraphQL/CachingGraphQLInvoker.cs @@ -0,0 +1,104 @@ +// ========================================================================== +// CachedGraphQLInvoker.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Linq; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Read.Apps; +using Squidex.Domain.Apps.Read.Assets.Repositories; +using Squidex.Domain.Apps.Read.Contents.Repositories; +using Squidex.Domain.Apps.Read.Schemas.Repositories; +using Squidex.Infrastructure; +using Squidex.Domain.Apps.Read.Utils; +using Microsoft.Extensions.Caching.Memory; +using Squidex.Infrastructure.CQRS.Events; +using System; +using Squidex.Infrastructure.Tasks; +using Squidex.Domain.Apps.Events; + +// ReSharper disable InvertIf + +namespace Squidex.Domain.Apps.Read.Contents.GraphQL +{ + public sealed class CachingGraphQLInvoker : CachingProviderBase, IGraphQLInvoker, IEventConsumer + { + private readonly IContentRepository contentRepository; + private readonly IAssetRepository assetRepository; + private readonly ISchemaRepository schemaRepository; + + public string Name + { + get { return GetType().Name; } + } + + public string EventsFilter + { + get { return "^(schema-)|(apps-)"; } + } + + public CachingGraphQLInvoker(IMemoryCache cache, ISchemaRepository schemaRepository, IAssetRepository assetRepository, IContentRepository contentRepository) + : base(cache) + { + Guard.NotNull(schemaRepository, nameof(schemaRepository)); + Guard.NotNull(assetRepository, nameof(assetRepository)); + Guard.NotNull(contentRepository, nameof(contentRepository)); + + this.schemaRepository = schemaRepository; + this.assetRepository = assetRepository; + this.contentRepository = contentRepository; + } + + public Task ClearAsync() + { + return TaskHelper.Done; + } + + public Task On(Envelope @event) + { + if (@event.Payload is AppEvent appEvent) + { + Cache.Remove(CreateCacheKey(appEvent.AppId.Id)); + } + + return TaskHelper.Done; + } + + public async Task QueryAsync(IAppEntity appEntity, GraphQLQuery query) + { + Guard.NotNull(appEntity, nameof(appEntity)); + Guard.NotNull(query, nameof(query)); + + var modelContext = await GetModelAsync(appEntity); + var queryContext = new QueryContext(appEntity, contentRepository, assetRepository); + + return await modelContext.ExecuteAsync(queryContext, query); + } + + private async Task GetModelAsync(IAppEntity appEntity) + { + var cacheKey = CreateCacheKey(appEntity.Id); + + var modelContext = Cache.Get(cacheKey); + + if (modelContext == null) + { + var schemas = await schemaRepository.QueryAllAsync(appEntity.Id); + + modelContext = new GraphQLModel(appEntity, schemas.Where(x => x.IsPublished)); + + Cache.Set(cacheKey, modelContext); + } + + return modelContext; + } + + private static object CreateCacheKey(Guid appId) + { + return $"GraphQLModel_{appId}"; + } + } +} diff --git a/src/Squidex.Domain.Apps.Read/Contents/GraphQL/GraphQLModel.cs b/src/Squidex.Domain.Apps.Read/Contents/GraphQL/GraphQLModel.cs new file mode 100644 index 000000000..b7046e38f --- /dev/null +++ b/src/Squidex.Domain.Apps.Read/Contents/GraphQL/GraphQLModel.cs @@ -0,0 +1,187 @@ +// ========================================================================== +// GraphQLContext.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using GraphQL; +using GraphQL.Resolvers; +using GraphQL.Types; +using Squidex.Domain.Apps.Core; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Read.Apps; +using Squidex.Domain.Apps.Read.Contents.GraphQL.Types; +using Squidex.Domain.Apps.Read.Schemas; +using Squidex.Infrastructure; +using GraphQLSchema = GraphQL.Types.Schema; + +// ReSharper disable InvertIf +// ReSharper disable ParameterHidesMember + +namespace Squidex.Domain.Apps.Read.Contents.GraphQL +{ + public sealed class GraphQLModel : IGraphQLContext + { + private readonly Dictionary> fieldInfos; + private readonly Dictionary schemaTypes = new Dictionary(); + private readonly Dictionary schemas; + private readonly PartitionResolver partitionResolver; + private readonly IGraphType assetType = new AssetGraphType(); + private readonly GraphQLSchema graphQLSchema; + + public GraphQLModel(IAppEntity appEntity, IEnumerable schemas) + { + partitionResolver = appEntity.PartitionResolver; + + var defaultResolver = + new FuncFieldResolver(c => c.Source.GetOrDefault(c.FieldName)); + + IGraphType assetListType = new ListGraphType(new NonNullGraphType(assetType)); + + var stringInfos = + (new StringGraphType(), defaultResolver); + + var booleanInfos = + (new BooleanGraphType(), defaultResolver); + + var numberInfos = + (new FloatGraphType(), defaultResolver); + + var dateTimeInfos = + (new DateGraphType(), defaultResolver); + + var jsonInfos = + (new ObjectGraphType(), defaultResolver); + + var geolocationInfos = + (new ObjectGraphType(), defaultResolver); + + fieldInfos = new Dictionary> + { + { + typeof(StringField), + field => stringInfos + }, + { + typeof(BooleanField), + field => booleanInfos + }, + { + typeof(NumberField), + field => numberInfos + }, + { + typeof(DateTimeField), + field => dateTimeInfos + }, + { + typeof(JsonField), + field => jsonInfos + }, + { + typeof(GeolocationField), + field => geolocationInfos + }, + { + typeof(AssetsField), + field => + { + var resolver = new FuncFieldResolver(c => + { + var context = (QueryContext)c.UserContext; + var contentIds = c.Source.GetOrDefault(c.FieldName); + + return context.GetReferencedAssets(contentIds); + }); + + return (assetListType, resolver); + } + }, + { + typeof(ReferencesField), + field => + { + var schemaId = ((ReferencesField)field).Properties.SchemaId; + var schemaType = GetSchemaType(schemaId); + + if (schemaType == null) + { + return (null, null); + } + + var resolver = new FuncFieldResolver(c => + { + var context = (QueryContext)c.UserContext; + var contentIds = c.Source.GetOrDefault(c.FieldName); + + return context.GetReferencedContents(schemaId, contentIds); + }); + + var schemaFieldType = new ListGraphType(new NonNullGraphType(GetSchemaType(schemaId))); + + return (schemaFieldType, resolver); + } + } + }; + + this.schemas = schemas.ToDictionary(x => x.Id); + + graphQLSchema = new GraphQLSchema { Query = new ContentQueryType(this, this.schemas.Values) }; + } + + public async Task ExecuteAsync(QueryContext context, GraphQLQuery query) + { + Guard.NotNull(context, nameof(context)); + + var result = await new DocumentExecuter().ExecuteAsync(options => + { + options.Query = query.Query; + options.Schema = graphQLSchema; + options.Inputs = query.Variables?.ToInputs() ?? new Inputs(); + options.UserContext = context; + options.OperationName = query.OperationName; + }).ConfigureAwait(false); + + if (result.Errors != null && result.Errors.Count > 0) + { + var errors = result.Errors.Select(x => new ValidationError(x.Message)).ToArray(); + + throw new ValidationException("Failed to execute GraphQL query.", errors); + } + + return result; + } + + public IFieldPartitioning ResolvePartition(Partitioning key) + { + return partitionResolver(key); + } + + public IGraphType GetAssetType() + { + return assetType; + } + + public (IGraphType ResolveType, IFieldResolver Resolver) GetGraphType(Field field) + { + return fieldInfos[field.GetType()](field); + } + + public IGraphType GetSchemaType(Guid schemaId) + { + return schemaTypes.GetOrAdd(schemaId, k => + { + var schemaEntity = schemas.GetOrDefault(k); + + return schemaEntity != null ? new ContentGraphType(schemaEntity.Schema, this) : null; + }); + } + } +} diff --git a/src/Squidex.Domain.Apps.Read/Contents/GraphQL/GraphQLQuery.cs b/src/Squidex.Domain.Apps.Read/Contents/GraphQL/GraphQLQuery.cs new file mode 100644 index 000000000..d8ee25be8 --- /dev/null +++ b/src/Squidex.Domain.Apps.Read/Contents/GraphQL/GraphQLQuery.cs @@ -0,0 +1,21 @@ +// ========================================================================== +// GraphQLQuery.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Domain.Apps.Read.Contents.GraphQL +{ + public class GraphQLQuery + { + public string OperationName { get; set; } + + public string NamedQuery { get; set; } + + public string Query { get; set; } + + public string Variables { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Read/GraphQl/IGraphQLContext.cs b/src/Squidex.Domain.Apps.Read/Contents/GraphQL/IGraphQLContext.cs similarity index 81% rename from src/Squidex.Domain.Apps.Read/GraphQl/IGraphQLContext.cs rename to src/Squidex.Domain.Apps.Read/Contents/GraphQL/IGraphQLContext.cs index 6d7faa1c2..955ebca91 100644 --- a/src/Squidex.Domain.Apps.Read/GraphQl/IGraphQLContext.cs +++ b/src/Squidex.Domain.Apps.Read/Contents/GraphQL/IGraphQLContext.cs @@ -6,20 +6,22 @@ // All rights reserved. // ========================================================================== +using System; using GraphQL.Resolvers; using GraphQL.Types; using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core.Schemas; -using Schema = Squidex.Domain.Apps.Core.Schemas.Schema; -namespace Squidex.Domain.Apps.Read.GraphQl +namespace Squidex.Domain.Apps.Read.Contents.GraphQL { public interface IGraphQLContext { - IGraphType GetSchemaListType(Schema schema); - IFieldPartitioning ResolvePartition(Partitioning key); + IGraphType GetAssetType(); + + IGraphType GetSchemaType(Guid schemaId); + (IGraphType ResolveType, IFieldResolver Resolver) GetGraphType(Field field); } } diff --git a/src/Squidex.Domain.Apps.Read/Contents/GraphQL/IGraphQLInvoker.cs b/src/Squidex.Domain.Apps.Read/Contents/GraphQL/IGraphQLInvoker.cs new file mode 100644 index 000000000..47bb9eab6 --- /dev/null +++ b/src/Squidex.Domain.Apps.Read/Contents/GraphQL/IGraphQLInvoker.cs @@ -0,0 +1,18 @@ +// ========================================================================== +// IGraphQLInvoker.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Threading.Tasks; +using Squidex.Domain.Apps.Read.Apps; + +namespace Squidex.Domain.Apps.Read.Contents.GraphQL +{ + public interface IGraphQLInvoker + { + Task QueryAsync(IAppEntity appEntity, GraphQLQuery query); + } +} diff --git a/src/Squidex.Domain.Apps.Read/Contents/GraphQL/QueryContext.cs b/src/Squidex.Domain.Apps.Read/Contents/GraphQL/QueryContext.cs new file mode 100644 index 000000000..be29037c3 --- /dev/null +++ b/src/Squidex.Domain.Apps.Read/Contents/GraphQL/QueryContext.cs @@ -0,0 +1,183 @@ +// ========================================================================== +// QueryContext.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Collections.Generic; +using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Read.Contents.Repositories; +using Squidex.Infrastructure; +using System.Collections.Concurrent; +using System.Linq; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Read.Apps; +using Squidex.Domain.Apps.Read.Assets; +using Squidex.Domain.Apps.Read.Assets.Repositories; + +// ReSharper disable InvertIf + +namespace Squidex.Domain.Apps.Read.Contents.GraphQL +{ + public sealed class QueryContext + { + private readonly ConcurrentDictionary cachedContents = new ConcurrentDictionary(); + private readonly ConcurrentDictionary cachedAssets = new ConcurrentDictionary(); + private readonly IContentRepository contentRepository; + private readonly IAssetRepository assetRepository; + private readonly IAppEntity appEntity; + + public QueryContext(IAppEntity appEntity, IContentRepository contentRepository, IAssetRepository assetRepository) + { + Guard.NotNull(contentRepository, nameof(contentRepository)); + Guard.NotNull(assetRepository, nameof(assetRepository)); + Guard.NotNull(appEntity, nameof(appEntity)); + + this.contentRepository = contentRepository; + this.assetRepository = assetRepository; + + this.appEntity = appEntity; + } + + public async Task FindAssetAsync(Guid id) + { + var asset = cachedAssets.GetOrDefault(id); + + if (asset == null) + { + asset = await assetRepository.FindAssetAsync(id); + + if (asset != null) + { + cachedAssets[asset.Id] = asset; + } + } + + return asset; + } + + public async Task FindContentAsync(Guid schemaId, Guid id) + { + var content = cachedContents.GetOrDefault(id); + + if (content == null) + { + content = await contentRepository.FindContentAsync(appEntity, schemaId, id); + + if (content != null) + { + cachedContents[content.Id] = content; + } + } + + return content; + } + + public async Task> QueryAssetsAsync(string query, int skip = 0, int take = 10) + { + var assets = await assetRepository.QueryAsync(appEntity.Id, null, null, query, take, skip); + + foreach (var asset in assets) + { + cachedAssets[asset.Id] = asset; + } + + return assets; + } + + public async Task> QueryContentsAsync(Guid schemaId, string query) + { + var contents = await contentRepository.QueryAsync(appEntity, schemaId, false, null, query); + + foreach (var content in contents) + { + cachedContents[content.Id] = content; + } + + return contents; + } + + public List GetReferencedAssets(JToken value) + { + var ids = ParseIds(value); + + return GetReferencedAssets(ids); + } + + public List GetReferencedAssets(ICollection ids) + { + Guard.NotNull(ids, nameof(ids)); + + var notLoadedAssets = new HashSet(ids.Where(id => !cachedAssets.ContainsKey(id))); + + if (notLoadedAssets.Count > 0) + { + Task.Run(async () => + { + var assets = await assetRepository.QueryAsync(appEntity.Id, null, notLoadedAssets, string.Empty, int.MaxValue).ConfigureAwait(false); + + foreach (var asset in assets) + { + cachedAssets[asset.Id] = asset; + } + }).Wait(); + } + + return ids.Select(id => cachedAssets.GetOrDefault(id)).Where(x => x != null).ToList(); + } + + public List GetReferencedContents(Guid schemaId, JToken value) + { + var ids = ParseIds(value); + + return GetReferencedContents(schemaId, ids); + } + + public List GetReferencedContents(Guid schemaId, ICollection ids) + { + Guard.NotNull(ids, nameof(ids)); + + var notLoadedContents = new HashSet(ids.Where(id => !cachedContents.ContainsKey(id))); + + if (notLoadedContents.Count > 0) + { + Task.Run(async () => + { + var contents = await contentRepository.QueryAsync(appEntity, schemaId, false, notLoadedContents, string.Empty).ConfigureAwait(false); + + foreach (var content in contents) + { + cachedContents[content.Id] = content; + } + }).Wait(); + } + + return ids.Select(id => cachedContents.GetOrDefault(id)).Where(x => x != null).ToList(); + } + + private static ICollection ParseIds(JToken value) + { + try + { + var result = new List(); + + if (value is JArray) + { + foreach (var id in value) + { + result.Add(Guid.Parse(id.ToString())); + } + } + + return result; + } + catch + { + return new List(); + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Read/Contents/GraphQL/Types/AssetGraphType.cs b/src/Squidex.Domain.Apps.Read/Contents/GraphQL/Types/AssetGraphType.cs new file mode 100644 index 000000000..ae49d1f8e --- /dev/null +++ b/src/Squidex.Domain.Apps.Read/Contents/GraphQL/Types/AssetGraphType.cs @@ -0,0 +1,62 @@ +// ========================================================================== +// AssetGraphType.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using GraphQL.Types; +using Squidex.Domain.Apps.Read.Assets; + +namespace Squidex.Domain.Apps.Read.Contents.GraphQL.Types +{ + public sealed class AssetGraphType : ObjectGraphType + { + public AssetGraphType() + { + Name = "AssetDto"; + + Field("id", x => x.Id.ToString()) + .Description("The id of the asset."); + + Field("version", x => x.Version) + .Description("The version of the asset."); + + Field("created", x => x.Created.ToDateTimeUtc()) + .Description("The date and time when the asset has been created."); + + Field("createdBy", x => x.CreatedBy.ToString()) + .Description("The user that has created the asset."); + + Field("lastModified", x => x.LastModified.ToDateTimeUtc()) + .Description("The date and time when the asset has been modified last."); + + Field("lastModifiedBy", x => x.LastModifiedBy.ToString()) + .Description("The user that has updated the asset last."); + + Field("mimeType", x => x.MimeType) + .Description("The mime type."); + + Field("fileName", x => x.FileName) + .Description("The file name."); + + Field("fileSize", x => x.FileSize) + .Description("The size of the file in bytes."); + + Field("fileVersion", x => x.FileVersion) + .Description("The version of the file."); + + Field("isImage", x => x.IsImage) + .Description("Determines of the created file is an image."); + + Field("pixelWidth", x => x.PixelWidth, true) + .Description("The width of the image in pixels if the asset is an image."); + + Field("pixelHeight", x => x.PixelHeight, true) + .Description("The height of the image in pixels if the asset is an image."); + + Description = "An asset"; + } + } +} diff --git a/src/Squidex.Domain.Apps.Read/Contents/GraphQL/Types/ContentDataGraphType.cs b/src/Squidex.Domain.Apps.Read/Contents/GraphQL/Types/ContentDataGraphType.cs new file mode 100644 index 000000000..cc345b87b --- /dev/null +++ b/src/Squidex.Domain.Apps.Read/Contents/GraphQL/Types/ContentDataGraphType.cs @@ -0,0 +1,69 @@ +// ========================================================================== +// ContentDataGraphType.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using GraphQL.Resolvers; +using GraphQL.Types; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Infrastructure; +using Schema = Squidex.Domain.Apps.Core.Schemas.Schema; + +// ReSharper disable InvertIf + +namespace Squidex.Domain.Apps.Read.Contents.GraphQL.Types +{ + public sealed class ContentDataGraphType : ObjectGraphType + { + private static readonly IFieldResolver FieldResolver = + new FuncFieldResolver(c => c.Source.GetOrDefault(c.FieldName)); + + public ContentDataGraphType(Schema schema, IGraphQLContext graphQLContext) + { + var schemaName = schema.Properties.Label.WithFallback(schema.Name); + + Name = $"{schema.Name.ToPascalCase()}DataDto"; + + foreach (var field in schema.Fields) + { + var fieldInfo = graphQLContext.GetGraphType(field); + + if (fieldInfo.ResolveType != null) + { + var fieldName = field.RawProperties.Label.WithFallback(field.Name); + var fieldGraphType = new ObjectGraphType + { + Name = $"{schema.Name.ToPascalCase()}Data{field.Name.ToPascalCase()}Dto" + }; + + var partition = graphQLContext.ResolvePartition(field.Paritioning); + + foreach (var partitionItem in partition) + { + fieldGraphType.AddField(new FieldType + { + Name = partitionItem.Key, + Resolver = fieldInfo.Resolver, + ResolvedType = fieldInfo.ResolveType, + Description = field.RawProperties.Hints + }); + } + + fieldGraphType.Description = $"The structure of the {fieldName} of a {schemaName} content type."; + + AddField(new FieldType + { + Name = field.Name, + Resolver = FieldResolver, + ResolvedType = fieldGraphType + }); + } + } + + Description = $"The structure of a {schemaName} content type."; + } + } +} diff --git a/src/Squidex.Domain.Apps.Read/GraphQl/ContentGraphType.cs b/src/Squidex.Domain.Apps.Read/Contents/GraphQL/Types/ContentGraphType.cs similarity index 69% rename from src/Squidex.Domain.Apps.Read/GraphQl/ContentGraphType.cs rename to src/Squidex.Domain.Apps.Read/Contents/GraphQL/Types/ContentGraphType.cs index b35cfe045..a21cab2e2 100644 --- a/src/Squidex.Domain.Apps.Read/GraphQl/ContentGraphType.cs +++ b/src/Squidex.Domain.Apps.Read/Contents/GraphQL/Types/ContentGraphType.cs @@ -9,46 +9,49 @@ using GraphQL.Resolvers; using GraphQL.Types; using Squidex.Domain.Apps.Core.Contents; -using Squidex.Domain.Apps.Read.Contents; using Squidex.Infrastructure; using Schema = Squidex.Domain.Apps.Core.Schemas.Schema; -namespace Squidex.Domain.Apps.Read.GraphQl +namespace Squidex.Domain.Apps.Read.Contents.GraphQL.Types { public sealed class ContentGraphType : ObjectGraphType { private static readonly IFieldResolver DataResolver = new FuncFieldResolver(c => c.Source.Data); - public ContentGraphType(Schema schema, IGraphQLContext context) + public ContentGraphType(Schema schema, IGraphQLContext graphQLContext) { var schemaName = schema.Properties.Label.WithFallback(schema.Name); - Field("id", x => x.Id) + Name = $"{schema.Name.ToPascalCase()}Dto"; + + Field("id", x => x.Id.ToString()) .Description($"The id of the {schemaName} content."); Field("version", x => x.Version) .Description($"The version of the {schemaName} content."); - Field("created", x => x.Created) + Field("created", x => x.Created.ToDateTimeUtc()) .Description($"The date and time when the {schemaName} content has been created."); Field("createdBy", x => x.CreatedBy.ToString()) .Description($"The user that has created the {schemaName} content."); - Field("lastModified", x => x.LastModified.ToString()) + Field("lastModified", x => x.LastModified.ToDateTimeUtc()) .Description($"The date and time when the {schemaName} content has been modified last."); - Field("lastModified", x => x.LastModified.ToString()) + Field("lastModifiedBy", x => x.LastModifiedBy.ToString()) .Description($"The user that has updated the {schemaName} content last."); - + AddField(new FieldType { Name = "data", Resolver = DataResolver, - ResolvedType = new SchemaDataGraphType(schema, context), - Description = $"The version of the {schemaName} content." + ResolvedType = new NonNullGraphType(new ContentDataGraphType(schema, graphQLContext)), + Description = $"The data of the {schemaName} content." }); + + Description = $"The structure of a {schemaName} content type."; } } } diff --git a/src/Squidex.Domain.Apps.Read/Contents/GraphQL/Types/ContentQueryType.cs b/src/Squidex.Domain.Apps.Read/Contents/GraphQL/Types/ContentQueryType.cs new file mode 100644 index 000000000..0189b9a93 --- /dev/null +++ b/src/Squidex.Domain.Apps.Read/Contents/GraphQL/Types/ContentQueryType.cs @@ -0,0 +1,192 @@ +// ========================================================================== +// GraphModelType.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using GraphQL.Resolvers; +using GraphQL.Types; +using Squidex.Domain.Apps.Read.Schemas; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Read.Contents.GraphQL.Types +{ + public sealed class ContentQueryType : ObjectGraphType + { + public ContentQueryType(IGraphQLContext graphQLContext, IEnumerable schemaEntities) + { + AddAssetFind(graphQLContext); + AddAssetsQuery(graphQLContext); + + foreach (var schemaEntity in schemaEntities) + { + var schemaName = schemaEntity.Schema.Properties.Label.WithFallback(schemaEntity.Schema.Name); + var schemaType = new ContentGraphType(schemaEntity.Schema, graphQLContext); + + AddContentFind(schemaEntity, schemaName, schemaType); + AddContentQuery(schemaEntity, schemaType, schemaName); + } + + Description = "The app queries."; + } + + private void AddAssetFind(IGraphQLContext graphQLContext) + { + AddField(new FieldType + { + Name = "findAsset", + Arguments = new QueryArguments + { + new QueryArgument(typeof(StringGraphType)) + { + Name = "id", + Description = "The id of the asset.", + DefaultValue = string.Empty + } + }, + ResolvedType = graphQLContext.GetAssetType(), + Resolver = new FuncFieldResolver(c => + { + var context = (QueryContext)c.UserContext; + var contentId = Guid.Parse(c.GetArgument("id", Guid.Empty.ToString())); + + return context.FindAssetAsync(contentId); + }), + Description = "Find an asset by id." + }); + } + + private void AddContentFind(ISchemaEntity schemaEntity, string schemaName, IGraphType schemaType) + { + AddField(new FieldType + { + Name = $"find{schemaEntity.Name.ToPascalCase()}", + Arguments = new QueryArguments + { + new QueryArgument(typeof(StringGraphType)) + { + Name = "id", + Description = $"The id of the {schemaName} content.", + DefaultValue = string.Empty + } + }, + ResolvedType = schemaType, + Resolver = new FuncFieldResolver(c => + { + var context = (QueryContext)c.UserContext; + var contentId = Guid.Parse(c.GetArgument("id", Guid.Empty.ToString())); + + return context.FindContentAsync(schemaEntity.Id, contentId); + }), + Description = $"Find an {schemaName} content by id." + }); + } + + private void AddAssetsQuery(IGraphQLContext graphQLContext) + { + AddField(new FieldType + { + Name = "queryAssets", + Arguments = new QueryArguments + { + new QueryArgument(typeof(IntGraphType)) + { + Name = "top", + Description = "Optional number of assets to take.", + DefaultValue = 20 + }, + new QueryArgument(typeof(IntGraphType)) + { + Name = "skip", + Description = "Optional number of assets to skip.", + DefaultValue = 0 + }, + new QueryArgument(typeof(StringGraphType)) + { + Name = "search", + Description = "Optional query.", + DefaultValue = string.Empty + } + }, + ResolvedType = new ListGraphType(new NonNullGraphType(graphQLContext.GetAssetType())), + Resolver = new FuncFieldResolver(c => + { + var context = (QueryContext)c.UserContext; + + var argTop = c.GetArgument("top", 20); + var argSkip = c.GetArgument("skip", 0); + var argQuery = c.GetArgument("query", string.Empty); + + return context.QueryAssetsAsync(argQuery, argSkip, argTop); + }), + Description = "Query assets items." + }); + } + + private void AddContentQuery(ISchemaEntity schemaEntity, IGraphType schemaType, string schemaName) + { + AddField(new FieldType + { + Name = $"query{schemaEntity.Name.ToPascalCase()}", + Arguments = new QueryArguments + { + new QueryArgument(typeof(IntGraphType)) + { + Name = "top", + Description = "Optional number of contents to take.", + DefaultValue = 20 + }, + new QueryArgument(typeof(IntGraphType)) + { + Name = "skip", + Description = "Optional number of contents to skip.", + DefaultValue = 0 + }, + new QueryArgument(typeof(StringGraphType)) + { + Name = "filter", + Description = "Optional OData filter.", + DefaultValue = string.Empty + }, + new QueryArgument(typeof(StringGraphType)) + { + Name = "search", + Description = "Optional OData full text search.", + DefaultValue = string.Empty + }, + new QueryArgument(typeof(StringGraphType)) + { + Name = "orderby", + Description = "Optional OData order definition.", + DefaultValue = string.Empty + } + }, + ResolvedType = new ListGraphType(new NonNullGraphType(schemaType)), + Resolver = new FuncFieldResolver(c => + { + var context = (QueryContext)c.UserContext; + var contentQuery = BuildODataQuery(c); + + return context.QueryContentsAsync(schemaEntity.Id, contentQuery); + }), + Description = $"Query {schemaName} content items." + }); + } + + private static string BuildODataQuery(ResolveFieldContext c) + { + var odataQuery = "?" + + string.Join("&", + c.Arguments + .Select(x => new { x.Key, Value = x.Value.ToString() }).Where(x => !string.IsNullOrWhiteSpace(x.Value)) + .Select(x => $"${x.Key}={x.Value}")); + + return odataQuery; + } + } +} diff --git a/src/Squidex.Domain.Apps.Read/Contents/Repositories/IContentRepository.cs b/src/Squidex.Domain.Apps.Read/Contents/Repositories/IContentRepository.cs index 8e9f5bbc0..ddb27c1e2 100644 --- a/src/Squidex.Domain.Apps.Read/Contents/Repositories/IContentRepository.cs +++ b/src/Squidex.Domain.Apps.Read/Contents/Repositories/IContentRepository.cs @@ -15,12 +15,12 @@ namespace Squidex.Domain.Apps.Read.Contents.Repositories { public interface IContentRepository { - Task> QueryAsync(Guid schemaId, bool nonPublished, HashSet ids, string odataQuery, IAppEntity appEntity); + Task> QueryAsync(IAppEntity appEntity, Guid schemaId, bool nonPublished, HashSet ids, string odataQuery); Task> QueryNotFoundAsync(Guid appId, Guid schemaId, IList contentIds); - Task CountAsync(Guid schemaId, bool nonPublished, HashSet ids, string odataQuery, IAppEntity appEntity); + Task CountAsync(IAppEntity appEntity, Guid schemaId, bool nonPublished, HashSet ids, string odataQuery); - Task FindContentAsync(Guid schemaId, Guid id, IAppEntity appEntity); + Task FindContentAsync(IAppEntity appEntity, Guid schemaId, Guid id); } } diff --git a/src/Squidex.Domain.Apps.Read/GraphQl/ContentDataGraphType.cs b/src/Squidex.Domain.Apps.Read/GraphQl/ContentDataGraphType.cs deleted file mode 100644 index 610ce4a6f..000000000 --- a/src/Squidex.Domain.Apps.Read/GraphQl/ContentDataGraphType.cs +++ /dev/null @@ -1,37 +0,0 @@ -// ========================================================================== -// SchemaGraphType.cs -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex Group -// All rights reserved. -// ========================================================================== - -using GraphQL.Resolvers; -using GraphQL.Types; -using Squidex.Domain.Apps.Core.Contents; -using Squidex.Infrastructure; -using Schema = Squidex.Domain.Apps.Core.Schemas.Schema; - -namespace Squidex.Domain.Apps.Read.GraphQl -{ - public sealed class ContentDataGraphType : ObjectGraphType - { - private static readonly IFieldResolver FieldResolver = - new FuncFieldResolver(c => c.Source.GetOrDefault(c.FieldName)); - - public ContentDataGraphType(Schema schema, IGraphQLContext context) - { - foreach (var field in schema.Fields) - { - var fieldName = field.Name; - - AddField(new FieldType - { - Name = fieldName, - Resolver = FieldResolver, - ResolvedType = new SchemaFieldGraphType(field, context), - }); - } - } - } -} diff --git a/src/Squidex.Domain.Apps.Read/GraphQl/ContentFieldGraphType.cs b/src/Squidex.Domain.Apps.Read/GraphQl/ContentFieldGraphType.cs deleted file mode 100644 index 22af68b94..000000000 --- a/src/Squidex.Domain.Apps.Read/GraphQl/ContentFieldGraphType.cs +++ /dev/null @@ -1,36 +0,0 @@ -// ========================================================================== -// SchemaGraphType.cs -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex Group -// All rights reserved. -// ========================================================================== - -using GraphQL.Types; -using Squidex.Domain.Apps.Core.Contents; -using Squidex.Domain.Apps.Core.Schemas; - -namespace Squidex.Domain.Apps.Read.GraphQl -{ - - public sealed class ContentFieldGraphType : ObjectGraphType - { - public ContentFieldGraphType(Field field, IGraphQLContext context) - { - var partition = context.ResolvePartition(field.Paritioning); - - foreach (var partitionItem in partition) - { - var fieldInfo = context.GetGraphType(field); - - AddField(new FieldType - { - Name = partitionItem.Key, - Resolver = fieldInfo.Resolver, - ResolvedType = fieldInfo.ResolveType, - Description = field.RawProperties.Hints - }); - } - } - } -} diff --git a/src/Squidex.Domain.Apps.Read/GraphQl/GraphQLContext.cs b/src/Squidex.Domain.Apps.Read/GraphQl/GraphQLContext.cs deleted file mode 100644 index 3bf18433c..000000000 --- a/src/Squidex.Domain.Apps.Read/GraphQl/GraphQLContext.cs +++ /dev/null @@ -1,94 +0,0 @@ -// ========================================================================== -// GraphQLContext.cs -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex Group -// All rights reserved. -// ========================================================================== - -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using GraphQL.Resolvers; -using GraphQL.Types; -using Squidex.Domain.Apps.Core; -using Squidex.Domain.Apps.Core.Contents; -using Squidex.Domain.Apps.Core.Schemas; -using Squidex.Domain.Apps.Read.Contents; -using Squidex.Infrastructure; -using Schema = Squidex.Domain.Apps.Core.Schemas.Schema; - -namespace Squidex.Domain.Apps.Read.GraphQl -{ - public sealed class GraphQLContext : IGraphQLContext - { - private readonly PartitionResolver partitionResolver; - private readonly Dictionary fieldInfos; - - public GraphQLContext(PartitionResolver partitionResolver) - { - Guard.NotNull(partitionResolver, nameof(partitionResolver)); - - this.partitionResolver = partitionResolver; - - var defaultResolver = - new FuncFieldResolver(c => c.Source.GetOrDefault(c.FieldName)); - - fieldInfos = new Dictionary - { - { - typeof(StringField), - (new StringGraphType(), defaultResolver) - }, - { - typeof(BooleanField), - (new BooleanGraphType(), defaultResolver) - }, - { - typeof(NumberField), - (new FloatGraphType(), defaultResolver) - }, - { - typeof(DateTimeField), - (new FloatGraphType(), defaultResolver) - }, - { - typeof(JsonField), - (new ObjectGraphType(), defaultResolver) - }, - { - typeof(GeolocationField), - (new ObjectGraphType(), defaultResolver) - }, - { - typeof(AssetsField), - (new ListGraphType(), defaultResolver) - }, - { - typeof(ReferencesField), - (new ListGraphType(), defaultResolver) - } - }; - } - - public IGraphType GetSchemaListType(Schema schema) - { - throw new NotImplementedException(); - } - - public IGraphType GetSchemaListType(Guid schemaId) - { - throw new NotImplementedException(); - } - - public IFieldPartitioning ResolvePartition(Partitioning key) - { - return partitionResolver(key); - } - - public (IGraphType ResolveType, IFieldResolver Resolver) GetGraphType(Field field) - { - return fieldInfos[field.GetType()]; - } - } -} diff --git a/src/Squidex/Config/Domain/ReadModule.cs b/src/Squidex/Config/Domain/ReadModule.cs index 2e81391da..787568d99 100644 --- a/src/Squidex/Config/Domain/ReadModule.cs +++ b/src/Squidex/Config/Domain/ReadModule.cs @@ -16,6 +16,7 @@ using Squidex.Domain.Apps.Read.Apps.Services; using Squidex.Domain.Apps.Read.Apps.Services.Implementations; using Squidex.Domain.Apps.Read.Contents; using Squidex.Domain.Apps.Read.Contents.Edm; +using Squidex.Domain.Apps.Read.Contents.GraphQL; using Squidex.Domain.Apps.Read.History; using Squidex.Domain.Apps.Read.Schemas; using Squidex.Domain.Apps.Read.Schemas.Services; @@ -87,6 +88,11 @@ namespace Squidex.Config.Domain builder.RegisterType() .AsSelf() .SingleInstance(); + + builder.RegisterType() + .As() + .AsSelf() + .InstancePerDependency(); } } } diff --git a/src/Squidex/Config/Domain/StoreMongoDbModule.cs b/src/Squidex/Config/Domain/StoreMongoDbModule.cs index 903d9dd27..07015185a 100644 --- a/src/Squidex/Config/Domain/StoreMongoDbModule.cs +++ b/src/Squidex/Config/Domain/StoreMongoDbModule.cs @@ -15,6 +15,7 @@ using MongoDB.Driver; using Squidex.Domain.Apps.Read.Apps.Repositories; using Squidex.Domain.Apps.Read.Apps.Services.Implementations; using Squidex.Domain.Apps.Read.Assets.Repositories; +using Squidex.Domain.Apps.Read.Contents.GraphQL; using Squidex.Domain.Apps.Read.Contents.Repositories; using Squidex.Domain.Apps.Read.History.Repositories; using Squidex.Domain.Apps.Read.MongoDb.Apps; @@ -170,6 +171,7 @@ namespace Squidex.Config.Domain builder.Register(c => new CompoundEventConsumer( c.Resolve(), + c.Resolve(), c.Resolve())) .As() .AsSelf() diff --git a/src/Squidex/Controllers/ContentApi/ContentsController.cs b/src/Squidex/Controllers/ContentApi/ContentsController.cs index e456e6343..5a4b90320 100644 --- a/src/Squidex/Controllers/ContentApi/ContentsController.cs +++ b/src/Squidex/Controllers/ContentApi/ContentsController.cs @@ -15,8 +15,10 @@ using Microsoft.Extensions.Primitives; using NSwag.Annotations; using Squidex.Controllers.ContentApi.Models; using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Read.Contents.GraphQL; using Squidex.Domain.Apps.Read.Contents.Repositories; using Squidex.Domain.Apps.Read.Schemas; +using Squidex.Domain.Apps.Read.Schemas.Repositories; using Squidex.Domain.Apps.Read.Schemas.Services; using Squidex.Domain.Apps.Write.Contents.Commands; using Squidex.Infrastructure.CQRS.Commands; @@ -33,15 +35,40 @@ namespace Squidex.Controllers.ContentApi { private readonly ISchemaProvider schemas; private readonly IContentRepository contentRepository; + private readonly IGraphQLInvoker graphQL; - public ContentsController(ICommandBus commandBus, ISchemaProvider schemas, IContentRepository contentRepository) + public ContentsController( + ICommandBus commandBus, + ISchemaProvider schemas, + IContentRepository contentRepository, + IGraphQLInvoker graphQL) : base(commandBus) { + this.graphQL = graphQL; this.schemas = schemas; - this.contentRepository = contentRepository; } + [HttpGet] + [Route("content/{app}/graphql")] + [ApiCosts(2)] + public async Task GetGraphQL([FromQuery] GraphQLQuery query) + { + var result = await graphQL.QueryAsync(App, query); + + return Ok(result); + } + + [HttpPost] + [Route("content/{app}/graphql")] + [ApiCosts(2)] + public async Task PostGraphQL([FromBody] GraphQLQuery query) + { + var result = await graphQL.QueryAsync(App, query); + + return Ok(result); + } + [HttpGet] [Route("content/{app}/{name}")] [ApiCosts(2)] @@ -64,8 +91,8 @@ namespace Squidex.Controllers.ContentApi var query = Request.QueryString.ToString(); - var taskForItems = contentRepository.QueryAsync(schemaEntity.Id, nonPublished, idsList, query, App); - var taskForCount = contentRepository.CountAsync(schemaEntity.Id, nonPublished, idsList, query, App); + var taskForItems = contentRepository.QueryAsync(App, schemaEntity.Id, nonPublished, idsList, query); + var taskForCount = contentRepository.CountAsync(App, schemaEntity.Id, nonPublished, idsList, query); await Task.WhenAll(taskForItems, taskForCount); @@ -100,7 +127,7 @@ namespace Squidex.Controllers.ContentApi return NotFound(); } - var entity = await contentRepository.FindContentAsync(schemaEntity.Id, id, App); + var entity = await contentRepository.FindContentAsync(App, schemaEntity.Id, id); if (entity == null) { diff --git a/src/Squidex/app/app.routes.ts b/src/Squidex/app/app.routes.ts index ebf192b20..3b4e8fea7 100644 --- a/src/Squidex/app/app.routes.ts +++ b/src/Squidex/app/app.routes.ts @@ -70,6 +70,10 @@ export const routes: Routes = [ { path: 'settings', loadChildren: './features/settings/module#SqxFeatureSettingsModule' + }, + { + path: 'api', + loadChildren: './features/api/module#SqxFeatureApiModule' } ] } diff --git a/src/Squidex/app/features/api/api-area.component.html b/src/Squidex/app/features/api/api-area.component.html new file mode 100644 index 000000000..abf56b677 --- /dev/null +++ b/src/Squidex/app/features/api/api-area.component.html @@ -0,0 +1,33 @@ + + + +
+
+

API

+
+ + + + +
+ +
+
+ +
+
+
+ + \ No newline at end of file diff --git a/src/Squidex/app/features/api/api-area.component.scss b/src/Squidex/app/features/api/api-area.component.scss new file mode 100644 index 000000000..3bda24ee9 --- /dev/null +++ b/src/Squidex/app/features/api/api-area.component.scss @@ -0,0 +1,12 @@ +@import '_vars'; +@import '_mixins'; + +.nav-link { + position: relative; + padding-top: .6rem; + padding-bottom: .6rem; +} + +.icon-angle-right { + @include absolute(14px, 2rem, auto, auto); +} \ No newline at end of file diff --git a/src/Squidex/app/features/api/api-area.component.ts b/src/Squidex/app/features/api/api-area.component.ts new file mode 100644 index 000000000..3351cbb63 --- /dev/null +++ b/src/Squidex/app/features/api/api-area.component.ts @@ -0,0 +1,26 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Sebastian Stehle. All rights reserved + */ + +import { Component } from '@angular/core'; + +import { + AppComponentBase, + AppsStoreService, + NotificationService +} from 'shared'; + +@Component({ + selector: 'sqx-api-area', + styleUrls: ['./api-area.component.scss'], + templateUrl: './api-area.component.html' +}) +export class ApiAreaComponent extends AppComponentBase { + constructor(apps: AppsStoreService, notifications: NotificationService + ) { + super(notifications, apps); + } +} \ No newline at end of file diff --git a/src/Squidex/app/features/api/declarations.ts b/src/Squidex/app/features/api/declarations.ts new file mode 100644 index 000000000..9a0ad2ccb --- /dev/null +++ b/src/Squidex/app/features/api/declarations.ts @@ -0,0 +1,10 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Sebastian Stehle. All rights reserved + */ + +export * from './pages/graphql/graphql-page.component'; + +export * from './api-area.component'; \ No newline at end of file diff --git a/src/Squidex/app/features/api/index.ts b/src/Squidex/app/features/api/index.ts new file mode 100644 index 000000000..9c0a4f6ee --- /dev/null +++ b/src/Squidex/app/features/api/index.ts @@ -0,0 +1,9 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Sebastian Stehle. All rights reserved + */ + +export * from './declarations'; +export * from './module'; \ No newline at end of file diff --git a/src/Squidex/app/features/api/module.ts b/src/Squidex/app/features/api/module.ts new file mode 100644 index 000000000..49268e04b --- /dev/null +++ b/src/Squidex/app/features/api/module.ts @@ -0,0 +1,50 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Sebastian Stehle. All rights reserved + */ + +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { DndModule } from 'ng2-dnd'; + +import { + SqxFrameworkModule, + SqxSharedModule +} from 'shared'; + +import { + ApiAreaComponent, + GraphQLPageComponent +} from './declarations'; + +const routes: Routes = [ + { + path: '', + component: ApiAreaComponent, + children: [ + { + path: '' + }, + { + path: 'graphql', + component: GraphQLPageComponent + } + ] + } +]; + +@NgModule({ + imports: [ + DndModule, + SqxFrameworkModule, + SqxSharedModule, + RouterModule.forChild(routes) + ], + declarations: [ + ApiAreaComponent, + GraphQLPageComponent + ] +}) +export class SqxFeatureApiModule { } \ No newline at end of file diff --git a/src/Squidex/app/features/api/pages/graphql/graphql-page.component.html b/src/Squidex/app/features/api/pages/graphql/graphql-page.component.html new file mode 100644 index 000000000..7dedbeb1b --- /dev/null +++ b/src/Squidex/app/features/api/pages/graphql/graphql-page.component.html @@ -0,0 +1,5 @@ + + + +
+
\ No newline at end of file diff --git a/src/Squidex/app/features/api/pages/graphql/graphql-page.component.scss b/src/Squidex/app/features/api/pages/graphql/graphql-page.component.scss new file mode 100644 index 000000000..00a5cbf4a --- /dev/null +++ b/src/Squidex/app/features/api/pages/graphql/graphql-page.component.scss @@ -0,0 +1,12 @@ +@import '_vars'; +@import '_mixins'; + +@import '~graphiql/graphiql'; + +.graphiql-container { + @include absolute(0, 0, 0, 0); +} + +.graphiql-container > * { + box-sizing: content-box; +} diff --git a/src/Squidex/app/features/api/pages/graphql/graphql-page.component.ts b/src/Squidex/app/features/api/pages/graphql/graphql-page.component.ts new file mode 100644 index 000000000..6feba81e2 --- /dev/null +++ b/src/Squidex/app/features/api/pages/graphql/graphql-page.component.ts @@ -0,0 +1,55 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Sebastian Stehle. All rights reserved + */ + +import { Component, ElementRef, OnInit, ViewChild, ViewEncapsulation } from '@angular/core'; + +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; + +const GraphiQL = require('graphiql'); + +import { + ApiUrlConfig, + AppComponentBase, + AppsStoreService, + AuthService, + NotificationService +} from 'shared'; + +@Component({ + selector: 'sqx-graphql-page', + styleUrls: ['./graphql-page.component.scss'], + templateUrl: './graphql-page.component.html', + encapsulation: ViewEncapsulation.None +}) +export class GraphQLPageComponent extends AppComponentBase implements OnInit { + @ViewChild('graphiQLContainer') + public graphiQLContainer: ElementRef; + + constructor(apps: AppsStoreService, notifications: NotificationService, + private readonly authService: AuthService, + private readonly apiUrl: ApiUrlConfig + ) { + super(notifications, apps); + } + + public ngOnInit() { + ReactDOM.render( + React.createElement(GraphiQL, { + fetcher: (params: any) => this.request(params) + }), + this.graphiQLContainer.nativeElement + ); + } + + private request(params: any) { + return this.appNameOnce() + .switchMap(app => this.authService.authPost(this.apiUrl.buildUrl(`api/content/${app}/graphql`), params).map(r => r.json())) + .toPromise(); + } +} + diff --git a/src/Squidex/app/framework/angular/panel-container.directive.ts b/src/Squidex/app/framework/angular/panel-container.directive.ts index 3931f34ca..a9539078f 100644 --- a/src/Squidex/app/framework/angular/panel-container.directive.ts +++ b/src/Squidex/app/framework/angular/panel-container.directive.ts @@ -5,50 +5,84 @@ * Copyright (c) Sebastian Stehle. All rights reserved */ -import { Directive, ElementRef, HostListener, OnDestroy, OnInit } from '@angular/core'; -import { Subscription } from 'rxjs'; +import { AfterViewInit, Directive, ElementRef, HostListener, OnDestroy, Renderer } from '@angular/core'; -import { PanelService } from './../services/panel.service'; +import { PanelComponent } from './panel.component'; @Directive({ selector: '.panel-container' }) -export class PanelContainerDirective implements OnInit, OnDestroy { - private subscription: Subscription; - private panelsSize: number | null = null; +export class PanelContainerDirective implements AfterViewInit, OnDestroy { + private readonly panels: PanelComponent[] = []; + private isInit = false; constructor( private readonly element: ElementRef, - private readonly panels: PanelService + private readonly renderer: Renderer ) { } @HostListener('window:resize') public onResize() { - this.resize(); + this.invalidate(); + } + + public ngAfterViewInit() { + this.invalidate(true); } public ngOnDestroy() { - this.subscription.unsubscribe(); + this.isInit = true; + } + + public push(panel: PanelComponent) { + this.panels.push(panel); + + this.invalidate(); } - public ngOnInit() { - this.subscription = - this.panels.changed.subscribe(width => { - this.panelsSize = width; + public pop() { + this.panels.splice(-1, 1); - this.resize(); - }); + this.invalidate(); } - private resize() { - if (!this.panelsSize) { + public invalidate(force = false) { + this.isInit = this.isInit || force; + + if (!this.isInit) { return; } - const currentWidth = this.element.nativeElement.getBoundingClientRect().width; + const containerWidth = this.element.nativeElement.getBoundingClientRect().width; + + let currentPosition = 0; + let currentLayer = this.panels.length * 10; + + const last = this.panels[this.panels.length - 1]; + + for (let panel of this.panels) { + const panelRoot = panel.panel.nativeElement; + + let width = panelRoot.getBoundingClientRect().width; + + if (panel.expand && panel === last) { + width = containerWidth - currentPosition; + + panel.panelWidth = width + 'px'; + } + + this.renderer.setElementStyle(panelRoot, 'top', '0px'); + this.renderer.setElementStyle(panelRoot, 'left', currentPosition + 'px'); + this.renderer.setElementStyle(panelRoot, 'bottom', '0px'); + this.renderer.setElementStyle(panelRoot, 'position', 'absolute'); + this.renderer.setElementStyle(panelRoot, 'z-index', currentLayer.toString()); + + currentPosition += width; + currentLayer -= 10; + } - const diff = this.panelsSize - currentWidth; + const diff = currentPosition - containerWidth; if (diff > 0) { this.element.nativeElement.scrollLeft = diff; diff --git a/src/Squidex/app/framework/angular/panel.component.ts b/src/Squidex/app/framework/angular/panel.component.ts index be1089b55..54d869546 100644 --- a/src/Squidex/app/framework/angular/panel.component.ts +++ b/src/Squidex/app/framework/angular/panel.component.ts @@ -5,16 +5,16 @@ * Copyright (c) Sebastian Stehle. All rights reserved */ -import { AfterViewInit, Component, ElementRef, Input, OnDestroy, Renderer, ViewChild } from '@angular/core'; +import { AfterViewInit, Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { slideRightAnimation } from './animations'; -import { PanelService } from './../services/panel.service'; +import { PanelContainerDirective } from './panel-container.directive'; @Component({ selector: 'sqx-panel', template: ` -
+
@@ -23,30 +23,33 @@ import { PanelService } from './../services/panel.service'; slideRightAnimation ] }) -export class PanelComponent implements OnDestroy, AfterViewInit { +export class PanelComponent implements AfterViewInit, OnDestroy, OnInit { @Input() public theme = 'light'; @Input() public panelWidth = '10rem'; + @Input() + public expand = false; + @ViewChild('panel') public panel: ElementRef; constructor( - private readonly renderer: Renderer, - private readonly panels: PanelService + private readonly container: PanelContainerDirective ) { } public ngOnDestroy() { - this.panels.pop(this.panel.nativeElement); - this.panels.render(this.renderer); - } - public ngAfterViewInit() { - this.panels.render(this.renderer); + this.container.pop(); } + public ngOnInit() { - this.panels.push(this.panel.nativeElement); + this.container.push(this); + } + + public ngAfterViewInit() { + this.container.invalidate(); } } \ No newline at end of file diff --git a/src/Squidex/app/framework/declarations.ts b/src/Squidex/app/framework/declarations.ts index 6ca5cb097..07c55c91e 100644 --- a/src/Squidex/app/framework/declarations.ts +++ b/src/Squidex/app/framework/declarations.ts @@ -52,7 +52,6 @@ export * from './services/clipboard.service'; export * from './services/local-store.service'; export * from './services/message-bus'; export * from './services/notification.service'; -export * from './services/panel.service'; export * from './services/resource-loader.service'; export * from './services/shortcut.service'; export * from './services/title.service'; diff --git a/src/Squidex/app/framework/module.ts b/src/Squidex/app/framework/module.ts index 3fff587a2..871b766c4 100644 --- a/src/Squidex/app/framework/module.ts +++ b/src/Squidex/app/framework/module.ts @@ -43,7 +43,6 @@ import { NotificationService, PanelContainerDirective, PanelComponent, - PanelService, ParentLinkDirective, PopupLinkDirective, ProgressBarComponent, @@ -177,7 +176,6 @@ export class SqxFrameworkModule { LocalStoreService, MessageBus, NotificationService, - PanelService, ResourceLoaderService, ShortcutService, TitleService diff --git a/src/Squidex/app/framework/services/panel.service.spec.ts b/src/Squidex/app/framework/services/panel.service.spec.ts deleted file mode 100644 index bee103b9e..000000000 --- a/src/Squidex/app/framework/services/panel.service.spec.ts +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Squidex Headless CMS - * - * @license - * Copyright (c) Sebastian Stehle. All rights reserved - */ - -import { PanelService, PanelServiceFactory } from './../'; - -interface Styling { element: any; property: string; value: string; } - -describe('PanelService', () => { - it('should instantiate from factory', () => { - const panelService = PanelServiceFactory(); - - expect(panelService).toBeDefined(); - }); - - it('should instantiate', () => { - const panelService = new PanelService(); - - expect(panelService).toBeDefined(); - }); - - it('should update elements with renderer service', () => { - let styles: Styling[] = []; - - const renderer = { - setElementStyle: (element: any, property: string, value: string) => { - styles.push({element, property, value}); - } - }; - - const panelService = new PanelService(); - - const element1 = { - getBoundingClientRect: () => { - return { width: 100 }; - } - }; - - const element2 = { - getBoundingClientRect: () => { - return { width: 200 }; - } - }; - - const element3 = { - getBoundingClientRect: () => { - return { width: 300 }; - } - }; - - let numPublished = 0; - panelService.changed.subscribe(() => { - numPublished++; - }); - - panelService.push(element1); - panelService.push(element2); - panelService.push(element3); - - styles = []; - - panelService.pop(element3); - panelService.render(renderer); - - expect(styles).toEqual([ - { element: element1, property: 'top', value: '0px' }, - { element: element1, property: 'left', value: '0px' }, - { element: element1, property: 'bottom', value: '0px' }, - { element: element1, property: 'position', value: 'absolute' }, - { element: element1, property: 'z-index', value: '20' }, - - { element: element2, property: 'top', value: '0px' }, - { element: element2, property: 'left', value: '100px' }, - { element: element2, property: 'bottom', value: '0px' }, - { element: element2, property: 'position', value: 'absolute' }, - { element: element2, property: 'z-index', value: '10' } - ]); - - expect(numPublished).toBe(1); - }); -}); \ No newline at end of file diff --git a/src/Squidex/app/framework/services/panel.service.ts b/src/Squidex/app/framework/services/panel.service.ts deleted file mode 100644 index 15a0e3e4a..000000000 --- a/src/Squidex/app/framework/services/panel.service.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Squidex Headless CMS - * - * @license - * Copyright (c) Sebastian Stehle. All rights reserved - */ - -import { Injectable, Renderer } from '@angular/core'; -import { Observable, Subject } from 'rxjs'; - -export const PanelServiceFactory = () => { - return new PanelService(); -}; - -@Injectable() -export class PanelService { - private readonly elements: any[] = []; - private readonly changed$ = new Subject(); - - public get changed(): Observable { - return this.changed$; - } - - public push(element: any) { - this.elements.push(element); - } - - public pop(element: any) { - this.elements.splice(-1, 1); - } - - public render(renderer: Renderer) { - let currentPosition = 0; - let currentLayer = this.elements.length * 10; - - for (let element of this.elements) { - const width = element.getBoundingClientRect().width; - - renderer.setElementStyle(element, 'top', '0px'); - renderer.setElementStyle(element, 'left', currentPosition + 'px'); - renderer.setElementStyle(element, 'bottom', '0px'); - renderer.setElementStyle(element, 'position', 'absolute'); - renderer.setElementStyle(element, 'z-index', currentLayer.toString()); - - currentPosition += width; - currentLayer -= 10; - } - - this.changed$.next(currentPosition); - } -} \ No newline at end of file diff --git a/src/Squidex/app/shell/pages/app/left-menu.component.html b/src/Squidex/app/shell/pages/app/left-menu.component.html index 44b65b629..5627fbac8 100644 --- a/src/Squidex/app/shell/pages/app/left-menu.component.html +++ b/src/Squidex/app/shell/pages/app/left-menu.component.html @@ -25,5 +25,10 @@ +
\ No newline at end of file diff --git a/src/Squidex/app/theme/icomoon/demo.html b/src/Squidex/app/theme/icomoon/demo.html index 0b3f476a8..285de7858 100644 --- a/src/Squidex/app/theme/icomoon/demo.html +++ b/src/Squidex/app/theme/icomoon/demo.html @@ -9,56 +9,24 @@
-

Font Name: icomoon (Glyphs: 69)

+

Font Name: icomoon (Glyphs: 67)

Grid Size: 16

- + - icon-elapsed + icon-earth
- - + +
liga: - -
-
-
-
- - - - icon-timeout -
-
- - -
-
- liga: - -
-
-
-
- - - - icon-checkmark -
-
- - -
-
- liga: - +
diff --git a/src/Squidex/app/theme/icomoon/fonts/icomoon.eot b/src/Squidex/app/theme/icomoon/fonts/icomoon.eot index b33df005009430f5bac6439c084803a5272ab7b2..351d6b2e750ccc2299e985e9e01aabf5ae8a80fc 100644 GIT binary patch delta 609 zcmYjNOK1~O6utMoHP=Y zy;_pDvm5RA0I<)0MQfY&dA<>6UcK}^cnusS;N-UW{y(NuG%q`o|U`LnJE&EZqh>D+x2B+w3{BvU{R1$!1+zH;#H>t#znVt)?f}R);!vs7nbwAJ*o+y?cIt-rw`Rzk>&uUqh893XqCy zsQfj%hq3*_)39Mb`*LPs5jb)na#`_#<4T1Y9K=ZB8D4T-AguxFfzgN1 z`aM!Ps#KKQgrSPD^2Jm`8?dNjiGeIC7I6`30o3TMQzJKBZvDKCIhNx@1(gZWIaTVDGIBE zM2o{=5ksn2W^+u5DABRaOzh~Ma^X1K?PldS2A$4;7n!^JSX27fRtuDfGCVm zjjX+ioqRiI7pOy!y4Xl>Vi=?r9c^}1osBb{cL>x>#T$&KAg6C`Yd-5^FtxK1i>>TT z{huSNQeU66I`VJgHBF-&7vkpL$ - - - + \ No newline at end of file diff --git a/src/Squidex/app/theme/icomoon/fonts/icomoon.ttf b/src/Squidex/app/theme/icomoon/fonts/icomoon.ttf index 06cd7e4f2320c65bbee8ea4faccde524b4cf94c3..38789983943a2add6c048ce70fc86ed6034836c6 100644 GIT binary patch delta 622 zcmYjPOK4L;6uo!my_cl<)x6|ok*}$ZR70wH`B1g_X%jz4TXbQin1u8bL(>LZF@lh+ ztWXd&-MDfk=uV8g774iP-i^4^MHAe*vJ&qL3SJn_oO{nbcV?KwC-`9-n}7gdgcfi( zGkaq)Vzo){DYH+jSgw`oeW@P>KsWKl)zW62aFlq1P`|qNaOJM}_ABugKysNotwp5&e27r@9o!eaR%Y1ksy09xI2q#9bbb%bg1{ zkx53uXhZLn`32tn-%WEpO3TMkCm5xoSso9Kqae!Wu3=P2?E7X5)v0JPBnmB>blouG zRJh1p`Ke$q?{azMnZkn{a`c}aleH(){so0IfA3nsdf_J+g(3gE{l=QCjb`n($s;?m6e(bLX98p5DT}+n56c zfEvgH5B{^~js;9+y+q0`oyk;2R_vqi-vFSC`1!OvuTVWiT&q>-nQK==Pn_?FF9Gyg zr>EpArm@Qc09&Q|$TSHCPB%#WCGnQ&%)(N2%JhQx9(h`3vZ~Qa=3rubIgwc2p7>vf zR^+ZOd1dHd#qruYB`GBxfwAGZYyXFXuu@m+iVu%HWC%H)9W-l0{(L@QFOA{u2!2OW zIU-hjBk{Hm;t?h3@C$A6h_@gh6jVk*w1@zo5?`KQGIjrinu-$tczx})2FkVgOvm`Q yM795;yfzw`hyy!}xv+{XOgkInu{E1}x6##jw={PEFMzGo&TTw8ZP_vPJ;uNL=)#=< diff --git a/src/Squidex/app/theme/icomoon/fonts/icomoon.woff b/src/Squidex/app/theme/icomoon/fonts/icomoon.woff index b7fafc67beac12da29e6cf9be8ae911e3a2b6dbc..2a114b73b63138ee6f459f3c7d6cf1776eec7909 100644 GIT binary patch delta 654 zcmYk4OK1~O6o&7aduP&g^0Jdmgvq0}Hc}g+=IN_xlE%b$E4WBSOv6|kL(>*pX$2u! z`Jf`oxN#*egt{wDcNNLPRdMS^TnMg`y(=T}+)%{}ALpEZ{&Vhw*?9`}C*WM0%H;?U zTvYJA>dj#&`Tm#eGms6E4e4@c1LnS~`CeOJ?)XnMiv ze3jeP(mHOOIf?aPk6UnKu~eNyt$>Y>IIl07e=RSqtl~|+|MOAIDE(+yU;_4x%Wx)= zLdAl|7YM`q54O^+z3%UB7ge0yBKs9|f4G_YkWloH#y05VR)LUilM&h^3egQm%2^8> zQIaqY(ZKLfNQi}C42C2xI8{HmLNpeQCnDka1aM0dB4O2S1CtG0PSx#$$Z3f91j*!- zTsA6@%I?PW^u})GZ#*_L+G0$x9=)C!6)jTTs`U3Q26GWjb={XNVx5V;^r2cz4^oM0 zUa#iK1q4x$WF`-Q@4~ei+WdbSpV~<&2f)N+Y1XQY1V@00inVEWu*3)dRIzd@IvW&O z9k;q-?suzHq*^`@$ZMKcIagfE0foU6!wP>Qd=9?c`FocR7VT$$5}EacFON6vJmJyk hq!N&CMAIHH3Yq5v{&24?4+Wz3%mV+@8urO|@COErjQ9Wm delta 850 zcmYjPUr1A76#vfs+;loOo%e3At!Y>1wCQEJKW6FtW^SNHO$muiaxL2e*RH5!KD2=p z86xC*sGb5}qF05Wd>9fv6g?D0K1I-z&`S^XQf{4ZGw{3oIOqJn^E==9!Oh--trZY& zo{K~X5Ilohq^1;;4@7rHiKk*JA&^AsJcnic~u0a<2*L7dd>- zZ6A`;v8#lzUF->P7$>esNhApg)nVPjp%XO2$<+KJaz4}}2QAfcoKD=7GViqK$yX1Ud+fZrGB z4njB8n!Q~C7j=Ne0g|Y#6iC)-%iEmU(bY_%H#GZbeEiXDs5h}X+quBlLZ{;KC~mdS z3?`$|1m-@~%^9H>Vm7DK#zNXidVP#bxO8ki9ofIb8_MDik5G6cnoJ^`6g(Yej{Db? z4CK*3196dV5+);PBO@Dx!@E;iK@UOn;y~UYKoDKDHp|*d2elk`D(FlriDpZXHqa?e6*ddGI7z43l&?$Bm%U