Files
Umbraco-CMS/src/Umbraco.Core/Services/DocumentUrlService.cs

989 lines
37 KiB
C#
Raw Normal View History

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 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 URLs.");
await RebuildAllUrlsAsync();
}
IEnumerable<PublishedDocumentUrlSegment> publishedDocumentUrlSegments = _documentUrlRepository.GetAll();
IEnumerable<ILanguage> languages = await _languageService.GetAllAsync();
var languageIdToIsoCode = languages.ToDictionary(x => x.Id, x => x.IsoCode);
foreach (PublishedDocumentUrlSegments publishedDocumentUrlSegment in ConvertToCacheModel(publishedDocumentUrlSegments))
{
if (cancellationToken.IsCancellationRequested)
{
break;
}
if (languageIdToIsoCode.TryGetValue(publishedDocumentUrlSegment.LanguageId, out var isoCode))
{
UpdateCache(_coreScopeProvider.Context!, publishedDocumentUrlSegment, isoCode);
}
}
_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)
{
_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)
{
_logger.LogWarning("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 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);
2025-01-13 08:52:43 +01:00
// 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 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/>
V15: Remove Nucache (#17166) * Remove nucache reference from Web.Common * Get tests building-ish * Move ReservedFieldNamesService to the right project * Remove IPublishedSnapshotStatus * Added functionality to the INavigationQueryService to get root keys * Fixed issue with navigation * Remove IPublishedSnapshot from UmbracoContext * Begin removing usage of IPublishedSnapshot from PublishedContentExtensions * Fix PublishedContentExtensions.cs * Don't use snapshots in delivery media api * Use IPublishedMediaCache in QueryMediaApiController * Remove more usages of IPublishedSnapshotAccessor * Comment out tests * Remove more usages of PublishedSnapshotAccessor * Remove PublishedSnapshot from property * Fixed test build * Fix errors * Fix some tests * Delete NuCache 🎉 * Implement DatabaseCacheRebuilder * Remove usage of IPublishedSnapshotService * Remove IPublishedSnapshotService * Remove TestPublishedSnapshotAccessor and make tests build * Don't test Snapshot cachelevel It's no longer supported * Fix BlockEditorConverter Element != Element document type * Remember to set cachemanager * Fix RichTextParserTests * Implement TryGetLevel on INavigationQueryService * Fake level and obsolete it in PublishedContent * Remove ChildrenForAllCultures * Hack Path property on PublishedContent * Remove usages of IPublishedSnapshot in tests * More ConvertersTests * Add hybrid cache to integration tests We can actually do this now because we no longer save files on disk * Rename IPublishedSnapshotRebuilder to ICacheRebuilder * Comment out tests * V15: Replacing the usages of Parent (navigation data) from IPublishedContent (#17125) * Fix .Parent references in PublishedContentExtensions * Add missing methods to FriendlyPublishedContentExtensions (ones that you were able to call on the content directly as they now require extra params) * Fix references from the extension methods * Fix dependencies in tests * Replace IPublishedSnapshotAccessor with the content cache in tests * Resolving more .Parent references * Fix unit tests * Obsolete and use extension methods * Remove private method and use extension instead * Moving code around * Fix tests * Fix more references * Cleanup * Fix more usages * Resolve merge conflict * Fix tests * Cleanup * Fix more tests * Fixed unit tests * Cleanup * Replace last usages --------- Co-authored-by: Bjarke Berg <mail@bergmania.dk> * Remove usage of IPublishedSnapshotAccessor from IRequestItemProvider * Post merge fixup * Remo IPublishedSnapshot * Add HasAny to IDocumentUrlService * Fix TextBuilder * Fix modelsbuilder tests * Use explicit types * Implement GetByContentType * Support element types in PublishedContentTypeCache * Run enlistments before publishing notifications * Fix elements cache refreshing * Implement GetByUdi * Implement GetAtRoot * Implement GetByRoute * Reimplement GetRouteById * Fix blocks unit tests * Initialize domain cache on boot * Only return routes with domains on non default lanauges * V15: Replacing the usages of `Children` (navigation data) from `IPublishedContent` (#17159) * Update params in PublishedContentExtensions to the general interfaces for the published cache and navigation service, so that we can use the extension methods on both documents and media * Introduce GetParent() which uses the right services * Fix obsolete message on .Parent * Obsolete .Children * Fix usages of Children for ApiMediaQueryService * Fix usage in internal * Fix usages in views * Fix indentation * Fix issue with delete language * Update nuget pacakges * Clear elements cache when content is deleted instead of trying to update it * Reset publishedModelFactory * Fixed publishing --------- Co-authored-by: Bjarke Berg <mail@bergmania.dk> Co-authored-by: Elitsa Marinovska <21998037+elit0451@users.noreply.github.com> Co-authored-by: kjac <kja@umbraco.dk>
2024-10-01 15:03:02 +02:00
public bool HasAny()
{
ThrowIfNotInitialized();
return _cache.Any();
}
/// <inheritdoc/>
[Obsolete("This method is obsolete and will be removed in future versions. Use IPublishedUrlInfoProvider.GetAllAsync instead.")]
public async Task<IEnumerable<UrlInfo>> ListUrlsAsync(Guid contentKey)
{
var result = new List<UrlInfo>();
Attempt<int> documentIdAttempt = _idKeyMap.GetIdForKey(contentKey, UmbracoObjectTypes.Document);
if (documentIdAttempt.Success is false)
{
return result;
}
IEnumerable<Guid> ancestorsOrSelfKeys = contentKey.Yield()
.Concat(_contentService.GetAncestors(documentIdAttempt.Result).Select(x => x.Key).Reverse());
IEnumerable<ILanguage> languages = await _languageService.GetAllAsync();
V15: Remove Nucache (#17166) * Remove nucache reference from Web.Common * Get tests building-ish * Move ReservedFieldNamesService to the right project * Remove IPublishedSnapshotStatus * Added functionality to the INavigationQueryService to get root keys * Fixed issue with navigation * Remove IPublishedSnapshot from UmbracoContext * Begin removing usage of IPublishedSnapshot from PublishedContentExtensions * Fix PublishedContentExtensions.cs * Don't use snapshots in delivery media api * Use IPublishedMediaCache in QueryMediaApiController * Remove more usages of IPublishedSnapshotAccessor * Comment out tests * Remove more usages of PublishedSnapshotAccessor * Remove PublishedSnapshot from property * Fixed test build * Fix errors * Fix some tests * Delete NuCache 🎉 * Implement DatabaseCacheRebuilder * Remove usage of IPublishedSnapshotService * Remove IPublishedSnapshotService * Remove TestPublishedSnapshotAccessor and make tests build * Don't test Snapshot cachelevel It's no longer supported * Fix BlockEditorConverter Element != Element document type * Remember to set cachemanager * Fix RichTextParserTests * Implement TryGetLevel on INavigationQueryService * Fake level and obsolete it in PublishedContent * Remove ChildrenForAllCultures * Hack Path property on PublishedContent * Remove usages of IPublishedSnapshot in tests * More ConvertersTests * Add hybrid cache to integration tests We can actually do this now because we no longer save files on disk * Rename IPublishedSnapshotRebuilder to ICacheRebuilder * Comment out tests * V15: Replacing the usages of Parent (navigation data) from IPublishedContent (#17125) * Fix .Parent references in PublishedContentExtensions * Add missing methods to FriendlyPublishedContentExtensions (ones that you were able to call on the content directly as they now require extra params) * Fix references from the extension methods * Fix dependencies in tests * Replace IPublishedSnapshotAccessor with the content cache in tests * Resolving more .Parent references * Fix unit tests * Obsolete and use extension methods * Remove private method and use extension instead * Moving code around * Fix tests * Fix more references * Cleanup * Fix more usages * Resolve merge conflict * Fix tests * Cleanup * Fix more tests * Fixed unit tests * Cleanup * Replace last usages --------- Co-authored-by: Bjarke Berg <mail@bergmania.dk> * Remove usage of IPublishedSnapshotAccessor from IRequestItemProvider * Post merge fixup * Remo IPublishedSnapshot * Add HasAny to IDocumentUrlService * Fix TextBuilder * Fix modelsbuilder tests * Use explicit types * Implement GetByContentType * Support element types in PublishedContentTypeCache * Run enlistments before publishing notifications * Fix elements cache refreshing * Implement GetByUdi * Implement GetAtRoot * Implement GetByRoute * Reimplement GetRouteById * Fix blocks unit tests * Initialize domain cache on boot * Only return routes with domains on non default lanauges * V15: Replacing the usages of `Children` (navigation data) from `IPublishedContent` (#17159) * Update params in PublishedContentExtensions to the general interfaces for the published cache and navigation service, so that we can use the extension methods on both documents and media * Introduce GetParent() which uses the right services * Fix obsolete message on .Parent * Obsolete .Children * Fix usages of Children for ApiMediaQueryService * Fix usage in internal * Fix usages in views * Fix indentation * Fix issue with delete language * Update nuget pacakges * Clear elements cache when content is deleted instead of trying to update it * Reset publishedModelFactory * Fixed publishing --------- Co-authored-by: Bjarke Berg <mail@bergmania.dk> Co-authored-by: Elitsa Marinovska <21998037+elit0451@users.noreply.github.com> Co-authored-by: kjac <kja@umbraco.dk>
2024-10-01 15:03:02 +02:00
var cultures = languages.ToDictionary(x=>x.IsoCode);
Guid[] ancestorsOrSelfKeysArray = ancestorsOrSelfKeys as Guid[] ?? ancestorsOrSelfKeys.ToArray();
Dictionary<Guid, Task<ILookup<string, Domain>>> ancestorOrSelfKeyToDomains = ancestorsOrSelfKeysArray
.ToDictionary(
x => x,
ancestorKey =>
{
Attempt<int> idAttempt = _idKeyMap.GetIdForKey(ancestorKey, UmbracoObjectTypes.Document);
if (idAttempt.Success is false)
{
return Task.FromResult((ILookup<string, Domain>)null!);
}
IEnumerable<Domain> domains = _domainCacheService.GetAssigned(idAttempt.Result, false);
return Task.FromResult(domains.ToLookup(x => x.Culture!));
})!;
V15: Remove Nucache (#17166) * Remove nucache reference from Web.Common * Get tests building-ish * Move ReservedFieldNamesService to the right project * Remove IPublishedSnapshotStatus * Added functionality to the INavigationQueryService to get root keys * Fixed issue with navigation * Remove IPublishedSnapshot from UmbracoContext * Begin removing usage of IPublishedSnapshot from PublishedContentExtensions * Fix PublishedContentExtensions.cs * Don't use snapshots in delivery media api * Use IPublishedMediaCache in QueryMediaApiController * Remove more usages of IPublishedSnapshotAccessor * Comment out tests * Remove more usages of PublishedSnapshotAccessor * Remove PublishedSnapshot from property * Fixed test build * Fix errors * Fix some tests * Delete NuCache 🎉 * Implement DatabaseCacheRebuilder * Remove usage of IPublishedSnapshotService * Remove IPublishedSnapshotService * Remove TestPublishedSnapshotAccessor and make tests build * Don't test Snapshot cachelevel It's no longer supported * Fix BlockEditorConverter Element != Element document type * Remember to set cachemanager * Fix RichTextParserTests * Implement TryGetLevel on INavigationQueryService * Fake level and obsolete it in PublishedContent * Remove ChildrenForAllCultures * Hack Path property on PublishedContent * Remove usages of IPublishedSnapshot in tests * More ConvertersTests * Add hybrid cache to integration tests We can actually do this now because we no longer save files on disk * Rename IPublishedSnapshotRebuilder to ICacheRebuilder * Comment out tests * V15: Replacing the usages of Parent (navigation data) from IPublishedContent (#17125) * Fix .Parent references in PublishedContentExtensions * Add missing methods to FriendlyPublishedContentExtensions (ones that you were able to call on the content directly as they now require extra params) * Fix references from the extension methods * Fix dependencies in tests * Replace IPublishedSnapshotAccessor with the content cache in tests * Resolving more .Parent references * Fix unit tests * Obsolete and use extension methods * Remove private method and use extension instead * Moving code around * Fix tests * Fix more references * Cleanup * Fix more usages * Resolve merge conflict * Fix tests * Cleanup * Fix more tests * Fixed unit tests * Cleanup * Replace last usages --------- Co-authored-by: Bjarke Berg <mail@bergmania.dk> * Remove usage of IPublishedSnapshotAccessor from IRequestItemProvider * Post merge fixup * Remo IPublishedSnapshot * Add HasAny to IDocumentUrlService * Fix TextBuilder * Fix modelsbuilder tests * Use explicit types * Implement GetByContentType * Support element types in PublishedContentTypeCache * Run enlistments before publishing notifications * Fix elements cache refreshing * Implement GetByUdi * Implement GetAtRoot * Implement GetByRoute * Reimplement GetRouteById * Fix blocks unit tests * Initialize domain cache on boot * Only return routes with domains on non default lanauges * V15: Replacing the usages of `Children` (navigation data) from `IPublishedContent` (#17159) * Update params in PublishedContentExtensions to the general interfaces for the published cache and navigation service, so that we can use the extension methods on both documents and media * Introduce GetParent() which uses the right services * Fix obsolete message on .Parent * Obsolete .Children * Fix usages of Children for ApiMediaQueryService * Fix usage in internal * Fix usages in views * Fix indentation * Fix issue with delete language * Update nuget pacakges * Clear elements cache when content is deleted instead of trying to update it * Reset publishedModelFactory * Fixed publishing --------- Co-authored-by: Bjarke Berg <mail@bergmania.dk> Co-authored-by: Elitsa Marinovska <21998037+elit0451@users.noreply.github.com> Co-authored-by: kjac <kja@umbraco.dk>
2024-10-01 15:03:02 +02:00
foreach ((string culture, ILanguage language) in cultures)
{
var urlSegments = new List<string>();
var foundDomains = new List<Domain?>();
var hasUrlInCulture = true;
foreach (Guid ancestorOrSelfKey in ancestorsOrSelfKeysArray)
{
ILookup<string, Domain> domainLookup = await ancestorOrSelfKeyToDomains[ancestorOrSelfKey];
if (domainLookup.Any())
{
IEnumerable<Domain> domains = domainLookup[culture];
foreach (Domain domain in domains)
{
Attempt<Guid> domainKeyAttempt =
_idKeyMap.GetKeyForId(domain.ContentId, UmbracoObjectTypes.Document);
if (domainKeyAttempt.Success)
{
if (_publishStatusQueryService.IsDocumentPublished(domainKeyAttempt.Result, culture))
{
foundDomains.Add(domain);
}
}
}
if (foundDomains.Any())
{
break;
}
}
if (TryGetPrimaryUrlSegment(ancestorOrSelfKey, culture, false, out string? segment))
{
urlSegments.Add(segment);
}
else
{
hasUrlInCulture = false;
}
}
// 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;
}
V15: Remove Nucache (#17166) * Remove nucache reference from Web.Common * Get tests building-ish * Move ReservedFieldNamesService to the right project * Remove IPublishedSnapshotStatus * Added functionality to the INavigationQueryService to get root keys * Fixed issue with navigation * Remove IPublishedSnapshot from UmbracoContext * Begin removing usage of IPublishedSnapshot from PublishedContentExtensions * Fix PublishedContentExtensions.cs * Don't use snapshots in delivery media api * Use IPublishedMediaCache in QueryMediaApiController * Remove more usages of IPublishedSnapshotAccessor * Comment out tests * Remove more usages of PublishedSnapshotAccessor * Remove PublishedSnapshot from property * Fixed test build * Fix errors * Fix some tests * Delete NuCache 🎉 * Implement DatabaseCacheRebuilder * Remove usage of IPublishedSnapshotService * Remove IPublishedSnapshotService * Remove TestPublishedSnapshotAccessor and make tests build * Don't test Snapshot cachelevel It's no longer supported * Fix BlockEditorConverter Element != Element document type * Remember to set cachemanager * Fix RichTextParserTests * Implement TryGetLevel on INavigationQueryService * Fake level and obsolete it in PublishedContent * Remove ChildrenForAllCultures * Hack Path property on PublishedContent * Remove usages of IPublishedSnapshot in tests * More ConvertersTests * Add hybrid cache to integration tests We can actually do this now because we no longer save files on disk * Rename IPublishedSnapshotRebuilder to ICacheRebuilder * Comment out tests * V15: Replacing the usages of Parent (navigation data) from IPublishedContent (#17125) * Fix .Parent references in PublishedContentExtensions * Add missing methods to FriendlyPublishedContentExtensions (ones that you were able to call on the content directly as they now require extra params) * Fix references from the extension methods * Fix dependencies in tests * Replace IPublishedSnapshotAccessor with the content cache in tests * Resolving more .Parent references * Fix unit tests * Obsolete and use extension methods * Remove private method and use extension instead * Moving code around * Fix tests * Fix more references * Cleanup * Fix more usages * Resolve merge conflict * Fix tests * Cleanup * Fix more tests * Fixed unit tests * Cleanup * Replace last usages --------- Co-authored-by: Bjarke Berg <mail@bergmania.dk> * Remove usage of IPublishedSnapshotAccessor from IRequestItemProvider * Post merge fixup * Remo IPublishedSnapshot * Add HasAny to IDocumentUrlService * Fix TextBuilder * Fix modelsbuilder tests * Use explicit types * Implement GetByContentType * Support element types in PublishedContentTypeCache * Run enlistments before publishing notifications * Fix elements cache refreshing * Implement GetByUdi * Implement GetAtRoot * Implement GetByRoute * Reimplement GetRouteById * Fix blocks unit tests * Initialize domain cache on boot * Only return routes with domains on non default lanauges * V15: Replacing the usages of `Children` (navigation data) from `IPublishedContent` (#17159) * Update params in PublishedContentExtensions to the general interfaces for the published cache and navigation service, so that we can use the extension methods on both documents and media * Introduce GetParent() which uses the right services * Fix obsolete message on .Parent * Obsolete .Children * Fix usages of Children for ApiMediaQueryService * Fix usage in internal * Fix usages in views * Fix indentation * Fix issue with delete language * Update nuget pacakges * Clear elements cache when content is deleted instead of trying to update it * Reset publishedModelFactory * Fixed publishing --------- Co-authored-by: Bjarke Berg <mail@bergmania.dk> Co-authored-by: Elitsa Marinovska <21998037+elit0451@users.noreply.github.com> Co-authored-by: kjac <kja@umbraco.dk>
2024-10-01 15:03:02 +02:00
var isRootFirstItem = GetTopMostRootKey(false, culture) == ancestorsOrSelfKeysArray.Last();
var leftToRight = _globalSettings.ForceCombineUrlPathLeftToRight
|| CultureInfo.GetCultureInfo(culture).TextInfo.IsRightToLeft is false;
if (leftToRight)
{
urlSegments.Reverse();
}
// If no domain was found, we need to add a null domain to the list to make sure we check for no domains.
if (foundDomains.Any() is false)
{
foundDomains.Add(null);
}
foreach (Domain? foundDomain in foundDomains)
{
var foundUrl = GetFullUrl(isRootFirstItem, urlSegments, foundDomain, leftToRight);
if (foundDomain is not null)
{
// if we found a domain, it should be safe to show url
result.Add(new UrlInfo(
text: foundUrl,
isUrl: hasUrlInCulture,
culture: culture));
}
else
{
// otherwise we need to ensure that no other page has the same url
// e.g. a site with two roots that both have a child with the same name
Guid? documentKeyByRoute = GetDocumentKeyByRoute(foundUrl, culture, foundDomain?.ContentId, false);
if (contentKey.Equals(documentKeyByRoute))
{
result.Add(new UrlInfo(
text: foundUrl,
isUrl: hasUrlInCulture,
culture: culture));
}
else
{
result.Add(new UrlInfo(
text: "Conflict: Other page has the same url",
isUrl: false,
culture: culture));
}
}
}
}
return result;
}
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;
}
}