From 89a701c86548f008db9e547a913efcdea7c7076d Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Sat, 9 Feb 2019 15:41:30 +0100 Subject: [PATCH 1/8] Initial PoC --- .../Implement/EntityRepository.cs | 21 ++++++++-- .../src/common/interceptors/_module.js | 2 + .../culturerequest.interceptor.js | 27 +++++++++++++ src/Umbraco.Web/Editors/EntityController.cs | 39 ++++++++++++------- .../Filters/HttpQueryStringFilterAttribute.cs | 34 +++++++++++++++- .../WebApi/HttpRequestMessageExtensions.cs | 7 ++++ 6 files changed, 112 insertions(+), 18 deletions(-) create mode 100644 src/Umbraco.Web.UI.Client/src/common/interceptors/culturerequest.interceptor.js diff --git a/src/Umbraco.Core/Persistence/Repositories/Implement/EntityRepository.cs b/src/Umbraco.Core/Persistence/Repositories/Implement/EntityRepository.cs index 85912694f0..fc7f36b1a3 100644 --- a/src/Umbraco.Core/Persistence/Repositories/Implement/EntityRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/Implement/EntityRepository.cs @@ -66,8 +66,22 @@ namespace Umbraco.Core.Persistence.Repositories.Implement //no matter what we always must have node id ordered at the end sql = ordering.Direction == Direction.Ascending ? sql.OrderBy("NodeId") : sql.OrderByDescending("NodeId"); - var page = Database.Page(pageIndex + 1, pageSize, sql); - var dtos = page.Items; + // for content we must query for ContentEntityDto entities to produce the correct culture variant entity names + var pageIndexToFetch = pageIndex + 1; + IEnumerable dtos; + if(isContent) + { + var page = Database.Page(pageIndexToFetch, pageSize, sql); + dtos = page.Items; + totalRecords = page.TotalItems; + } + else + { + var page = Database.Page(pageIndexToFetch, pageSize, sql); + dtos = page.Items; + totalRecords = page.TotalItems; + } + var entities = dtos.Select(x => BuildEntity(isContent, isMedia, x)).ToArray(); if (isContent) @@ -75,9 +89,8 @@ namespace Umbraco.Core.Persistence.Repositories.Implement // TODO: see https://github.com/umbraco/Umbraco-CMS/pull/3460#issuecomment-434903930 we need to not load any property data at all for media if (isMedia) - BuildProperties(entities, dtos); + BuildProperties(entities, dtos.ToList()); - totalRecords = page.TotalItems; return entities; } diff --git a/src/Umbraco.Web.UI.Client/src/common/interceptors/_module.js b/src/Umbraco.Web.UI.Client/src/common/interceptors/_module.js index 05263cebf2..69a4fe35c9 100644 --- a/src/Umbraco.Web.UI.Client/src/common/interceptors/_module.js +++ b/src/Umbraco.Web.UI.Client/src/common/interceptors/_module.js @@ -7,4 +7,6 @@ angular.module('umbraco.interceptors', []) $httpProvider.interceptors.push('securityInterceptor'); $httpProvider.interceptors.push('debugRequestInterceptor'); $httpProvider.interceptors.push('doNotPostDollarVariablesOnPostRequestInterceptor'); + $httpProvider.interceptors.push('cultureRequestInterceptor'); + }]); diff --git a/src/Umbraco.Web.UI.Client/src/common/interceptors/culturerequest.interceptor.js b/src/Umbraco.Web.UI.Client/src/common/interceptors/culturerequest.interceptor.js new file mode 100644 index 0000000000..a0d827948b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/common/interceptors/culturerequest.interceptor.js @@ -0,0 +1,27 @@ +(function() { + 'use strict'; + + /** + * Used to set the current client culture on all requests API requests + * @param {any} $q + * @param {any} $routeParams + */ + function cultureRequestInterceptor($q, $routeParams) { + return { + //dealing with requests: + 'request': function(config) { + var apiPattern = /\/umbraco\/backoffice\//; + if (!apiPattern.test(config.url)) { + // it's not an API request, no handling + return config; + } + // it's an API request, add the current client culture as a header value + config.headers["X-UMB-CULTURE"] = $routeParams.cculture ? $routeParams.cculture : $routeParams.mculture; + return config; + } + }; + } + + angular.module('umbraco.interceptors').factory('cultureRequestInterceptor', cultureRequestInterceptor); + +})(); diff --git a/src/Umbraco.Web/Editors/EntityController.cs b/src/Umbraco.Web/Editors/EntityController.cs index 55604e0eb9..ad750c3b53 100644 --- a/src/Umbraco.Web/Editors/EntityController.cs +++ b/src/Umbraco.Web/Editors/EntityController.cs @@ -503,10 +503,14 @@ namespace Umbraco.Web.Editors return new PagedResult(0, 0, 0); } + var culture = Request.ClientCulture(); var pagedResult = new PagedResult(totalRecords, pageNumber, pageSize) { Items = entities.Select(entity => Mapper.Map(entity, options => - options.AfterMap((src, dest) => { dest.AdditionalData["hasChildren"] = src.HasChildren; }) + { + options.SetCulture(culture); + options.AfterMap((src, dest) => { dest.AdditionalData["hasChildren"] = src.HasChildren; }); + } ) ) }; @@ -585,7 +589,7 @@ namespace Umbraco.Web.Editors var pagedResult = new PagedResult(totalRecords, pageNumber, pageSize) { - Items = entities.Select(Mapper.Map) + Items = entities.Select(MapEntities()) }; return pagedResult; @@ -632,7 +636,7 @@ namespace Umbraco.Web.Editors return Services.EntityService.GetChildren(id, objectType.Value) .WhereNotNull() - .Select(Mapper.Map); + .Select(MapEntities()); } //now we need to convert the unknown ones switch (entityType) @@ -693,7 +697,7 @@ namespace Umbraco.Web.Editors : Services.EntityService.GetAll(objectType.Value, ids) .WhereNotNull() .OrderBy(x => x.Level) - .Select(x => Mapper.Map(x, opts => { opts.SetCulture(culture);})); + .Select(MapEntities(culture)); } //now we need to convert the unknown ones switch (entityType) @@ -719,7 +723,7 @@ namespace Umbraco.Web.Editors { var entities = Services.EntityService.GetAll(objectType.Value, keys) .WhereNotNull() - .Select(Mapper.Map); + .Select(MapEntities()); // entities are in "some" order, put them back in order var xref = entities.ToDictionary(x => x.Key); @@ -751,7 +755,7 @@ namespace Umbraco.Web.Editors { var entities = Services.EntityService.GetAll(objectType.Value, ids) .WhereNotNull() - .Select(Mapper.Map); + .Select(MapEntities()); // entities are in "some" order, put them back in order var xref = entities.ToDictionary(x => x.Id); @@ -815,7 +819,7 @@ namespace Umbraco.Web.Editors { throw new HttpResponseException(HttpStatusCode.NotFound); } - return Mapper.Map(found); + return MapEntity(found); } //now we need to convert the unknown ones switch (entityType) @@ -886,7 +890,7 @@ namespace Umbraco.Web.Editors if (objectType.HasValue) { // TODO: Should we order this by something ? - var entities = Services.EntityService.GetAll(objectType.Value).WhereNotNull().Select(Mapper.Map); + var entities = Services.EntityService.GetAll(objectType.Value).WhereNotNull().Select(MapEntities()); return ExecutePostFilter(entities, postFilter); } //now we need to convert the unknown ones @@ -895,13 +899,13 @@ namespace Umbraco.Web.Editors case UmbracoEntityTypes.Template: var templates = Services.FileService.GetTemplates(); var filteredTemplates = ExecutePostFilter(templates, postFilter); - return filteredTemplates.Select(Mapper.Map); + return filteredTemplates.Select(MapEntities()); case UmbracoEntityTypes.Macro: //Get all macros from the macro service var macros = Services.MacroService.GetAll().WhereNotNull().OrderBy(x => x.Name); var filteredMacros = ExecutePostFilter(macros, postFilter); - return filteredMacros.Select(Mapper.Map); + return filteredMacros.Select(MapEntities()); case UmbracoEntityTypes.PropertyType: @@ -936,14 +940,14 @@ namespace Umbraco.Web.Editors if (!postFilter.IsNullOrWhiteSpace()) throw new NotSupportedException("Filtering on stylesheets is not currently supported"); - return Services.FileService.GetStylesheets().Select(Mapper.Map); + return Services.FileService.GetStylesheets().Select(MapEntities()); case UmbracoEntityTypes.Language: if (!postFilter.IsNullOrWhiteSpace() ) throw new NotSupportedException("Filtering on languages is not currently supported"); - return Services.LocalizationService.GetAllLanguages().Select(Mapper.Map); + return Services.LocalizationService.GetAllLanguages().Select(MapEntities()); case UmbracoEntityTypes.DictionaryItem: if (!postFilter.IsNullOrWhiteSpace()) @@ -1039,8 +1043,17 @@ namespace Umbraco.Web.Editors return queryCondition; } + private Func MapEntities(string culture = null) + { + culture = culture ?? Request.ClientCulture(); + return x => MapEntity(x, culture); + } - + private EntityBasic MapEntity(object entity, string culture = null) + { + culture = culture ?? Request.ClientCulture(); + return Mapper.Map(entity, opts => { opts.SetCulture(culture); }); + } #region Methods to get all dictionary items private IEnumerable GetAllDictionaryItems() diff --git a/src/Umbraco.Web/WebApi/Filters/HttpQueryStringFilterAttribute.cs b/src/Umbraco.Web/WebApi/Filters/HttpQueryStringFilterAttribute.cs index eea4ef7e67..56f0eefe5c 100644 --- a/src/Umbraco.Web/WebApi/Filters/HttpQueryStringFilterAttribute.cs +++ b/src/Umbraco.Web/WebApi/Filters/HttpQueryStringFilterAttribute.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Net.Http.Formatting; using System.Web.Http.Controllers; using System.Web.Http.Filters; @@ -32,7 +33,14 @@ namespace Umbraco.Web.WebApi.Filters var queryStrings = actionContext.Request.Properties["MS_QueryNameValuePairs"] as IEnumerable>; if (queryStrings == null) return; - var formData = new FormDataCollection(queryStrings); + var queryStringKeys = queryStrings.Select(kvp => kvp.Key).ToArray(); + var additionalParameters = new Dictionary(); + + if(queryStringKeys.Contains("culture") == false) { + additionalParameters["culture"] = actionContext.Request.ClientCulture(); + } + + var formData = new FormDataCollection(queryStrings.Union(additionalParameters)); actionContext.ActionArguments[ParameterName] = formData; } @@ -40,4 +48,28 @@ namespace Umbraco.Web.WebApi.Filters base.OnActionExecuting(actionContext); } } + + public sealed class UnwrapClientCultureFilterAttribute : ActionFilterAttribute + { + private readonly string _parameterName; + + public UnwrapClientCultureFilterAttribute(string parameterName = "culture") + { + if (string.IsNullOrEmpty(parameterName)) + throw new ArgumentException("ParameterName is required."); + _parameterName = parameterName; + } + + public override void OnActionExecuting(HttpActionContext actionContext) + { + if (actionContext.ActionArguments.ContainsKey(_parameterName) && string.IsNullOrWhiteSpace(actionContext.ActionArguments[_parameterName]?.ToString()) == false) + { + return; + } + + actionContext.ActionArguments[_parameterName] = actionContext.Request.ClientCulture(); + + base.OnActionExecuting(actionContext); + } + } } diff --git a/src/Umbraco.Web/WebApi/HttpRequestMessageExtensions.cs b/src/Umbraco.Web/WebApi/HttpRequestMessageExtensions.cs index 96c9c0e9a0..744b60cd52 100644 --- a/src/Umbraco.Web/WebApi/HttpRequestMessageExtensions.cs +++ b/src/Umbraco.Web/WebApi/HttpRequestMessageExtensions.cs @@ -1,4 +1,6 @@ using System; +using System.Globalization; +using System.Linq; using System.Net; using System.Net.Http; using System.Web; @@ -158,6 +160,11 @@ namespace Umbraco.Web.WebApi msg.Headers.Add("X-Status-Reason", "Validation failed"); return msg; } + + public static string ClientCulture(this HttpRequestMessage request) + { + return request.Headers.Contains("X-UMB-CULTURE") ? request.Headers.GetValues("X-UMB-CULTURE").First() : null; + } } } From 0dd5ba61ab102062bf026f5dcea10b23210380cc Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Sat, 9 Feb 2019 17:48:45 +0100 Subject: [PATCH 2/8] Clean up unused culture attribute --- .../Filters/HttpQueryStringFilterAttribute.cs | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/src/Umbraco.Web/WebApi/Filters/HttpQueryStringFilterAttribute.cs b/src/Umbraco.Web/WebApi/Filters/HttpQueryStringFilterAttribute.cs index 56f0eefe5c..0b93f8db4b 100644 --- a/src/Umbraco.Web/WebApi/Filters/HttpQueryStringFilterAttribute.cs +++ b/src/Umbraco.Web/WebApi/Filters/HttpQueryStringFilterAttribute.cs @@ -48,28 +48,4 @@ namespace Umbraco.Web.WebApi.Filters base.OnActionExecuting(actionContext); } } - - public sealed class UnwrapClientCultureFilterAttribute : ActionFilterAttribute - { - private readonly string _parameterName; - - public UnwrapClientCultureFilterAttribute(string parameterName = "culture") - { - if (string.IsNullOrEmpty(parameterName)) - throw new ArgumentException("ParameterName is required."); - _parameterName = parameterName; - } - - public override void OnActionExecuting(HttpActionContext actionContext) - { - if (actionContext.ActionArguments.ContainsKey(_parameterName) && string.IsNullOrWhiteSpace(actionContext.ActionArguments[_parameterName]?.ToString()) == false) - { - return; - } - - actionContext.ActionArguments[_parameterName] = actionContext.Request.ClientCulture(); - - base.OnActionExecuting(actionContext); - } - } } From cb33442e7c5e6d274cf81afd7f3957310635ebec Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Sat, 9 Feb 2019 17:50:29 +0100 Subject: [PATCH 3/8] Tidy up a bit --- src/Umbraco.Web/Editors/EntityController.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Umbraco.Web/Editors/EntityController.cs b/src/Umbraco.Web/Editors/EntityController.cs index ad750c3b53..842f22bda2 100644 --- a/src/Umbraco.Web/Editors/EntityController.cs +++ b/src/Umbraco.Web/Editors/EntityController.cs @@ -503,7 +503,7 @@ namespace Umbraco.Web.Editors return new PagedResult(0, 0, 0); } - var culture = Request.ClientCulture(); + var culture = ClientCulture(); var pagedResult = new PagedResult(totalRecords, pageNumber, pageSize) { Items = entities.Select(entity => Mapper.Map(entity, options => @@ -1045,16 +1045,18 @@ namespace Umbraco.Web.Editors private Func MapEntities(string culture = null) { - culture = culture ?? Request.ClientCulture(); + culture = culture ?? ClientCulture(); return x => MapEntity(x, culture); } private EntityBasic MapEntity(object entity, string culture = null) { - culture = culture ?? Request.ClientCulture(); + culture = culture ?? ClientCulture(); return Mapper.Map(entity, opts => { opts.SetCulture(culture); }); } + private string ClientCulture() => Request.ClientCulture(); + #region Methods to get all dictionary items private IEnumerable GetAllDictionaryItems() { From 462a6168b3ff9f1e44176c520576c9041767b574 Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Sat, 9 Feb 2019 17:56:13 +0100 Subject: [PATCH 4/8] Don't show the language selector in content pickers (use the current client culture) --- .../propertyeditors/contentpicker/contentpicker.controller.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/contentpicker/contentpicker.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/contentpicker/contentpicker.controller.js index 8b5915026c..b558cba459 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/contentpicker/contentpicker.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/contentpicker/contentpicker.controller.js @@ -144,9 +144,7 @@ function contentPickerController($scope, entityResource, editorState, iconHelper }, treeAlias: $scope.model.config.startNode.type, section: $scope.model.config.startNode.type, - idType: "int", - //only show the lang selector for content - showLanguageSelector: $scope.model.config.startNode.type === "content" + idType: "int" }; //since most of the pre-value config's are used in the dialog options (i.e. maxNumber, minNumber, etc...) we'll merge the From 0c860e999bc3c5ffa44b5db2daf47c0edbfbb678 Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Sat, 9 Feb 2019 18:26:03 +0100 Subject: [PATCH 5/8] Reimplement after merge --- .../WebApi/Filters/HttpQueryStringModelBinder.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Web/WebApi/Filters/HttpQueryStringModelBinder.cs b/src/Umbraco.Web/WebApi/Filters/HttpQueryStringModelBinder.cs index 6ffbb239f8..653c996950 100644 --- a/src/Umbraco.Web/WebApi/Filters/HttpQueryStringModelBinder.cs +++ b/src/Umbraco.Web/WebApi/Filters/HttpQueryStringModelBinder.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using System.Net.Http.Formatting; using System.Web.Http.Controllers; using System.Web.Http.ModelBinding; @@ -21,7 +22,13 @@ namespace Umbraco.Web.WebApi.Filters { if (actionContext.Request.Properties["MS_QueryNameValuePairs"] is IEnumerable> queryStrings) { - var formData = new FormDataCollection(queryStrings); + var queryStringKeys = queryStrings.Select(kvp => kvp.Key).ToArray(); + var additionalParameters = new Dictionary(); + if(queryStringKeys.Contains("culture") == false) { + additionalParameters["culture"] = actionContext.Request.ClientCulture(); + } + + var formData = new FormDataCollection(queryStrings.Union(additionalParameters)); bindingContext.Model = formData; return true; } @@ -29,4 +36,4 @@ namespace Umbraco.Web.WebApi.Filters return false; } } -} \ No newline at end of file +} From c75ac83c98153b32327d2c8e3789281d63df25db Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Sat, 9 Feb 2019 18:42:55 +0100 Subject: [PATCH 6/8] Show missing translations as "(name)", not "((name))" --- src/Umbraco.Web/Models/Mapping/ContentMapperProfile.cs | 2 +- src/Umbraco.Web/Models/Mapping/EntityMapperProfile.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Web/Models/Mapping/ContentMapperProfile.cs b/src/Umbraco.Web/Models/Mapping/ContentMapperProfile.cs index 652ac12014..2dc9f1cea7 100644 --- a/src/Umbraco.Web/Models/Mapping/ContentMapperProfile.cs +++ b/src/Umbraco.Web/Models/Mapping/ContentMapperProfile.cs @@ -138,7 +138,7 @@ namespace Umbraco.Web.Models.Mapping // if we don't have a name for a culture, it means the culture is not available, and // hey we should probably not be mapping it, but it's too late, return a fallback name - return source.CultureInfos.TryGetValue(culture, out var name) && !name.Name.IsNullOrWhiteSpace() ? name.Name : $"(({source.Name}))"; + return source.CultureInfos.TryGetValue(culture, out var name) && !name.Name.IsNullOrWhiteSpace() ? name.Name : $"({source.Name})"; } } diff --git a/src/Umbraco.Web/Models/Mapping/EntityMapperProfile.cs b/src/Umbraco.Web/Models/Mapping/EntityMapperProfile.cs index ab3929166f..5b711d7251 100644 --- a/src/Umbraco.Web/Models/Mapping/EntityMapperProfile.cs +++ b/src/Umbraco.Web/Models/Mapping/EntityMapperProfile.cs @@ -209,7 +209,7 @@ namespace Umbraco.Web.Models.Mapping // if we don't have a name for a culture, it means the culture is not available, and // hey we should probably not be mapping it, but it's too late, return a fallback name - return doc.CultureNames.TryGetValue(culture, out var name) && !name.IsNullOrWhiteSpace() ? name : $"(({source.Name}))"; + return doc.CultureNames.TryGetValue(culture, out var name) && !name.IsNullOrWhiteSpace() ? name : $"({source.Name})"; } } } From 825d8421ed411919d6fd122ce061902f95da14ee Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Thu, 14 Feb 2019 10:12:59 +0100 Subject: [PATCH 7/8] Don't use a hardcoded /umbraco path --- .../interceptors/culturerequest.interceptor.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/common/interceptors/culturerequest.interceptor.js b/src/Umbraco.Web.UI.Client/src/common/interceptors/culturerequest.interceptor.js index a0d827948b..6496d7a1f9 100644 --- a/src/Umbraco.Web.UI.Client/src/common/interceptors/culturerequest.interceptor.js +++ b/src/Umbraco.Web.UI.Client/src/common/interceptors/culturerequest.interceptor.js @@ -1,5 +1,5 @@ (function() { - 'use strict'; + 'use strict'; /** * Used to set the current client culture on all requests API requests @@ -9,9 +9,12 @@ function cultureRequestInterceptor($q, $routeParams) { return { //dealing with requests: - 'request': function(config) { - var apiPattern = /\/umbraco\/backoffice\//; - if (!apiPattern.test(config.url)) { + 'request': function (config) { + if (!Umbraco.Sys.ServerVariables.umbracoSettings.umbracoPath) { + // no settings available, we're probably on the login screen + return config; + } + if (!config.url.match(RegExp(Umbraco.Sys.ServerVariables.umbracoSettings.umbracoPath + "\/backoffice\/", "i"))) { // it's not an API request, no handling return config; } @@ -20,7 +23,7 @@ return config; } }; - } + } angular.module('umbraco.interceptors').factory('cultureRequestInterceptor', cultureRequestInterceptor); From 7045eb196ca51bded0bd2ebef995714901f0e578 Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Thu, 14 Feb 2019 10:14:56 +0100 Subject: [PATCH 8/8] Use InvariantContains instead of Contains when looking for culture in the querystring --- src/Umbraco.Web/WebApi/Filters/HttpQueryStringModelBinder.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Web/WebApi/Filters/HttpQueryStringModelBinder.cs b/src/Umbraco.Web/WebApi/Filters/HttpQueryStringModelBinder.cs index 653c996950..4d58e2b512 100644 --- a/src/Umbraco.Web/WebApi/Filters/HttpQueryStringModelBinder.cs +++ b/src/Umbraco.Web/WebApi/Filters/HttpQueryStringModelBinder.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Net.Http.Formatting; using System.Web.Http.Controllers; using System.Web.Http.ModelBinding; +using Umbraco.Core; namespace Umbraco.Web.WebApi.Filters { @@ -24,7 +25,7 @@ namespace Umbraco.Web.WebApi.Filters { var queryStringKeys = queryStrings.Select(kvp => kvp.Key).ToArray(); var additionalParameters = new Dictionary(); - if(queryStringKeys.Contains("culture") == false) { + if(queryStringKeys.InvariantContains("culture") == false) { additionalParameters["culture"] = actionContext.Request.ClientCulture(); }