diff --git a/src/Umbraco.Core/Models/ContentBaseExtensions.cs b/src/Umbraco.Core/Models/ContentBaseExtensions.cs
index 09aeee2f7dc1..0953242c2520 100644
--- a/src/Umbraco.Core/Models/ContentBaseExtensions.cs
+++ b/src/Umbraco.Core/Models/ContentBaseExtensions.cs
@@ -11,7 +11,7 @@ public static class ContentBaseExtensions
private static DefaultUrlSegmentProvider? _defaultUrlSegmentProvider;
///
- /// Gets the URL segment for a specified content and culture.
+ /// Gets a single URL segment for a specified content and culture.
///
/// The content.
///
@@ -19,29 +19,73 @@ public static class ContentBaseExtensions
/// The culture.
/// Whether to get the published or draft.
/// The URL segment.
+ ///
+ /// If more than one URL segment provider is available, the first one that returns a non-null value will be returned.
+ ///
public static string? GetUrlSegment(this IContentBase content, IShortStringHelper shortStringHelper, IEnumerable urlSegmentProviders, string? culture = null, bool published = true)
{
- if (content == null)
- {
- throw new ArgumentNullException(nameof(content));
- }
+ var urlSegment = GetUrlSegments(content, urlSegmentProviders, culture, published).FirstOrDefault();
+
+ // Ensure we have at least the segment from the default URL provider returned.
+ urlSegment ??= GetDefaultUrlSegment(shortStringHelper, content, culture, published);
- if (urlSegmentProviders == null)
+ return urlSegment;
+ }
+
+ ///
+ /// Gets all URL segments for a specified content and culture.
+ ///
+ /// The content.
+ ///
+ ///
+ /// The culture.
+ /// Whether to get the published or draft.
+ /// The collection of URL segments.
+ public static IEnumerable GetUrlSegments(this IContentBase content, IShortStringHelper shortStringHelper, IEnumerable urlSegmentProviders, string? culture = null, bool published = true)
+ {
+ var urlSegments = GetUrlSegments(content, urlSegmentProviders, culture, published).Distinct().ToList();
+
+ // Ensure we have at least the segment from the default URL provider returned.
+ if (urlSegments.Count == 0)
{
- throw new ArgumentNullException(nameof(urlSegmentProviders));
+ var defaultUrlSegment = GetDefaultUrlSegment(shortStringHelper, content, culture, published);
+ if (defaultUrlSegment is not null)
+ {
+ urlSegments.Add(defaultUrlSegment);
+ }
}
- var url = urlSegmentProviders.Select(p => p.GetUrlSegment(content, published, culture)).FirstOrDefault(u => u != null);
- if (url == null)
+ return urlSegments;
+ }
+
+ private static IEnumerable GetUrlSegments(
+ IContentBase content,
+ IEnumerable urlSegmentProviders,
+ string? culture,
+ bool published)
+ {
+ foreach (IUrlSegmentProvider urlSegmentProvider in urlSegmentProviders)
{
- if (_defaultUrlSegmentProvider == null)
+ var segment = urlSegmentProvider.GetUrlSegment(content, published, culture);
+ if (string.IsNullOrEmpty(segment) == false)
{
- _defaultUrlSegmentProvider = new DefaultUrlSegmentProvider(shortStringHelper);
- }
+ yield return segment;
- url = _defaultUrlSegmentProvider.GetUrlSegment(content, culture); // be safe
+ if (urlSegmentProvider.AllowAdditionalSegments is false)
+ {
+ yield break;
+ }
+ }
}
+ }
- return url;
+ private static string? GetDefaultUrlSegment(
+ IShortStringHelper shortStringHelper,
+ IContentBase content,
+ string? culture,
+ bool published)
+ {
+ _defaultUrlSegmentProvider ??= new DefaultUrlSegmentProvider(shortStringHelper);
+ return _defaultUrlSegmentProvider.GetUrlSegment(content, published, culture);
}
}
diff --git a/src/Umbraco.Core/Models/PublishedDocumentUrlSegment.cs b/src/Umbraco.Core/Models/PublishedDocumentUrlSegment.cs
index 81451d3223af..a16e7423aa26 100644
--- a/src/Umbraco.Core/Models/PublishedDocumentUrlSegment.cs
+++ b/src/Umbraco.Core/Models/PublishedDocumentUrlSegment.cs
@@ -1,9 +1,32 @@
namespace Umbraco.Cms.Core.Models;
+///
+/// Represents a URL segment for a published document.
+///
public class PublishedDocumentUrlSegment
{
+ ///
+ /// Gets or sets the document key.
+ ///
public required Guid DocumentKey { get; set; }
+
+ ///
+ /// Gets or sets the language Id.
+ ///
public required int LanguageId { get; set; }
+
+ ///
+ /// Gets or sets the URL segment string.
+ ///
public required string UrlSegment { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the URL segment is for a draft.
+ ///
public required bool IsDraft { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the URL segment is the primary one (first resolved from the collection of URL providers).
+ ///
+ public required bool IsPrimary { get; set; }
}
diff --git a/src/Umbraco.Core/Services/DocumentUrlService.cs b/src/Umbraco.Core/Services/DocumentUrlService.cs
index 09c2e434eaf6..c73b2c41e6db 100644
--- a/src/Umbraco.Core/Services/DocumentUrlService.cs
+++ b/src/Umbraco.Core/Services/DocumentUrlService.cs
@@ -1,4 +1,5 @@
using System.Collections.Concurrent;
+using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Runtime.CompilerServices;
using Microsoft.Extensions.Logging;
@@ -15,6 +16,9 @@
namespace Umbraco.Cms.Core.Services;
+///
+/// Implements operations for handling document URLs.
+///
public class DocumentUrlService : IDocumentUrlService
{
private const string RebuildKey = "UmbracoUrlGeneration";
@@ -34,9 +38,39 @@ public class DocumentUrlService : IDocumentUrlService
private readonly IPublishStatusQueryService _publishStatusQueryService;
private readonly IDomainCacheService _domainCacheService;
- private readonly ConcurrentDictionary _cache = new();
+ private readonly ConcurrentDictionary _cache = new();
private bool _isInitialized;
+ ///
+ /// Model used to cache a single published document along with all it's URL segments.
+ ///
+ private class PublishedDocumentUrlSegments
+ {
+ public required Guid DocumentKey { get; set; }
+
+ public required int LanguageId { get; set; }
+
+ public required IList UrlSegments { get; set; }
+
+ public required bool IsDraft { get; set; }
+
+ public class UrlSegment
+ {
+ public UrlSegment(string segment, bool isPrimary)
+ {
+ Segment = segment;
+ IsPrimary = isPrimary;
+ }
+
+ public string Segment { get; }
+
+ public bool IsPrimary { get; }
+ }
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
public DocumentUrlService(
ILogger logger,
IDocumentUrlRepository documentUrlRepository,
@@ -69,6 +103,7 @@ public DocumentUrlService(
_domainCacheService = domainCacheService;
}
+ ///
public async Task InitAsync(bool forceEmpty, CancellationToken cancellationToken)
{
if (forceEmpty)
@@ -79,9 +114,9 @@ public async Task InitAsync(bool forceEmpty, CancellationToken cancellationToken
}
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
- if (await ShouldRebuildUrlsAsync())
+ if (ShouldRebuildUrls())
{
- _logger.LogInformation("Rebuilding all urls.");
+ _logger.LogInformation("Rebuilding all URLs.");
await RebuildAllUrlsAsync();
}
@@ -89,7 +124,7 @@ public async Task InitAsync(bool forceEmpty, CancellationToken cancellationToken
IEnumerable languages = await _languageService.GetAllAsync();
var languageIdToIsoCode = languages.ToDictionary(x => x.Id, x => x.IsoCode);
- foreach (PublishedDocumentUrlSegment publishedDocumentUrlSegment in publishedDocumentUrlSegments)
+ foreach (PublishedDocumentUrlSegments publishedDocumentUrlSegment in ConvertToCacheModel(publishedDocumentUrlSegments))
{
if (cancellationToken.IsCancellationRequested)
{
@@ -101,39 +136,77 @@ public async Task InitAsync(bool forceEmpty, CancellationToken cancellationToken
UpdateCache(_coreScopeProvider.Context!, publishedDocumentUrlSegment, isoCode);
}
}
+
_isInitialized = true;
scope.Complete();
}
- private void UpdateCache(IScopeContext scopeContext, PublishedDocumentUrlSegment publishedDocumentUrlSegment, string isoCode)
+ private bool ShouldRebuildUrls()
{
- var cacheKey = CreateCacheKey(publishedDocumentUrlSegment.DocumentKey, isoCode, publishedDocumentUrlSegment.IsDraft);
+ var persistedValue = GetPersistedRebuildValue();
+ var currentValue = GetCurrentRebuildValue();
- scopeContext.Enlist("UpdateCache_" + cacheKey, () =>
- {
- _cache.TryGetValue(cacheKey, out PublishedDocumentUrlSegment? existingValue);
+ return string.Equals(persistedValue, currentValue) is false;
+ }
- if (existingValue is null)
+ private string? GetPersistedRebuildValue() => _keyValueService.GetValue(RebuildKey);
+
+ private string GetCurrentRebuildValue() => string.Join("|", _urlSegmentProviderCollection.Select(x => x.GetType().Name));
+
+ ///
+ public async Task RebuildAllUrlsAsync()
+ {
+ using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
+ scope.ReadLock(Constants.Locks.ContentTree);
+
+ IEnumerable documents = _documentRepository.GetMany(Array.Empty());
+
+ await CreateOrUpdateUrlSegmentsAsync(documents);
+
+ _keyValueService.SetValue(RebuildKey, GetCurrentRebuildValue());
+
+ scope.Complete();
+ }
+
+ private static IEnumerable ConvertToCacheModel(IEnumerable publishedDocumentUrlSegments)
+ {
+ var cacheModels = new List();
+ foreach (PublishedDocumentUrlSegment model in publishedDocumentUrlSegments)
+ {
+ PublishedDocumentUrlSegments? existingCacheModel = GetModelFromCache(cacheModels, model);
+ if (existingCacheModel is null)
{
- if (_cache.TryAdd(cacheKey, publishedDocumentUrlSegment) is false)
+ cacheModels.Add(new PublishedDocumentUrlSegments
{
- _logger.LogError("Could not add the document url cache.");
- return false;
- }
+ DocumentKey = model.DocumentKey,
+ LanguageId = model.LanguageId,
+ UrlSegments = [new PublishedDocumentUrlSegments.UrlSegment(model.UrlSegment, model.IsPrimary)],
+ IsDraft = model.IsDraft,
+ });
}
else
{
- if (_cache.TryUpdate(cacheKey, publishedDocumentUrlSegment, existingValue) is false)
- {
- _logger.LogError("Could not update the document url cache.");
- return false;
- }
+ existingCacheModel.UrlSegments = GetUpdatedUrlSegments(existingCacheModel.UrlSegments, model.UrlSegment, model.IsPrimary);
}
+ }
- return true;
- });
+ return cacheModels;
+ }
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static PublishedDocumentUrlSegments? GetModelFromCache(List cacheModels, PublishedDocumentUrlSegment model)
+ => cacheModels
+ .SingleOrDefault(x => x.DocumentKey == model.DocumentKey && x.LanguageId == model.LanguageId && x.IsDraft == model.IsDraft);
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static IList GetUpdatedUrlSegments(IList urlSegments, string segment, bool isPrimary)
+ {
+ if (urlSegments.FirstOrDefault(x => x.Segment == segment) is null)
+ {
+ urlSegments.Add(new PublishedDocumentUrlSegments.UrlSegment(segment, isPrimary));
+ }
+
+ return urlSegments;
}
private void RemoveFromCache(IScopeContext scopeContext, Guid documentKey, string isoCode, bool isDraft)
@@ -152,40 +225,58 @@ private void RemoveFromCache(IScopeContext scopeContext, Guid documentKey, strin
});
}
- public async Task RebuildAllUrlsAsync()
+ private void UpdateCache(IScopeContext scopeContext, PublishedDocumentUrlSegments publishedDocumentUrlSegments, string isoCode)
{
- using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
- scope.ReadLock(Constants.Locks.ContentTree);
-
- IEnumerable documents = _documentRepository.GetMany(Array.Empty());
+ var cacheKey = CreateCacheKey(publishedDocumentUrlSegments.DocumentKey, isoCode, publishedDocumentUrlSegments.IsDraft);
- await CreateOrUpdateUrlSegmentsAsync(documents);
+ scopeContext.Enlist("UpdateCache_" + cacheKey, () =>
+ {
+ _cache.TryGetValue(cacheKey, out PublishedDocumentUrlSegments? existingValue);
- _keyValueService.SetValue(RebuildKey, GetCurrentRebuildValue());
+ if (existingValue is null)
+ {
+ if (_cache.TryAdd(cacheKey, publishedDocumentUrlSegments) is false)
+ {
+ _logger.LogError("Could not add to the document url cache.");
+ return false;
+ }
+ }
+ else
+ {
+ if (_cache.TryUpdate(cacheKey, publishedDocumentUrlSegments, existingValue) is false)
+ {
+ _logger.LogError("Could not update the document url cache.");
+ return false;
+ }
+ }
- scope.Complete();
+ return true;
+ });
}
- private Task ShouldRebuildUrlsAsync()
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static string CreateCacheKey(Guid documentKey, string culture, bool isDraft) => $"{documentKey}|{culture}|{isDraft}".ToLowerInvariant();
+
+ ///
+ public string? GetUrlSegment(Guid documentKey, string culture, bool isDraft)
{
- var persistedValue = GetPersistedRebuildValue();
- var currentValue = GetCurrentRebuildValue();
+ ThrowIfNotInitialized();
+ var cacheKey = CreateCacheKey(documentKey, culture, isDraft);
- return Task.FromResult(string.Equals(persistedValue, currentValue) is false);
- }
+ _cache.TryGetValue(cacheKey, out PublishedDocumentUrlSegments? urlSegment);
- private string GetCurrentRebuildValue() => string.Join("|", _urlSegmentProviderCollection.Select(x => x.GetType().Name));
-
- private string? GetPersistedRebuildValue() => _keyValueService.GetValue(RebuildKey);
+ return urlSegment?.UrlSegments.FirstOrDefault(x => x.IsPrimary)?.Segment;
+ }
- public string? GetUrlSegment(Guid documentKey, string culture, bool isDraft)
+ ///
+ public IEnumerable GetUrlSegments(Guid documentKey, string culture, bool isDraft)
{
ThrowIfNotInitialized();
var cacheKey = CreateCacheKey(documentKey, culture, isDraft);
- _cache.TryGetValue(cacheKey, out PublishedDocumentUrlSegment? urlSegment);
+ _cache.TryGetValue(cacheKey, out PublishedDocumentUrlSegments? urlSegments);
- return urlSegment?.UrlSegment;
+ return urlSegments?.UrlSegments.Select(x => x.Segment) ?? Enumerable.Empty();
}
private void ThrowIfNotInitialized()
@@ -196,10 +287,35 @@ private void ThrowIfNotInitialized()
}
}
+ ///
+ public async Task CreateOrUpdateUrlSegmentsAsync(Guid key)
+ {
+ IContent? content = _contentService.GetById(key);
+
+ if (content is not null)
+ {
+ await CreateOrUpdateUrlSegmentsAsync(content.Yield());
+ }
+ }
+
+ ///
+ public async Task CreateOrUpdateUrlSegmentsWithDescendantsAsync(Guid key)
+ {
+ var id = _idKeyMap.GetIdForKey(key, UmbracoObjectTypes.Document).Result;
+ IContent item = _contentService.GetById(id)!;
+ IEnumerable descendants = _contentService.GetPagedDescendants(id, 0, int.MaxValue, out _);
+
+ await CreateOrUpdateUrlSegmentsAsync(new List(descendants)
+ {
+ item,
+ });
+ }
+
+ ///
public async Task CreateOrUpdateUrlSegmentsAsync(IEnumerable documentsEnumerable)
{
IEnumerable documents = documentsEnumerable as IContent[] ?? documentsEnumerable.ToArray();
- if(documents.Any() is false)
+ if (documents.Any() is false)
{
return;
}
@@ -209,7 +325,7 @@ public async Task CreateOrUpdateUrlSegmentsAsync(IEnumerable documents
var toSave = new List();
IEnumerable languages = await _languageService.GetAllAsync();
- var languageDictionary = languages.ToDictionary(x=>x.IsoCode);
+ var languageDictionary = languages.ToDictionary(x => x.IsoCode);
foreach (IContent document in documents)
{
@@ -224,7 +340,7 @@ public async Task CreateOrUpdateUrlSegmentsAsync(IEnumerable documents
}
}
- if(toSave.Any())
+ if (toSave.Count > 0)
{
_documentUrlRepository.Save(toSave);
}
@@ -234,9 +350,9 @@ public async Task CreateOrUpdateUrlSegmentsAsync(IEnumerable documents
private void HandleCaching(IScopeContext scopeContext, IContent document, string? culture, ILanguage language, List toSave)
{
- IEnumerable<(PublishedDocumentUrlSegment model, bool shouldCache)> modelsAndStatus = GenerateModels(document, culture, language);
+ IEnumerable<(PublishedDocumentUrlSegments model, bool shouldCache)> modelsAndStatus = GenerateModels(document, culture, language);
- foreach ((PublishedDocumentUrlSegment model, bool shouldCache) in modelsAndStatus)
+ foreach ((PublishedDocumentUrlSegments model, bool shouldCache) in modelsAndStatus)
{
if (shouldCache is false)
{
@@ -244,69 +360,91 @@ private void HandleCaching(IScopeContext scopeContext, IContent document, string
}
else
{
- toSave.Add(model);
+ toSave.AddRange(ConvertToPersistedModel(model));
UpdateCache(scopeContext, model, language.IsoCode);
}
}
}
- private IEnumerable<(PublishedDocumentUrlSegment model, bool shouldCache)> GenerateModels(IContent document, string? culture, ILanguage language)
+ private IEnumerable<(PublishedDocumentUrlSegments model, bool shouldCache)> GenerateModels(IContent document, string? culture, ILanguage language)
{
if (document.Trashed is false
&& (IsInvariantAndPublished(document) || IsVariantAndPublishedForCulture(document, culture)))
{
- var publishedUrlSegment =
- document.GetUrlSegment(_shortStringHelper, _urlSegmentProviderCollection, culture);
- if (publishedUrlSegment.IsNullOrWhiteSpace())
+ string[] publishedUrlSegments = document.GetUrlSegments(_shortStringHelper, _urlSegmentProviderCollection, culture).ToArray();
+ if (publishedUrlSegments.Length == 0)
{
- _logger.LogWarning("No published url segment found for document {DocumentKey} in culture {Culture}", document.Key, culture ?? "{null}");
+ _logger.LogWarning("No published URL segments found for document {DocumentKey} in culture {Culture}", document.Key, culture ?? "{null}");
}
else
{
- yield return (new PublishedDocumentUrlSegment()
+ yield return (new PublishedDocumentUrlSegments
{
DocumentKey = document.Key,
LanguageId = language.Id,
- UrlSegment = publishedUrlSegment,
- IsDraft = false
+ UrlSegments = publishedUrlSegments
+ .Select((x, i) => new PublishedDocumentUrlSegments.UrlSegment(x, i == 0))
+ .ToList(),
+ IsDraft = false,
}, true);
}
}
else
{
- yield return (new PublishedDocumentUrlSegment()
+ yield return (new PublishedDocumentUrlSegments
{
DocumentKey = document.Key,
LanguageId = language.Id,
- UrlSegment = string.Empty,
- IsDraft = false
+ UrlSegments = [],
+ IsDraft = false,
}, false);
}
- var draftUrlSegment = document.GetUrlSegment(_shortStringHelper, _urlSegmentProviderCollection, culture, false);
+ string[] draftUrlSegments = document.GetUrlSegments(_shortStringHelper, _urlSegmentProviderCollection, culture, false).ToArray();
- if(draftUrlSegment.IsNullOrWhiteSpace())
+ if (draftUrlSegments.Any() is false)
{
- _logger.LogWarning("No draft url segment found for document {DocumentKey} in culture {Culture}", document.Key, culture ?? "{null}");
+ _logger.LogWarning("No draft URL segments found for document {DocumentKey} in culture {Culture}", document.Key, culture ?? "{null}");
}
else
{
- yield return (new PublishedDocumentUrlSegment()
+ yield return (new PublishedDocumentUrlSegments
{
- DocumentKey = document.Key, LanguageId = language.Id, UrlSegment = draftUrlSegment, IsDraft = true
+ DocumentKey = document.Key,
+ LanguageId = language.Id,
+ UrlSegments = draftUrlSegments
+ .Select((x, i) => new PublishedDocumentUrlSegments.UrlSegment(x, i == 0))
+ .ToList(),
+ IsDraft = true,
}, document.Trashed is false);
}
}
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- private static bool IsVariantAndPublishedForCulture(IContent document, string? culture) =>
- document.PublishCultureInfos?.Values.Any(x => x.Culture == culture) ?? false;
-
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool IsInvariantAndPublished(IContent document)
=> document.ContentType.VariesByCulture() is false // Is Invariant
&& document.Published; // Is Published
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static bool IsVariantAndPublishedForCulture(IContent document, string? culture) =>
+ document.PublishCultureInfos?.Values.Any(x => x.Culture == culture) ?? false;
+
+ private IEnumerable ConvertToPersistedModel(PublishedDocumentUrlSegments model)
+ {
+ foreach (PublishedDocumentUrlSegments.UrlSegment urlSegment in model.UrlSegments)
+ {
+ yield return new PublishedDocumentUrlSegment
+ {
+ DocumentKey = model.DocumentKey,
+ LanguageId = model.LanguageId,
+ UrlSegment = urlSegment.Segment,
+ IsDraft = model.IsDraft,
+ IsPrimary = urlSegment.IsPrimary,
+ };
+ }
+ }
+
+ ///
public async Task DeleteUrlsFromCacheAsync(IEnumerable documentKeysEnumerable)
{
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
@@ -327,6 +465,7 @@ public async Task DeleteUrlsFromCacheAsync(IEnumerable documentKeysEnumera
scope.Complete();
}
+ ///
public Guid? GetDocumentKeyByRoute(string route, string? culture, int? documentStartNodeId, bool isDraft)
{
var urlSegments = route.Split(Constants.CharArrays.ForwardSlash, StringSplitOptions.RemoveEmptyEntries);
@@ -361,7 +500,7 @@ public async Task DeleteUrlsFromCacheAsync(IEnumerable documentKeysEnumera
// Otherwise we have to find the child with that segment anc follow that
foreach (var urlSegment in urlSegments)
{
- //Get the children of the runnerKey and find the child (if any) with the correct url segment
+ // Get the children of the runnerKey and find the child (if any) with the correct url segment
IEnumerable childKeys = GetChildKeys(runnerKey.Value);
runnerKey = GetChildWithUrlSegment(childKeys, urlSegment, culture, isDraft);
@@ -371,7 +510,8 @@ public async Task DeleteUrlsFromCacheAsync(IEnumerable documentKeysEnumera
{
break;
}
- //if part of the path is unpublished, we need to break
+
+ // If part of the path is unpublished, we need to break
if (isDraft is false && IsContentPublished(runnerKey.Value, culture) is false)
{
return null;
@@ -380,8 +520,9 @@ public async Task DeleteUrlsFromCacheAsync(IEnumerable documentKeysEnumera
return runnerKey;
}
+
// If there is no parts, it means it is a root (and no assigned domain)
- if(urlSegments.Length == 0)
+ if (urlSegments.Length == 0)
{
// // if we do not hide the top level and no domain was found, it mean there is no content.
// // TODO we can remove this to keep consistency with the old routing, but it seems incorrect to allow that.
@@ -435,13 +576,106 @@ public async Task DeleteUrlsFromCacheAsync(IEnumerable documentKeysEnumera
return runnerKey;
}
+ private Guid? GetStartNodeKey(int? documentStartNodeId)
+ {
+ if (documentStartNodeId is null)
+ {
+ return null;
+ }
+
+ Attempt attempt = _idKeyMap.GetKeyForId(documentStartNodeId.Value, UmbracoObjectTypes.Document);
+ return attempt.Success ? attempt.Result : null;
+ }
+
private bool IsContentPublished(Guid contentKey, string culture) => _publishStatusQueryService.IsDocumentPublished(contentKey, culture);
+ ///
+ /// Gets the children based on the latest published version of the content. (No aware of things in this scope).
+ ///
+ /// The key of the document to get children from.
+ /// The keys of all the children of the document.
+ private IEnumerable GetChildKeys(Guid documentKey)
+ {
+ if (_documentNavigationQueryService.TryGetChildrenKeys(documentKey, out IEnumerable childrenKeys))
+ {
+ return childrenKeys;
+ }
+
+ return [];
+ }
+
+ private Guid? GetChildWithUrlSegment(IEnumerable childKeys, string urlSegment, string culture, bool isDraft)
+ {
+ foreach (Guid childKey in childKeys)
+ {
+ IEnumerable childUrlSegments = GetUrlSegments(childKey, culture, isDraft);
+
+ if (childUrlSegments.Contains(urlSegment))
+ {
+ return childKey;
+ }
+ }
+
+ return null;
+ }
+
+ private Guid? GetTopMostRootKey(bool isDraft, string culture) => GetRootKeys(isDraft, culture).Cast().FirstOrDefault();
+
+ private IEnumerable GetRootKeys(bool isDraft, string culture)
+ {
+ if (_documentNavigationQueryService.TryGetRootKeys(out IEnumerable rootKeys))
+ {
+ foreach (Guid rootKey in rootKeys)
+ {
+ if (isDraft || IsContentPublished(rootKey, culture))
+ {
+ yield return rootKey;
+ }
+ }
+ }
+ }
+
+ private IEnumerable GetKeysInRoot(bool considerFirstLevelAsRoot, bool isDraft, string culture)
+ {
+ if (_documentNavigationQueryService.TryGetRootKeys(out IEnumerable rootKeysEnumerable) is false)
+ {
+ yield break;
+ }
+
+ IEnumerable rootKeys = rootKeysEnumerable as Guid[] ?? rootKeysEnumerable.ToArray();
+
+ if (considerFirstLevelAsRoot)
+ {
+ foreach (Guid rootKey in rootKeys)
+ {
+ if (isDraft is false && IsContentPublished(rootKey, culture) is false)
+ {
+ continue;
+ }
+
+ IEnumerable childKeys = GetChildKeys(rootKey);
+
+ foreach (Guid childKey in childKeys)
+ {
+ yield return childKey;
+ }
+ }
+ }
+ else
+ {
+ foreach (Guid rootKey in rootKeys)
+ {
+ yield return rootKey;
+ }
+ }
+ }
+
+ ///
public string GetLegacyRouteFormat(Guid documentKey, string? culture, bool isDraft)
{
Attempt documentIdAttempt = _idKeyMap.GetIdForKey(documentKey, UmbracoObjectTypes.Document);
- if(documentIdAttempt.Success is false)
+ if (documentIdAttempt.Success is false)
{
return "#";
}
@@ -451,19 +685,19 @@ public string GetLegacyRouteFormat(Guid documentKey, string? culture, bool isDra
return "#";
}
- if(isDraft is false && string.IsNullOrWhiteSpace(culture) is false && _publishStatusQueryService.IsDocumentPublished(documentKey, culture) is false)
+ if (isDraft is false && string.IsNullOrWhiteSpace(culture) is false && _publishStatusQueryService.IsDocumentPublished(documentKey, culture) is false)
{
return "#";
}
- var cultureOrDefault = string.IsNullOrWhiteSpace(culture) is false ? culture : _languageService.GetDefaultIsoCodeAsync().GetAwaiter().GetResult();
+ string cultureOrDefault = GetCultureOrDefault(culture);
Guid[] ancestorsOrSelfKeysArray = ancestorsOrSelfKeys as Guid[] ?? ancestorsOrSelfKeys.ToArray();
ILookup ancestorOrSelfKeyToDomains = ancestorsOrSelfKeysArray.ToLookup(x => x, ancestorKey =>
{
Attempt idAttempt = _idKeyMap.GetIdForKey(ancestorKey, UmbracoObjectTypes.Document);
- if(idAttempt.Success is false)
+ if (idAttempt.Success is false)
{
return null;
}
@@ -472,9 +706,7 @@ public string GetLegacyRouteFormat(Guid documentKey, string? culture, bool isDra
// If no culture is specified, we assume invariant and return the first domain.
// This is also only used to later to specify the node id in the route, so it does not matter what culture it is.
- return string.IsNullOrEmpty(culture)
- ? domains.FirstOrDefault()
- : domains.FirstOrDefault(x => x.Culture?.Equals(culture, StringComparison.InvariantCultureIgnoreCase) ?? false);
+ return GetDomainForCultureOrInvariant(domains, culture);
});
var urlSegments = new List();
@@ -483,16 +715,16 @@ public string GetLegacyRouteFormat(Guid documentKey, string? culture, bool isDra
foreach (Guid ancestorOrSelfKey in ancestorsOrSelfKeysArray)
{
- var domains = ancestorOrSelfKeyToDomains[ancestorOrSelfKey].WhereNotNull();
+ IEnumerable domains = ancestorOrSelfKeyToDomains[ancestorOrSelfKey].WhereNotNull();
if (domains.Any())
{
foundDomain = domains.First();// What todo here that is better?
break;
}
- if (_cache.TryGetValue(CreateCacheKey(ancestorOrSelfKey, cultureOrDefault, isDraft), out PublishedDocumentUrlSegment? publishedDocumentUrlSegment))
+ if (TryGetPrimaryUrlSegment(ancestorOrSelfKey, cultureOrDefault, isDraft, out string? segment))
{
- urlSegments.Add(publishedDocumentUrlSegment.UrlSegment);
+ urlSegments.Add(segment);
}
if (foundDomain is not null)
@@ -501,8 +733,7 @@ public string GetLegacyRouteFormat(Guid documentKey, string? culture, bool isDra
}
}
- var leftToRight = _globalSettings.ForceCombineUrlPathLeftToRight
- || CultureInfo.GetCultureInfo(cultureOrDefault).TextInfo.IsRightToLeft is false;
+ bool leftToRight = ArePathsLeftToRight(cultureOrDefault);
if (leftToRight)
{
urlSegments.Reverse();
@@ -510,7 +741,7 @@ public string GetLegacyRouteFormat(Guid documentKey, string? culture, bool isDra
if (foundDomain is not null)
{
- //we found a domain, and not to construct the route in the funny legacy way
+ // We found a domain, and not to construct the route in the funny legacy way
return foundDomain.ContentId + "/" + string.Join("/", urlSegments);
}
@@ -518,13 +749,69 @@ public string GetLegacyRouteFormat(Guid documentKey, string? culture, bool isDra
return GetFullUrl(isRootFirstItem, urlSegments, null, leftToRight);
}
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private string GetCultureOrDefault(string? culture)
+ => string.IsNullOrWhiteSpace(culture) is false
+ ? culture
+ : _languageService.GetDefaultIsoCodeAsync().GetAwaiter().GetResult();
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private bool ArePathsLeftToRight(string cultureOrDefault)
+ => _globalSettings.ForceCombineUrlPathLeftToRight ||
+ CultureInfo.GetCultureInfo(cultureOrDefault).TextInfo.IsRightToLeft is false;
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static Domain? GetDomainForCultureOrInvariant(IEnumerable domains, string? culture)
+ => string.IsNullOrEmpty(culture)
+ ? domains.FirstOrDefault()
+ : domains.FirstOrDefault(x => x.Culture?.Equals(culture, StringComparison.InvariantCultureIgnoreCase) ?? false);
+
+ private string GetFullUrl(bool isRootFirstItem, List segments, Domain? foundDomain, bool leftToRight)
+ {
+ var urlSegments = new List(segments);
+
+ if (foundDomain is not null)
+ {
+ return foundDomain.Name.EnsureEndsWith("/") + string.Join('/', urlSegments);
+ }
+
+ var hideTopLevel = HideTopLevel(_globalSettings.HideTopLevelNodeFromPath, isRootFirstItem, urlSegments);
+ if (leftToRight)
+ {
+ return '/' + string.Join('/', urlSegments.Skip(hideTopLevel ? 1 : 0));
+ }
+
+ if (hideTopLevel)
+ {
+ urlSegments.RemoveAt(urlSegments.Count - 1);
+ }
+
+ return '/' + string.Join('/', urlSegments);
+ }
+
+ private bool HideTopLevel(bool hideTopLevelNodeFromPath, bool isRootFirstItem, List urlSegments)
+ {
+ if (hideTopLevelNodeFromPath is false)
+ {
+ return false;
+ }
+
+ if (isRootFirstItem is false && urlSegments.Count == 1)
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
public bool HasAny()
{
ThrowIfNotInitialized();
return _cache.Any();
}
-
+ ///
[Obsolete("This method is obsolete and will be removed in future versions. Use IPublishedUrlInfoProvider.GetAllAsync instead.")]
public async Task> ListUrlsAsync(Guid contentKey)
{
@@ -532,7 +819,7 @@ public async Task> ListUrlsAsync(Guid contentKey)
Attempt documentIdAttempt = _idKeyMap.GetIdForKey(contentKey, UmbracoObjectTypes.Document);
- if(documentIdAttempt.Success is false)
+ if (documentIdAttempt.Success is false)
{
return result;
}
@@ -544,30 +831,34 @@ public async Task> ListUrlsAsync(Guid contentKey)
var cultures = languages.ToDictionary(x=>x.IsoCode);
Guid[] ancestorsOrSelfKeysArray = ancestorsOrSelfKeys as Guid[] ?? ancestorsOrSelfKeys.ToArray();
- Dictionary>> ancestorOrSelfKeyToDomains = ancestorsOrSelfKeysArray.ToDictionary(x => x, async ancestorKey =>
- {
- Attempt idAttempt = _idKeyMap.GetIdForKey(ancestorKey, UmbracoObjectTypes.Document);
+ Dictionary>> ancestorOrSelfKeyToDomains = ancestorsOrSelfKeysArray
+ .ToDictionary(
+ x => x,
+ ancestorKey =>
+ {
+ Attempt idAttempt = _idKeyMap.GetIdForKey(ancestorKey, UmbracoObjectTypes.Document);
- if(idAttempt.Success is false)
- {
- return null;
- }
- IEnumerable domains = _domainCacheService.GetAssigned(idAttempt.Result, false);
- return domains.ToLookup(x => x.Culture!);
- })!;
+ if (idAttempt.Success is false)
+ {
+ return Task.FromResult((ILookup)null!);
+ }
+
+ IEnumerable domains = _domainCacheService.GetAssigned(idAttempt.Result, false);
+ return Task.FromResult(domains.ToLookup(x => x.Culture!));
+ })!;
foreach ((string culture, ILanguage language) in cultures)
{
var urlSegments = new List();
- List foundDomains = new List();
+ var foundDomains = new List();
var hasUrlInCulture = true;
foreach (Guid ancestorOrSelfKey in ancestorsOrSelfKeysArray)
{
- var domainLookup = await ancestorOrSelfKeyToDomains[ancestorOrSelfKey];
+ ILookup domainLookup = await ancestorOrSelfKeyToDomains[ancestorOrSelfKey];
if (domainLookup.Any())
{
- var domains = domainLookup[culture];
+ IEnumerable domains = domainLookup[culture];
foreach (Domain domain in domains)
{
Attempt domainKeyAttempt =
@@ -587,11 +878,9 @@ public async Task> ListUrlsAsync(Guid contentKey)
}
}
- if (_cache.TryGetValue(
- CreateCacheKey(ancestorOrSelfKey, culture, false),
- out PublishedDocumentUrlSegment? publishedDocumentUrlSegment))
+ if (TryGetPrimaryUrlSegment(ancestorOrSelfKey, culture, false, out string? segment))
{
- urlSegments.Add(publishedDocumentUrlSegment.UrlSegment);
+ urlSegments.Add(segment);
}
else
{
@@ -599,7 +888,7 @@ public async Task> ListUrlsAsync(Guid contentKey)
}
}
- //If we did not find a domain and this is not the default language, then the content is not routable
+ // If we did not find a domain and this is not the default language, then the content is not routable
if (foundDomains.Any() is false && language.IsDefault is false)
{
continue;
@@ -631,8 +920,7 @@ public async Task> ListUrlsAsync(Guid contentKey)
result.Add(new UrlInfo(
text: foundUrl,
isUrl: hasUrlInCulture,
- culture: culture
- ));
+ culture: culture));
}
else
{
@@ -644,190 +932,37 @@ public async Task> ListUrlsAsync(Guid contentKey)
result.Add(new UrlInfo(
text: foundUrl,
isUrl: hasUrlInCulture,
- culture: culture
- ));
+ culture: culture));
}
else
{
result.Add(new UrlInfo(
text: "Conflict: Other page has the same url",
isUrl: false,
- culture: culture
- ));
+ culture: culture));
}
}
-
-
-
}
}
return result;
}
-
- private string GetFullUrl(bool isRootFirstItem, List segments, Domain? foundDomain, bool leftToRight)
- {
- var urlSegments = new List(segments);
-
- if (foundDomain is not null)
- {
- return foundDomain.Name.EnsureEndsWith("/") + string.Join('/', urlSegments);
- }
-
- var hideTopLevel = HideTopLevel(_globalSettings.HideTopLevelNodeFromPath, isRootFirstItem, urlSegments);
- if (leftToRight)
- {
- return '/' + string.Join('/', urlSegments.Skip(hideTopLevel ? 1 : 0));
- }
-
- if (hideTopLevel)
- {
- urlSegments.RemoveAt(urlSegments.Count - 1);
- }
-
- return '/' + string.Join('/', urlSegments);
- }
-
- private bool HideTopLevel(bool hideTopLevelNodeFromPath, bool isRootFirstItem, List urlSegments)
+ private bool TryGetPrimaryUrlSegment(Guid documentKey, string culture, bool isDraft, [NotNullWhen(true)] out string? segment)
{
- if (hideTopLevelNodeFromPath is false)
+ if (_cache.TryGetValue(
+ CreateCacheKey(documentKey, culture, isDraft),
+ out PublishedDocumentUrlSegments? publishedDocumentUrlSegments))
{
- return false;
- }
-
- if(isRootFirstItem is false && urlSegments.Count == 1)
- {
- return false;
- }
-
- return true;
- }
-
- public async Task CreateOrUpdateUrlSegmentsWithDescendantsAsync(Guid key)
- {
- var id = _idKeyMap.GetIdForKey(key, UmbracoObjectTypes.Document).Result;
- IContent item = _contentService.GetById(id)!;
- IEnumerable descendants = _contentService.GetPagedDescendants(id, 0, int.MaxValue, out _);
-
- await CreateOrUpdateUrlSegmentsAsync(new List(descendants)
- {
- item
- });
- }
-
- public async Task CreateOrUpdateUrlSegmentsAsync(Guid key)
- {
- IContent? content = _contentService.GetById(key);
-
- if (content is not null)
- {
- await CreateOrUpdateUrlSegmentsAsync(content.Yield());
- }
- }
-
- private IEnumerable GetKeysInRoot(bool considerFirstLevelAsRoot, bool isDraft, string culture)
- {
- if (_documentNavigationQueryService.TryGetRootKeys(out IEnumerable rootKeysEnumerable) is false)
- {
- yield break;
- }
-
- IEnumerable rootKeys = rootKeysEnumerable as Guid[] ?? rootKeysEnumerable.ToArray();
-
- if (considerFirstLevelAsRoot)
- {
- foreach (Guid rootKey in rootKeys)
+ PublishedDocumentUrlSegments.UrlSegment? primaryUrlSegment = publishedDocumentUrlSegments.UrlSegments.FirstOrDefault(x => x.IsPrimary);
+ if (primaryUrlSegment is not null)
{
- if (isDraft is false && IsContentPublished(rootKey, culture) is false)
- {
- continue;
- }
-
- IEnumerable childKeys = GetChildKeys(rootKey);
-
- foreach (Guid childKey in childKeys)
- {
- yield return childKey;
- }
- }
- }
- else
- {
- foreach (Guid rootKey in rootKeys)
- {
- yield return rootKey;
+ segment = primaryUrlSegment.Segment;
+ return true;
}
}
+ segment = null;
+ return false;
}
-
- private Guid? GetChildWithUrlSegment(IEnumerable childKeys, string urlSegment, string culture, bool isDraft)
- {
- foreach (Guid childKey in childKeys)
- {
- var childUrlSegment = GetUrlSegment(childKey, culture, isDraft);
-
- if (string.Equals(childUrlSegment, urlSegment))
- {
- return childKey;
- }
- }
-
- return null;
- }
-
- ///
- /// Gets the children based on the latest published version of the content. (No aware of things in this scope).
- ///
- /// The key of the document to get children from.
- /// The keys of all the children of the document.
- private IEnumerable GetChildKeys(Guid documentKey)
- {
- if(_documentNavigationQueryService.TryGetChildrenKeys(documentKey, out IEnumerable childrenKeys))
- {
- return childrenKeys;
- }
-
- return Enumerable.Empty();
- }
-
- private IEnumerable GetRootKeys(bool isDraft, string culture)
- {
- if (_documentNavigationQueryService.TryGetRootKeys(out IEnumerable rootKeys))
- {
- foreach (Guid rootKey in rootKeys)
- {
- if (isDraft || IsContentPublished(rootKey, culture))
- {
- yield return rootKey;
- }
- }
- }
- }
-
-
- ///
- /// Gets the top most root key.
- ///
- /// The top most root key.
- private Guid? GetTopMostRootKey(bool isDraft, string culture)
- {
- return GetRootKeys(isDraft, culture).Cast().FirstOrDefault();
- }
-
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- private static string CreateCacheKey(Guid documentKey, string culture, bool isDraft) => $"{documentKey}|{culture}|{isDraft}".ToLowerInvariant();
-
- private Guid? GetStartNodeKey(int? documentStartNodeId)
- {
- if (documentStartNodeId is null)
- {
- return null;
- }
-
- Attempt attempt = _idKeyMap.GetKeyForId(documentStartNodeId.Value, UmbracoObjectTypes.Document);
- return attempt.Success ? attempt.Result : null;
- }
-
}
diff --git a/src/Umbraco.Core/Services/IDocumentUrlService.cs b/src/Umbraco.Core/Services/IDocumentUrlService.cs
index 3aa0811cd457..052980f7f4bb 100644
--- a/src/Umbraco.Core/Services/IDocumentUrlService.cs
+++ b/src/Umbraco.Core/Services/IDocumentUrlService.cs
@@ -1,38 +1,97 @@
-using Umbraco.Cms.Core.Media.EmbedProviders;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Routing;
namespace Umbraco.Cms.Core.Services;
+///
+/// Defines operations for handling document URLs.
+///
public interface IDocumentUrlService
{
///
/// Initializes the service and ensure the content in the database is correct with the current configuration.
///
- ///
- ///
- ///
+ /// Forces an early return when we know there are no routes (i.e. on install).
+ /// The cancellation token.
Task InitAsync(bool forceEmpty, CancellationToken cancellationToken);
+ ///
+ /// Rebuilds all document URLs.
+ ///
Task RebuildAllUrlsAsync();
+
///
- /// Gets the Url from a document key, culture and segment. Preview urls are returned if isPreview is true.
+ /// Gets a single URL segment from a document key and culture. Preview urls are returned if isDraft is true.
///
/// The key of the document.
/// The culture code.
/// Whether to get the url of the draft or published document.
- /// The url of the document.
+ /// A URL segment for the document.
+ /// If more than one segment is available, the first retrieved and indicated as primary will be returned.
string? GetUrlSegment(Guid documentKey, string culture, bool isDraft);
+ ///
+ /// Gets the URL segments from a document key and culture. Preview urls are returned if isDraft is true.
+ ///
+ /// The key of the document.
+ /// The culture code.
+ /// Whether to get the url of the draft or published document.
+ /// The URL segments for the document.
+ IEnumerable GetUrlSegments(Guid documentKey, string culture, bool isDraft)
+ => throw new NotImplementedException();
+
+ ///
+ /// Creates or updates the URL segments for a single document.
+ ///
+ /// The document key.
+ Task CreateOrUpdateUrlSegmentsAsync(Guid key);
+
+ ///
+ /// Creates or updates the URL segments for a document and it's descendants.
+ ///
+ /// The document key.
+ Task CreateOrUpdateUrlSegmentsWithDescendantsAsync(Guid key);
+
+ ///
+ /// Creates or updates the URL segments for a collection of documents.
+ ///
+ /// The document collection.
Task CreateOrUpdateUrlSegmentsAsync(IEnumerable documents);
+ ///
+ /// Deletes all URLs from the cache for a collection of document keys.
+ ///
+ /// The collection of document keys.
Task DeleteUrlsFromCacheAsync(IEnumerable documentKeys);
+ ///
+ /// Gets a document key by route.
+ ///
+ /// The route.
+ /// The culture code.
+ /// The document start node Id.
+ /// Whether to get the url of the draft or published document.
+ /// The document key, or null if not found.
Guid? GetDocumentKeyByRoute(string route, string? culture, int? documentStartNodeId, bool isDraft);
+
+ ///
+ /// Gets all the URLs for a given content key.
+ ///
+ /// The content key.
+ [Obsolete("This method is obsolete and will be removed in future versions. Use IPublishedUrlInfoProvider.GetAllAsync instead. Scheduled for removal in Umbraco 17.")]
Task> ListUrlsAsync(Guid contentKey);
- Task CreateOrUpdateUrlSegmentsWithDescendantsAsync(Guid key);
- Task CreateOrUpdateUrlSegmentsAsync(Guid key);
+
+ ///
+ /// Gets the legacy route format for a document key and culture.
+ ///
+ /// The key of the document.
+ /// The culture code.
+ /// Whether to get the url of the draft or published document.
+ ///
string GetLegacyRouteFormat(Guid key, string? culture, bool isDraft);
+ ///
+ /// Gets a value indicating whether any URLs have been cached.
+ ///
bool HasAny();
}
diff --git a/src/Umbraco.Core/Strings/IUrlSegmentProvider.cs b/src/Umbraco.Core/Strings/IUrlSegmentProvider.cs
index 6e9f1db32607..075b52b4eea1 100644
--- a/src/Umbraco.Core/Strings/IUrlSegmentProvider.cs
+++ b/src/Umbraco.Core/Strings/IUrlSegmentProvider.cs
@@ -8,6 +8,16 @@ namespace Umbraco.Cms.Core.Strings;
/// Url segments should comply with IETF RFCs regarding content, encoding, etc.
public interface IUrlSegmentProvider
{
+ ///
+ /// Gets a value indicating whether the URL segment provider allows additional segments after providing one.
+ ///
+ ///
+ /// If set to true, when more than one URL segment provider is available, futher providers after this one in the collection will be called
+ /// even if the current provider provides a segment.
+ /// If false, the provider will terminate the chain of URL segment providers if it provides a segment.
+ ///
+ bool AllowAdditionalSegments => false;
+
///
/// Gets the URL segment for a specified content and culture.
///
@@ -20,6 +30,18 @@ public interface IUrlSegmentProvider
/// URL per culture.
///
string? GetUrlSegment(IContentBase content, string? culture = null);
+
+ ///
+ /// Gets the URL segment for a specified content, published status and and culture.
+ ///
+ /// The content.
+ /// The culture.
+ /// The URL segment.
+ ///
+ /// This is for when Umbraco is capable of managing more than one URL
+ /// per content, in 1-to-1 multilingual configurations. Then there would be one
+ /// URL per culture.
+ ///
string? GetUrlSegment(IContentBase content, bool published, string? culture = null) => GetUrlSegment(content, culture);
// TODO: For the 301 tracking, we need to add another extended interface to this so that
diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs
index 599b2fb9d3b6..fe14f785d8b9 100644
--- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs
+++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs
@@ -111,5 +111,8 @@ protected virtual void DefinePlan()
// To 15.3.0
To("{7B11F01E-EE33-4B0B-81A1-F78F834CA45B}");
+
+ // To 15.4.0
+ To("{A9E72794-4036-4563-B543-1717C73B8879}");
}
}
diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_3_0/AddNameAndDescriptionToWebhooks.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_3_0/AddNameAndDescriptionToWebhooks.cs
index eee8d5e26cd4..a8cdd96f2e64 100644
--- a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_3_0/AddNameAndDescriptionToWebhooks.cs
+++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_3_0/AddNameAndDescriptionToWebhooks.cs
@@ -28,7 +28,7 @@ protected override void Migrate()
}
else
{
- Logger.LogWarning($"Table {Constants.DatabaseSchema.Tables.Webhook} does not exist so the addition of the name and description by columnss in migration {nameof(AddNameAndDescriptionToWebhooks)} cannot be completed.");
+ Logger.LogWarning($"Table {Constants.DatabaseSchema.Tables.Webhook} does not exist so the addition of the name and description by columns in migration {nameof(AddNameAndDescriptionToWebhooks)} cannot be completed.");
}
}
diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_4_0/UpdateDocumentUrlToPersistMultipleSegmentsPerDocument.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_4_0/UpdateDocumentUrlToPersistMultipleSegmentsPerDocument.cs
new file mode 100644
index 000000000000..092e7f81bad4
--- /dev/null
+++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_4_0/UpdateDocumentUrlToPersistMultipleSegmentsPerDocument.cs
@@ -0,0 +1,63 @@
+using Microsoft.Extensions.Logging;
+using Umbraco.Cms.Core;
+using Umbraco.Cms.Infrastructure.Persistence.Dtos;
+
+namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_15_4_0;
+
+///
+/// Migration to make necessary schema updates to support multiple segments per document.
+///
+public class UpdateDocumentUrlToPersistMultipleSegmentsPerDocument : MigrationBase
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public UpdateDocumentUrlToPersistMultipleSegmentsPerDocument(IMigrationContext context)
+ : base(context)
+ {
+ }
+
+ ///
+ protected override void Migrate()
+ {
+ Logger.LogDebug("Schema updates to {TableName} for support of multiple segments per document", Constants.DatabaseSchema.Tables.DocumentUrl);
+
+ if (TableExists(Constants.DatabaseSchema.Tables.DocumentUrl))
+ {
+ ExtendUniqueIndexAcrossSegmentField();
+ AddAndPopulateIsPrimaryColumn();
+ }
+ else
+ {
+ throw new InvalidOperationException(
+ $"Table {Constants.DatabaseSchema.Tables.DocumentUrl} does not exist so the migration {nameof(UpdateDocumentUrlToPersistMultipleSegmentsPerDocument)} could not be completed.");
+ }
+ }
+
+ private void ExtendUniqueIndexAcrossSegmentField()
+ {
+ Logger.LogDebug("Extending the unique index on {TableName} to include the urlSegment column", Constants.DatabaseSchema.Tables.DocumentUrl);
+
+ var indexName = "IX_" + Constants.DatabaseSchema.Tables.DocumentUrl;
+ if (IndexExists(indexName))
+ {
+ DeleteIndex(indexName);
+ }
+
+ CreateIndex(indexName);
+ }
+
+ private void AddAndPopulateIsPrimaryColumn()
+ {
+ const string IsPrimaryColumnName = "isPrimary";
+
+ Logger.LogDebug("Adding the {Column} column {TableName} to include the urlSegment column", IsPrimaryColumnName, Constants.DatabaseSchema.Tables.DocumentUrl);
+
+ var columns = Context.SqlContext.SqlSyntax.GetColumnsInSchema(Context.Database).ToList();
+ if (columns
+ .SingleOrDefault(x => x.TableName == Constants.DatabaseSchema.Tables.DocumentUrl && x.ColumnName == IsPrimaryColumnName) is null)
+ {
+ AddColumn(Constants.DatabaseSchema.Tables.DocumentUrl, IsPrimaryColumnName);
+ }
+ }
+}
diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/DocumentUrlDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/DocumentUrlDto.cs
index 4a006f9307b0..133df789c77a 100644
--- a/src/Umbraco.Infrastructure/Persistence/Dtos/DocumentUrlDto.cs
+++ b/src/Umbraco.Infrastructure/Persistence/Dtos/DocumentUrlDto.cs
@@ -16,7 +16,7 @@ public class DocumentUrlDto
[PrimaryKeyColumn(Clustered = false, AutoIncrement = true)]
public int NodeId { get; set; }
- [Index(IndexTypes.UniqueClustered, ForColumns = "uniqueId, languageId, isDraft", Name = "IX_" + TableName)]
+ [Index(IndexTypes.UniqueClustered, ForColumns = "uniqueId, languageId, isDraft, urlSegment", Name = "IX_" + TableName)]
[Column("uniqueId")]
[ForeignKey(typeof(NodeDto), Column = "uniqueId")]
public Guid UniqueId { get; set; }
@@ -28,14 +28,11 @@ public class DocumentUrlDto
[ForeignKey(typeof(LanguageDto))]
public int LanguageId { get; set; }
- //
- // [Column("segment")]
- // [NullSetting(NullSetting = NullSettings.Null)]
- // [Length(PropertyDataDto.SegmentLength)]
- // public string Segment { get; set; } = string.Empty;
-
[Column("urlSegment")]
[NullSetting(NullSetting = NullSettings.NotNull)]
public string UrlSegment { get; set; } = string.Empty;
+ [Column("isPrimary")]
+ [Constraint(Default = 1)]
+ public bool IsPrimary { get; set; }
}
diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentUrlRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentUrlRepository.cs
index ded011440da0..07a36f49eedc 100644
--- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentUrlRepository.cs
+++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentUrlRepository.cs
@@ -1,18 +1,11 @@
-using Microsoft.Extensions.Logging;
using NPoco;
using Umbraco.Cms.Core;
-using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Models;
-using Umbraco.Cms.Core.Models.Entities;
-using Umbraco.Cms.Core.Persistence.Querying;
using Umbraco.Cms.Core.Persistence.Repositories;
-using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Infrastructure.Persistence.Dtos;
-using Umbraco.Cms.Infrastructure.Persistence.Factories;
using Umbraco.Cms.Infrastructure.Scoping;
using Umbraco.Extensions;
-
namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement;
public class DocumentUrlRepository : IDocumentUrlRepository
@@ -36,14 +29,15 @@ private IUmbracoDatabase Database
public void Save(IEnumerable publishedDocumentUrlSegments)
{
- //TODO avoid this is called as first thing on first restart after install
+ // TODO: avoid this is called as first thing on first restart after install
IEnumerable documentKeys = publishedDocumentUrlSegments.Select(x => x.DocumentKey).Distinct();
- Dictionary<(Guid UniqueId, int LanguageId, bool isDraft), DocumentUrlDto> dtoDictionary = publishedDocumentUrlSegments.Select(BuildDto).ToDictionary(x=> (x.UniqueId, x.LanguageId, x.IsDraft));
+ Dictionary<(Guid UniqueId, int LanguageId, bool isDraft, string urlSegment), DocumentUrlDto> dtoDictionary = publishedDocumentUrlSegments
+ .Select(BuildDto)
+ .ToDictionary(x => (x.UniqueId, x.LanguageId, x.IsDraft, x.UrlSegment));
- var toUpdate = new List();
var toDelete = new List();
- var toInsert = dtoDictionary.Values.ToDictionary(x => (x.UniqueId, x.LanguageId, x.IsDraft));
+ var toInsert = dtoDictionary.Values.ToDictionary(x => (x.UniqueId, x.LanguageId, x.IsDraft, x.UrlSegment));
foreach (IEnumerable group in documentKeys.InGroupsOf(Constants.Sql.MaxParameterCount))
{
@@ -57,18 +51,12 @@ public void Save(IEnumerable publishedDocumentUrlSe
foreach (DocumentUrlDto existing in existingUrlsInBatch)
{
-
- if (dtoDictionary.TryGetValue((existing.UniqueId, existing.LanguageId, existing.IsDraft), out DocumentUrlDto? found))
+ if (dtoDictionary.TryGetValue((existing.UniqueId, existing.LanguageId, existing.IsDraft, existing.UrlSegment), out DocumentUrlDto? found))
{
found.NodeId = existing.NodeId;
- // Only update if the url segment is different
- if (found.UrlSegment != existing.UrlSegment)
- {
- toUpdate.Add(found);
- }
- // if we found it, we know we should not insert it as a new
- toInsert.Remove((found.UniqueId, found.LanguageId, found.IsDraft));
+ // If we found it, we know we should not insert it as a new record.
+ toInsert.Remove((found.UniqueId, found.LanguageId, found.IsDraft, found.UrlSegment));
}
else
{
@@ -83,14 +71,6 @@ public void Save(IEnumerable publishedDocumentUrlSe
Database.DeleteMany().Where(x => toDelete.Contains(x.NodeId)).Execute();
}
- if (toUpdate.Any())
- {
- foreach (DocumentUrlDto updated in toUpdate)
- {
- Database.Update(updated);
- }
- }
-
Database.InsertBulk(toInsert.Values);
}
@@ -115,7 +95,8 @@ private PublishedDocumentUrlSegment BuildModel(DocumentUrlDto dto) =>
UrlSegment = dto.UrlSegment,
DocumentKey = dto.UniqueId,
LanguageId = dto.LanguageId,
- IsDraft = dto.IsDraft
+ IsDraft = dto.IsDraft,
+ IsPrimary = dto.IsPrimary
};
private DocumentUrlDto BuildDto(PublishedDocumentUrlSegment model)
@@ -126,6 +107,7 @@ private DocumentUrlDto BuildDto(PublishedDocumentUrlSegment model)
UniqueId = model.DocumentKey,
LanguageId = model.LanguageId,
IsDraft = model.IsDraft,
+ IsPrimary = model.IsPrimary,
};
}
}
diff --git a/tests/Umbraco.Tests.Integration/CompatibilitySuppressions.xml b/tests/Umbraco.Tests.Integration/CompatibilitySuppressions.xml
index 7c6ea3140b7f..918b3ea4fed7 100644
--- a/tests/Umbraco.Tests.Integration/CompatibilitySuppressions.xml
+++ b/tests/Umbraco.Tests.Integration/CompatibilitySuppressions.xml
@@ -1,6 +1,20 @@
+
+ CP0001
+ T:Umbraco.Cms.Tests.Integration.Umbraco.Core.Services.DocumentUrlServiceTest
+ lib/net9.0/Umbraco.Tests.Integration.dll
+ lib/net9.0/Umbraco.Tests.Integration.dll
+ true
+
+
+ CP0001
+ T:Umbraco.Cms.Tests.Integration.Umbraco.Core.Services.DocumentUrlServiceTest_HideTopLevel_False
+ lib/net9.0/Umbraco.Tests.Integration.dll
+ lib/net9.0/Umbraco.Tests.Integration.dll
+ true
+
CP0001
T:Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.PropertyEditors.BlockEditorBackwardsCompatibilityTests
diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTests.cs
similarity index 57%
rename from tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTest.cs
rename to tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTests.cs
index cfc775c88ed1..3f015e5a940c 100644
--- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTest.cs
+++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTests.cs
@@ -3,6 +3,7 @@
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Services;
+using Umbraco.Cms.Core.Strings;
using Umbraco.Cms.Core.Sync;
using Umbraco.Cms.Tests.Common.Builders;
using Umbraco.Cms.Tests.Common.Testing;
@@ -13,9 +14,13 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services;
[TestFixture]
[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest, Logger = UmbracoTestOptions.Logger.Mock)]
-public class DocumentUrlServiceTest : UmbracoIntegrationTestWithContent
+public class DocumentUrlServiceTests : UmbracoIntegrationTestWithContent
{
+ private const string SubSubPage2Key = "48AE405E-5142-4EBE-929F-55EB616F51F2";
+ private const string SubSubPage3Key = "AACF2979-3F53-4184-B071-BA34D3338497";
+
protected IDocumentUrlService DocumentUrlService => GetRequiredService();
+
protected ILanguageService LanguageService => GetRequiredService();
protected override void CustomTestSetup(IUmbracoBuilder builder)
@@ -24,8 +29,60 @@ protected override void CustomTestSetup(IUmbracoBuilder builder)
builder.AddNotificationHandler();
builder.Services.AddNotificationAsyncHandler();
+
+ builder.UrlSegmentProviders().Insert();
+ builder.UrlSegmentProviders().Insert();
+ }
+
+ private abstract class CustomUrlSegmentProviderBase
+ {
+ private readonly IUrlSegmentProvider _defaultProvider;
+
+ public CustomUrlSegmentProviderBase(IShortStringHelper stringHelper) => _defaultProvider = new DefaultUrlSegmentProvider(stringHelper);
+
+ protected string? GetUrlSegment(IContentBase content, string? culture, params Guid[] pageKeys)
+ {
+ if (pageKeys.Contains(content.Key) is false)
+ {
+ return null;
+ }
+
+ var segment = _defaultProvider.GetUrlSegment(content, culture);
+ return segment is not null ? segment + "-custom" : null;
+ }
}
+ ///
+ /// A test implementation of that provides a custom URL segment for a specific page
+ /// and allows for additional providers to provide segments too.
+ ///
+ private class CustomUrlSegmentProvider1 : CustomUrlSegmentProviderBase, IUrlSegmentProvider
+ {
+ public CustomUrlSegmentProvider1(IShortStringHelper stringHelper)
+ : base(stringHelper)
+ {
+ }
+
+ public bool AllowAdditionalSegments => true;
+
+ public string? GetUrlSegment(IContentBase content, string? culture = null)
+ => GetUrlSegment(content, culture, Guid.Parse(SubPageKey), Guid.Parse(SubSubPage3Key));
+ }
+
+ ///
+ /// A test implementation of that provides a custom URL segment for a specific page
+ /// and terminates, not allowing additional providers to provide segments too.
+ ///
+ private class CustomUrlSegmentProvider2 : CustomUrlSegmentProviderBase, IUrlSegmentProvider
+ {
+ public CustomUrlSegmentProvider2(IShortStringHelper stringHelper)
+ : base(stringHelper)
+ {
+ }
+
+ public string? GetUrlSegment(IContentBase content, string? culture = null)
+ => GetUrlSegment(content, culture, Guid.Parse(SubPage2Key), Guid.Parse(SubSubPage2Key));
+ }
public override void Setup()
{
@@ -52,7 +109,7 @@ public override void Setup()
// }
[Test]
- public async Task Trashed_documents_do_not_have_a_url_segment()
+ public async Task GetUrlSegment_For_Deleted_Document_Does_Not_Have_Url_Segment()
{
var isoCode = (await LanguageService.GetDefaultLanguageAsync()).IsoCode;
@@ -64,7 +121,7 @@ public async Task Trashed_documents_do_not_have_a_url_segment()
//TODO test with the urlsegment property value!
[Test]
- public async Task Deleted_documents_do_not_have_a_url_segment__Parent_deleted()
+ public async Task GetUrlSegment_For_Document_With_Parent_Deleted_Does_Not_Have_Url_Segment()
{
ContentService.PublishBranch(Textpage, PublishBranchFilter.IncludeUnpublished, ["*"]);
@@ -78,7 +135,7 @@ public async Task Deleted_documents_do_not_have_a_url_segment__Parent_deleted()
}
[Test]
- public async Task Deleted_documents_do_not_have_a_url_segment()
+ public async Task GetUrlSegment_For_Published_Then_Deleted_Document_Does_Not_Have_Url_Segment()
{
ContentService.PublishBranch(Textpage, PublishBranchFilter.IncludeUnpublished, ["*"]);
@@ -91,16 +148,19 @@ public async Task Deleted_documents_do_not_have_a_url_segment()
Assert.IsNull(actual);
}
- [Test]
[TestCase("/", "en-US", true, ExpectedResult = TextpageKey)]
[TestCase("/text-page-1", "en-US", true, ExpectedResult = SubPageKey)]
- [TestCase("/text-page-2", "en-US", true, ExpectedResult = SubPage2Key)]
+ [TestCase("/text-page-1-custom", "en-US", true, ExpectedResult = SubPageKey)] // Uses the segment registered by the custom IIUrlSegmentProvider that allows for more than one segment per document.
+ [TestCase("/text-page-2", "en-US", true, ExpectedResult = null)]
+ [TestCase("/text-page-2-custom", "en-US", true, ExpectedResult = SubPage2Key)] // Uses the segment registered by the custom IIUrlSegmentProvider that does not allow for more than one segment per document.
[TestCase("/text-page-3", "en-US", true, ExpectedResult = SubPage3Key)]
[TestCase("/", "en-US", false, ExpectedResult = TextpageKey)]
[TestCase("/text-page-1", "en-US", false, ExpectedResult = SubPageKey)]
- [TestCase("/text-page-2", "en-US", false, ExpectedResult = SubPage2Key)]
+ [TestCase("/text-page-1-custom", "en-US", false, ExpectedResult = SubPageKey)] // Uses the segment registered by the custom IIUrlSegmentProvider that allows for more than one segment per document.
+ [TestCase("/text-page-2", "en-US", false, ExpectedResult = null)]
+ [TestCase("/text-page-2-custom", "en-US", false, ExpectedResult = SubPage2Key)] // Uses the segment registered by the custom IIUrlSegmentProvider that does not allow for more than one segment per document.
[TestCase("/text-page-3", "en-US", false, ExpectedResult = SubPage3Key)]
- public string? Expected_Routes(string route, string isoCode, bool loadDraft)
+ public string? GetDocumentKeyByRoute_Returns_Expected_Route(string route, string isoCode, bool loadDraft)
{
if (loadDraft is false)
{
@@ -111,16 +171,16 @@ public async Task Deleted_documents_do_not_have_a_url_segment()
}
[Test]
- public void No_Published_Route_when_not_published()
+ public void GetDocumentKeyByRoute_UnPublished_Documents_Have_No_Published_Route()
{
Assert.IsNotNull(DocumentUrlService.GetDocumentKeyByRoute("/text-page-1", "en-US", null, true));
Assert.IsNull(DocumentUrlService.GetDocumentKeyByRoute("/text-page-1", "en-US", null, false));
}
[Test]
- public void Unpublished_Pages_Are_not_available()
+ public void GetDocumentKeyByRoute_Published_Then_Unpublished_Documents_Have_No_Published_Route()
{
- //Arrange
+ // Arrange
ContentService.PublishBranch(Textpage, PublishBranchFilter.IncludeUnpublished, ["*"]);
Assert.Multiple(() =>
@@ -131,7 +191,7 @@ public void Unpublished_Pages_Are_not_available()
Assert.IsNotNull(DocumentUrlService.GetDocumentKeyByRoute("/text-page-1", "en-US", null, false));
});
- //Act
+ // Act
ContentService.Unpublish(Textpage );
Assert.Multiple(() =>
@@ -144,18 +204,32 @@ public void Unpublished_Pages_Are_not_available()
Assert.IsNotNull(DocumentUrlService.GetDocumentKeyByRoute("/text-page-1", "en-US", null, true));
Assert.IsNull(DocumentUrlService.GetDocumentKeyByRoute("/text-page-1", "en-US", null, false));
});
-
}
-
- [Test]
[TestCase("/text-page-1/sub-page-1", "en-US", true, ExpectedResult = "DF49F477-12F2-4E33-8563-91A7CC1DCDBB")]
[TestCase("/text-page-1/sub-page-1", "en-US", false, ExpectedResult = "DF49F477-12F2-4E33-8563-91A7CC1DCDBB")]
- public string? Expected_Routes_with_subpages(string route, string isoCode, bool loadDraft)
+ public string? GetDocumentKeyByRoute_Returns_Expected_Route_For_SubPage(string route, string isoCode, bool loadDraft)
+ => ExecuteSubPageTest("DF49F477-12F2-4E33-8563-91A7CC1DCDBB", "Sub Page 1", route, isoCode, loadDraft);
+
+ [TestCase("/text-page-1/sub-page-2-custom", "en-US", true, ExpectedResult = SubSubPage2Key)]
+ [TestCase("/text-page-1/sub-page-2-custom", "en-US", false, ExpectedResult = SubSubPage2Key)]
+ [TestCase("/text-page-1/sub-page-2", "en-US", true, ExpectedResult = null)]
+ [TestCase("/text-page-1/sub-page-2", "en-US", false, ExpectedResult = null)]
+ public string? GetDocumentKeyByRoute_Returns_Expected_Route_For_SubPage_With_Terminating_Custom_Url_Provider(string route, string isoCode, bool loadDraft)
+ => ExecuteSubPageTest(SubSubPage2Key, "Sub Page 2", route, isoCode, loadDraft);
+
+ [TestCase("/text-page-1/sub-page-3-custom", "en-US", true, ExpectedResult = SubSubPage3Key)]
+ [TestCase("/text-page-1/sub-page-3-custom", "en-US", false, ExpectedResult = SubSubPage3Key)]
+ [TestCase("/text-page-1/sub-page-3", "en-US", true, ExpectedResult = SubSubPage3Key)]
+ [TestCase("/text-page-1/sub-page-3", "en-US", false, ExpectedResult = SubSubPage3Key)]
+ public string? GetDocumentKeyByRoute_Returns_Expected_Route_For_SubPage_With_Non_Terminating_Custom_Url_Provider(string route, string isoCode, bool loadDraft)
+ => ExecuteSubPageTest(SubSubPage3Key, "Sub Page 3", route, isoCode, loadDraft);
+
+ private string? ExecuteSubPageTest(string documentKey, string documentName, string route, string isoCode, bool loadDraft)
{
// Create a subpage
- var subsubpage = ContentBuilder.CreateSimpleContent(ContentType, "Sub Page 1", Subpage.Id);
- subsubpage.Key = new Guid("DF49F477-12F2-4E33-8563-91A7CC1DCDBB");
+ var subsubpage = ContentBuilder.CreateSimpleContent(ContentType, documentName, Subpage.Id);
+ subsubpage.Key = Guid.Parse(documentKey);
var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.Now.AddMinutes(-5), null);
ContentService.Save(subsubpage, -1, contentSchedule);
@@ -164,13 +238,12 @@ public void Unpublished_Pages_Are_not_available()
ContentService.PublishBranch(Textpage, PublishBranchFilter.IncludeUnpublished, ["*"]);
}
- return DocumentUrlService.GetDocumentKeyByRoute(route, isoCode, null, loadDraft)?.ToString()?.ToUpper();
+ return DocumentUrlService.GetDocumentKeyByRoute(route, isoCode, null, loadDraft)?.ToString()?.ToUpper();
}
- [Test]
[TestCase("/second-root", "en-US", true, ExpectedResult = "8E21BCD4-02CA-483D-84B0-1FC92702E198")]
[TestCase("/second-root", "en-US", false, ExpectedResult = "8E21BCD4-02CA-483D-84B0-1FC92702E198")]
- public string? Second_root_cannot_hide_url(string route, string isoCode, bool loadDraft)
+ public string? GetDocumentKeyByRoute_Second_Root_Does_Not_Hide_Url(string route, string isoCode, bool loadDraft)
{
// Create a second root
var secondRoot = ContentBuilder.CreateSimpleContent(ContentType, "Second Root", null);
@@ -187,10 +260,9 @@ public void Unpublished_Pages_Are_not_available()
return DocumentUrlService.GetDocumentKeyByRoute(route, isoCode, null, loadDraft)?.ToString()?.ToUpper();
}
- [Test]
[TestCase("/child-of-second-root", "en-US", true, ExpectedResult = "FF6654FB-BC68-4A65-8C6C-135567F50BD6")]
[TestCase("/child-of-second-root", "en-US", false, ExpectedResult = "FF6654FB-BC68-4A65-8C6C-135567F50BD6")]
- public string? Child_of_second_root_do_not_have_parents_url_as_prefix(string route, string isoCode, bool loadDraft)
+ public string? GetDocumentKeyByRoute_Child_Of_Second_Root_Does_Not_Have_Parents_Url_As_Prefix(string route, string isoCode, bool loadDraft)
{
// Create a second root
var secondRoot = ContentBuilder.CreateSimpleContent(ContentType, "Second Root", null);
@@ -205,7 +277,6 @@ public void Unpublished_Pages_Are_not_available()
// Publish both the main root and the second root with descendants
if (loadDraft is false)
{
-
ContentService.PublishBranch(Textpage, PublishBranchFilter.IncludeUnpublished, ["*"]);
ContentService.PublishBranch(secondRoot, PublishBranchFilter.IncludeUnpublished, ["*"]);
}
@@ -213,6 +284,16 @@ public void Unpublished_Pages_Are_not_available()
return DocumentUrlService.GetDocumentKeyByRoute(route, isoCode, null, loadDraft)?.ToString()?.ToUpper();
}
+ [TestCase(TextpageKey, "en-US", ExpectedResult = "/")]
+ [TestCase(SubPageKey, "en-US", ExpectedResult = "/text-page-1-custom")] // Has non-terminating custom URL segment provider.
+ [TestCase(SubPage2Key, "en-US", ExpectedResult = "/text-page-2-custom")] // Has terminating custom URL segment provider.
+ [TestCase(SubPage3Key, "en-US", ExpectedResult = "/text-page-3")]
+ public string? GetLegacyRouteFormat_Returns_Expected_Route(string documentKey, string culture)
+ {
+ ContentService.PublishBranch(Textpage, PublishBranchFilter.IncludeUnpublished, ["*"]);
+ return DocumentUrlService.GetLegacyRouteFormat(Guid.Parse(documentKey), culture, false);
+ }
+
//TODO test cases:
// - Find the root, when a domain is set
diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTest_hidetoplevel_false.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTests_HideTopLevel_False.cs
similarity index 90%
rename from tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTest_hidetoplevel_false.cs
rename to tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTests_HideTopLevel_False.cs
index 5efc52f77877..5158e421c19e 100644
--- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTest_hidetoplevel_false.cs
+++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTests_HideTopLevel_False.cs
@@ -2,12 +2,10 @@
using NUnit.Framework;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Configuration.Models;
-using Umbraco.Cms.Core.Handlers;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Sync;
-using Umbraco.Cms.Tests.Common.Attributes;
using Umbraco.Cms.Tests.Common.Builders;
using Umbraco.Cms.Tests.Common.Testing;
using Umbraco.Cms.Tests.Integration.Testing;
@@ -17,7 +15,7 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services;
[TestFixture]
[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest, Logger = UmbracoTestOptions.Logger.Console)]
-public class DocumentUrlServiceTest_HideTopLevel_False : UmbracoIntegrationTestWithContent
+public class DocumentUrlServiceTests_HideTopLevel_False : UmbracoIntegrationTestWithContent
{
protected IDocumentUrlService DocumentUrlService => GetRequiredService();
protected ILanguageService LanguageService => GetRequiredService();
@@ -28,7 +26,6 @@ protected override void CustomTestSetup(IUmbracoBuilder builder)
builder.Services.AddUnique();
builder.AddNotificationHandler();
-
}
public override void Setup()
@@ -46,7 +43,7 @@ public override void Setup()
[TestCase("/textpage/text-page-1", "en-US", false, ExpectedResult = SubPageKey)]
[TestCase("/textpage/text-page-2", "en-US", false, ExpectedResult = SubPage2Key)]
[TestCase("/textpage/text-page-3", "en-US", false, ExpectedResult = SubPage3Key)]
- public string? Expected_Routes(string route, string isoCode, bool loadDraft)
+ public string? GetDocumentKeyByRoute_Returns_Expected_Route(string route, string isoCode, bool loadDraft)
{
if (loadDraft is false)
{
@@ -60,7 +57,7 @@ public override void Setup()
[Test]
[TestCase("/textpage/text-page-1/sub-page-1", "en-US", true, ExpectedResult = "DF49F477-12F2-4E33-8563-91A7CC1DCDBB")]
[TestCase("/textpage/text-page-1/sub-page-1", "en-US", false, ExpectedResult = "DF49F477-12F2-4E33-8563-91A7CC1DCDBB")]
- public string? Expected_Routes_with_subpages(string route, string isoCode, bool loadDraft)
+ public string? GetDocumentKeyByRoute_Returns_Expected_Route_For_SubPage(string route, string isoCode, bool loadDraft)
{
// Create a subpage
var subsubpage = ContentBuilder.CreateSimpleContent(ContentType, "Sub Page 1", Subpage.Id);
@@ -79,7 +76,7 @@ public override void Setup()
[Test]
[TestCase("/second-root", "en-US", true, ExpectedResult = "8E21BCD4-02CA-483D-84B0-1FC92702E198")]
[TestCase("/second-root", "en-US", false, ExpectedResult = "8E21BCD4-02CA-483D-84B0-1FC92702E198")]
- public string? Second_root_cannot_hide_url(string route, string isoCode, bool loadDraft)
+ public string? GetDocumentKeyByRoute_Second_Root_Does_Not_Hide_Url(string route, string isoCode, bool loadDraft)
{
// Create a second root
var secondRoot = ContentBuilder.CreateSimpleContent(ContentType, "Second Root", null);
@@ -99,7 +96,7 @@ public override void Setup()
[Test]
[TestCase("/second-root/child-of-second-root", "en-US", true, ExpectedResult = "FF6654FB-BC68-4A65-8C6C-135567F50BD6")]
[TestCase("/second-root/child-of-second-root", "en-US", false, ExpectedResult = "FF6654FB-BC68-4A65-8C6C-135567F50BD6")]
- public string? Child_of_second_root_do_not_have_parents_url_as_prefix(string route, string isoCode, bool loadDraft)
+ public string? GetDocumentKeyByRoute_Child_Of_Second_Root_Does_Not_Have_Parents_Url_As_Prefix(string route, string isoCode, bool loadDraft)
{
// Create a second root
var secondRoot = ContentBuilder.CreateSimpleContent(ContentType, "Second Root", null);