* Added request caching to media picker media retrieval, to improve performance in save operations.
* WIP: Update or insert in bulk when updating property data.
* Add tests verifying UpdateBatch.
* Fixed issue with UpdateBatch and SQL Server.
* Removed stopwatch.
* Fix test on SQLite (failing on SQLServer).
* Added temporary test for direct call to NPoco UpdateBatch.
* Fixed test on SQLServer.
* Add integration test verifying the same property data is persisted as before the performance refactor.
* Log expected warning in DocumentUrlService as debug.
(cherry picked from commit 12adfd52bd)
861 lines
32 KiB
C#
861 lines
32 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Diagnostics.CodeAnalysis;
|
|
using System.Globalization;
|
|
using System.Runtime.CompilerServices;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using Umbraco.Cms.Core.Configuration.Models;
|
|
using Umbraco.Cms.Core.Models;
|
|
using Umbraco.Cms.Core.Persistence.Repositories;
|
|
using Umbraco.Cms.Core.PublishedCache;
|
|
using Umbraco.Cms.Core.Routing;
|
|
using Umbraco.Cms.Core.Scoping;
|
|
using Umbraco.Cms.Core.Services.Navigation;
|
|
using Umbraco.Cms.Core.Strings;
|
|
using Umbraco.Extensions;
|
|
|
|
namespace Umbraco.Cms.Core.Services;
|
|
|
|
/// <summary>
|
|
/// Implements <see href="IDocumentUrlService" /> operations for handling document URLs.
|
|
/// </summary>
|
|
public class DocumentUrlService : IDocumentUrlService
|
|
{
|
|
private const string RebuildKey = "UmbracoUrlGeneration";
|
|
|
|
private readonly ILogger<DocumentUrlService> _logger;
|
|
private readonly IDocumentUrlRepository _documentUrlRepository;
|
|
private readonly IDocumentRepository _documentRepository;
|
|
private readonly ICoreScopeProvider _coreScopeProvider;
|
|
private readonly GlobalSettings _globalSettings;
|
|
private readonly UrlSegmentProviderCollection _urlSegmentProviderCollection;
|
|
private readonly IContentService _contentService;
|
|
private readonly IShortStringHelper _shortStringHelper;
|
|
private readonly ILanguageService _languageService;
|
|
private readonly IKeyValueService _keyValueService;
|
|
private readonly IIdKeyMap _idKeyMap;
|
|
private readonly IDocumentNavigationQueryService _documentNavigationQueryService;
|
|
private readonly IPublishStatusQueryService _publishStatusQueryService;
|
|
private readonly IDomainCacheService _domainCacheService;
|
|
|
|
private readonly ConcurrentDictionary<string, PublishedDocumentUrlSegments> _cache = new();
|
|
private bool _isInitialized;
|
|
|
|
/// <summary>
|
|
/// Model used to cache a single published document along with all it's URL segments.
|
|
/// </summary>
|
|
/// <remarks>Internal for the purpose of unit and benchmark testing.</remarks>
|
|
internal sealed class PublishedDocumentUrlSegments
|
|
{
|
|
/// <summary>
|
|
/// Gets or sets the document key.
|
|
/// </summary>
|
|
public required Guid DocumentKey { get; set; }
|
|
|
|
/// <summary>
|
|
/// Gets or sets the language Id.
|
|
/// </summary>
|
|
public required int LanguageId { get; set; }
|
|
|
|
/// <summary>
|
|
/// Gets or sets the collection of <see cref="UrlSegment"/> for the document, language and state.
|
|
/// </summary>
|
|
public required IList<UrlSegment> UrlSegments { get; set; }
|
|
|
|
/// <summary>
|
|
/// Gets or sets a value indicating whether the document is a draft version or not.
|
|
/// </summary>
|
|
public required bool IsDraft { get; set; }
|
|
|
|
/// <summary>
|
|
/// Model used to represent a URL segment for a document in the cache.
|
|
/// </summary>
|
|
public class UrlSegment
|
|
{
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="UrlSegment"/> class.
|
|
/// </summary>
|
|
public UrlSegment(string segment, bool isPrimary)
|
|
{
|
|
Segment = segment;
|
|
IsPrimary = isPrimary;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the URL segment string.
|
|
/// </summary>
|
|
public string Segment { get; }
|
|
|
|
/// <summary>
|
|
/// Gets a value indicating whether this URL segment is the primary one for the document, language and state.
|
|
/// </summary>
|
|
public bool IsPrimary { get; }
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="DocumentUrlService"/> class.
|
|
/// </summary>
|
|
public DocumentUrlService(
|
|
ILogger<DocumentUrlService> logger,
|
|
IDocumentUrlRepository documentUrlRepository,
|
|
IDocumentRepository documentRepository,
|
|
ICoreScopeProvider coreScopeProvider,
|
|
IOptions<GlobalSettings> globalSettings,
|
|
UrlSegmentProviderCollection urlSegmentProviderCollection,
|
|
IContentService contentService,
|
|
IShortStringHelper shortStringHelper,
|
|
ILanguageService languageService,
|
|
IKeyValueService keyValueService,
|
|
IIdKeyMap idKeyMap,
|
|
IDocumentNavigationQueryService documentNavigationQueryService,
|
|
IPublishStatusQueryService publishStatusQueryService,
|
|
IDomainCacheService domainCacheService)
|
|
{
|
|
_logger = logger;
|
|
_documentUrlRepository = documentUrlRepository;
|
|
_documentRepository = documentRepository;
|
|
_coreScopeProvider = coreScopeProvider;
|
|
_globalSettings = globalSettings.Value;
|
|
_urlSegmentProviderCollection = urlSegmentProviderCollection;
|
|
_contentService = contentService;
|
|
_shortStringHelper = shortStringHelper;
|
|
_languageService = languageService;
|
|
_keyValueService = keyValueService;
|
|
_idKeyMap = idKeyMap;
|
|
_documentNavigationQueryService = documentNavigationQueryService;
|
|
_publishStatusQueryService = publishStatusQueryService;
|
|
_domainCacheService = domainCacheService;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async Task InitAsync(bool forceEmpty, CancellationToken cancellationToken)
|
|
{
|
|
if (forceEmpty)
|
|
{
|
|
// We have this use case when umbraco is installing, we know there is no routes. And we can execute the normal logic because the connection string is missing.
|
|
_isInitialized = true;
|
|
return;
|
|
}
|
|
|
|
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
|
|
if (ShouldRebuildUrls())
|
|
{
|
|
_logger.LogInformation("Rebuilding all document URLs.");
|
|
await RebuildAllUrlsAsync();
|
|
}
|
|
|
|
_logger.LogInformation("Caching document URLs.");
|
|
|
|
IEnumerable<PublishedDocumentUrlSegment> publishedDocumentUrlSegments = _documentUrlRepository.GetAll();
|
|
|
|
IEnumerable<ILanguage> languages = await _languageService.GetAllAsync();
|
|
var languageIdToIsoCode = languages.ToDictionary(x => x.Id, x => x.IsoCode);
|
|
|
|
int numberOfCachedUrls = 0;
|
|
foreach (PublishedDocumentUrlSegments publishedDocumentUrlSegment in ConvertToCacheModel(publishedDocumentUrlSegments))
|
|
{
|
|
if (cancellationToken.IsCancellationRequested)
|
|
{
|
|
break;
|
|
}
|
|
|
|
if (languageIdToIsoCode.TryGetValue(publishedDocumentUrlSegment.LanguageId, out var isoCode))
|
|
{
|
|
UpdateCache(_coreScopeProvider.Context!, publishedDocumentUrlSegment, isoCode);
|
|
numberOfCachedUrls++;
|
|
}
|
|
}
|
|
|
|
_logger.LogInformation("Cached {NumberOfUrls} document URLs.", numberOfCachedUrls);
|
|
|
|
_isInitialized = true;
|
|
scope.Complete();
|
|
}
|
|
|
|
private bool ShouldRebuildUrls()
|
|
{
|
|
var persistedValue = GetPersistedRebuildValue();
|
|
var currentValue = GetCurrentRebuildValue();
|
|
|
|
return string.Equals(persistedValue, currentValue) is false;
|
|
}
|
|
|
|
private string? GetPersistedRebuildValue() => _keyValueService.GetValue(RebuildKey);
|
|
|
|
private string GetCurrentRebuildValue() => string.Join("|", _urlSegmentProviderCollection.Select(x => x.GetType().Name));
|
|
|
|
/// <inheritdoc/>
|
|
public async Task RebuildAllUrlsAsync()
|
|
{
|
|
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
|
|
scope.ReadLock(Constants.Locks.ContentTree);
|
|
|
|
IEnumerable<IContent> documents = _documentRepository.GetMany(Array.Empty<int>());
|
|
|
|
await CreateOrUpdateUrlSegmentsAsync(documents);
|
|
|
|
_keyValueService.SetValue(RebuildKey, GetCurrentRebuildValue());
|
|
|
|
scope.Complete();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Converts a collection of <see cref="PublishedDocumentUrlSegment"/> to a collection of <see cref="PublishedDocumentUrlSegments"/> for caching purposes.
|
|
/// </summary>
|
|
/// <param name="publishedDocumentUrlSegments">The collection of <see cref="PublishedDocumentUrlSegment"/> retrieved from the database on startup.</param>
|
|
/// <returns>The collection of cache models.</returns>
|
|
/// <remarks>Internal for the purpose of unit and benchmark testing.</remarks>
|
|
internal static IEnumerable<PublishedDocumentUrlSegments> ConvertToCacheModel(IEnumerable<PublishedDocumentUrlSegment> publishedDocumentUrlSegments)
|
|
{
|
|
var cacheModels = new Dictionary<(Guid DocumentKey, int LanguageId, bool IsDraft), PublishedDocumentUrlSegments>();
|
|
|
|
foreach (PublishedDocumentUrlSegment model in publishedDocumentUrlSegments)
|
|
{
|
|
(Guid DocumentKey, int LanguageId, bool IsDraft) key = (model.DocumentKey, model.LanguageId, model.IsDraft);
|
|
|
|
if (!cacheModels.TryGetValue(key, out PublishedDocumentUrlSegments? existingCacheModel))
|
|
{
|
|
cacheModels[key] = new PublishedDocumentUrlSegments
|
|
{
|
|
DocumentKey = model.DocumentKey,
|
|
LanguageId = model.LanguageId,
|
|
UrlSegments = [new PublishedDocumentUrlSegments.UrlSegment(model.UrlSegment, model.IsPrimary)],
|
|
IsDraft = model.IsDraft,
|
|
};
|
|
}
|
|
else
|
|
{
|
|
if (existingCacheModel.UrlSegments.Any(x => x.Segment == model.UrlSegment) is false)
|
|
{
|
|
existingCacheModel.UrlSegments.Add(new PublishedDocumentUrlSegments.UrlSegment(model.UrlSegment, model.IsPrimary));
|
|
}
|
|
}
|
|
}
|
|
|
|
return cacheModels.Values;
|
|
}
|
|
|
|
private void RemoveFromCache(IScopeContext scopeContext, Guid documentKey, string isoCode, bool isDraft)
|
|
{
|
|
var cacheKey = CreateCacheKey(documentKey, isoCode, isDraft);
|
|
|
|
scopeContext.Enlist("RemoveFromCache_" + cacheKey, () =>
|
|
{
|
|
if (_cache.TryRemove(cacheKey, out _) is false)
|
|
{
|
|
_logger.LogDebug("Could not remove the document url cache. But the important thing is that it is not there.");
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
}
|
|
|
|
private void UpdateCache(IScopeContext scopeContext, PublishedDocumentUrlSegments publishedDocumentUrlSegments, string isoCode)
|
|
{
|
|
var cacheKey = CreateCacheKey(publishedDocumentUrlSegments.DocumentKey, isoCode, publishedDocumentUrlSegments.IsDraft);
|
|
|
|
scopeContext.Enlist("UpdateCache_" + cacheKey, () =>
|
|
{
|
|
_cache.TryGetValue(cacheKey, out PublishedDocumentUrlSegments? existingValue);
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
});
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
private static string CreateCacheKey(Guid documentKey, string culture, bool isDraft) => $"{documentKey}|{culture}|{isDraft}".ToLowerInvariant();
|
|
|
|
/// <inheritdoc/>
|
|
public string? GetUrlSegment(Guid documentKey, string culture, bool isDraft)
|
|
{
|
|
ThrowIfNotInitialized();
|
|
var cacheKey = CreateCacheKey(documentKey, culture, isDraft);
|
|
|
|
_cache.TryGetValue(cacheKey, out PublishedDocumentUrlSegments? urlSegment);
|
|
|
|
return urlSegment?.UrlSegments.FirstOrDefault(x => x.IsPrimary)?.Segment;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public IEnumerable<string> GetUrlSegments(Guid documentKey, string culture, bool isDraft)
|
|
{
|
|
ThrowIfNotInitialized();
|
|
var cacheKey = CreateCacheKey(documentKey, culture, isDraft);
|
|
|
|
_cache.TryGetValue(cacheKey, out PublishedDocumentUrlSegments? urlSegments);
|
|
|
|
return urlSegments?.UrlSegments.Select(x => x.Segment) ?? Enumerable.Empty<string>();
|
|
}
|
|
|
|
private void ThrowIfNotInitialized()
|
|
{
|
|
if (_isInitialized is false)
|
|
{
|
|
throw new InvalidOperationException("The service needs to be initialized before it can be used.");
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async Task CreateOrUpdateUrlSegmentsAsync(Guid key)
|
|
{
|
|
IContent? content = _contentService.GetById(key);
|
|
|
|
if (content is not null)
|
|
{
|
|
await CreateOrUpdateUrlSegmentsAsync(content.Yield());
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async Task CreateOrUpdateUrlSegmentsWithDescendantsAsync(Guid key)
|
|
{
|
|
var id = _idKeyMap.GetIdForKey(key, UmbracoObjectTypes.Document).Result;
|
|
IContent item = _contentService.GetById(id)!;
|
|
IEnumerable<IContent> descendants = _contentService.GetPagedDescendants(id, 0, int.MaxValue, out _);
|
|
|
|
await CreateOrUpdateUrlSegmentsAsync(new List<IContent>(descendants)
|
|
{
|
|
item,
|
|
});
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async Task CreateOrUpdateUrlSegmentsAsync(IEnumerable<IContent> documentsEnumerable)
|
|
{
|
|
IEnumerable<IContent> documents = documentsEnumerable as IContent[] ?? documentsEnumerable.ToArray();
|
|
if (documents.Any() is false)
|
|
{
|
|
return;
|
|
}
|
|
|
|
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
|
|
|
|
var toSave = new List<PublishedDocumentUrlSegment>();
|
|
|
|
IEnumerable<ILanguage> languages = await _languageService.GetAllAsync();
|
|
var languageDictionary = languages.ToDictionary(x => x.IsoCode);
|
|
|
|
foreach (IContent document in documents)
|
|
{
|
|
if (_logger.IsEnabled(LogLevel.Trace))
|
|
{
|
|
_logger.LogTrace("Rebuilding urls for document with key {DocumentKey}", document.Key);
|
|
}
|
|
|
|
foreach ((string culture, ILanguage language) in languageDictionary)
|
|
{
|
|
HandleCaching(_coreScopeProvider.Context!, document, document.ContentType.VariesByCulture() ? culture : null, language, toSave);
|
|
}
|
|
}
|
|
|
|
if (toSave.Count > 0)
|
|
{
|
|
scope.WriteLock(Constants.Locks.DocumentUrls);
|
|
_documentUrlRepository.Save(toSave);
|
|
}
|
|
|
|
scope.Complete();
|
|
}
|
|
|
|
private void HandleCaching(IScopeContext scopeContext, IContent document, string? culture, ILanguage language, List<PublishedDocumentUrlSegment> toSave)
|
|
{
|
|
IEnumerable<(PublishedDocumentUrlSegments model, bool shouldCache)> modelsAndStatus = GenerateModels(document, culture, language);
|
|
|
|
foreach ((PublishedDocumentUrlSegments model, bool shouldCache) in modelsAndStatus)
|
|
{
|
|
if (shouldCache is false)
|
|
{
|
|
RemoveFromCache(scopeContext, model.DocumentKey, language.IsoCode, model.IsDraft);
|
|
}
|
|
else
|
|
{
|
|
toSave.AddRange(ConvertToPersistedModel(model));
|
|
UpdateCache(scopeContext, model, language.IsoCode);
|
|
}
|
|
}
|
|
}
|
|
|
|
private IEnumerable<(PublishedDocumentUrlSegments model, bool shouldCache)> GenerateModels(IContent document, string? culture, ILanguage language)
|
|
{
|
|
if (document.Trashed is false
|
|
&& (IsInvariantAndPublished(document) || IsVariantAndPublishedForCulture(document, culture)))
|
|
{
|
|
string[] publishedUrlSegments = document.GetUrlSegments(_shortStringHelper, _urlSegmentProviderCollection, culture).ToArray();
|
|
if (publishedUrlSegments.Length == 0)
|
|
{
|
|
_logger.LogWarning("No published URL segments found for document {DocumentKey} in culture {Culture}", document.Key, culture ?? "{null}");
|
|
}
|
|
else
|
|
{
|
|
yield return (new PublishedDocumentUrlSegments
|
|
{
|
|
DocumentKey = document.Key,
|
|
LanguageId = language.Id,
|
|
UrlSegments = publishedUrlSegments
|
|
.Select((x, i) => new PublishedDocumentUrlSegments.UrlSegment(x, i == 0))
|
|
.ToList(),
|
|
IsDraft = false,
|
|
}, true);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
yield return (new PublishedDocumentUrlSegments
|
|
{
|
|
DocumentKey = document.Key,
|
|
LanguageId = language.Id,
|
|
UrlSegments = [],
|
|
IsDraft = false,
|
|
}, false);
|
|
}
|
|
|
|
string[] draftUrlSegments = document.GetUrlSegments(_shortStringHelper, _urlSegmentProviderCollection, culture, false).ToArray();
|
|
|
|
if (draftUrlSegments.Any() is false)
|
|
{
|
|
// Log at debug level because this is expected when a document is not published in a given language.
|
|
_logger.LogDebug("No draft URL segments found for document {DocumentKey} in culture {Culture}", document.Key, culture ?? "{null}");
|
|
}
|
|
else
|
|
{
|
|
yield return (new PublishedDocumentUrlSegments
|
|
{
|
|
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 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 static IEnumerable<PublishedDocumentUrlSegment> 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,
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async Task DeleteUrlsFromCacheAsync(IEnumerable<Guid> documentKeysEnumerable)
|
|
{
|
|
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
|
|
|
|
IEnumerable<ILanguage> languages = await _languageService.GetAllAsync();
|
|
|
|
IEnumerable<Guid> documentKeys = documentKeysEnumerable as Guid[] ?? documentKeysEnumerable.ToArray();
|
|
|
|
foreach (ILanguage language in languages)
|
|
{
|
|
foreach (Guid documentKey in documentKeys)
|
|
{
|
|
RemoveFromCache(_coreScopeProvider.Context!, documentKey, language.IsoCode, true);
|
|
RemoveFromCache(_coreScopeProvider.Context!, documentKey, language.IsoCode, false);
|
|
}
|
|
}
|
|
|
|
scope.Complete();
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public Guid? GetDocumentKeyByRoute(string route, string? culture, int? documentStartNodeId, bool isDraft)
|
|
{
|
|
var urlSegments = route.Split(Constants.CharArrays.ForwardSlash, StringSplitOptions.RemoveEmptyEntries);
|
|
|
|
// We need to translate legacy int ids to guid keys.
|
|
Guid? runnerKey = GetStartNodeKey(documentStartNodeId);
|
|
var hideTopLevelNodeFromPath = _globalSettings.HideTopLevelNodeFromPath;
|
|
|
|
culture ??= _languageService.GetDefaultIsoCodeAsync().GetAwaiter().GetResult();
|
|
|
|
if (!_globalSettings.ForceCombineUrlPathLeftToRight
|
|
&& CultureInfo.GetCultureInfo(culture).TextInfo.IsRightToLeft)
|
|
{
|
|
urlSegments = urlSegments.Reverse().ToArray();
|
|
}
|
|
|
|
// If a domain is assigned to this route, we need to follow the url segments
|
|
if (runnerKey.HasValue)
|
|
{
|
|
// if the domain node is unpublished, we need to return null.
|
|
if (isDraft is false && IsContentPublished(runnerKey.Value, culture) is false)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
// If there is no url segments it means the domain root has been requested
|
|
if (urlSegments.Length == 0)
|
|
{
|
|
return runnerKey.Value;
|
|
}
|
|
|
|
// 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
|
|
IEnumerable<Guid> childKeys = GetChildKeys(runnerKey.Value);
|
|
|
|
runnerKey = GetChildWithUrlSegment(childKeys, urlSegment, culture, isDraft);
|
|
|
|
|
|
if (runnerKey is null)
|
|
{
|
|
break;
|
|
}
|
|
|
|
// If part of the path is unpublished, we need to break
|
|
if (isDraft is false && IsContentPublished(runnerKey.Value, culture) is false)
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return runnerKey;
|
|
}
|
|
|
|
// If there is no parts, it means it is a root (and no assigned domain)
|
|
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.
|
|
// if (hideTopLevelNodeFromPath is false)
|
|
// {
|
|
// return null;
|
|
// }
|
|
|
|
return GetTopMostRootKey(isDraft, culture);
|
|
}
|
|
|
|
// Special case for all top level nodes except the first (that will have /)
|
|
if (runnerKey is null && urlSegments.Length == 1 && hideTopLevelNodeFromPath is true)
|
|
{
|
|
IEnumerable<Guid> rootKeys = GetKeysInRoot(false, isDraft, culture);
|
|
Guid? rootKeyWithUrlSegment = GetChildWithUrlSegment(rootKeys, urlSegments.First(), culture, isDraft);
|
|
|
|
if (rootKeyWithUrlSegment is not null)
|
|
{
|
|
return rootKeyWithUrlSegment;
|
|
}
|
|
}
|
|
|
|
// Otherwise we have to find the root items (or child of the roots when hideTopLevelNodeFromPath is true) and follow the url segments in them to get to correct document key
|
|
for (var index = 0; index < urlSegments.Length; index++)
|
|
{
|
|
var urlSegment = urlSegments[index];
|
|
IEnumerable<Guid> runnerKeys;
|
|
if (index == 0)
|
|
{
|
|
runnerKeys = GetKeysInRoot(hideTopLevelNodeFromPath, isDraft, culture);
|
|
}
|
|
else
|
|
{
|
|
if (runnerKey is null)
|
|
{
|
|
break;
|
|
}
|
|
|
|
runnerKeys = GetChildKeys(runnerKey.Value);
|
|
}
|
|
|
|
runnerKey = GetChildWithUrlSegment(runnerKeys, urlSegment, culture, isDraft);
|
|
}
|
|
|
|
if (isDraft is false && runnerKey.HasValue && IsContentPublished(runnerKey.Value, culture) is false)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return runnerKey;
|
|
}
|
|
|
|
private Guid? GetStartNodeKey(int? documentStartNodeId)
|
|
{
|
|
if (documentStartNodeId is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
Attempt<Guid> attempt = _idKeyMap.GetKeyForId(documentStartNodeId.Value, UmbracoObjectTypes.Document);
|
|
return attempt.Success ? attempt.Result : null;
|
|
}
|
|
|
|
private bool IsContentPublished(Guid contentKey, string culture) => _publishStatusQueryService.IsDocumentPublished(contentKey, culture);
|
|
|
|
/// <summary>
|
|
/// Gets the children based on the latest published version of the content. (No aware of things in this scope).
|
|
/// </summary>
|
|
/// <param name="documentKey">The key of the document to get children from.</param>
|
|
/// <returns>The keys of all the children of the document.</returns>
|
|
private IEnumerable<Guid> GetChildKeys(Guid documentKey)
|
|
{
|
|
if (_documentNavigationQueryService.TryGetChildrenKeys(documentKey, out IEnumerable<Guid> childrenKeys))
|
|
{
|
|
return childrenKeys;
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
private Guid? GetChildWithUrlSegment(IEnumerable<Guid> childKeys, string urlSegment, string culture, bool isDraft)
|
|
{
|
|
foreach (Guid childKey in childKeys)
|
|
{
|
|
IEnumerable<string> childUrlSegments = GetUrlSegments(childKey, culture, isDraft);
|
|
|
|
if (childUrlSegments.Contains(urlSegment))
|
|
{
|
|
return childKey;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private Guid? GetTopMostRootKey(bool isDraft, string culture) => GetRootKeys(isDraft, culture).Cast<Guid?>().FirstOrDefault();
|
|
|
|
private IEnumerable<Guid> GetRootKeys(bool isDraft, string culture)
|
|
{
|
|
if (_documentNavigationQueryService.TryGetRootKeys(out IEnumerable<Guid> rootKeys))
|
|
{
|
|
foreach (Guid rootKey in rootKeys)
|
|
{
|
|
if (isDraft || IsContentPublished(rootKey, culture))
|
|
{
|
|
yield return rootKey;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private IEnumerable<Guid> GetKeysInRoot(bool considerFirstLevelAsRoot, bool isDraft, string culture)
|
|
{
|
|
if (_documentNavigationQueryService.TryGetRootKeys(out IEnumerable<Guid> rootKeysEnumerable) is false)
|
|
{
|
|
yield break;
|
|
}
|
|
|
|
IEnumerable<Guid> rootKeys = rootKeysEnumerable as Guid[] ?? rootKeysEnumerable.ToArray();
|
|
|
|
if (considerFirstLevelAsRoot)
|
|
{
|
|
foreach (Guid rootKey in rootKeys)
|
|
{
|
|
if (isDraft is false && IsContentPublished(rootKey, culture) is false)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
IEnumerable<Guid> childKeys = GetChildKeys(rootKey);
|
|
|
|
foreach (Guid childKey in childKeys)
|
|
{
|
|
yield return childKey;
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
foreach (Guid rootKey in rootKeys)
|
|
{
|
|
yield return rootKey;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public string GetLegacyRouteFormat(Guid documentKey, string? culture, bool isDraft)
|
|
{
|
|
Attempt<int> documentIdAttempt = _idKeyMap.GetIdForKey(documentKey, UmbracoObjectTypes.Document);
|
|
|
|
if (documentIdAttempt.Success is false)
|
|
{
|
|
return "#";
|
|
}
|
|
|
|
if (_documentNavigationQueryService.TryGetAncestorsOrSelfKeys(documentKey, out IEnumerable<Guid> ancestorsOrSelfKeys) is false)
|
|
{
|
|
return "#";
|
|
}
|
|
|
|
if (isDraft is false && string.IsNullOrWhiteSpace(culture) is false && _publishStatusQueryService.IsDocumentPublished(documentKey, culture) is false)
|
|
{
|
|
return "#";
|
|
}
|
|
|
|
string cultureOrDefault = GetCultureOrDefault(culture);
|
|
|
|
Guid[] ancestorsOrSelfKeysArray = ancestorsOrSelfKeys as Guid[] ?? ancestorsOrSelfKeys.ToArray();
|
|
ILookup<Guid, Domain?> ancestorOrSelfKeyToDomains = ancestorsOrSelfKeysArray.ToLookup(x => x, ancestorKey =>
|
|
{
|
|
Attempt<int> idAttempt = _idKeyMap.GetIdForKey(ancestorKey, UmbracoObjectTypes.Document);
|
|
|
|
if (idAttempt.Success is false)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
IEnumerable<Domain> domains = _domainCacheService.GetAssigned(idAttempt.Result, false);
|
|
|
|
// 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 GetDomainForCultureOrInvariant(domains, culture);
|
|
});
|
|
|
|
var urlSegments = new List<string>();
|
|
|
|
Domain? foundDomain = null;
|
|
|
|
foreach (Guid ancestorOrSelfKey in ancestorsOrSelfKeysArray)
|
|
{
|
|
IEnumerable<Domain> domains = ancestorOrSelfKeyToDomains[ancestorOrSelfKey].WhereNotNull();
|
|
if (domains.Any())
|
|
{
|
|
foundDomain = domains.First();// What todo here that is better?
|
|
break;
|
|
}
|
|
|
|
if (TryGetPrimaryUrlSegment(ancestorOrSelfKey, cultureOrDefault, isDraft, out string? segment))
|
|
{
|
|
urlSegments.Add(segment);
|
|
}
|
|
|
|
if (foundDomain is not null)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
bool leftToRight = ArePathsLeftToRight(cultureOrDefault);
|
|
if (leftToRight)
|
|
{
|
|
urlSegments.Reverse();
|
|
}
|
|
|
|
if (foundDomain is not null)
|
|
{
|
|
// We found a domain, and not to construct the route in the funny legacy way
|
|
return foundDomain.ContentId + "/" + string.Join("/", urlSegments);
|
|
}
|
|
|
|
var isRootFirstItem = GetTopMostRootKey(isDraft, cultureOrDefault) == ancestorsOrSelfKeysArray.Last();
|
|
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<Domain> domains, string? culture)
|
|
=> string.IsNullOrEmpty(culture)
|
|
? domains.FirstOrDefault()
|
|
: domains.FirstOrDefault(x => x.Culture?.Equals(culture, StringComparison.InvariantCultureIgnoreCase) ?? false);
|
|
|
|
private string GetFullUrl(bool isRootFirstItem, List<string> segments, Domain? foundDomain, bool leftToRight)
|
|
{
|
|
var urlSegments = new List<string>(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 static bool HideTopLevel(bool hideTopLevelNodeFromPath, bool isRootFirstItem, List<string> urlSegments)
|
|
{
|
|
if (hideTopLevelNodeFromPath is false)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (isRootFirstItem is false && urlSegments.Count == 1)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public bool HasAny()
|
|
{
|
|
ThrowIfNotInitialized();
|
|
return _cache.Any();
|
|
}
|
|
|
|
private bool TryGetPrimaryUrlSegment(Guid documentKey, string culture, bool isDraft, [NotNullWhen(true)] out string? segment)
|
|
{
|
|
if (_cache.TryGetValue(
|
|
CreateCacheKey(documentKey, culture, isDraft),
|
|
out PublishedDocumentUrlSegments? publishedDocumentUrlSegments))
|
|
{
|
|
PublishedDocumentUrlSegments.UrlSegment? primaryUrlSegment = publishedDocumentUrlSegments.UrlSegments.FirstOrDefault(x => x.IsPrimary);
|
|
if (primaryUrlSegment is not null)
|
|
{
|
|
segment = primaryUrlSegment.Segment;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
segment = null;
|
|
return false;
|
|
}
|
|
}
|