From 60c14fd0327a94b80f4c7aa6244646e9760316d4 Mon Sep 17 00:00:00 2001 From: "EP\\adza" Date: Wed, 20 Mar 2024 10:54:13 +0000 Subject: [PATCH 1/5] categories hierarchy handling with feature flag --- README.md | 5 +- .../Configuration/CategoriesOptions.cs | 3 +- .../DefaultCategoryContentLoader.cs | 113 +++++++++++++++++- .../ICategoryContentLoader.cs | 18 ++- .../Routing/CategoryModelBinder.cs | 32 ++++- .../Routing/CategoryPartialRouter.cs | 63 ++++++---- 6 files changed, 202 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 757fe6d..7473e27 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,8 @@ Instead of going to admin mode to manage categories, you now do it in edit mode, - Possibility to hide root categories based on [AllowedTypes] setting - ShowDefaultCategoryProperty - default to **false** - Allows you to show the default Episerver category property +- UseUrlPathForCategoryRetrieval - default to **false** + - Enables retrieval of categories that match the exact URL path instead of searching for a URL segment. You can configure categories in Startup.cs. Below is an example where we change the category seperator: @@ -102,7 +104,8 @@ In addition, the configuration can be read from the `appsettings.json`: "CategorySeparator": "__", "DisableCategoryAsLinkableType": false, "HideDisallowedRootCategories": false, - "ShowDefaultCategoryProperty": false + "ShowDefaultCategoryProperty": false, + "UseUrlPathForCategoryRetrieval": false } } ``` diff --git a/src/Geta.Optimizely.Categories/Configuration/CategoriesOptions.cs b/src/Geta.Optimizely.Categories/Configuration/CategoriesOptions.cs index f606b88..3c77b51 100644 --- a/src/Geta.Optimizely.Categories/Configuration/CategoriesOptions.cs +++ b/src/Geta.Optimizely.Categories/Configuration/CategoriesOptions.cs @@ -6,8 +6,9 @@ namespace Geta.Optimizely.Categories.Configuration public class CategoriesOptions { public string CategorySeparator { get; set; } = "__"; + public bool UseUrlPathForCategoryRetrieval { get; set; } public bool DisableCategoryAsLinkableType { get; set; } public bool HideDisallowedRootCategories { get; set; } public bool ShowDefaultCategoryProperty { get; set; } } -} \ No newline at end of file +} diff --git a/src/Geta.Optimizely.Categories/DefaultCategoryContentLoader.cs b/src/Geta.Optimizely.Categories/DefaultCategoryContentLoader.cs index 3d8c313..e258681 100644 --- a/src/Geta.Optimizely.Categories/DefaultCategoryContentLoader.cs +++ b/src/Geta.Optimizely.Categories/DefaultCategoryContentLoader.cs @@ -17,11 +17,12 @@ public class DefaultCategoryContentLoader : ICategoryContentLoader { protected readonly IContentRepository ContentRepository; protected readonly LanguageResolver LanguageResolver; - - public DefaultCategoryContentLoader(IContentRepository contentRepository, LanguageResolver languageResolver) + protected readonly CategorySettings CategorySettings; + public DefaultCategoryContentLoader(IContentRepository contentRepository, LanguageResolver languageResolver, CategorySettings categorySettings) { ContentRepository = contentRepository; LanguageResolver = languageResolver; + CategorySettings = categorySettings; } public virtual T Get(ContentReference categoryLink) where T : CategoryData @@ -95,6 +96,95 @@ public virtual T GetFirstBySegment(ContentReference parentLink, string urlSeg return categories.FirstOrDefault(x => x.RouteSegment.Equals(urlSegment, StringComparison.InvariantCultureIgnoreCase)); } + public IEnumerable GetCategoriesBySegment(string urlSegment) where T : CategoryData + { + return GetCategoriesBySegment(urlSegment, CreateDefaultLoadOptions()); + } + + public IEnumerable GetCategoriesBySegment(string urlSegment, CultureInfo culture) where T : CategoryData + { + var loaderOptions = new LoaderOptions + { + LanguageLoaderOption.Specific(culture) + }; + + return GetCategoriesBySegment(urlSegment, loaderOptions); + } + + public IEnumerable GetCategoriesBySegment(string urlSegment, LoaderOptions loaderOptions) where T : CategoryData + { + if (SiteDefinition.Current.SiteAssetsRoot != SiteDefinition.Current.GlobalAssetsRoot) + { + var siteCategory = GetCategoriesBySegment(ContentRepository.GetOrCreateSiteCategoriesRoot(), urlSegment, loaderOptions); + + if (siteCategory != null && siteCategory.Any()) + { + return siteCategory; + } + } + + return GetCategoriesBySegment(ContentRepository.GetOrCreateGlobalCategoriesRoot(), urlSegment, loaderOptions); + } + + public virtual IEnumerable GetCategoriesBySegment(ContentReference parentLink, string urlSegment, LoaderOptions loaderOptions) where T : CategoryData + { + var descendents = ContentRepository.GetDescendents(parentLink); + + var categories = ContentRepository + .GetItems(descendents, loaderOptions) + .OfType(); + + return categories.Where(x => x.RouteSegment.Equals(urlSegment, StringComparison.InvariantCultureIgnoreCase)); + } + + public T GetCategoryByPath(string path) where T : CategoryData + { + return GetCategoryByPath(path, CreateDefaultLoadOptions()); + } + + public T GetCategoryByPath(string path, CultureInfo culture) where T : CategoryData + { + var loaderOptions = new LoaderOptions + { + LanguageLoaderOption.Specific(culture) + }; + + return GetCategoryByPath(path, loaderOptions); + } + + public virtual T GetCategoryByPath(string path, LoaderOptions loaderOptions) where T : CategoryData + { + if (SiteDefinition.Current.SiteAssetsRoot != SiteDefinition.Current.GlobalAssetsRoot) + { + var siteCategory = GetCategoryByPath(ContentRepository.GetOrCreateSiteCategoriesRoot(), path, loaderOptions); + + if (siteCategory != null) + { + return siteCategory; + } + } + + return GetCategoryByPath(ContentRepository.GetOrCreateGlobalCategoriesRoot(), path, loaderOptions); + } + + + public virtual T GetCategoryByPath(ContentReference parentLink, string path, LoaderOptions loaderOptions) where T : CategoryData + { + var trimmedUrl = path.TrimEnd('/'); + var urlSegment = trimmedUrl.Split('/').Last(); + + // Efficiently fetch descendants once and filter in-memory. + var descendants = ContentRepository.GetDescendents(parentLink); + var categories = ContentRepository + .GetItems(descendants, loaderOptions) + .OfType() + .Where(x => x.RouteSegment.Equals(urlSegment, StringComparison.InvariantCultureIgnoreCase)); + + return categories + .FirstOrDefault( + category => CategoryPath(category).Equals(trimmedUrl, StringComparison.InvariantCultureIgnoreCase)); + } + public virtual IEnumerable GetGlobalCategories() where T : CategoryData { return GetChildren(ContentRepository.GetOrCreateGlobalCategoriesRoot()); @@ -145,5 +235,22 @@ protected virtual LoaderOptions CreateDefaultListOptions() LanguageLoaderOption.Fallback(LanguageResolver.GetPreferredCulture()) }; } + + private string CategoryPath(CategoryData category, string path = "") + { + if (category.ContentLink.ID != CategorySettings.GlobalCategoriesRoot && category.ContentLink.ID != CategorySettings.SiteCategoriesRoot) + { + path = "/" + category.RouteSegment + path; + + if (ContentRepository.TryGet(category.ParentLink, out var parentCategory) && parentCategory != null) + { + return CategoryPath(parentCategory, path); + } + } + + path = path.TrimEnd('/'); + + return path.TrimStart('/'); + } } -} \ No newline at end of file +} diff --git a/src/Geta.Optimizely.Categories/ICategoryContentLoader.cs b/src/Geta.Optimizely.Categories/ICategoryContentLoader.cs index 6561060..fc2a116 100644 --- a/src/Geta.Optimizely.Categories/ICategoryContentLoader.cs +++ b/src/Geta.Optimizely.Categories/ICategoryContentLoader.cs @@ -25,6 +25,22 @@ public interface ICategoryContentLoader T GetFirstBySegment(ContentReference parentLink, string urlSegment, LoaderOptions loaderOptions) where T : CategoryData; + IEnumerable GetCategoriesBySegment(string urlSegment) where T : CategoryData; + + IEnumerable GetCategoriesBySegment(string urlSegment, CultureInfo culture) where T : CategoryData; + + IEnumerable GetCategoriesBySegment(string urlSegment, LoaderOptions loaderOptions) where T : CategoryData; + + IEnumerable GetCategoriesBySegment(ContentReference parentLink, string urlSegment, LoaderOptions loaderOptions) where T : CategoryData; + + T GetCategoryByPath(string path) where T : CategoryData; + + T GetCategoryByPath(string path, CultureInfo culture) where T : CategoryData; + + T GetCategoryByPath(string path, LoaderOptions loaderOptions) where T : CategoryData; + + T GetCategoryByPath(ContentReference parentLink, string urlSegment, LoaderOptions loaderOptions) where T : CategoryData; + IEnumerable GetGlobalCategories() where T : CategoryData; IEnumerable GetGlobalCategories(CultureInfo culture) where T : CategoryData; @@ -39,4 +55,4 @@ public interface ICategoryContentLoader bool TryGet(ContentReference categoryLink, out T category) where T : CategoryData; } -} \ No newline at end of file +} diff --git a/src/Geta.Optimizely.Categories/Routing/CategoryModelBinder.cs b/src/Geta.Optimizely.Categories/Routing/CategoryModelBinder.cs index d81a67a..18c5439 100644 --- a/src/Geta.Optimizely.Categories/Routing/CategoryModelBinder.cs +++ b/src/Geta.Optimizely.Categories/Routing/CategoryModelBinder.cs @@ -1,14 +1,18 @@ // Copyright (c) Geta Digital. All rights reserved. // Licensed under Apache-2.0. See the LICENSE file in the project root for more information +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using EPiServer.Core; using EPiServer.Globalization; +using EPiServer.Shell.Web; using EPiServer.Web.Routing; +using Geta.Optimizely.Categories.Configuration; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.Options; namespace Geta.Optimizely.Categories.Routing { @@ -16,13 +20,18 @@ public class CategoryModelBinder : IModelBinder { private readonly ICategoryContentLoader _categoryContentLoader; private readonly IPageRouteHelper _pageRouteHelper; + private readonly CategoriesOptions _configuration; + + private readonly string _trailingSlash = "/"; public CategoryModelBinder( ICategoryContentLoader categoryContentLoader, - IPageRouteHelper pageRouteHelper) + IPageRouteHelper pageRouteHelper, + IOptions options) { _categoryContentLoader = categoryContentLoader; _pageRouteHelper = pageRouteHelper; + _configuration = options.Value; } public Task BindModelAsync(ModelBindingContext bindingContext) @@ -39,11 +48,24 @@ private IList GetCategoriesFromRequest(HttpRequest request) { return new List(); } - - var culture = (_pageRouteHelper.Content as ILocale).Language ?? ContentLanguage.PreferredCulture; - return categorySegments.Select(x => _categoryContentLoader.GetFirstBySegment(x, culture)) - .ToList(); + var preferredCulture = (_pageRouteHelper.Content as ILocale).Language ?? ContentLanguage.PreferredCulture; + + var categories = new List(); + + foreach (var categorySegment in categorySegments) + { + if (_configuration.UseUrlPathForCategoryRetrieval) + { + categories.Add(_categoryContentLoader.GetCategoryByPath(categorySegment, preferredCulture)); + } + else + { + categories.AddRange(_categoryContentLoader.GetCategoriesBySegment(categorySegment, preferredCulture)); + } + } + + return categories.Distinct().ToList(); } } } diff --git a/src/Geta.Optimizely.Categories/Routing/CategoryPartialRouter.cs b/src/Geta.Optimizely.Categories/Routing/CategoryPartialRouter.cs index a3a1dfb..d7129cc 100644 --- a/src/Geta.Optimizely.Categories/Routing/CategoryPartialRouter.cs +++ b/src/Geta.Optimizely.Categories/Routing/CategoryPartialRouter.cs @@ -3,11 +3,15 @@ using System; using System.Collections.Generic; +using System.IO; +using System.Linq; using EPiServer; using EPiServer.Core; using EPiServer.Core.Routing; using EPiServer.Core.Routing.Pipeline; +using EPiServer.DataAbstraction; using EPiServer.Globalization; +using EPiServer.Shell.Web; using EPiServer.Web; using Geta.Optimizely.Categories.Configuration; using Geta.Optimizely.Categories.Extensions; @@ -24,6 +28,8 @@ public class CategoryPartialRouter : IPartialRouter Configuration.CategorySeparator; + + private readonly string _trailingSlash = "/"; private HttpContext HttpContext => _httpContextAccessor.HttpContext; public CategoryPartialRouter( @@ -47,40 +53,55 @@ public object RoutePartial(ICategoryRoutableContent content, UrlResolverContext return null; } - var thisSegment = segmentContext.RemainingPath; - var nextSegment = segmentContext.GetNextRemainingSegment(segmentContext.RemainingPath); - - while (!string.IsNullOrEmpty(nextSegment.Remaining)) - { - nextSegment = segmentContext.GetNextRemainingSegment(nextSegment.Remaining); - } + // Trim the trailing slash from the remaining path to normalize the segment processing. + var remainingPath = segmentContext.RemainingPath.TrimEnd(_trailingSlash); - if (!string.IsNullOrWhiteSpace(nextSegment.Next)) + if (!string.IsNullOrWhiteSpace(remainingPath)) { var localizableContent = content as ILocale; var preferredCulture = localizableContent?.Language ?? ContentLanguage.PreferredCulture; + var segments = remainingPath.Split(new[] { CategorySeparator }, StringSplitOptions.RemoveEmptyEntries); - var categoryUrlSegments = nextSegment.Next.Split(new [] { CategorySeparator }, StringSplitOptions.RemoveEmptyEntries); - // Verify that all categories exist - foreach (var categoryUrlSegment in categoryUrlSegments) + var categoryUrlSegments = new List(); + var categoriesFound = false; + + foreach (var segment in segments) { - var category = CategoryLoader.GetFirstBySegment(categoryUrlSegment, preferredCulture); - if (category == null) + if (Configuration.UseUrlPathForCategoryRetrieval) { - return null; + var category = CategoryLoader.GetCategoryByPath(segment, preferredCulture); + if (category != null) + { + categoryUrlSegments.Add(segment); + categoriesFound = true; + } + } + else + { + var urlSegment = segment.Contains(_trailingSlash) ? segment.Split(_trailingSlash).Last() : segment; + var category = CategoryLoader.GetFirstBySegment(urlSegment, preferredCulture); + + if (category != null) + { + categoryUrlSegments.Add(urlSegment); + categoriesFound = true; + } } } - segmentContext.RemainingPath = thisSegment.Substring(0, thisSegment.LastIndexOf(nextSegment.Next, StringComparison.InvariantCultureIgnoreCase)); - - HttpContext.Request.RouteValues.Add(CategoryRoutingConstants.CurrentCategories, categoryUrlSegments); - - return content; + if (categoriesFound) + { + // Reset the remaining path and store the found categories in the route values for further processing. + segmentContext.RemainingPath = string.Empty; + HttpContext.Request.RouteValues.Add(CategoryRoutingConstants.CurrentCategories, categoryUrlSegments.Distinct().ToArray()); + return content; + } } return null; } + private bool CategoriesResolved() { return HttpContext.Request.RouteValues.ContainsKey(CategoryRoutingConstants.CurrentCategories); @@ -110,7 +131,7 @@ public PartialRouteData GetPartialVirtualPath(ICategoryRoutableContent content, if (!ContentLoader.TryGet(categoryContentLink, out CategoryData category)) { return null; - } + } categorySegments.Add(category.RouteSegment); } @@ -130,4 +151,4 @@ public PartialRouteData GetPartialVirtualPath(ICategoryRoutableContent content, return null; } } -} \ No newline at end of file +} From 46dbe9acbf90033b7e50a89e3974f2b2a253800a Mon Sep 17 00:00:00 2001 From: "EP\\adza" Date: Wed, 20 Mar 2024 11:38:50 +0000 Subject: [PATCH 2/5] added option to get categories from GlobalCategoriesRoot and SiteCategoriesRoot --- .../DefaultCategoryContentLoader.cs | 29 ++++++++++++------- .../Routing/CategoryPartialRouter.cs | 2 -- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/Geta.Optimizely.Categories/DefaultCategoryContentLoader.cs b/src/Geta.Optimizely.Categories/DefaultCategoryContentLoader.cs index e258681..4dfff17 100644 --- a/src/Geta.Optimizely.Categories/DefaultCategoryContentLoader.cs +++ b/src/Geta.Optimizely.Categories/DefaultCategoryContentLoader.cs @@ -113,17 +113,27 @@ public IEnumerable GetCategoriesBySegment(string urlSegment, CultureInfo c public IEnumerable GetCategoriesBySegment(string urlSegment, LoaderOptions loaderOptions) where T : CategoryData { + var categories = new List(); + if (SiteDefinition.Current.SiteAssetsRoot != SiteDefinition.Current.GlobalAssetsRoot) { - var siteCategory = GetCategoriesBySegment(ContentRepository.GetOrCreateSiteCategoriesRoot(), urlSegment, loaderOptions); + var siteCategories = + GetCategoriesBySegment(ContentRepository.GetOrCreateSiteCategoriesRoot(), urlSegment, loaderOptions); - if (siteCategory != null && siteCategory.Any()) + if (siteCategories != null && siteCategories.Any()) { - return siteCategory; + categories.AddRange(siteCategories); } } - return GetCategoriesBySegment(ContentRepository.GetOrCreateGlobalCategoriesRoot(), urlSegment, loaderOptions); + var globalCategories = GetCategoriesBySegment(ContentRepository.GetOrCreateGlobalCategoriesRoot(), urlSegment, loaderOptions); + + if (globalCategories != null && globalCategories.Any()) + { + categories.AddRange(globalCategories); + } + + return categories; } public virtual IEnumerable GetCategoriesBySegment(ContentReference parentLink, string urlSegment, LoaderOptions loaderOptions) where T : CategoryData @@ -238,14 +248,11 @@ protected virtual LoaderOptions CreateDefaultListOptions() private string CategoryPath(CategoryData category, string path = "") { - if (category.ContentLink.ID != CategorySettings.GlobalCategoriesRoot && category.ContentLink.ID != CategorySettings.SiteCategoriesRoot) - { - path = "/" + category.RouteSegment + path; + path = "/" + category.RouteSegment + path; - if (ContentRepository.TryGet(category.ParentLink, out var parentCategory) && parentCategory != null) - { - return CategoryPath(parentCategory, path); - } + if (ContentRepository.TryGet(category.ParentLink, out var parentCategory) && parentCategory != null) + { + return CategoryPath(parentCategory, path); } path = path.TrimEnd('/'); diff --git a/src/Geta.Optimizely.Categories/Routing/CategoryPartialRouter.cs b/src/Geta.Optimizely.Categories/Routing/CategoryPartialRouter.cs index d7129cc..fdc4e34 100644 --- a/src/Geta.Optimizely.Categories/Routing/CategoryPartialRouter.cs +++ b/src/Geta.Optimizely.Categories/Routing/CategoryPartialRouter.cs @@ -3,13 +3,11 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; using EPiServer; using EPiServer.Core; using EPiServer.Core.Routing; using EPiServer.Core.Routing.Pipeline; -using EPiServer.DataAbstraction; using EPiServer.Globalization; using EPiServer.Shell.Web; using EPiServer.Web; From 49f87d2dde355a88b171c08bd07f14a4f80f3cc9 Mon Sep 17 00:00:00 2001 From: "EP\\adza" Date: Wed, 20 Mar 2024 15:14:54 +0000 Subject: [PATCH 3/5] updated documentation --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 7473e27..c7448c3 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,10 @@ public interface ICategoryContentLoader IEnumerable GetSiteCategories() where T : CategoryData; //... + overloads bool TryGet(ContentReference categoryLink, out T category) where T : CategoryData; + //... + overloads + IEnumerable GetCategoriesBySegment(string urlSegment) where T : CategoryData; + //... + overloads + T GetCategoryByPath(string path) where T : CategoryData; } ``` From 483363646049510bace8a40c6cf8dc6f8fc9a575 Mon Sep 17 00:00:00 2001 From: "EP\\adza" Date: Thu, 21 Mar 2024 11:56:21 +0000 Subject: [PATCH 4/5] updated documentation --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c7448c3..b336069 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ Two routes are mapped during initialization. One for site categories and one for ![for this site routing](/docs/for-this-site.jpg) -Using above example, the URL "/siteassets/topics/sports/" would be routed to the site category called "Sports". Similarly you could go to "/globalassets/topics/global-category-1" for the global category "Global category 1". +As illustrated in the given example, if the UseUrlPathForCategoryRetrieval is enabled (set to true), navigating to the URL "/topics/sports/" directs you to the site's "Sports" category. In a similar manner, accessing "/global-topics/global-category-1" takes you to the "Global category 1". On the other hand, when UseUrlPathForCategoryRetrieval is disabled (set to false), the URL "/topics/sports/" will retrieve all categories associated with the segment "sports". Likewise, the URL "/global-topics/global-category-1" will fetch all categories, both global and site-specific, linked with the segment "global-category-1" ### ICategoryRoutableContent interface From f4ec076f2918ce2df7241c163db186e28961faad Mon Sep 17 00:00:00 2001 From: "EP\\adza" Date: Thu, 21 Mar 2024 14:05:00 +0000 Subject: [PATCH 5/5] updated parameter name --- src/Geta.Optimizely.Categories/ICategoryContentLoader.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Geta.Optimizely.Categories/ICategoryContentLoader.cs b/src/Geta.Optimizely.Categories/ICategoryContentLoader.cs index fc2a116..420718c 100644 --- a/src/Geta.Optimizely.Categories/ICategoryContentLoader.cs +++ b/src/Geta.Optimizely.Categories/ICategoryContentLoader.cs @@ -39,7 +39,7 @@ public interface ICategoryContentLoader T GetCategoryByPath(string path, LoaderOptions loaderOptions) where T : CategoryData; - T GetCategoryByPath(ContentReference parentLink, string urlSegment, LoaderOptions loaderOptions) where T : CategoryData; + T GetCategoryByPath(ContentReference parentLink, string path, LoaderOptions loaderOptions) where T : CategoryData; IEnumerable GetGlobalCategories() where T : CategoryData;