diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index e009ee2294..cea5859486 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -60,10 +60,10 @@ Great question! The short version goes like this: ![Clone the fork](img/clonefork.png) - * **Switch to the correct branch** - switch to the v8-dev branch + * **Switch to the correct branch** - switch to the `v8/contrib` branch * **Build** - build your fork of Umbraco locally as described in [building Umbraco from source code](BUILD.md) * **Change** - make your changes, experiment, have fun, explore and learn, and don't be afraid. We welcome all contributions and will [happily give feedback](#questions) - * **Commit** - done? Yay! 🎉 **Important:** create a new branch now and name it after the issue you're fixing, we usually follow the format: `temp-12345`. This means it's a temporary branch for the particular issue you're working on, in this case `12345`. When you have a branch, commit your changes. Don't commit to `v8/dev`, create a new branch first. + * **Commit** - done? Yay! 🎉 **Important:** create a new branch now and name it after the issue you're fixing, we usually follow the format: `temp-12345`. This means it's a temporary branch for the particular issue you're working on, in this case `12345`. When you have a branch, commit your changes. Don't commit to `v8/contrib`, create a new branch first. * **Push** - great, now you can push the changes up to your fork on GitHub * **Create pull request** - exciting! You're ready to show us your changes (or not quite ready, you just need some feedback to progress - you can now make use of GitHub's draft pull request status, detailed [here] (https://github.blog/2019-02-14-introducing-draft-pull-requests/)). GitHub has picked up on the new branch you've pushed and will offer to create a Pull Request. Click that green button and away you go. @@ -158,7 +158,7 @@ To find the general areas for something you're looking to fix or improve, have a ### Which branch should I target for my contributions? -We like to use [Gitflow as much as possible](https://jeffkreeftmeijer.com/git-flow/), but don't worry if you are not familiar with it. The most important thing you need to know is that when you fork the Umbraco repository, the default branch is set to something, usually `v8/dev`. If you are working on v8, this is the branch you should be targetting. For v7 contributions, please target 'v7/dev'. +We like to use [Gitflow as much as possible](https://jeffkreeftmeijer.com/git-flow/), but don't worry if you are not familiar with it. The most important thing you need to know is that when you fork the Umbraco repository, the default branch is set to something, usually `v8/contrib`. If you are working on v8, this is the branch you should be targetting. For v7 contributions, please target 'v7/dev'. Please note: we are no longer accepting features for v7 but will continue to merge bug fixes as and when they arise. @@ -184,10 +184,10 @@ Then when you want to get the changes from the main repository: ``` git fetch upstream -git rebase upstream/v8/dev +git rebase upstream/v8/contrib ``` -In this command we're syncing with the `v8/dev` branch, but you can of course choose another one if needed. +In this command we're syncing with the `v8/contrib` branch, but you can of course choose another one if needed. (More info on how this works: [http://robots.thoughtbot.com/post/5133345960/keeping-a-git-fork-updated](http://robots.thoughtbot.com/post/5133345960/keeping-a-git-fork-updated)) diff --git a/.github/README.md b/.github/README.md index d6d978c3d6..467ca6e5e6 100644 --- a/.github/README.md +++ b/.github/README.md @@ -1,4 +1,4 @@ -# [Umbraco CMS](https://umbraco.com) · [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](../LICENSE.md) [![Build status](https://umbraco.visualstudio.com/Umbraco%20Cms/_apis/build/status/Cms%208%20Continuous?branchName=v8/dev)](https://umbraco.visualstudio.com/Umbraco%20Cms/_build?definitionId=75) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md) [![Twitter](https://img.shields.io/twitter/follow/umbraco.svg?style=social&label=Follow)](https://twitter.com/intent/follow?screen_name=umbraco) +# [Umbraco CMS](https://umbraco.com) · [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](../LICENSE.md) [![Build status](https://umbraco.visualstudio.com/Umbraco%20Cms/_apis/build/status/Cms%208%20Continuous?branchName=v8/contrib)](https://umbraco.visualstudio.com/Umbraco%20Cms/_build?definitionId=75) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md) [![Twitter](https://img.shields.io/twitter/follow/umbraco.svg?style=social&label=Follow)](https://twitter.com/intent/follow?screen_name=umbraco) Umbraco is the friendliest, most flexible and fastest growing ASP.NET CMS, and used by more than 500,000 websites worldwide. Our mission is to help you deliver delightful digital experiences by making Umbraco friendly, simpler and social. diff --git a/.github/img/defaultbranch.png b/.github/img/defaultbranch.png index f3a5b9efbc..3550b5c34c 100644 Binary files a/.github/img/defaultbranch.png and b/.github/img/defaultbranch.png differ diff --git a/src/Umbraco.Abstractions/Constants-AppSettings.cs b/src/Umbraco.Abstractions/Constants-AppSettings.cs index 2217863122..4c47f12ba0 100644 --- a/src/Umbraco.Abstractions/Constants-AppSettings.cs +++ b/src/Umbraco.Abstractions/Constants-AppSettings.cs @@ -9,6 +9,12 @@ namespace Umbraco.Core /// public static class AppSettings { + public const string MainDomLock = "Umbraco.Core.MainDom.Lock"; + + // TODO: Kill me - still used in Umbraco.Core.IO.SystemFiles:27 + [Obsolete("We need to kill this appsetting as we do not use XML content cache umbraco.config anymore due to NuCache")] + public const string ContentXML = "Umbraco.Core.ContentXML"; //umbracoContentXML + /// /// TODO: FILL ME IN /// diff --git a/src/Umbraco.Abstractions/ContentVariationExtensions.cs b/src/Umbraco.Abstractions/ContentVariationExtensions.cs index a1b374f3c8..1c34a61c3a 100644 --- a/src/Umbraco.Abstractions/ContentVariationExtensions.cs +++ b/src/Umbraco.Abstractions/ContentVariationExtensions.cs @@ -12,126 +12,260 @@ namespace Umbraco.Core /// /// Determines whether the content type is invariant. /// + /// The content type. + /// + /// A value indicating whether the content type is invariant. + /// public static bool VariesByNothing(this ISimpleContentType contentType) => contentType.Variations.VariesByNothing(); - /// - /// Determines whether the content type varies by culture. - /// - public static bool VariesByCulture(this ISimpleContentType contentType) => contentType.Variations.VariesByCulture(); - - /// - /// Determines whether the content type varies by segment. - /// - public static bool VariesBySegment(this ISimpleContentType contentType) => contentType.Variations.VariesBySegment(); - /// /// Determines whether the content type is invariant. /// + /// The content type. + /// + /// A value indicating whether the content type is invariant. + /// public static bool VariesByNothing(this IContentTypeBase contentType) => contentType.Variations.VariesByNothing(); - /// - /// Determines whether the content type varies by culture. - /// - /// And then it could also vary by segment. - public static bool VariesByCulture(this IContentTypeBase contentType) => contentType.Variations.VariesByCulture(); - - /// - /// Determines whether the content type varies by segment. - /// - /// And then it could also vary by culture. - public static bool VariesBySegment(this IContentTypeBase contentType) => contentType.Variations.VariesBySegment(); - - /// - /// Determines whether the content type varies by culture and segment. - /// - public static bool VariesByCultureAndSegment(this IContentTypeBase contentType) => contentType.Variations.VariesByCultureAndSegment(); - - /// - /// Determines whether the property type is invariant. - /// - public static bool VariesByNothing(this IPropertyType propertyType) => propertyType.Variations.VariesByNothing(); - - /// - /// Determines whether the property type varies by culture. - /// - /// And then it could also vary by segment. - public static bool VariesByCulture(this IPropertyType propertyType) => propertyType.Variations.VariesByCulture(); - - /// - /// Determines whether the property type varies by segment. - /// - /// And then it could also vary by culture. - public static bool VariesBySegment(this IPropertyType propertyType) => propertyType.Variations.VariesBySegment(); - - /// - /// Determines whether the property type varies by culture and segment. - /// - public static bool VariesByCultureAndSegment(this IPropertyType propertyType) => propertyType.Variations.VariesByCultureAndSegment(); - /// /// Determines whether the content type is invariant. /// + /// The content type. + /// + /// A value indicating whether the content type is invariant. + /// public static bool VariesByNothing(this IPublishedContentType contentType) => contentType.Variations.VariesByNothing(); /// - /// Determines whether the content type varies by culture. + /// Determines whether the property type is invariant. /// - /// And then it could also vary by segment. - public static bool VariesByCulture(this IPublishedContentType contentType) => contentType.Variations.VariesByCulture(); - - /// - /// Determines whether the content type varies by segment. - /// - /// And then it could also vary by culture. - public static bool VariesBySegment(this IPublishedContentType contentType) => contentType.Variations.VariesBySegment(); - - /// - /// Determines whether the content type varies by culture and segment. - /// - public static bool VariesByCultureAndSegment(this IPublishedContentType contentType) => contentType.Variations.VariesByCultureAndSegment(); + /// The property type. + /// + /// A value indicating whether the property type is invariant. + /// + public static bool VariesByNothing(this IPropertyType propertyType) => propertyType.Variations.VariesByNothing(); /// /// Determines whether the property type is invariant. /// + /// The property type. + /// + /// A value indicating whether the property type is invariant. + /// public static bool VariesByNothing(this IPublishedPropertyType propertyType) => propertyType.Variations.VariesByNothing(); - /// - /// Determines whether the property type varies by culture. - /// - public static bool VariesByCulture(this IPublishedPropertyType propertyType) => propertyType.Variations.VariesByCulture(); - - /// - /// Determines whether the property type varies by segment. - /// - public static bool VariesBySegment(this IPublishedPropertyType propertyType) => propertyType.Variations.VariesBySegment(); - - /// - /// Determines whether the property type varies by culture and segment. - /// - public static bool VariesByCultureAndSegment(this IPublishedPropertyType propertyType) => propertyType.Variations.VariesByCultureAndSegment(); - /// /// Determines whether a variation is invariant. /// + /// The variation. + /// + /// A value indicating whether the variation is invariant. + /// public static bool VariesByNothing(this ContentVariation variation) => variation == ContentVariation.Nothing; + /// + /// Determines whether the content type varies by culture. + /// + /// The content type. + /// + /// A value indicating whether the content type varies by culture. + /// + public static bool VariesByCulture(this ISimpleContentType contentType) => contentType.Variations.VariesByCulture(); + + /// + /// Determines whether the content type varies by culture. + /// + /// The content type. + /// + /// A value indicating whether the content type varies by culture. + /// + public static bool VariesByCulture(this IContentTypeBase contentType) => contentType.Variations.VariesByCulture(); + + /// + /// Determines whether the content type varies by culture. + /// + /// The content type. + /// + /// A value indicating whether the content type varies by culture. + /// + public static bool VariesByCulture(this IPublishedContentType contentType) => contentType.Variations.VariesByCulture(); + + /// + /// Determines whether the property type varies by culture. + /// + /// The property type. + /// + /// A value indicating whether the property type varies by culture. + /// + public static bool VariesByCulture(this IPropertyType propertyType) => propertyType.Variations.VariesByCulture(); + + /// + /// Determines whether the property type varies by culture. + /// + /// The property type. + /// + /// A value indicating whether the property type varies by culture. + /// + public static bool VariesByCulture(this IPublishedPropertyType propertyType) => propertyType.Variations.VariesByCulture(); + /// /// Determines whether a variation varies by culture. /// - /// And then it could also vary by segment. + /// The variation. + /// + /// A value indicating whether the variation varies by culture. + /// public static bool VariesByCulture(this ContentVariation variation) => (variation & ContentVariation.Culture) > 0; + /// + /// Determines whether the content type varies by segment. + /// + /// The content type. + /// + /// A value indicating whether the content type varies by segment. + /// + public static bool VariesBySegment(this ISimpleContentType contentType) => contentType.Variations.VariesBySegment(); + + /// + /// Determines whether the content type varies by segment. + /// + /// The content type. + /// + /// A value indicating whether the content type varies by segment. + /// + public static bool VariesBySegment(this IContentTypeBase contentType) => contentType.Variations.VariesBySegment(); + + /// + /// Determines whether the content type varies by segment. + /// + /// The content type. + /// + /// A value indicating whether the content type varies by segment. + /// + public static bool VariesBySegment(this IPublishedContentType contentType) => contentType.Variations.VariesBySegment(); + + /// + /// Determines whether the property type varies by segment. + /// + /// The property type. + /// + /// A value indicating whether the property type varies by segment. + /// + public static bool VariesBySegment(this IPropertyType propertyType) => propertyType.Variations.VariesBySegment(); + + /// + /// Determines whether the property type varies by segment. + /// + /// The property type. + /// + /// A value indicating whether the property type varies by segment. + /// + public static bool VariesBySegment(this IPublishedPropertyType propertyType) => propertyType.Variations.VariesBySegment(); + /// /// Determines whether a variation varies by segment. /// - /// And then it could also vary by culture. + /// The variation. + /// + /// A value indicating whether the variation varies by segment. + /// public static bool VariesBySegment(this ContentVariation variation) => (variation & ContentVariation.Segment) > 0; + /// + /// Determines whether the content type varies by culture and segment. + /// + /// The content type. + /// + /// A value indicating whether the content type varies by culture and segment. + /// + public static bool VariesByCultureAndSegment(this ISimpleContentType contentType) => contentType.Variations.VariesByCultureAndSegment(); + + /// + /// Determines whether the content type varies by culture and segment. + /// + /// The content type. + /// + /// A value indicating whether the content type varies by culture and segment. + /// + public static bool VariesByCultureAndSegment(this IContentTypeBase contentType) => contentType.Variations.VariesByCultureAndSegment(); + + /// + /// Determines whether the content type varies by culture and segment. + /// + /// The content type. + /// + /// A value indicating whether the content type varies by culture and segment. + /// + public static bool VariesByCultureAndSegment(this IPublishedContentType contentType) => contentType.Variations.VariesByCultureAndSegment(); + + /// + /// Determines whether the property type varies by culture and segment. + /// + /// The property type. + /// + /// A value indicating whether the property type varies by culture and segment. + /// + public static bool VariesByCultureAndSegment(this IPropertyType propertyType) => propertyType.Variations.VariesByCultureAndSegment(); + + /// + /// Determines whether the property type varies by culture and segment. + /// + /// The property type. + /// + /// A value indicating whether the property type varies by culture and segment. + /// + public static bool VariesByCultureAndSegment(this IPublishedPropertyType propertyType) => propertyType.Variations.VariesByCultureAndSegment(); + /// /// Determines whether a variation varies by culture and segment. /// + /// The variation. + /// + /// A value indicating whether the variation varies by culture and segment. + /// public static bool VariesByCultureAndSegment(this ContentVariation variation) => (variation & ContentVariation.CultureAndSegment) == ContentVariation.CultureAndSegment; + /// + /// Sets or removes the content type variation depending on the specified value. + /// + /// The content type. + /// The variation to set or remove. + /// If set to true sets the variation; otherwise, removes the variation. + /// + /// This method does not support setting the variation to nothing. + /// + public static void SetVariesBy(this IContentTypeBase contentType, ContentVariation variation, bool value = true) => contentType.Variations = contentType.Variations.SetFlag(variation, value); + + /// + /// Sets or removes the property type variation depending on the specified value. + /// + /// The property type. + /// The variation to set or remove. + /// If set to true sets the variation; otherwise, removes the variation. + /// + /// This method does not support setting the variation to nothing. + /// + public static void SetVariesBy(this IPropertyType propertyType, ContentVariation variation, bool value = true) => propertyType.Variations = propertyType.Variations.SetFlag(variation, value); + + /// + /// Returns the variations with the variation set or removed depending on the specified value. + /// + /// The existing variations. + /// The variation to set or remove. + /// If set to true sets the variation; otherwise, removes the variation. + /// + /// The variations with the variation set or removed. + /// + /// + /// This method does not support setting the variation to nothing. + /// + public static ContentVariation SetFlag(this ContentVariation variations, ContentVariation variation, bool value = true) + { + return value + ? variations | variation // Set flag using bitwise logical OR + : variations & ~variation; // Remove flag using bitwise logical AND with bitwise complement (reversing the bit) + } + /// /// Validates that a combination of culture and segment is valid for the variation. /// @@ -140,16 +274,18 @@ namespace Umbraco.Core /// The segment. /// A value indicating whether to perform exact validation. /// A value indicating whether to support wildcards. - /// A value indicating whether to throw a when the combination is invalid. - /// True if the combination is valid; otherwise false. + /// A value indicating whether to throw a when the combination is invalid. + /// + /// true if the combination is valid; otherwise false. + /// + /// Occurs when the combination is invalid, and is true. /// /// When validation is exact, the combination must match the variation exactly. For instance, if the variation is Culture, then /// a culture is required. When validation is not strict, the combination must be equivalent, or more restrictive: if the variation is /// Culture, an invariant combination is ok. /// Basically, exact is for one content type, or one property type, and !exact is for "all property types" of one content type. - /// Both and can be "*" to indicate "all of them". + /// Both and can be "*" to indicate "all of them". /// - /// Occurs when the combination is invalid, and is true. public static bool ValidateVariation(this ContentVariation variation, string culture, string segment, bool exact, bool wildcards, bool throwIfInvalid) { culture = culture.NullOrWhiteSpaceAsNull(); @@ -171,6 +307,7 @@ namespace Umbraco.Core { if (throwIfInvalid) throw new NotSupportedException($"Culture may not be null because culture variation is enabled."); + return false; } } @@ -183,6 +320,7 @@ namespace Umbraco.Core { if (throwIfInvalid) throw new NotSupportedException($"Culture \"{culture}\" is invalid because culture variation is disabled."); + return false; } } @@ -195,6 +333,7 @@ namespace Umbraco.Core { if (throwIfInvalid) throw new NotSupportedException($"Segment \"{segment}\" is invalid because segment variation is disabled."); + return false; } diff --git a/src/Umbraco.Abstractions/EnumExtensions.cs b/src/Umbraco.Abstractions/EnumExtensions.cs index b2e5f32c9a..9097432f64 100644 --- a/src/Umbraco.Abstractions/EnumExtensions.cs +++ b/src/Umbraco.Abstractions/EnumExtensions.cs @@ -3,81 +3,42 @@ namespace Umbraco.Core { /// - /// Provides extension methods to enums. + /// Provides extension methods to . /// public static class EnumExtensions { - // note: - // - no need to HasFlagExact, that's basically an == test - // - HasFlagAll cannot be named HasFlag because ext. methods never take priority over instance methods - /// - /// Determines whether a flag enum has all the specified values. + /// Determines whether all the flags/bits are set within the enum value. /// - /// - /// True when all bits set in are set in , though other bits may be set too. - /// This is the behavior of the original method. - /// - public static bool HasFlagAll(this T use, T uses) + /// The enum type. + /// The enum value. + /// The flags. + /// + /// true if all the flags/bits are set within the enum value; otherwise, false. + /// + [Obsolete("Use Enum.HasFlag() or bitwise operations (if performance is important) instead.")] + public static bool HasFlagAll(this T value, T flags) where T : Enum { - var num = Convert.ToUInt64(use); - var nums = Convert.ToUInt64(uses); - - return (num & nums) == nums; + return value.HasFlag(flags); } /// - /// Determines whether a flag enum has any of the specified values. + /// Determines whether any of the flags/bits are set within the enum value. /// - /// - /// True when at least one of the bits set in is set in . - /// - public static bool HasFlagAny(this T use, T uses) + /// The enum type. + /// The value. + /// The flags. + /// + /// true if any of the flags/bits are set within the enum value; otherwise, false. + /// + public static bool HasFlagAny(this T value, T flags) where T : Enum { - var num = Convert.ToUInt64(use); - var nums = Convert.ToUInt64(uses); + var v = Convert.ToUInt64(value); + var f = Convert.ToUInt64(flags); - return (num & nums) > 0; - } - - /// - /// Sets a flag of the given input enum - /// - /// - /// Enum to set flag of - /// Flag to set - /// A new enum with the flag set - public static T SetFlag(this T input, T flag) - where T : Enum - { - var i = Convert.ToUInt64(input); - var f = Convert.ToUInt64(flag); - - // bitwise OR to set flag f of enum i - var result = i | f; - - return (T)Enum.ToObject(typeof(T), result); - } - - /// - /// Unsets a flag of the given input enum - /// - /// - /// Enum to unset flag of - /// Flag to unset - /// A new enum with the flag unset - public static T UnsetFlag(this T input, T flag) - where T : Enum - { - var i = Convert.ToUInt64(input); - var f = Convert.ToUInt64(flag); - - // bitwise AND combined with bitwise complement to unset flag f of enum i - var result = i & ~f; - - return (T)Enum.ToObject(typeof(T), result); + return (v & f) > 0; } } } diff --git a/src/Umbraco.Abstractions/MediaTypeExtensions.cs b/src/Umbraco.Abstractions/MediaTypeExtensions.cs new file mode 100644 index 0000000000..4e2ae5822a --- /dev/null +++ b/src/Umbraco.Abstractions/MediaTypeExtensions.cs @@ -0,0 +1,10 @@ +namespace Umbraco.Core.Models +{ + internal static class MediaTypeExtensions + { + internal static bool IsSystemMediaType(this IMediaType mediaType) => + mediaType.Alias == Constants.Conventions.MediaTypes.File + || mediaType.Alias == Constants.Conventions.MediaTypes.Folder + || mediaType.Alias == Constants.Conventions.MediaTypes.Image; + } +} diff --git a/src/Umbraco.Abstractions/Persistence/Constants-Locks.cs b/src/Umbraco.Abstractions/Persistence/Constants-Locks.cs index 1dcd2408e7..e64f40ced7 100644 --- a/src/Umbraco.Abstractions/Persistence/Constants-Locks.cs +++ b/src/Umbraco.Abstractions/Persistence/Constants-Locks.cs @@ -8,6 +8,11 @@ namespace Umbraco.Core /// public static class Locks { + /// + /// The lock + /// + public const int MainDom = -1000; + /// /// All servers. /// diff --git a/src/Umbraco.Abstractions/PropertyEditors/MultiUrlPickerConfiguration.cs b/src/Umbraco.Abstractions/PropertyEditors/MultiUrlPickerConfiguration.cs index 73a098cc9d..239569478f 100644 --- a/src/Umbraco.Abstractions/PropertyEditors/MultiUrlPickerConfiguration.cs +++ b/src/Umbraco.Abstractions/PropertyEditors/MultiUrlPickerConfiguration.cs @@ -16,6 +16,9 @@ namespace Umbraco.Web.PropertyEditors Description = "Selecting this option allows a user to choose nodes that they normally don't have access to.")] public bool IgnoreUserStartNodes { get; set; } - + [ConfigurationField("hideAnchor", + "Hide anchor/query string input", "boolean", + Description = "Selecting this hides the anchor/query string input field in the linkpicker overlay.")] + public bool HideAnchor { get; set; } } } diff --git a/src/Umbraco.Abstractions/IMainDom.cs b/src/Umbraco.Abstractions/Runtime/IMainDom.cs similarity index 96% rename from src/Umbraco.Abstractions/IMainDom.cs rename to src/Umbraco.Abstractions/Runtime/IMainDom.cs index 31b2e2eee0..444fc1c7d0 100644 --- a/src/Umbraco.Abstractions/IMainDom.cs +++ b/src/Umbraco.Abstractions/Runtime/IMainDom.cs @@ -1,5 +1,6 @@ using System; +// TODO: Can't change namespace due to breaking changes, change in netcore namespace Umbraco.Core { /// diff --git a/src/Umbraco.Abstractions/Runtime/IMainDomLock.cs b/src/Umbraco.Abstractions/Runtime/IMainDomLock.cs new file mode 100644 index 0000000000..6a62f48194 --- /dev/null +++ b/src/Umbraco.Abstractions/Runtime/IMainDomLock.cs @@ -0,0 +1,29 @@ +using System; +using System.Threading.Tasks; + +namespace Umbraco.Core.Runtime +{ + /// + /// An application-wide distributed lock + /// + /// + /// Disposing releases the lock + /// + public interface IMainDomLock : IDisposable + { + /// + /// Acquires an application-wide distributed lock + /// + /// + /// + /// An awaitable boolean value which will be false if the elapsed millsecondsTimeout value is exceeded + /// + Task AcquireLockAsync(int millisecondsTimeout); + + /// + /// Wait on a background thread to receive a signal from another AppDomain + /// + /// + Task ListenAsync(); + } +} diff --git a/src/Umbraco.Infrastructure/MainDom.cs b/src/Umbraco.Abstractions/Runtime/MainDom.cs similarity index 76% rename from src/Umbraco.Infrastructure/MainDom.cs rename to src/Umbraco.Abstractions/Runtime/MainDom.cs index be962beffc..2c56852095 100644 --- a/src/Umbraco.Infrastructure/MainDom.cs +++ b/src/Umbraco.Abstractions/Runtime/MainDom.cs @@ -6,8 +6,9 @@ using System.Threading; using Umbraco.Core.Hosting; using Umbraco.Core.Logging; -namespace Umbraco.Core +namespace Umbraco.Core.Runtime { + /// /// Provides the full implementation of . /// @@ -21,18 +22,11 @@ namespace Umbraco.Core private readonly ILogger _logger; private readonly IHostingEnvironment _hostingEnvironment; + private readonly IMainDomLock _mainDomLock; // our own lock for local consistency private object _locko = new object(); - // async lock representing the main domain lock - private readonly SystemLock _systemLock; - private IDisposable _systemLocker; - - // event wait handle used to notify current main domain that it should - // release the lock because a new domain wants to be the main domain - private readonly EventWaitHandle _signal; - private bool _isInitialized; // indicates whether... private bool _isMainDom; // we are the main domain @@ -41,41 +35,20 @@ namespace Umbraco.Core // actions to run before releasing the main domain private readonly List> _callbacks = new List>(); - private const int LockTimeoutMilliseconds = 90000; // (1.5 * 60 * 1000) == 1 min 30 seconds + private const int LockTimeoutMilliseconds = 40000; // 40 seconds #endregion #region Ctor // initializes a new instance of MainDom - public MainDom(ILogger logger, IHostingEnvironment hostingEnvironment) + public MainDom(ILogger logger, IHostingEnvironment hostingEnvironment, IMainDomLock systemLock) { - hostingEnvironment.RegisterObject(this); _logger = logger; _hostingEnvironment = hostingEnvironment; - - // HostingEnvironment.ApplicationID is null in unit tests, making ReplaceNonAlphanumericChars fail - var appId = hostingEnvironment.ApplicationId?.ReplaceNonAlphanumericChars(string.Empty); - - // combining with the physical path because if running on eg IIS Express, - // two sites could have the same appId even though they are different. - // - // now what could still collide is... two sites, running in two different processes - // and having the same appId, and running on the same app physical path - // - // we *cannot* use the process ID here because when an AppPool restarts it is - // a new process for the same application path - - var appPath = hostingEnvironment.ApplicationPhysicalPath?.ToLowerInvariant() ?? string.Empty; - var hash = (appId + ":::" + appPath).GenerateHash(); - - var lockName = "UMBRACO-" + hash + "-MAINDOM-LCK"; - _systemLock = new SystemLock(lockName); - - var eventName = "UMBRACO-" + hash + "-MAINDOM-EVT"; - _signal = new EventWaitHandle(false, EventResetMode.AutoReset, eventName); + _mainDomLock = systemLock; } #endregion @@ -144,13 +117,14 @@ namespace Umbraco.Core continue; } } + _logger.Debug("Stopped ({SignalSource})", source); } finally { // in any case... _isMainDom = false; - _systemLocker?.Dispose(); + _mainDomLock.Dispose(); _logger.Info("Released ({SignalSource})", source); } @@ -170,37 +144,34 @@ namespace Umbraco.Core _logger.Info("Acquiring."); - // signal other instances that we want the lock, then wait one the lock, - // which may timeout, and this is accepted - see comments below + // Get the lock + var acquired = _mainDomLock.AcquireLockAsync(LockTimeoutMilliseconds).GetAwaiter().GetResult(); - // signal, then wait for the lock, then make sure the event is - // reset (maybe there was noone listening..) - _signal.Set(); + if (!acquired) + { + _logger.Info("Cannot acquire (timeout)."); - // if more than 1 instance reach that point, one will get the lock - // and the other one will timeout, which is accepted + // In previous versions we'd let a TimeoutException be thrown + // and the appdomain would not start. We have the opportunity to allow it to + // start without having MainDom? This would mean that it couldn't write + // to nucache/examine and would only be ok if this was a super short lived appdomain. + // maybe safer to just keep throwing in this case. + + throw new TimeoutException("Cannot acquire MainDom"); + // return false; + } - //This can throw a TimeoutException - in which case should this be in a try/finally to ensure the signal is always reset. try { - _systemLocker = _systemLock.Lock(LockTimeoutMilliseconds); + // Listen for the signal from another AppDomain coming online to release the lock + _mainDomLock.ListenAsync().ContinueWith(_ => OnSignal("signal")); } - finally + catch (OperationCanceledException ex) { - // we need to reset the event, because otherwise we would end up - // signaling ourselves and committing suicide immediately. - // only 1 instance can reach that point, but other instances may - // have started and be trying to get the lock - they will timeout, - // which is accepted - - _signal.Reset(); + // the waiting task could be canceled if this appdomain is naturally shutting down, we'll just swallow this exception + _logger.Warn(ex, ex.Message); } - //WaitOneAsync (ext method) will wait for a signal without blocking the main thread, the waiting is done on a background thread - - _signal.WaitOneAsync() - .ContinueWith(_ => OnSignal("signal")); - _logger.Info("Acquired."); return true; } @@ -208,6 +179,10 @@ namespace Umbraco.Core /// /// Gets a value indicating whether the current domain is the main domain. /// + /// + /// The lazy initializer call will only call the Acquire callback when it's not been initialized, else it will just return + /// the value from _isMainDom which means when we set _isMainDom to false again after being signaled, this will return false; + /// public bool IsMainDom => LazyInitializer.EnsureInitialized(ref _isMainDom, ref _isInitialized, ref _locko, () => Acquire()); // IRegisteredObject @@ -233,8 +208,7 @@ namespace Umbraco.Core { if (disposing) { - _signal?.Close(); - _signal?.Dispose(); + _mainDomLock.Dispose(); } disposedValue = true; @@ -247,5 +221,25 @@ namespace Umbraco.Core } #endregion + + public static string GetMainDomId(IHostingEnvironment hostingEnvironment) + { + // HostingEnvironment.ApplicationID is null in unit tests, making ReplaceNonAlphanumericChars fail + var appId = hostingEnvironment.ApplicationId?.ReplaceNonAlphanumericChars(string.Empty) ?? string.Empty; + + // combining with the physical path because if running on eg IIS Express, + // two sites could have the same appId even though they are different. + // + // now what could still collide is... two sites, running in two different processes + // and having the same appId, and running on the same app physical path + // + // we *cannot* use the process ID here because when an AppPool restarts it is + // a new process for the same application path + + var appPath = hostingEnvironment.ApplicationPhysicalPath?.ToLowerInvariant() ?? string.Empty; + var hash = (appId + ":::" + appPath).GenerateHash(); + + return hash; + } } } diff --git a/src/Umbraco.Abstractions/Services/IContentTypeServiceBase.cs b/src/Umbraco.Abstractions/Services/IContentTypeServiceBase.cs index 51e5d756eb..6ed3c85e91 100644 --- a/src/Umbraco.Abstractions/Services/IContentTypeServiceBase.cs +++ b/src/Umbraco.Abstractions/Services/IContentTypeServiceBase.cs @@ -39,6 +39,11 @@ namespace Umbraco.Core.Services int Count(); + /// + /// Returns true or false depending on whether content nodes have been created based on the provided content type id. + /// + bool HasContentNodes(int id); + IEnumerable GetAll(params int[] ids); IEnumerable GetAll(IEnumerable ids); diff --git a/src/Umbraco.Abstractions/Umbraco.Abstractions.csproj b/src/Umbraco.Abstractions/Umbraco.Abstractions.csproj index 2eff63c4b8..fb5d256c95 100644 --- a/src/Umbraco.Abstractions/Umbraco.Abstractions.csproj +++ b/src/Umbraco.Abstractions/Umbraco.Abstractions.csproj @@ -27,6 +27,7 @@ + diff --git a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs index bd0b733623..da6574670b 100644 --- a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs +++ b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs @@ -155,6 +155,8 @@ namespace Umbraco.Core.Migrations.Install _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.Domains, Name = "Domains" }); _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.KeyValues, Name = "KeyValues" }); _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.Languages, Name = "Languages" }); + + _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.MainDom, Name = "MainDom" }); } private void CreateContentTypeData() diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs index 2bb9b404f4..67dfe346d6 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs @@ -186,13 +186,12 @@ namespace Umbraco.Core.Migrations.Upgrade To("{0372A42B-DECF-498D-B4D1-6379E907EB94}"); To("{5B1E0D93-F5A3-449B-84BA-65366B84E2D4}"); - // to 8.5.0... + // to 8.6.0... To("{4759A294-9860-46BC-99F9-B4C975CAE580}"); To("{0BC866BC-0665-487A-9913-0290BD0169AD}"); - - // to 8.6.0 To("{3D67D2C8-5E65-47D0-A9E1-DC2EE0779D6B}"); To("{EE288A91-531B-4995-8179-1D62D9AA3E2E}"); + To("{2AB29964-02A1-474D-BD6B-72148D2A53A2}"); //FINAL } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_8_6_0/AddMainDomLock.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_8_6_0/AddMainDomLock.cs new file mode 100644 index 0000000000..6ca493ac7e --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_8_6_0/AddMainDomLock.cs @@ -0,0 +1,16 @@ +using Umbraco.Core.Persistence.Dtos; + +namespace Umbraco.Core.Migrations.Upgrade.V_8_6_0 +{ + public class AddMainDomLock : MigrationBase + { + public AddMainDomLock(IMigrationContext context) + : base(context) + { } + + public override void Migrate() + { + Database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.MainDom, Name = "MainDom" }); + } + } +} diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_8_6_0/AddPropertyTypeValidationMessageColumns.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_8_6_0/AddPropertyTypeValidationMessageColumns.cs index 30eb30109e..f44695da69 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_8_6_0/AddPropertyTypeValidationMessageColumns.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_8_6_0/AddPropertyTypeValidationMessageColumns.cs @@ -3,6 +3,7 @@ using Umbraco.Core.Persistence.Dtos; namespace Umbraco.Core.Migrations.Upgrade.V_8_6_0 { + public class AddPropertyTypeValidationMessageColumns : MigrationBase { public AddPropertyTypeValidationMessageColumns(IMigrationContext context) diff --git a/src/Umbraco.Infrastructure/Models/BackOfficeTour.cs b/src/Umbraco.Infrastructure/Models/BackOfficeTour.cs index 7391765193..7396d3d00d 100644 --- a/src/Umbraco.Infrastructure/Models/BackOfficeTour.cs +++ b/src/Umbraco.Infrastructure/Models/BackOfficeTour.cs @@ -40,5 +40,8 @@ namespace Umbraco.Web.Models [DataMember(Name = "culture")] public string Culture { get; set; } + + [DataMember(Name = "contentType")] + public string ContentType { get; set; } } } diff --git a/src/Umbraco.Infrastructure/Models/ContentEditing/MediaTypeDisplay.cs b/src/Umbraco.Infrastructure/Models/ContentEditing/MediaTypeDisplay.cs index 40227184db..ea0393336c 100644 --- a/src/Umbraco.Infrastructure/Models/ContentEditing/MediaTypeDisplay.cs +++ b/src/Umbraco.Infrastructure/Models/ContentEditing/MediaTypeDisplay.cs @@ -5,6 +5,7 @@ namespace Umbraco.Web.Models.ContentEditing [DataContract(Name = "contentType", Namespace = "")] public class MediaTypeDisplay : ContentTypeCompositionDisplay { - + [DataMember(Name = "isSystemMediaType")] + public bool IsSystemMediaType { get; set; } } } diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/IContentTypeRepositoryBase.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/IContentTypeRepositoryBase.cs index 69b0698a96..254e04d2d5 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/IContentTypeRepositoryBase.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/IContentTypeRepositoryBase.cs @@ -26,5 +26,10 @@ namespace Umbraco.Core.Persistence.Repositories /// /// bool HasContainerInPath(string contentPath); + + /// + /// Returns true or false depending on whether content nodes have been created based on the provided content type id. + /// + bool HasContentNodes(int id); } } diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentTypeRepositoryBase.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentTypeRepositoryBase.cs index 8890a859a8..b716a121be 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentTypeRepositoryBase.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentTypeRepositoryBase.cs @@ -1326,6 +1326,17 @@ WHERE {Constants.DatabaseSchema.Tables.Content}.nodeId IN (@ids) AND cmsContentT return Database.ExecuteScalar(sql) > 0; } + /// + /// Returns true or false depending on whether content nodes have been created based on the provided content type id. + /// + public bool HasContentNodes(int id) + { + var sql = new Sql( + $"SELECT CASE WHEN EXISTS (SELECT * FROM {Constants.DatabaseSchema.Tables.Content} WHERE contentTypeId = @id) THEN 1 ELSE 0 END", + new { id }); + return Database.ExecuteScalar(sql) == 1; + } + protected override IEnumerable GetDeleteClauses() { // in theory, services should have ensured that content items of the given content type diff --git a/src/Umbraco.Infrastructure/Persistence/SqlSyntax/SqlServerSyntaxProvider.cs b/src/Umbraco.Infrastructure/Persistence/SqlSyntax/SqlServerSyntaxProvider.cs index 37375ef25d..a478c88412 100644 --- a/src/Umbraco.Infrastructure/Persistence/SqlSyntax/SqlServerSyntaxProvider.cs +++ b/src/Umbraco.Infrastructure/Persistence/SqlSyntax/SqlServerSyntaxProvider.cs @@ -251,6 +251,11 @@ where tbl.[name]=@0 and col.[name]=@1;", tableName, columnName) } public override void WriteLock(IDatabase db, params int[] lockIds) + { + WriteLock(db, TimeSpan.FromMilliseconds(1800), lockIds); + } + + public void WriteLock(IDatabase db, TimeSpan timeout, params int[] lockIds) { // soon as we get Database, a transaction is started @@ -261,7 +266,7 @@ where tbl.[name]=@0 and col.[name]=@1;", tableName, columnName) // *not* using a unique 'WHERE IN' query here because the *order* of lockIds is important to avoid deadlocks foreach (var lockId in lockIds) { - db.Execute(@"SET LOCK_TIMEOUT 1800;"); + db.Execute($"SET LOCK_TIMEOUT {timeout.TotalMilliseconds};"); var i = db.Execute(@"UPDATE umbracoLock WITH (REPEATABLEREAD) SET value = (CASE WHEN (value=1) THEN -1 ELSE 1 END) WHERE id=@id", new { id = lockId }); if (i == 0) // ensure we are actually locking! throw new ArgumentException($"LockObject with id={lockId} does not exist."); diff --git a/src/Umbraco.Infrastructure/Runtime/CoreRuntime.cs b/src/Umbraco.Infrastructure/Runtime/CoreRuntime.cs index 26b71b1325..8e4401495d 100644 --- a/src/Umbraco.Infrastructure/Runtime/CoreRuntime.cs +++ b/src/Umbraco.Infrastructure/Runtime/CoreRuntime.cs @@ -167,6 +167,18 @@ namespace Umbraco.Core.Runtime // type finder/loader var typeLoader = new TypeLoader(IOHelper, TypeFinder, appCaches.RuntimeCache, new DirectoryInfo(HostingEnvironment.LocalTempPath), ProfilingLogger); + // runtime state + // beware! must use '() => _factory.GetInstance()' and NOT '_factory.GetInstance' + // as the second one captures the current value (null) and therefore fails + _state = new RuntimeState(Logger, + Configs.Settings(), Configs.Global(), + new Lazy(() => _factory.GetInstance()), + new Lazy(() => _factory.GetInstance()), + UmbracoVersion, HostingEnvironment, BackOfficeInfo) + { + Level = RuntimeLevel.Boot + }; + // create the composition composition = new Composition(register, typeLoader, ProfilingLogger, _state, Configs, IOHelper, appCaches); composition.RegisterEssentials(Logger, Profiler, ProfilingLogger, MainDom, appCaches, databaseFactory, typeLoader, _state, TypeFinder, IOHelper, UmbracoVersion, DbProviderFactoryCreator); diff --git a/src/Umbraco.Infrastructure/Runtime/MainDomSemaphoreLock.cs b/src/Umbraco.Infrastructure/Runtime/MainDomSemaphoreLock.cs new file mode 100644 index 0000000000..419a1781a2 --- /dev/null +++ b/src/Umbraco.Infrastructure/Runtime/MainDomSemaphoreLock.cs @@ -0,0 +1,96 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Umbraco.Core.Hosting; +using Umbraco.Core.Logging; + +namespace Umbraco.Core.Runtime +{ + /// + /// Uses a system-wide Semaphore and EventWaitHandle to synchronize the current AppDomain + /// + public class MainDomSemaphoreLock : IMainDomLock + { + private readonly SystemLock _systemLock; + + // event wait handle used to notify current main domain that it should + // release the lock because a new domain wants to be the main domain + private readonly EventWaitHandle _signal; + private readonly ILogger _logger; + private IDisposable _lockRelease; + + public MainDomSemaphoreLock(ILogger logger, IHostingEnvironment hostingEnvironment) + { + var lockName = "UMBRACO-" + MainDom.GetMainDomId(hostingEnvironment) + "-MAINDOM-LCK"; + _systemLock = new SystemLock(lockName); + + var eventName = "UMBRACO-" + MainDom.GetMainDomId(hostingEnvironment) + "-MAINDOM-EVT"; + _signal = new EventWaitHandle(false, EventResetMode.AutoReset, eventName); + _logger = logger; + } + + //WaitOneAsync (ext method) will wait for a signal without blocking the main thread, the waiting is done on a background thread + public Task ListenAsync() => _signal.WaitOneAsync(); + + public Task AcquireLockAsync(int millisecondsTimeout) + { + // signal other instances that we want the lock, then wait on the lock, + // which may timeout, and this is accepted - see comments below + + // signal, then wait for the lock, then make sure the event is + // reset (maybe there was noone listening..) + _signal.Set(); + + // if more than 1 instance reach that point, one will get the lock + // and the other one will timeout, which is accepted + + //This can throw a TimeoutException - in which case should this be in a try/finally to ensure the signal is always reset. + try + { + _lockRelease = _systemLock.Lock(millisecondsTimeout); + return Task.FromResult(true); + } + catch (TimeoutException ex) + { + _logger.Error(ex); + return Task.FromResult(false); + } + finally + { + // we need to reset the event, because otherwise we would end up + // signaling ourselves and committing suicide immediately. + // only 1 instance can reach that point, but other instances may + // have started and be trying to get the lock - they will timeout, + // which is accepted + + _signal.Reset(); + } + } + + #region IDisposable Support + private bool disposedValue = false; // To detect redundant calls + + protected virtual void Dispose(bool disposing) + { + if (!disposedValue) + { + if (disposing) + { + _lockRelease?.Dispose(); + _signal.Close(); + _signal.Dispose(); + } + + disposedValue = true; + } + } + + // This code added to correctly implement the disposable pattern. + public void Dispose() + { + // Do not change this code. Put cleanup code in Dispose(bool disposing) above. + Dispose(true); + } + #endregion + } +} diff --git a/src/Umbraco.Infrastructure/Runtime/SqlMainDomLock.cs b/src/Umbraco.Infrastructure/Runtime/SqlMainDomLock.cs new file mode 100644 index 0000000000..4e1feb221a --- /dev/null +++ b/src/Umbraco.Infrastructure/Runtime/SqlMainDomLock.cs @@ -0,0 +1,416 @@ +using System; +using System.Data; +using System.Data.SqlClient; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Umbraco.Core.Configuration; +using Umbraco.Core.Logging; +using Umbraco.Core.Persistence; +using Umbraco.Core.Persistence.Dtos; +using Umbraco.Core.Persistence.Mappers; +using Umbraco.Core.Persistence.SqlSyntax; + +namespace Umbraco.Core.Runtime +{ + public class SqlMainDomLock : IMainDomLock + { + private string _lockId; + private const string MainDomKey = "Umbraco.Core.Runtime.SqlMainDom"; + private const string UpdatedSuffix = "_updated"; + private readonly ILogger _logger; + private IUmbracoDatabase _db; + private CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource(); + private SqlServerSyntaxProvider _sqlServerSyntax = new SqlServerSyntaxProvider(); + private bool _mainDomChanging = false; + private readonly UmbracoDatabaseFactory _dbFactory; + private bool _hasError; + private object _locker = new object(); + + public SqlMainDomLock(ILogger logger, Configs configs, IDbProviderFactoryCreator dbProviderFactoryCreator) + { + // unique id for our appdomain, this is more unique than the appdomain id which is just an INT counter to its safer + _lockId = Guid.NewGuid().ToString(); + _logger = logger; + _dbFactory = new UmbracoDatabaseFactory( + Constants.System.UmbracoConnectionName, + _logger, + new Lazy(() => new MapperCollection(Enumerable.Empty())), + configs, dbProviderFactoryCreator + ); + } + + public async Task AcquireLockAsync(int millisecondsTimeout) + { + if (!(_dbFactory.SqlContext.SqlSyntax is SqlServerSyntaxProvider sqlServerSyntaxProvider)) + throw new NotSupportedException("SqlMainDomLock is only supported for Sql Server"); + + _sqlServerSyntax = sqlServerSyntaxProvider; + + _logger.Debug("Acquiring lock..."); + + var db = GetDatabase(); + + var tempId = Guid.NewGuid().ToString(); + + try + { + db.BeginTransaction(IsolationLevel.ReadCommitted); + + try + { + // wait to get a write lock + _sqlServerSyntax.WriteLock(db, TimeSpan.FromMilliseconds(millisecondsTimeout), Constants.Locks.MainDom); + } + catch (Exception ex) + { + if (IsLockTimeoutException(ex)) + { + _logger.Error(ex, "Sql timeout occurred, could not acquire MainDom."); + _hasError = true; + return false; + } + + // unexpected (will be caught below) + throw; + } + + var result = InsertLockRecord(tempId); //we change the row to a random Id to signal other MainDom to shutdown + if (result == RecordPersistenceType.Insert) + { + // if we've inserted, then there was no MainDom so we can instantly acquire + + // TODO: see the other TODO, could we just delete the row and that would indicate that we + // are MainDom? then we don't leave any orphan rows behind. + + InsertLockRecord(_lockId); // so update with our appdomain id + _logger.Debug("Acquired with ID {LockId}", _lockId); + return true; + } + + // if we've updated, this means there is an active MainDom, now we need to wait to + // for the current MainDom to shutdown which also requires releasing our write lock + } + catch (Exception ex) + { + ResetDatabase(); + // unexpected + _logger.Error(ex, "Unexpected error, cannot acquire MainDom"); + _hasError = true; + return false; + } + finally + { + db?.CompleteTransaction(); + } + + return await WaitForExistingAsync(tempId, millisecondsTimeout); + } + + public Task ListenAsync() + { + if (_hasError) + { + _logger.Warn("Could not acquire MainDom, listening is canceled."); + return Task.CompletedTask; + } + + // Create a long running task (dedicated thread) + // to poll to check if we are still the MainDom registered in the DB + return Task.Factory.StartNew(ListeningLoop, _cancellationTokenSource.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default); + + } + + private void ListeningLoop() + { + while (true) + { + // poll every 1 second + Thread.Sleep(1000); + + lock (_locker) + { + // If cancellation has been requested we will just exit. Depending on timing of the shutdown, + // we will have already flagged _mainDomChanging = true, or we're shutting down faster than + // the other MainDom is taking to startup. In this case the db row will just be deleted and the + // new MainDom will just take over. + if (_cancellationTokenSource.IsCancellationRequested) + return; + + var db = GetDatabase(); + + try + { + db.BeginTransaction(IsolationLevel.ReadCommitted); + + // get a read lock + _sqlServerSyntax.ReadLock(db, Constants.Locks.MainDom); + + // TODO: We could in theory just check if the main dom row doesn't exist, that could indicate that + // we are still the maindom. An empty value might be better because then we won't have any orphan rows + // if the app is terminated. Could that work? + + if (!IsMainDomValue(_lockId)) + { + // we are no longer main dom, another one has come online, exit + _mainDomChanging = true; + _logger.Debug("Detected new booting application, releasing MainDom lock."); + return; + } + } + catch (Exception ex) + { + ResetDatabase(); + // unexpected + _logger.Error(ex, "Unexpected error, listening is canceled."); + _hasError = true; + return; + } + finally + { + db?.CompleteTransaction(); + } + } + + } + } + + private void ResetDatabase() + { + if (_db.InTransaction) + _db.AbortTransaction(); + _db.Dispose(); + _db = null; + } + + private IUmbracoDatabase GetDatabase() + { + if (_db != null) + return _db; + + _db = _dbFactory.CreateDatabase(); + return _db; + } + + /// + /// Wait for any existing MainDom to release so we can continue booting + /// + /// + /// + /// + private Task WaitForExistingAsync(string tempId, int millisecondsTimeout) + { + var updatedTempId = tempId + UpdatedSuffix; + + return Task.Run(() => + { + var db = GetDatabase(); + var watch = new Stopwatch(); + watch.Start(); + while(true) + { + // poll very often, we need to take over as fast as we can + Thread.Sleep(100); + + try + { + db.BeginTransaction(IsolationLevel.ReadCommitted); + + // get a read lock + _sqlServerSyntax.ReadLock(db, Constants.Locks.MainDom); + + // the row + var mainDomRows = db.Fetch("SELECT * FROM umbracoKeyValue WHERE [key] = @key", new { key = MainDomKey }); + + if (mainDomRows.Count == 0 || mainDomRows[0].Value == updatedTempId) + { + // the other main dom has updated our record + // Or the other maindom shutdown super fast and just deleted the record + // which indicates that we + // can acquire it and it has shutdown. + + _sqlServerSyntax.WriteLock(db, Constants.Locks.MainDom); + + // so now we update the row with our appdomain id + InsertLockRecord(_lockId); + _logger.Debug("Acquired with ID {LockId}", _lockId); + return true; + } + else if (mainDomRows.Count == 1 && !mainDomRows[0].Value.StartsWith(tempId)) + { + // in this case, the prefixed ID is different which means + // another new AppDomain has come online and is wanting to take over. In that case, we will not + // acquire. + + _logger.Debug("Cannot acquire, another booting application detected."); + + return false; + } + } + catch (Exception ex) + { + ResetDatabase(); + + if (IsLockTimeoutException(ex)) + { + _logger.Error(ex, "Sql timeout occurred, waiting for existing MainDom is canceled."); + _hasError = true; + return false; + } + // unexpected + _logger.Error(ex, "Unexpected error, waiting for existing MainDom is canceled."); + _hasError = true; + return false; + } + finally + { + db?.CompleteTransaction(); + } + + if (watch.ElapsedMilliseconds >= millisecondsTimeout) + { + // if the timeout has elapsed, it either means that the other main dom is taking too long to shutdown, + // or it could mean that the previous appdomain was terminated and didn't clear out the main dom SQL row + // and it's just been left as an orphan row. + // There's really know way of knowing unless we are constantly updating the row for the current maindom + // which isn't ideal. + // So... we're going to 'just' take over, if the writelock works then we'll assume we're ok + + _logger.Debug("Timeout elapsed, assuming orphan row, acquiring MainDom."); + + try + { + db.BeginTransaction(IsolationLevel.ReadCommitted); + + _sqlServerSyntax.WriteLock(db, Constants.Locks.MainDom); + + // so now we update the row with our appdomain id + InsertLockRecord(_lockId); + _logger.Debug("Acquired with ID {LockId}", _lockId); + return true; + } + catch (Exception ex) + { + ResetDatabase(); + + if (IsLockTimeoutException(ex)) + { + // something is wrong, we cannot acquire, not much we can do + _logger.Error(ex, "Sql timeout occurred, could not forcibly acquire MainDom."); + _hasError = true; + return false; + } + _logger.Error(ex, "Unexpected error, could not forcibly acquire MainDom."); + _hasError = true; + return false; + } + finally + { + db?.CompleteTransaction(); + } + } + } + }, _cancellationTokenSource.Token); + } + + /// + /// Inserts or updates the key/value row + /// + private RecordPersistenceType InsertLockRecord(string id) + { + var db = GetDatabase(); + return db.InsertOrUpdate(new KeyValueDto + { + Key = MainDomKey, + Value = id, + Updated = DateTime.Now + }); + } + + /// + /// Checks if the DB row value is equals the value + /// + /// + private bool IsMainDomValue(string val) + { + var db = GetDatabase(); + return db.ExecuteScalar("SELECT COUNT(*) FROM umbracoKeyValue WHERE [key] = @key AND [value] = @val", + new { key = MainDomKey, val = val }) == 1; + } + + /// + /// Checks if the exception is an SQL timeout + /// + /// + /// + private bool IsLockTimeoutException(Exception exception) => exception is SqlException sqlException && sqlException.Number == 1222; + + #region IDisposable Support + private bool _disposedValue = false; // To detect redundant calls + + protected virtual void Dispose(bool disposing) + { + if (!_disposedValue) + { + if (disposing) + { + lock (_locker) + { + // immediately cancel all sub-tasks, we don't want them to keep querying + _cancellationTokenSource.Cancel(); + _cancellationTokenSource.Dispose(); + + var db = GetDatabase(); + try + { + db.BeginTransaction(IsolationLevel.ReadCommitted); + + // get a write lock + _sqlServerSyntax.WriteLock(db, Constants.Locks.MainDom); + + // When we are disposed, it means we have released the MainDom lock + // and called all MainDom release callbacks, in this case + // if another maindom is actually coming online we need + // to signal to the MainDom coming online that we have shutdown. + // To do that, we update the existing main dom DB record with a suffixed "_updated" string. + // Otherwise, if we are just shutting down, we want to just delete the row. + if (_mainDomChanging) + { + _logger.Debug("Releasing MainDom, updating row, new application is booting."); + db.Execute($"UPDATE umbracoKeyValue SET [value] = [value] + '{UpdatedSuffix}' WHERE [key] = @key", new { key = MainDomKey }); + } + else + { + _logger.Debug("Releasing MainDom, deleting row, application is shutting down."); + db.Execute("DELETE FROM umbracoKeyValue WHERE [key] = @key", new { key = MainDomKey }); + } + } + catch (Exception ex) + { + ResetDatabase(); + _logger.Error(ex, "Unexpected error during dipsose."); + _hasError = true; + } + finally + { + db?.CompleteTransaction(); + ResetDatabase(); + } + } + } + + _disposedValue = true; + } + } + + // This code added to correctly implement the disposable pattern. + public void Dispose() + { + // Do not change this code. Put cleanup code in Dispose(bool disposing) above. + Dispose(true); + } + #endregion + + } +} diff --git a/src/Umbraco.Infrastructure/Scoping/IScopeProvider.cs b/src/Umbraco.Infrastructure/Scoping/IScopeProvider.cs index 4a7ccae481..dce6658f16 100644 --- a/src/Umbraco.Infrastructure/Scoping/IScopeProvider.cs +++ b/src/Umbraco.Infrastructure/Scoping/IScopeProvider.cs @@ -13,7 +13,7 @@ namespace Umbraco.Core.Scoping /// Provides scopes. /// public interface IScopeProvider - { + { /// /// Creates an ambient scope. /// diff --git a/src/Umbraco.Infrastructure/Services/Implement/ContentTypeServiceBaseOfTRepositoryTItemTService.cs b/src/Umbraco.Infrastructure/Services/Implement/ContentTypeServiceBaseOfTRepositoryTItemTService.cs index 45baf9720f..50f12ba73e 100644 --- a/src/Umbraco.Infrastructure/Services/Implement/ContentTypeServiceBaseOfTRepositoryTItemTService.cs +++ b/src/Umbraco.Infrastructure/Services/Implement/ContentTypeServiceBaseOfTRepositoryTItemTService.cs @@ -369,6 +369,15 @@ namespace Umbraco.Core.Services.Implement } } + public bool HasContentNodes(int id) + { + using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + { + scope.ReadLock(ReadLockIds); + return Repository.HasContentNodes(id); + } + } + #endregion #region Save diff --git a/src/Umbraco.TestData/SegmentTestController.cs b/src/Umbraco.TestData/SegmentTestController.cs index 33badbbb55..650820760e 100644 --- a/src/Umbraco.TestData/SegmentTestController.cs +++ b/src/Umbraco.TestData/SegmentTestController.cs @@ -30,9 +30,8 @@ namespace Umbraco.TestData if (ct.Variations.VariesBySegment()) return Content($"The document type {alias} already allows segments, nothing has been changed"); - ct.Variations = ct.Variations.SetFlag(ContentVariation.Segment); - - propType.Variations = propType.Variations.SetFlag(ContentVariation.Segment); + ct.SetVariesBy(ContentVariation.Segment); + propType.SetVariesBy(ContentVariation.Segment); Services.ContentTypeService.Save(ct); return Content($"The document type {alias} and property type {propertyTypeAlias} now allows segments"); @@ -50,7 +49,7 @@ namespace Umbraco.TestData if (!ct.VariesBySegment()) return Content($"The document type {alias} does not allow segments, nothing has been changed"); - ct.Variations = ct.Variations.UnsetFlag(ContentVariation.Segment); + ct.SetVariesBy(ContentVariation.Segment, false); Services.ContentTypeService.Save(ct); return Content($"The document type {alias} no longer allows segments"); diff --git a/src/Umbraco.Tests/Cache/SnapDictionaryTests.cs b/src/Umbraco.Tests/Cache/SnapDictionaryTests.cs index b435af9e77..00ba721bdb 100644 --- a/src/Umbraco.Tests/Cache/SnapDictionaryTests.cs +++ b/src/Umbraco.Tests/Cache/SnapDictionaryTests.cs @@ -223,7 +223,7 @@ namespace Umbraco.Tests.Cache { var d = new SnapDictionary(); d.Test.CollectAuto = false; - + // gen 1 d.Set(1, "one"); Assert.AreEqual(1, d.Test.GetValues(1).Length); @@ -321,7 +321,7 @@ namespace Umbraco.Tests.Cache { var d = new SnapDictionary(); d.Test.CollectAuto = false; - + Assert.AreEqual(0, d.Test.GetValues(1).Length); // gen 1 @@ -416,7 +416,7 @@ namespace Umbraco.Tests.Cache { var d = new SnapDictionary(); d.Test.CollectAuto = false; - + // gen 1 d.Set(1, "one"); Assert.AreEqual(1, d.Test.GetValues(1).Length); @@ -578,7 +578,7 @@ namespace Umbraco.Tests.Cache { var d = new SnapDictionary(); d.Test.CollectAuto = false; - + d.Set(1, "one"); d.Set(2, "two"); @@ -689,7 +689,7 @@ namespace Umbraco.Tests.Cache { // gen 3 Assert.AreEqual(2, d.Test.GetValues(1).Length); - d.Set(1, "ein"); + d.SetLocked(1, "ein"); Assert.AreEqual(3, d.Test.GetValues(1).Length); Assert.AreEqual(3, d.Test.LiveGen); @@ -727,31 +727,25 @@ namespace Umbraco.Tests.Cache using (var w1 = d.GetScopedWriteLock(scopeProvider)) { Assert.AreEqual(1, t.LiveGen); - Assert.AreEqual(1, t.WLocked); + Assert.IsTrue(t.IsLocked); Assert.IsTrue(t.NextGen); - using (var w2 = d.GetScopedWriteLock(scopeProvider)) + Assert.Throws(() => { - Assert.AreEqual(1, t.LiveGen); - Assert.AreEqual(2, t.WLocked); - Assert.IsTrue(t.NextGen); - - Assert.AreNotSame(w1, w2); // get a new writer each time - - d.Set(1, "one"); - - Assert.AreEqual(0, d.CreateSnapshot().Gen); - } + using (var w2 = d.GetScopedWriteLock(scopeProvider)) + { + } + }); Assert.AreEqual(1, t.LiveGen); - Assert.AreEqual(1, t.WLocked); + Assert.IsTrue(t.IsLocked); Assert.IsTrue(t.NextGen); Assert.AreEqual(0, d.CreateSnapshot().Gen); } Assert.AreEqual(1, t.LiveGen); - Assert.AreEqual(0, t.WLocked); + Assert.IsFalse(t.IsLocked); Assert.IsTrue(t.NextGen); Assert.AreEqual(1, d.CreateSnapshot().Gen); @@ -772,11 +766,14 @@ namespace Umbraco.Tests.Cache using (var w1 = d.GetScopedWriteLock(scopeProvider)) { + // This one is interesting, although we don't allow recursive locks, since this is + // using the same ScopeContext/key, the lock acquisition is only done once + using (var w2 = d.GetScopedWriteLock(scopeProvider)) { Assert.AreSame(w1, w2); - d.Set(1, "one"); + d.SetLocked(1, "one"); } } } @@ -797,19 +794,16 @@ namespace Umbraco.Tests.Cache using (var w1 = d.GetScopedWriteLock(scopeProvider1)) { Assert.AreEqual(1, t.LiveGen); - Assert.AreEqual(1, t.WLocked); + Assert.IsTrue(t.IsLocked); Assert.IsTrue(t.NextGen); - using (var w2 = d.GetScopedWriteLock(scopeProvider2)) + Assert.Throws(() => { - Assert.AreEqual(1, t.LiveGen); - Assert.AreEqual(2, t.WLocked); - Assert.IsTrue(t.NextGen); + using (var w2 = d.GetScopedWriteLock(scopeProvider2)) + { + } + }); - Assert.AreNotSame(w1, w2); - - d.Set(1, "one"); - } } } @@ -848,13 +842,13 @@ namespace Umbraco.Tests.Cache Assert.IsFalse(d.Test.NextGen); Assert.AreEqual("uno", s2.Get(1)); - var scopeProvider = GetScopeProvider(); + var scopeProvider = GetScopeProvider(); using (d.GetScopedWriteLock(scopeProvider)) { // gen 3 Assert.AreEqual(2, d.Test.GetValues(1).Length); - d.Set(1, "ein"); + d.SetLocked(1, "ein"); Assert.AreEqual(3, d.Test.GetValues(1).Length); Assert.AreEqual(3, d.Test.LiveGen); @@ -881,6 +875,7 @@ namespace Umbraco.Tests.Cache { var d = new SnapDictionary(); d.Test.CollectAuto = false; + // gen 1 d.Set(1, "one"); @@ -894,12 +889,11 @@ namespace Umbraco.Tests.Cache Assert.AreEqual("uno", s2.Get(1)); var scopeProvider = GetScopeProvider(); - using (d.GetScopedWriteLock(scopeProvider)) { // creating a snapshot in a write-lock does NOT return the "current" content // it uses the previous snapshot, so new snapshot created only on release - d.Set(1, "ein"); + d.SetLocked(1, "ein"); var s3 = d.CreateSnapshot(); Assert.AreEqual(2, s3.Gen); Assert.AreEqual("uno", s3.Get(1)); @@ -934,12 +928,11 @@ namespace Umbraco.Tests.Cache var scopeContext = new ScopeContext(); var scopeProvider = GetScopeProvider(scopeContext); - using (d.GetScopedWriteLock(scopeProvider)) { // creating a snapshot in a write-lock does NOT return the "current" content // it uses the previous snapshot, so new snapshot created only on release - d.Set(1, "ein"); + d.SetLocked(1, "ein"); var s3 = d.CreateSnapshot(); Assert.AreEqual(2, s3.Gen); Assert.AreEqual("uno", s3.Get(1)); @@ -967,7 +960,7 @@ namespace Umbraco.Tests.Cache var d = new SnapDictionary(); var t = d.Test; t.CollectAuto = false; - + // gen 1 d.Set(1, "one"); var s1 = d.CreateSnapshot(); @@ -984,12 +977,11 @@ namespace Umbraco.Tests.Cache var scopeContext = new ScopeContext(); var scopeProvider = GetScopeProvider(scopeContext); - using (d.GetScopedWriteLock(scopeProvider)) { // creating a snapshot in a write-lock does NOT return the "current" content // it uses the previous snapshot, so new snapshot created only on release - d.Set(1, "ein"); + d.SetLocked(1, "ein"); var s3 = d.CreateSnapshot(); Assert.AreEqual(2, s3.Gen); Assert.AreEqual("uno", s3.Get(1)); @@ -997,7 +989,7 @@ namespace Umbraco.Tests.Cache // we made some changes, so a next gen is required Assert.AreEqual(3, t.LiveGen); Assert.IsTrue(t.NextGen); - Assert.AreEqual(1, t.WLocked); + Assert.IsTrue(t.IsLocked); // but live snapshot contains changes var ls = t.LiveSnapshot; @@ -1008,7 +1000,7 @@ namespace Umbraco.Tests.Cache // nothing is committed until scope exits Assert.AreEqual(3, t.LiveGen); Assert.IsTrue(t.NextGen); - Assert.AreEqual(1, t.WLocked); + Assert.IsTrue(t.IsLocked); // no changes until exit var s4 = d.CreateSnapshot(); @@ -1020,7 +1012,7 @@ namespace Umbraco.Tests.Cache // now things have changed Assert.AreEqual(2, t.LiveGen); Assert.IsFalse(t.NextGen); - Assert.AreEqual(0, t.WLocked); + Assert.IsFalse(t.IsLocked); // no changes since not completed var s5 = d.CreateSnapshot(); @@ -1097,9 +1089,10 @@ namespace Umbraco.Tests.Cache // writer is scope contextual and scoped // when disposed, nothing happens // when the context exists, the writer is released + using (d.GetScopedWriteLock(scopeProvider)) { - d.Set(1, "ein"); + d.SetLocked(1, "ein"); Assert.IsTrue(d.Test.NextGen); Assert.AreEqual(3, d.Test.LiveGen); Assert.IsNotNull(d.Test.GenObj); @@ -1107,7 +1100,7 @@ namespace Umbraco.Tests.Cache } // writer has not released - Assert.AreEqual(1, d.Test.WLocked); + Assert.IsTrue(d.Test.IsLocked); Assert.IsNotNull(d.Test.GenObj); Assert.AreEqual(2, d.Test.GenObj.Gen); @@ -1118,7 +1111,7 @@ namespace Umbraco.Tests.Cache // panic! var s2 = d.CreateSnapshot(); - Assert.AreEqual(1, d.Test.WLocked); + Assert.IsTrue(d.Test.IsLocked); Assert.IsNotNull(d.Test.GenObj); Assert.AreEqual(2, d.Test.GenObj.Gen); Assert.AreEqual(3, d.Test.LiveGen); @@ -1127,7 +1120,7 @@ namespace Umbraco.Tests.Cache // release writer scopeContext.ScopeExit(true); - Assert.AreEqual(0, d.Test.WLocked); + Assert.IsFalse(d.Test.IsLocked); Assert.IsNotNull(d.Test.GenObj); Assert.AreEqual(2, d.Test.GenObj.Gen); Assert.AreEqual(3, d.Test.LiveGen); @@ -1135,7 +1128,7 @@ namespace Umbraco.Tests.Cache var s3 = d.CreateSnapshot(); - Assert.AreEqual(0, d.Test.WLocked); + Assert.IsFalse(d.Test.IsLocked); Assert.IsNotNull(d.Test.GenObj); Assert.AreEqual(3, d.Test.GenObj.Gen); Assert.AreEqual(3, d.Test.LiveGen); @@ -1150,4 +1143,45 @@ namespace Umbraco.Tests.Cache return scopeProvider; } } + + /// + /// Used for tests so that we don't have to wrap every Set/Clear call in locks + /// + public static class SnapDictionaryExtensions + { + internal static void Set(this SnapDictionary d, TKey key, TValue value) + where TValue : class + { + using (d.GetScopedWriteLock(GetScopeProvider())) + { + d.SetLocked(key, value); + } + } + + internal static void Clear(this SnapDictionary d) + where TValue : class + { + using (d.GetScopedWriteLock(GetScopeProvider())) + { + d.ClearLocked(); + } + } + + internal static void Clear(this SnapDictionary d, TKey key) + where TValue : class + { + using (d.GetScopedWriteLock(GetScopeProvider())) + { + d.ClearLocked(key); + } + } + + private static IScopeProvider GetScopeProvider() + { + var scopeProvider = Mock.Of(); + Mock.Get(scopeProvider) + .Setup(x => x.Context).Returns(() => null); + return scopeProvider; + } + } } diff --git a/src/Umbraco.Tests/CoreThings/EnumExtensionsTests.cs b/src/Umbraco.Tests/CoreThings/EnumExtensionsTests.cs index 4a0c1d0f41..faa15b0077 100644 --- a/src/Umbraco.Tests/CoreThings/EnumExtensionsTests.cs +++ b/src/Umbraco.Tests/CoreThings/EnumExtensionsTests.cs @@ -1,6 +1,7 @@ -using NUnit.Framework; -using Umbraco.Web.Trees; +using System; +using NUnit.Framework; using Umbraco.Core; +using Umbraco.Web.Trees; namespace Umbraco.Tests.CoreThings { @@ -22,6 +23,7 @@ namespace Umbraco.Tests.CoreThings Assert.IsFalse(value.HasFlag(test)); } + [Obsolete] [TestCase(TreeUse.Dialog, TreeUse.Dialog, true)] [TestCase(TreeUse.Dialog, TreeUse.Main, false)] [TestCase(TreeUse.Dialog | TreeUse.Main, TreeUse.Dialog, true)] @@ -51,47 +53,5 @@ namespace Umbraco.Tests.CoreThings else Assert.IsFalse(value.HasFlagAny(test)); } - - [TestCase(TreeUse.None, TreeUse.None, TreeUse.None)] - [TestCase(TreeUse.None, TreeUse.Main, TreeUse.Main)] - [TestCase(TreeUse.None, TreeUse.Dialog, TreeUse.Dialog)] - [TestCase(TreeUse.None, TreeUse.Main | TreeUse.Dialog, TreeUse.Main | TreeUse.Dialog)] - [TestCase(TreeUse.Main, TreeUse.None, TreeUse.Main)] - [TestCase(TreeUse.Main, TreeUse.Main, TreeUse.Main)] - [TestCase(TreeUse.Main, TreeUse.Dialog, TreeUse.Main | TreeUse.Dialog)] - [TestCase(TreeUse.Main, TreeUse.Main | TreeUse.Dialog, TreeUse.Main | TreeUse.Dialog)] - [TestCase(TreeUse.Dialog, TreeUse.None, TreeUse.Dialog)] - [TestCase(TreeUse.Dialog, TreeUse.Main, TreeUse.Main | TreeUse.Dialog)] - [TestCase(TreeUse.Dialog, TreeUse.Dialog, TreeUse.Dialog)] - [TestCase(TreeUse.Dialog, TreeUse.Main | TreeUse.Dialog, TreeUse.Main | TreeUse.Dialog)] - [TestCase(TreeUse.Main | TreeUse.Dialog, TreeUse.None, TreeUse.Main | TreeUse.Dialog)] - [TestCase(TreeUse.Main | TreeUse.Dialog, TreeUse.Main, TreeUse.Main | TreeUse.Dialog)] - [TestCase(TreeUse.Main | TreeUse.Dialog, TreeUse.Dialog, TreeUse.Main | TreeUse.Dialog)] - [TestCase(TreeUse.Main | TreeUse.Dialog, TreeUse.Main | TreeUse.Dialog, TreeUse.Main | TreeUse.Dialog)] - public void SetFlagTests(TreeUse value, TreeUse flag, TreeUse expected) - { - Assert.AreEqual(expected, value.SetFlag(flag)); - } - - [TestCase(TreeUse.None, TreeUse.None, TreeUse.None)] - [TestCase(TreeUse.None, TreeUse.Main, TreeUse.None)] - [TestCase(TreeUse.None, TreeUse.Dialog, TreeUse.None)] - [TestCase(TreeUse.None, TreeUse.Main | TreeUse.Dialog, TreeUse.None)] - [TestCase(TreeUse.Main, TreeUse.None, TreeUse.Main)] - [TestCase(TreeUse.Main, TreeUse.Main, TreeUse.None)] - [TestCase(TreeUse.Main, TreeUse.Dialog, TreeUse.Main)] - [TestCase(TreeUse.Main, TreeUse.Main | TreeUse.Dialog, TreeUse.None)] - [TestCase(TreeUse.Dialog, TreeUse.None, TreeUse.Dialog)] - [TestCase(TreeUse.Dialog, TreeUse.Main, TreeUse.Dialog)] - [TestCase(TreeUse.Dialog, TreeUse.Dialog, TreeUse.None)] - [TestCase(TreeUse.Dialog, TreeUse.Main | TreeUse.Dialog, TreeUse.None)] - [TestCase(TreeUse.Main | TreeUse.Dialog, TreeUse.None, TreeUse.Main | TreeUse.Dialog)] - [TestCase(TreeUse.Main | TreeUse.Dialog, TreeUse.Main, TreeUse.Dialog)] - [TestCase(TreeUse.Main | TreeUse.Dialog, TreeUse.Dialog, TreeUse.Main)] - [TestCase(TreeUse.Main | TreeUse.Dialog, TreeUse.Main | TreeUse.Dialog, TreeUse.None)] - public void UnsetFlagTests(TreeUse value, TreeUse flag, TreeUse expected) - { - Assert.AreEqual(expected, value.UnsetFlag(flag)); - } } } diff --git a/src/Umbraco.Tests/LegacyXmlPublishedCache/XmlPublishedSnapshotService.cs b/src/Umbraco.Tests/LegacyXmlPublishedCache/XmlPublishedSnapshotService.cs index c723c14c80..2e8f52a6a7 100644 --- a/src/Umbraco.Tests/LegacyXmlPublishedCache/XmlPublishedSnapshotService.cs +++ b/src/Umbraco.Tests/LegacyXmlPublishedCache/XmlPublishedSnapshotService.cs @@ -10,6 +10,7 @@ using Umbraco.Core.Models; using Umbraco.Core.Models.Membership; using Umbraco.Core.Models.PublishedContent; using Umbraco.Core.Persistence.Repositories; +using Umbraco.Core.Runtime; using Umbraco.Core.Scoping; using Umbraco.Core.Services; using Umbraco.Core.Strings; diff --git a/src/Umbraco.Tests/LegacyXmlPublishedCache/XmlStore.cs b/src/Umbraco.Tests/LegacyXmlPublishedCache/XmlStore.cs index f989fd3832..df46ab755f 100644 --- a/src/Umbraco.Tests/LegacyXmlPublishedCache/XmlStore.cs +++ b/src/Umbraco.Tests/LegacyXmlPublishedCache/XmlStore.cs @@ -15,6 +15,7 @@ using Umbraco.Core.Models; using Umbraco.Core.Persistence; using Umbraco.Core.Persistence.Repositories; using Umbraco.Core.Persistence.Repositories.Implement; +using Umbraco.Core.Runtime; using Umbraco.Core.Scoping; using Umbraco.Core.Services; using Umbraco.Core.Services.Changes; diff --git a/src/Umbraco.Tests/Persistence/Repositories/ContentTypeRepositoryTest.cs b/src/Umbraco.Tests/Persistence/Repositories/ContentTypeRepositoryTest.cs index 07f74e29e6..53a632132d 100644 --- a/src/Umbraco.Tests/Persistence/Repositories/ContentTypeRepositoryTest.cs +++ b/src/Umbraco.Tests/Persistence/Repositories/ContentTypeRepositoryTest.cs @@ -971,6 +971,32 @@ namespace Umbraco.Tests.Persistence.Repositories } } + [Test] + public void Can_Verify_Content_Type_Has_Content_Nodes() + { + // Arrange + var provider = TestObjects.GetScopeProvider(Logger); + using (var scope = provider.CreateScope()) + { + ContentTypeRepository repository; + var contentRepository = CreateRepository((IScopeAccessor)provider, out repository); + var contentTypeId = NodeDto.NodeIdSeed + 1; + var contentType = repository.Get(contentTypeId); + + // Act + var result = repository.HasContentNodes(contentTypeId); + + var subpage = MockedContent.CreateTextpageContent(contentType, "Test Page 1", contentType.Id); + contentRepository.Save(subpage); + + var result2 = repository.HasContentNodes(contentTypeId); + + // Assert + Assert.That(result, Is.False); + Assert.That(result2, Is.True); + } + } + public void CreateTestData() { //Create and Save ContentType "umbTextpage" -> (NodeDto.NodeIdSeed) diff --git a/src/Umbraco.Tests/PublishedContent/NuCacheChildrenTests.cs b/src/Umbraco.Tests/PublishedContent/NuCacheChildrenTests.cs index b03ee25e4a..5b2838df39 100644 --- a/src/Umbraco.Tests/PublishedContent/NuCacheChildrenTests.cs +++ b/src/Umbraco.Tests/PublishedContent/NuCacheChildrenTests.cs @@ -925,6 +925,14 @@ namespace Umbraco.Tests.PublishedContent var snapshot = _snapshotService.CreatePublishedSnapshot(previewToken: null); _snapshotAccessor.PublishedSnapshot = snapshot; + var snapshotService = (PublishedSnapshotService)_snapshotService; + var contentStore = snapshotService.GetContentStore(); + + var parentNodes = contentStore.Test.GetValues(1); + var parentNode = parentNodes[0]; + AssertLinkedNode(parentNode.contentNode, -1, -1, 2, 4, 6); + Assert.AreEqual(1, parentNode.gen); + var documents = snapshot.Content.GetAtRoot().ToArray(); AssertDocuments(documents, "N1", "N2", "N3"); @@ -941,6 +949,15 @@ namespace Umbraco.Tests.PublishedContent new ContentCacheRefresher.JsonPayload(2, Guid.Empty, TreeChangeTypes.RefreshNode), }, out _, out _); + parentNodes = contentStore.Test.GetValues(1); + Assert.AreEqual(2, parentNodes.Length); + parentNode = parentNodes[1]; // get the first gen + AssertLinkedNode(parentNode.contentNode, -1, -1, 2, 4, 6); // the structure should have remained the same + Assert.AreEqual(1, parentNode.gen); + parentNode = parentNodes[0]; // get the latest gen + AssertLinkedNode(parentNode.contentNode, -1, -1, 2, 4, 6); // the structure should have remained the same + Assert.AreEqual(2, parentNode.gen); + documents = snapshot.Content.GetAtRoot().ToArray(); AssertDocuments(documents, "N1", "N2", "N3"); @@ -949,6 +966,8 @@ namespace Umbraco.Tests.PublishedContent documents = snapshot.Content.GetById(2).Children(_variationAccesor).ToArray(); AssertDocuments(documents, "N9", "N8", "N7"); + + } [Test] diff --git a/src/Umbraco.Tests/TestHelpers/TestHelper.cs b/src/Umbraco.Tests/TestHelpers/TestHelper.cs index 2064051a11..eb126edcff 100644 --- a/src/Umbraco.Tests/TestHelpers/TestHelper.cs +++ b/src/Umbraco.Tests/TestHelpers/TestHelper.cs @@ -22,6 +22,7 @@ using Umbraco.Core.Models.Entities; using Umbraco.Core.Models.PublishedContent; using Umbraco.Core.Persistence; using Umbraco.Core.PropertyEditors; +using Umbraco.Core.Runtime; using Umbraco.Core.Services; using Umbraco.Core.Strings; using Umbraco.Core.Sync; @@ -96,7 +97,7 @@ namespace Umbraco.Tests.TestHelpers public static IIOHelper IOHelper { get; } = new IOHelper(GetHostingEnvironment()); - public static IMainDom MainDom { get; } = new MainDom(Mock.Of(), GetHostingEnvironment()); + public static IMainDom MainDom { get; } = new MainDom(Mock.Of(), GetHostingEnvironment(), new MainDomSemaphoreLock(Mock.Of(), GetHostingEnvironment())); /// /// Maps the given making it rooted on . must start with ~/ /// diff --git a/src/Umbraco.Tests/Views/web.config b/src/Umbraco.Tests/Views/web.config new file mode 100644 index 0000000000..efd80424e5 --- /dev/null +++ b/src/Umbraco.Tests/Views/web.config @@ -0,0 +1,74 @@ + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Umbraco.Web.UI.Client/lib/bootstrap/less/type.less b/src/Umbraco.Web.UI.Client/lib/bootstrap/less/type.less index bf1167f950..3f93deaf56 100644 --- a/src/Umbraco.Web.UI.Client/lib/bootstrap/less/type.less +++ b/src/Umbraco.Web.UI.Client/lib/bootstrap/less/type.less @@ -132,6 +132,10 @@ ol.inline { display: inline-block; padding-left: 5px; padding-right: 5px; + + &.-no-padding-left{ + padding-left: 0; + } } } diff --git a/src/Umbraco.Web.UI.Client/lib/markdown/markdown.editor.js b/src/Umbraco.Web.UI.Client/lib/markdown/markdown.editor.js index a75a7f1f3c..60118dbdb3 100644 --- a/src/Umbraco.Web.UI.Client/lib/markdown/markdown.editor.js +++ b/src/Umbraco.Web.UI.Client/lib/markdown/markdown.editor.js @@ -60,6 +60,7 @@ * its own image insertion dialog, this hook should return true, and the callback should be called with the chosen * image url (or null if the user cancelled). If this hook returns false, the default dialog will be used. */ + hooks.addFalse("insertLinkDialog"); this.getConverter = function () { return markdownConverter; } @@ -1636,7 +1637,7 @@ var that = this; // The function to be executed when you enter a link and press OK or Cancel. // Marks up the link and adds the ref. - var linkEnteredCallback = function (link) { + var linkEnteredCallback = function (link, title) { if (link !== null) { // ( $1 @@ -1667,10 +1668,10 @@ if (!chunk.selection) { if (isImage) { - chunk.selection = "enter image description here"; + chunk.selection = title || "enter image description here"; } else { - chunk.selection = "enter link description here"; + chunk.selection = title || "enter link description here"; } } } @@ -1683,7 +1684,8 @@ ui.prompt('Insert Image', imageDialogText, imageDefaultText, linkEnteredCallback); } else { - ui.prompt('Insert Link', linkDialogText, linkDefaultText, linkEnteredCallback); + if (!this.hooks.insertLinkDialog(linkEnteredCallback)) + ui.prompt('Insert Link', linkDialogText, linkDefaultText, linkEnteredCallback); } return true; } diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/content/edit.controller.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/content/edit.controller.js index cab71842b1..7429f60e0d 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/content/edit.controller.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/content/edit.controller.js @@ -791,6 +791,7 @@ $scope.content.variants[i].expireDate = model.variants[i].expireDate; $scope.content.variants[i].releaseDateFormatted = model.variants[i].releaseDateFormatted; $scope.content.variants[i].expireDateFormatted = model.variants[i].expireDateFormatted; + $scope.content.variants[i].save = model.variants[i].save; } model.submitButtonState = "busy"; diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/editor/umbeditorcontentheader.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/editor/umbeditorcontentheader.directive.js index b3948bd7c4..fe2a6aa40a 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/editor/umbeditorcontentheader.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/editor/umbeditorcontentheader.directive.js @@ -17,8 +17,8 @@ scope.isNew = scope.content.state == "NotCreated"; localizationService.localizeMany([ - scope.isNew ? "placeholders_a11yCreateItem" : "placeholders_a11yEdit", - "placeholders_a11yName", + scope.isNew ? "visuallyHiddenTexts_createItem" : "visuallyHiddenTexts_edit", + "visuallyHiddenTexts_name", scope.isNew ? "general_new" : "general_edit"] ).then(function (data) { diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/editor/umbeditorheader.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/editor/umbeditorheader.directive.js index 431a05778c..87053c083c 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/editor/umbeditorheader.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/editor/umbeditorheader.directive.js @@ -233,8 +233,8 @@ Use this directive to construct a header inside the main editor window. editorState.current.id === "-1"; var localizeVars = [ - scope.isNew ? "placeholders_a11yCreateItem" : "placeholders_a11yEdit", - "placeholders_a11yName", + scope.isNew ? "visuallyHiddenTexts_createItem" : "visuallyHiddenTexts_edit", + "visuallyHiddenTexts_name", scope.isNew ? "general_new" : "general_edit" ]; diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/overlays/umboverlay.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/overlays/umboverlay.directive.js index bc3993458e..fa1f4227a2 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/overlays/umboverlay.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/overlays/umboverlay.directive.js @@ -213,7 +213,6 @@ Opens an overlay to show a custom YSOD.
var unsubscribe = []; function activate() { - setView(); setButtonText(); @@ -247,10 +246,20 @@ Opens an overlay to show a custom YSOD.
setOverlayIndent(); + focusOnOverlayHeading() }); } + // Ideally this would focus on the first natively focusable element in the overlay, but as the content can be dynamic, it is focusing on the heading. + function focusOnOverlayHeading() { + var heading = el.find(".umb-overlay__title"); + + if(heading) { + heading.focus(); + } + } + function setView() { if (scope.view) { diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/umbchildselector.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/umbchildselector.directive.js index 4fc22c4b74..a33fd4be53 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/umbchildselector.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/umbchildselector.directive.js @@ -188,6 +188,22 @@ Use this directive to render a ui component for selecting child items to a paren syncParentIcon(); })); + // sortable options for allowed child content types + scope.sortableOptions = { + axis: "y", + containment: "parent", + distance: 10, + opacity: 0.7, + tolerance: "pointer", + scroll: true, + zIndex: 6000, + update: function (e, ui) { + if(scope.onSort) { + scope.onSort(); + } + } + }; + // clean up scope.$on('$destroy', function(){ // unbind watchers @@ -209,7 +225,8 @@ Use this directive to render a ui component for selecting child items to a paren parentIcon: "=", parentId: "=", onRemove: "=", - onAdd: "=" + onAdd: "=", + onSort: "=" }, link: link }; diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/util/konami.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/util/konami.directive.js deleted file mode 100644 index 7914dfc3f0..0000000000 --- a/src/Umbraco.Web.UI.Client/src/common/directives/util/konami.directive.js +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Konami Code directive for AngularJS - * @version v0.0.1 - * @license MIT License, https://www.opensource.org/licenses/MIT - */ - -angular.module('umbraco.directives') - .directive('konamiCode', ['$document', function ($document) { - var konamiKeysDefault = [38, 38, 40, 40, 37, 39, 37, 39, 66, 65]; - - return { - restrict: 'A', - link: function (scope, element, attr) { - - if (!attr.konamiCode) { - throw ('Konami directive must receive an expression as value.'); - } - - // Let user define a custom code. - var konamiKeys = attr.konamiKeys || konamiKeysDefault; - var keyIndex = 0; - - /** - * Fired when konami code is type. - */ - function activated() { - if ('konamiOnce' in attr) { - stopListening(); - } - // Execute expression. - scope.$eval(attr.konamiCode); - } - - /** - * Handle keydown events. - */ - function keydown(e) { - if (e.keyCode === konamiKeys[keyIndex++]) { - if (keyIndex === konamiKeys.length) { - keyIndex = 0; - activated(); - } - } else { - keyIndex = 0; - } - } - - /** - * Stop to listen typing. - */ - function stopListening() { - $document.off('keydown', keydown); - } - - // Start listening to key typing. - $document.on('keydown', keydown); - - // Stop listening when scope is destroyed. - scope.$on('$destroy', stopListening); - } - }; - }]); diff --git a/src/Umbraco.Web.UI.Client/src/common/resources/contenttype.resource.js b/src/Umbraco.Web.UI.Client/src/common/resources/contenttype.resource.js index 64accc18c1..97bebef062 100644 --- a/src/Umbraco.Web.UI.Client/src/common/resources/contenttype.resource.js +++ b/src/Umbraco.Web.UI.Client/src/common/resources/contenttype.resource.js @@ -351,6 +351,16 @@ function contentTypeResource($q, $http, umbRequestHelper, umbDataFormatter, loca return umbRequestHelper.resourcePromise( $http.post(umbRequestHelper.getApiUrl("contentTypeApiBaseUrl", "PostCreateDefaultTemplate", { id: id })), 'Failed to create default template for content type with id ' + id); + }, + + hasContentNodes: function (id) { + return umbRequestHelper.resourcePromise( + $http.get( + umbRequestHelper.getApiUrl( + "contentTypeApiBaseUrl", + "HasContentNodes", + [{ id: id }])), + 'Failed to retrieve indication for whether content type with id ' + id + ' has associated content nodes'); } }; } diff --git a/src/Umbraco.Web.UI.Client/src/common/resources/entity.resource.js b/src/Umbraco.Web.UI.Client/src/common/resources/entity.resource.js index 9cf1181cfa..61d646afc0 100644 --- a/src/Umbraco.Web.UI.Client/src/common/resources/entity.resource.js +++ b/src/Umbraco.Web.UI.Client/src/common/resources/entity.resource.js @@ -127,6 +127,25 @@ function entityResource($q, $http, umbRequestHelper) { 'Failed to retrieve url for id:' + id); }, + getUrlByUdi: function (udi, culture) { + + if (!udi) { + return ""; + } + + if (!culture) { + culture = ""; + } + + return umbRequestHelper.resourcePromise( + $http.get( + umbRequestHelper.getApiUrl( + "entityApiBaseUrl", + "GetUrl", + [{ udi: udi }, {culture: culture }])), + 'Failed to retrieve url for UDI:' + udi); + }, + /** * @ngdoc method * @name umbraco.resources.entityResource#getById @@ -166,18 +185,22 @@ function entityResource($q, $http, umbRequestHelper) { }, - getUrlAndAnchors: function (id) { + getUrlAndAnchors: function (id, culture) { if (id === -1 || id === "-1") { return null; } + if (!culture) { + culture = ""; + } + return umbRequestHelper.resourcePromise( $http.get( umbRequestHelper.getApiUrl( "entityApiBaseUrl", "GetUrlAndAnchors", - [{ id: id }])), + [{ id: id }, {culture: culture }])), 'Failed to retrieve url and anchors data for id ' + id); }, diff --git a/src/Umbraco.Web.UI.Client/src/common/resources/tour.resource.js b/src/Umbraco.Web.UI.Client/src/common/resources/tour.resource.js index 40baf0f389..485b0d299a 100644 --- a/src/Umbraco.Web.UI.Client/src/common/resources/tour.resource.js +++ b/src/Umbraco.Web.UI.Client/src/common/resources/tour.resource.js @@ -20,10 +20,21 @@ "GetTours")), 'Failed to get tours'); } + + function getToursForDoctype(doctypeAlias) { + return umbRequestHelper.resourcePromise( + $http.get( + umbRequestHelper.getApiUrl( + "tourApiBaseUrl", + "GetToursForDoctype", + [{ doctypeAlias: doctypeAlias }])), + 'Failed to get tours'); + } var resource = { - getTours: getTours + getTours: getTours, + getToursForDoctype: getToursForDoctype }; return resource; diff --git a/src/Umbraco.Web.UI.Client/src/common/services/editor.service.js b/src/Umbraco.Web.UI.Client/src/common/services/editor.service.js index 1d80d3a3ed..284a7db4d8 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/editor.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/editor.service.js @@ -640,6 +640,23 @@ When building a custom infinite editor view you can use the same components as a editor.view = "views/mediatypes/edit.html"; open(editor); } + + /** + * @ngdoc method + * @name umbraco.services.editorService#memberTypeEditor + * @methodOf umbraco.services.editorService + * + * @description + * Opens the member type editor in infinite editing, the submit callback returns the saved member type + * @param {Object} editor rendering options + * @param {Callback} editor.submit Submits the editor + * @param {Callback} editor.close Closes the editor + * @returns {Object} editor object + */ + function memberTypeEditor(editor) { + editor.view = "views/membertypes/edit.html"; + open(editor); + } /** * @ngdoc method @@ -1011,6 +1028,7 @@ When building a custom infinite editor view you can use the same components as a iconPicker: iconPicker, documentTypeEditor: documentTypeEditor, mediaTypeEditor: mediaTypeEditor, + memberTypeEditor: memberTypeEditor, queryBuilder: queryBuilder, treePicker: treePicker, nodePermissions: nodePermissions, diff --git a/src/Umbraco.Web.UI.Client/src/common/services/editorstate.service.js b/src/Umbraco.Web.UI.Client/src/common/services/editorstate.service.js index 97a9ac5c4b..28daa3f245 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/editorstate.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/editorstate.service.js @@ -10,7 +10,7 @@ * * it is possible to modify this object, so should be used with care */ -angular.module('umbraco.services').factory("editorState", function ($rootScope) { +angular.module('umbraco.services').factory("editorState", function ($rootScope, eventsService) { var current = null; @@ -30,6 +30,7 @@ angular.module('umbraco.services').factory("editorState", function ($rootScope) */ set: function (entity) { current = entity; + eventsService.emit("editorState.changed", { entity: entity }); }, /** diff --git a/src/Umbraco.Web.UI.Client/src/common/services/tour.service.js b/src/Umbraco.Web.UI.Client/src/common/services/tour.service.js index 62af17146c..8fcab445b3 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/tour.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/tour.service.js @@ -134,36 +134,38 @@ var groupedTours = []; tours.forEach(function (item) { - var groupExists = false; - var newGroup = { - "group": "", - "tours": [] - }; + if (item.contentType === null || item.contentType === '') { + var groupExists = false; + var newGroup = { + "group": "", + "tours": [] + }; - groupedTours.forEach(function(group){ - // extend existing group if it is already added - if(group.group === item.group) { - if(item.groupOrder) { - group.groupOrder = item.groupOrder - } - groupExists = true; + groupedTours.forEach(function (group) { + // extend existing group if it is already added + if (group.group === item.group) { + if (item.groupOrder) { + group.groupOrder = item.groupOrder; + } + groupExists = true; if(item.hidden === false){ group.tours.push(item); } - } - }); + } + }); - // push new group to array if it doesn't exist - if(!groupExists) { - newGroup.group = item.group; - if(item.groupOrder) { - newGroup.groupOrder = item.groupOrder - } + // push new group to array if it doesn't exist + if (!groupExists) { + newGroup.group = item.group; + if (item.groupOrder) { + newGroup.groupOrder = item.groupOrder; + } - if(item.hidden === false){ - newGroup.tours.push(item); - groupedTours.push(newGroup); + if(item.hidden === false){ + newGroup.tours.push(item); + groupedTours.push(newGroup); + } } } @@ -194,6 +196,24 @@ return deferred.promise; } + /** + * @ngdoc method + * @name umbraco.services.tourService#getToursForDoctype + * @methodOf umbraco.services.tourService + * + * @description + * Returns a promise of the tours found by documenttype alias. + * @param {Object} doctypeAlias The doctype alias for which the tours which should be returned + * @returns {Array} An array of tour objects for the doctype + */ + function getToursForDoctype(doctypeAlias) { + var deferred = $q.defer(); + tourResource.getToursForDoctype(doctypeAlias).then(function (tours) { + deferred.resolve(tours); + }); + return deferred.promise; + } + /////////// /** @@ -275,7 +295,8 @@ completeTour: completeTour, getCurrentTour: getCurrentTour, getGroupedTours: getGroupedTours, - getTourByAlias: getTourByAlias + getTourByAlias: getTourByAlias, + getToursForDoctype : getToursForDoctype }; return service; diff --git a/src/Umbraco.Web.UI.Client/src/less/components/application/umb-search.less b/src/Umbraco.Web.UI.Client/src/less/components/application/umb-search.less index 70e4f3d372..2f9430ef41 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/application/umb-search.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/application/umb-search.less @@ -16,6 +16,10 @@ box-shadow: 0 10px 20px rgba(0,0,0,.12),0 6px 6px rgba(0,0,0,.14); } +.umb-search__label{ + margin: 0; +} + /* Search field */ @@ -107,4 +111,4 @@ .umb-search-result__description { color: @gray-5; font-size: 13px; -} \ No newline at end of file +} diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-child-selector.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-child-selector.less index b6cdc0e8d9..da690663d0 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-child-selector.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-child-selector.less @@ -30,6 +30,9 @@ .umb-child-selector__children-container { margin-left: 30px; + .umb-child-selector__child { + cursor: move; + } } .umb-child-selector__child-description { diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-grid.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-grid.less index 479074fee9..26d61412ae 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-grid.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-grid.less @@ -162,6 +162,8 @@ } .umb-grid .umb-row .umb-cell-placeholder { + display: block; + width: 100%; min-height: 88px; border-width: 1px; border-style: dashed; @@ -226,6 +228,7 @@ .umb-grid .cell-tools-add.-bar { display: block; + width: calc(100% - 20px); text-align: center; padding: 5px; border: 1px dashed @ui-action-discreet-border; diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-iconpicker.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-iconpicker.less index e8a62f739d..98b2b1d72d 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-iconpicker.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-iconpicker.less @@ -15,7 +15,9 @@ overflow: hidden; } -.umb-iconpicker-item a { +.umb-iconpicker-item button { + background: transparent; + border: 0 none; display: flex; justify-content: center; align-items: center; @@ -26,8 +28,8 @@ border-radius: 3px; } -.umb-iconpicker-item a:hover, -.umb-iconpicker-item a:focus { +.umb-iconpicker-item button:hover, +.umb-iconpicker-item button:focus { background: @gray-10; outline: none; } @@ -39,7 +41,7 @@ box-sizing: border-box; } -.umb-iconpicker-item a:active { +.umb-iconpicker-item button:active { background: @gray-10; } diff --git a/src/Umbraco.Web.UI.Client/src/views/common/drawers/help/help.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/drawers/help/help.controller.js index 3323f2bfb3..268bfb3a8c 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/drawers/help/help.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/common/drawers/help/help.controller.js @@ -1,7 +1,7 @@ (function () { "use strict"; - function HelpDrawerController($scope, $routeParams, $timeout, dashboardResource, localizationService, userService, eventsService, helpService, appState, tourService, $filter) { + function HelpDrawerController($scope, $routeParams, $timeout, dashboardResource, localizationService, userService, eventsService, helpService, appState, tourService, $filter, editorState) { var vm = this; var evts = []; @@ -18,6 +18,10 @@ vm.startTour = startTour; vm.getTourGroupCompletedPercentage = getTourGroupCompletedPercentage; vm.showTourButton = showTourButton; + + vm.showDocTypeTour = false; + vm.docTypeTours = []; + vm.nodeName = ''; function startTour(tour) { tourService.startTour(tour); @@ -58,9 +62,16 @@ handleSectionChange(); })); + evts.push(eventsService.on("editorState.changed", + function (e, args) { + setDocTypeTour(args.entity); + })); + findHelp(vm.section, vm.tree, vm.userType, vm.userLang); }); + + setDocTypeTour(editorState.getCurrent()); // check if a tour is running - if it is open the matching group var currentTour = tourService.getCurrentTour(); @@ -84,7 +95,7 @@ setSectionName(); findHelp(vm.section, vm.tree, vm.userType, vm.userLang); - + setDocTypeTour(); } }); } @@ -168,6 +179,26 @@ }); } + function setDocTypeTour(node) { + vm.showDocTypeTour = false; + vm.docTypeTours = []; + vm.nodeName = ''; + + if (vm.section === 'content' && vm.tree === 'content') { + + if (node) { + tourService.getToursForDoctype(node.contentTypeAlias).then(function (data) { + if (data && data.length > 0) { + vm.docTypeTours = data; + var currentVariant = _.find(node.variants, (x) => x.active); + vm.nodeName = currentVariant.name; + vm.showDocTypeTour = true; + } + }); + } + } + } + evts.push(eventsService.on("appState.tour.complete", function (event, tour) { tourService.getGroupedTours().then(function(groupedTours) { vm.tours = groupedTours; diff --git a/src/Umbraco.Web.UI.Client/src/views/common/drawers/help/help.html b/src/Umbraco.Web.UI.Client/src/views/common/drawers/help/help.html index 96f4a404bc..aa6126e73e 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/drawers/help/help.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/drawers/help/help.html @@ -8,12 +8,36 @@ - -
+ +
+
Need help editing current item '{{vm.nodeName}}' ?
-
Tours
+
-
+ +
+
+
+
+ {{ tour.name }} +
+
+ +
+
+
+
+
+
+ + +
+ +
+ Tours +
+ +
@@ -25,7 +49,9 @@ {{tourGroup.group}} - Other + + Other +
-
+
- -
-
-
{{dashboard.label}}
-
-
-
+ +
+
+
{{dashboard.label}}
+
+
+
+
-
- -
-
Articles
-
    -
  • - - - - {{topic.name}} - - - {{topic.description}} - + +
    +
    + Articles +
    +
    @@ -85,7 +113,9 @@
    -
    Videos
    +
    + Videos +
    diff --git a/src/Umbraco.Web.UI.Client/src/views/membertypes/edit.controller.js b/src/Umbraco.Web.UI.Client/src/views/membertypes/edit.controller.js index ab54a453b5..b2e515e187 100644 --- a/src/Umbraco.Web.UI.Client/src/views/membertypes/edit.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/membertypes/edit.controller.js @@ -13,8 +13,13 @@ var evts = []; var vm = this; + var infiniteMode = $scope.model && $scope.model.infiniteMode; + var memberTypeId = infiniteMode ? $scope.model.id : $routeParams.id; + var create = infiniteMode ? $scope.model.create : $routeParams.create; vm.save = save; + vm.close = close; + vm.editorfor = "visuallyHiddenTexts_newMember"; vm.header = {}; vm.header.editorfor = "content_membergroup"; @@ -25,6 +30,7 @@ vm.page.loading = false; vm.page.saveButtonState = "init"; vm.labels = {}; + vm.saveButtonKey = infiniteMode ? "buttons_saveAndClose" : "buttons_save"; var labelKeys = [ "general_design", @@ -86,7 +92,7 @@ vm.page.defaultButton = { hotKey: "ctrl+s", hotKeyWhenHidden: true, - labelKey: "buttons_save", + labelKey: vm.saveButtonKey, letter: "S", type: "submit", handler: function () { vm.save(); } @@ -94,7 +100,7 @@ vm.page.subButtons = [{ hotKey: "ctrl+g", hotKeyWhenHidden: true, - labelKey: "buttons_saveAndGenerateModels", + labelKey: infiniteMode ? "buttons_generateModelsAndClose" : "buttons_saveAndGenerateModels", letter: "G", handler: function () { @@ -147,12 +153,12 @@ } }); - if ($routeParams.create) { + if (create) { vm.page.loading = true; //we are creating so get an empty data type item - memberTypeResource.getScaffold($routeParams.id) + memberTypeResource.getScaffold(memberTypeId) .then(function (dt) { init(dt); @@ -163,10 +169,12 @@ vm.page.loading = true; - memberTypeResource.getById($routeParams.id).then(function (dt) { + memberTypeResource.getById(memberTypeId).then(function (dt) { init(dt); - syncTreeNode(vm.contentType, dt.path, true); + if(!infiniteMode) { + syncTreeNode(vm.contentType, dt.path, true); + } vm.page.loading = false; }); @@ -219,10 +227,16 @@ } }).then(function (data) { //success - syncTreeNode(vm.contentType, data.path); + if(!infiniteMode) { + syncTreeNode(vm.contentType, data.path); + } vm.page.saveButtonState = "success"; + if(infiniteMode && $scope.model.submit) { + $scope.model.submit(); + } + deferred.resolve(data); }, function (err) { //error @@ -307,6 +321,12 @@ }); } + + function close() { + if(infiniteMode && $scope.model.close) { + $scope.model.close(); + } + } evts.push(eventsService.on("editors.groupsBuilder.changed", function(name, args) { angularHelper.getCurrentForm($scope).$setDirty(); diff --git a/src/Umbraco.Web.UI.Client/src/views/membertypes/edit.html b/src/Umbraco.Web.UI.Client/src/views/membertypes/edit.html index 07824bc7ec..c4c521c857 100644 --- a/src/Umbraco.Web.UI.Client/src/views/membertypes/edit.html +++ b/src/Umbraco.Web.UI.Client/src/views/membertypes/edit.html @@ -40,6 +40,14 @@ + + + + label-key="{{vm.saveButtonKey}}"> ... -
    +
    diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/datepicker/datepicker.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/datepicker/datepicker.controller.js index 0f19bf3b1a..24affc6ac1 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/datepicker/datepicker.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/datepicker/datepicker.controller.js @@ -85,13 +85,27 @@ function dateTimePickerController($scope, angularHelper, dateHelper, validationM }; $scope.datePickerChange = function(date) { - setDate(date); + const momentDate = moment(date); + setDate(momentDate); setDatePickerVal(); }; - $scope.inputChanged = function() { - setDate($scope.model.datetimePickerValue); - setDatePickerVal(); + $scope.inputChanged = function () { + if ($scope.model.datetimePickerValue === "" && $scope.hasDatetimePickerValue) { + // $scope.hasDatetimePickerValue indicates that we had a value before the input was changed, + // but now the input is empty. + $scope.clearDate(); + } else if ($scope.model.datetimePickerValue) { + var momentDate = moment($scope.model.datetimePickerValue, $scope.model.config.format, true); + if (!momentDate || !momentDate.isValid()) { + momentDate = moment(new Date($scope.model.datetimePickerValue)); + } + if (momentDate && momentDate.isValid()) { + setDate(momentDate); + } + setDatePickerVal(); + flatPickr.setDate($scope.model.value, false); + } } //here we declare a special method which will be called whenever the value has changed from the server @@ -103,15 +117,14 @@ function dateTimePickerController($scope, angularHelper, dateHelper, validationM var newDate = moment(newVal); if (newDate.isAfter(minDate)) { - setDate(newVal); + setDate(newDate); } else { $scope.clearDate(); } } }; - function setDate(date) { - const momentDate = moment(date); + function setDate(momentDate) { angularHelper.safeApply($scope, function() { // when a date is changed, update the model if (momentDate && momentDate.isValid()) { @@ -123,12 +136,11 @@ function dateTimePickerController($scope, angularHelper, dateHelper, validationM $scope.hasDatetimePickerValue = false; $scope.model.datetimePickerValue = null; } - updateModelValue(date); + updateModelValue(momentDate); }); } - function updateModelValue(date) { - const momentDate = moment(date); + function updateModelValue(momentDate) { if ($scope.hasDatetimePickerValue) { if ($scope.model.config.pickTime) { //check if we are supposed to offset the time diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/grid/grid.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/grid/grid.controller.js index 3db1221f5e..5adf121a56 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/grid/grid.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/grid/grid.controller.js @@ -402,6 +402,15 @@ angular.module("umbraco") eventsService.emit("grid.rowAdded", { scope: $scope, element: $element, row: row }); + // TODO: find a nicer way to do this without relying on setTimeout + setTimeout(function () { + var newRowEl = $element.find("[data-rowid='" + row.$uniqueId + "']"); + + if(newRowEl !== null) { + newRowEl.focus(); + } + }, 0); + }; $scope.removeRow = function (section, $index) { diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/grid/grid.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/grid/grid.html index e889067321..bde2b9148e 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/grid/grid.html +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/grid/grid.html @@ -95,7 +95,7 @@
    - + -
    +
    +
    - +
    - +
    -
    +
    @@ -249,14 +249,16 @@
    - - - - + + + Add new role + +
    @@ -264,10 +266,11 @@

    -
    + ng-click="addRow(section, layout)" + type="button">
    @@ -285,7 +288,7 @@ {{layout.label || layout.name}} -
    +
    diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/listview/listview.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/listview/listview.controller.js index fe83a4d451..3c954b9316 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/listview/listview.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/listview/listview.controller.js @@ -620,7 +620,7 @@ function listViewController($scope, $interpolate, $routeParams, $injector, $time currentNode: $scope.contentId, submit: function (model) { if (model.target) { - performCopy(model.target, model.relateToOriginal); + performCopy(model.target, model.relateToOriginal, model.includeDescendants); } editorService.close(); }, @@ -631,9 +631,9 @@ function listViewController($scope, $interpolate, $routeParams, $injector, $time editorService.copy(copyEditor); }; - function performCopy(target, relateToOriginal) { + function performCopy(target, relateToOriginal, includeDescendants) { applySelected( - function (selected, index) { return contentResource.copy({ parentId: target.id, id: getIdCallback(selected[index]), relateToOriginal: relateToOriginal }); }, + function (selected, index) { return contentResource.copy({ parentId: target.id, id: getIdCallback(selected[index]), relateToOriginal: relateToOriginal, recursive: includeDescendants }); }, function (count, total) { var key = (total === 1 ? "bulk_copiedItemOfItem" : "bulk_copiedItemOfItems"); return localizationService.localize(key, [count, total]); diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/listview/listview.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/listview/listview.html index 6549c08b17..ee1847b430 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/listview/listview.html +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/listview/listview.html @@ -21,11 +21,11 @@
    - @@ -46,7 +46,7 @@
    - @@ -70,21 +70,21 @@ diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/listview/orderDirection.prevalues.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/listview/orderDirection.prevalues.html index 83a905ccf7..7ec0895936 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/listview/orderDirection.prevalues.html +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/listview/orderDirection.prevalues.html @@ -1,10 +1,16 @@ 
    - - +
      +
    • + +
    • +
    • + +
    • +
    - Required + Required
    diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/markdowneditor/markdowneditor.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/markdowneditor/markdowneditor.controller.js index f05b1e31d8..bb87f0463d 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/markdowneditor/markdowneditor.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/markdowneditor/markdowneditor.controller.js @@ -27,6 +27,20 @@ function MarkdownEditorController($scope, $element, assetsService, editorService editorService.mediaPicker(mediaPicker); } + function openLinkPicker(callback) { + var linkPicker = { + hideTarget: true, + submit: function(model) { + callback(model.target.url, model.target.name); + editorService.close(); + }, + close: function() { + editorService.close(); + } + }; + editorService.linkPicker(linkPicker); + } + assetsService .load([ "lib/markdown/markdown.converter.js", @@ -53,6 +67,12 @@ function MarkdownEditorController($scope, $element, assetsService, editorService return true; // tell the editor that we'll take care of getting the image url }); + //subscribe to the link dialog clicks + editor2.hooks.set("insertLinkDialog", function (callback) { + openLinkPicker(callback); + return true; // tell the editor that we'll take care of getting the link url + }); + editor2.hooks.set("onPreviewRefresh", function () { // We must manually update the model as there is no way to hook into the markdown editor events without exstensive edits to the library. if ($scope.model.value !== $("textarea", $element).val()) { diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/multiurlpicker/multiurlpicker.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/multiurlpicker/multiurlpicker.controller.js index 6b7d9dd7ae..2e4313ec76 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/multiurlpicker/multiurlpicker.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/multiurlpicker/multiurlpicker.controller.js @@ -82,6 +82,7 @@ function multiUrlPickerController($scope, angularHelper, localizationService, en currentTarget: target, dataTypeKey: $scope.model.dataTypeKey, ignoreUserStartNodes : ($scope.model.config && $scope.model.config.ignoreUserStartNodes) ? $scope.model.config.ignoreUserStartNodes : "0", + hideAnchor: $scope.model.config && $scope.model.config.hideAnchor ? true : false, submit: function (model) { if (model.target.url || model.target.anchor) { // if an anchor exists, check that it is appropriately prefixed @@ -143,6 +144,16 @@ function multiUrlPickerController($scope, angularHelper, localizationService, en if ($scope.model.validation && $scope.model.validation.mandatory && !$scope.model.config.minNumber) { $scope.model.config.minNumber = 1; } + + _.each($scope.model.value, function (item){ + // we must reload the "document" link URLs to match the current editor culture + if (item.udi.indexOf("/document/") > 0) { + item.url = null; + entityResource.getUrlByUdi(item.udi).then(function (data) { + item.url = data; + }); + } + }); } init(); diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/nestedcontent/nestedcontent.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/nestedcontent/nestedcontent.controller.js index b61f8b94c2..7de3a5b567 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/nestedcontent/nestedcontent.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/nestedcontent/nestedcontent.controller.js @@ -41,7 +41,7 @@ if (vm.maxItems === 0) vm.maxItems = 1000; - vm.singleMode = vm.minItems === 1 && vm.maxItems === 1; + vm.singleMode = vm.minItems === 1 && vm.maxItems === 1 && model.config.contentTypes.length === 1;; vm.showIcons = Object.toBoolean(model.config.showIcons); vm.wideMode = Object.toBoolean(model.config.hideLabel); vm.hasContentTypes = model.config.contentTypes.length > 0; @@ -131,6 +131,7 @@ setCurrentNode(newNode); setDirty(); + validate(); }; vm.openNodeTypePicker = function ($event) { @@ -234,12 +235,22 @@ } }; + vm.canDeleteNode = function (idx) { + return (vm.nodes.length > vm.minItems) + ? true + : model.config.contentTypes.length > 1; + } + function deleteNode(idx) { vm.nodes.splice(idx, 1); setDirty(); updateModel(); + validate(); }; vm.requestDeleteNode = function (idx) { + if (!vm.canDeleteNode(idx)) { + return; + } if (model.config.confirmDeletes === true) { localizationService.localizeMany(["content_nestedContentDeleteItem", "general_delete", "general_cancel", "contentTypeEditor_yesDelete"]).then(function (data) { const overlay = { @@ -468,8 +479,8 @@ } } - // Auto-fill with elementTypes, but only if we have one type to choose from, and if this property is empty. - if (vm.singleMode === true && vm.nodes.length === 0 && model.config.minItems > 0) { + // Enforce min items if we only have one scaffold type + if (vm.nodes.length < vm.minItems && vm.scaffolds.length === 1) { for (var i = vm.nodes.length; i < model.config.minItems; i++) { addNode(vm.scaffolds[0].contentTypeAlias); } @@ -480,6 +491,8 @@ setCurrentNode(vm.nodes[0]); } + validate(); + vm.inited = true; updatePropertyActionStates(); @@ -585,25 +598,28 @@ updateModel(); }); + var validate = function () { + if (vm.nodes.length < vm.minItems) { + $scope.nestedContentForm.minCount.$setValidity("minCount", false); + } + else { + $scope.nestedContentForm.minCount.$setValidity("minCount", true); + } + + if (vm.nodes.length > vm.maxItems) { + $scope.nestedContentForm.maxCount.$setValidity("maxCount", false); + } + else { + $scope.nestedContentForm.maxCount.$setValidity("maxCount", true); + } + } + var watcher = $scope.$watch( function () { return vm.nodes.length; }, function () { - //Validate! - if (vm.nodes.length < vm.minItems) { - $scope.nestedContentForm.minCount.$setValidity("minCount", false); - } - else { - $scope.nestedContentForm.minCount.$setValidity("minCount", true); - } - - if (vm.nodes.length > vm.maxItems) { - $scope.nestedContentForm.maxCount.$setValidity("maxCount", false); - } - else { - $scope.nestedContentForm.maxCount.$setValidity("maxCount", true); - } + validate(); } ); diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/nestedcontent/nestedcontent.doctypepicker.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/nestedcontent/nestedcontent.doctypepicker.html index c6860140a5..f62894e043 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/nestedcontent/nestedcontent.doctypepicker.html +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/nestedcontent/nestedcontent.doctypepicker.html @@ -56,11 +56,13 @@

    Group:
    - Select the group whose properties should be displayed. If left blank, the first group on the element type will be used. + Select the group whose properties should be displayed. If left blank, the first group on the element type will be used.

    Template:
    - Enter an angular expression to evaluate against each item for its name. Use {{$index}} to display the item index + Enter an angular expression to evaluate against each item for its name. Use + {{$index}} + to display the item index

    diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/nestedcontent/nestedcontent.propertyeditor.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/nestedcontent/nestedcontent.propertyeditor.html index b3821cff3d..da6e466b50 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/nestedcontent/nestedcontent.propertyeditor.html +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/nestedcontent/nestedcontent.propertyeditor.html @@ -2,13 +2,13 @@ - +
    -
    +
    @@ -17,7 +17,7 @@ {{vm.labels.copy_icon_title}} -
    - diff --git a/src/Umbraco.Web.UI.Client/src/views/users/views/users/users.controller.js b/src/Umbraco.Web.UI.Client/src/views/users/views/users/users.controller.js index 4ee31806a3..935b1bdfb1 100644 --- a/src/Umbraco.Web.UI.Client/src/views/users/views/users/users.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/users/views/users/users.controller.js @@ -451,7 +451,7 @@ var search = _.debounce(function () { $scope.$apply(function () { - getUsers(); + changePageNumber(1); }); }, 500); @@ -512,7 +512,7 @@ } updateLocation("userStates", vm.usersOptions.userStates.join(",")); - getUsers(); + changePageNumber(1); } function setUserGroupFilter(userGroup) { @@ -529,7 +529,7 @@ } updateLocation("userGroups", vm.usersOptions.userGroups.join(",")); - getUsers(); + changePageNumber(1); } function setOrderByFilter(value, direction) { diff --git a/src/Umbraco.Web.UI.Docs/umb-docs.css b/src/Umbraco.Web.UI.Docs/umb-docs.css index 931c22b255..0f2e3e7f74 100644 --- a/src/Umbraco.Web.UI.Docs/umb-docs.css +++ b/src/Umbraco.Web.UI.Docs/umb-docs.css @@ -7,6 +7,21 @@ body { } +.container, .navbar-static-top .container, .navbar-fixed-top .container, .navbar-fixed-bottom .container { + max-width: 1500px; + width: 95%; +} + +.span3 { + width: 220px; + width: calc(90% / 12 * 3); +} + +.span9 { + width: 700px; + width: calc(90% / 12 * 9); +} + .h1, .h2, .h3, .h4, .h5, .h6, h1, h2, h3, h4, h5, h6 { font-family: inherit; font-weight: 400; diff --git a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj index 15bfc12fb3..a8b7ea86d1 100644 --- a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj +++ b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj @@ -112,6 +112,12 @@ + + + {fb5676ed-7a69-492c-b802-e7b24144c0fc} + Umbraco.TestData + + {29aa69d9-b597-4395-8d42-43b1263c240a} diff --git a/src/Umbraco.Web.UI/Umbraco/PartialViewMacros/Templates/EditProfile.cshtml b/src/Umbraco.Web.UI/Umbraco/PartialViewMacros/Templates/EditProfile.cshtml index 7034404ca3..74ec033f25 100644 --- a/src/Umbraco.Web.UI/Umbraco/PartialViewMacros/Templates/EditProfile.cshtml +++ b/src/Umbraco.Web.UI/Umbraco/PartialViewMacros/Templates/EditProfile.cshtml @@ -23,7 +23,7 @@ { if (success) { - @* This message will show if RedirectOnSucces is set to false (default) *@ + @* This message will show if profileModel.RedirectUrl is not defined (default) *@

    Profile updated

    } diff --git a/src/Umbraco.Web.UI/Umbraco/PartialViewMacros/Templates/RegisterMember.cshtml b/src/Umbraco.Web.UI/Umbraco/PartialViewMacros/Templates/RegisterMember.cshtml index 17ed95ea31..804e2307f0 100644 --- a/src/Umbraco.Web.UI/Umbraco/PartialViewMacros/Templates/RegisterMember.cshtml +++ b/src/Umbraco.Web.UI/Umbraco/PartialViewMacros/Templates/RegisterMember.cshtml @@ -45,7 +45,7 @@ @if (success) { - @* This message will show if RedirectOnSucces is set to false (default) *@ + @* This message will show if registerModel.RedirectUrl is not defined (default) *@

    Registration succeeded.

    } else diff --git a/src/Umbraco.Web.UI/Umbraco/config/lang/da.xml b/src/Umbraco.Web.UI/Umbraco/config/lang/da.xml index 205c7da224..78075d9b7a 100644 --- a/src/Umbraco.Web.UI/Umbraco/config/lang/da.xml +++ b/src/Umbraco.Web.UI/Umbraco/config/lang/da.xml @@ -35,7 +35,9 @@ Sæt rettigheder for siden %0% Vælg hvor du vil kopiere Vælg hvortil du vil flytte + Vælg hvor du vil flytte de valgte elementer hen til i træstrukturen nedenfor + Vælg hvor du vil kopiere de valgte elementer til blev flyttet til blev kopieret til blev slettet @@ -446,6 +448,7 @@ Se cache element Relatér til original Inkludér undersider + Det venligste community Link til side Åben linket i et nyt vindue eller fane Link til medie @@ -539,9 +542,6 @@ #value eller ?key=value Indtast alias... Genererer alias... - Opret element - Rediger - Navn Opret brugerdefineret listevisning @@ -751,6 +751,9 @@ nuværende Indlejring valgt + Andet + Artikler + Videoer Blå @@ -1115,7 +1118,10 @@ Mange hilsner fra Umbraco robotten Formularer + Tours De bedste Umbraco video tutorials + Besøg our.umbraco.com + Besøg umbraco.tv Standardskabelon @@ -1733,6 +1739,9 @@ Mange hilsner fra Umbraco robotten Aktivt sprog Skift sprog til Opret ny mappe + Opret element + Rediger + Navn Referencer diff --git a/src/Umbraco.Web.UI/Umbraco/config/lang/en.xml b/src/Umbraco.Web.UI/Umbraco/config/lang/en.xml index 1e94f3717a..85b23f53ed 100644 --- a/src/Umbraco.Web.UI/Umbraco/config/lang/en.xml +++ b/src/Umbraco.Web.UI/Umbraco/config/lang/en.xml @@ -36,6 +36,8 @@ Choose where to copy Choose where to move to in the tree structure below + Choose where to copy the selected item(s) + Choose where to move the selected item(s) was moved to was copied to was deleted @@ -280,6 +282,9 @@ No content types are configured for this property. Add element type Select element type + Select the group whose properties should be displayed. If left blank, the first group on the element type will be used. + Enter an angular expression to evaluate against each item for its name. Use + to display the item index Add another text box Remove this text box Content root @@ -759,6 +764,11 @@ current Embed selected + Other + Articles + Videos + Clear + Installing Blue @@ -1339,7 +1349,10 @@ To manage your website, simply open the Umbraco back office and start adding con Users + Tours The best Umbraco video tutorials + Visit our.umbraco.com + Visit umbraco.tv Default template @@ -1637,6 +1650,7 @@ To manage your website, simply open the Umbraco back office and start adding con Element type Is an Element type An Element type is meant to be used for instance in Nested Content, and not in the tree. + A document type cannot be changed to an Element type once it has been used to create one or more content items. This is not applicable for an Element type You have made changes to this property. Are you sure you want to discard them? @@ -2202,6 +2216,10 @@ To manage your website, simply open the Umbraco back office and start adding con Search the redirect dashboard Search the user group section Search the users section + Create item + Create + Edit + Name References @@ -2367,5 +2385,9 @@ To manage your website, simply open the Umbraco back office and start adding con Welcome to The Friendly CMS Thank you for choosing Umbraco - we think this could be the beginning of something beautiful. While it may feel overwhelming at first, we've done a lot to make the learning curve as smooth and fast as possible. + + + Umbraco Forms + Create forms using an intuitive drag and drop interface. From simple contact forms that sends e-mails to advanced questionaires that integrate with CRM systems. Your clients will love it! diff --git a/src/Umbraco.Web.UI/Umbraco/config/lang/en_us.xml b/src/Umbraco.Web.UI/Umbraco/config/lang/en_us.xml index 229f1cb3f2..fb1ed6fa63 100644 --- a/src/Umbraco.Web.UI/Umbraco/config/lang/en_us.xml +++ b/src/Umbraco.Web.UI/Umbraco/config/lang/en_us.xml @@ -36,6 +36,8 @@ Choose where to copy Choose where to move to in the tree structure below + Choose where to copy the selected item(s) + Choose where to move the selected item(s) was moved to was copied to was deleted @@ -284,6 +286,9 @@ No content types are configured for this property. Add element type Select element type + Select the group whose properties should be displayed. If left blank, the first group on the element type will be used. + Enter an angular expression to evaluate against each item for its name. Use + to display the item index Add another text box Remove this text box Content root @@ -551,10 +556,6 @@ #value or ?key=value Enter alias... Generating alias... - Create item - Create - Edit - Name Create custom list view @@ -764,6 +765,11 @@ current Embed selected + Other + Articles + Videos + Clear + Installing Blue @@ -1339,7 +1345,10 @@ To manage your website, simply open the Umbraco back office and start adding con Users + Tours The best Umbraco video tutorials + Visit our.umbraco.com + Visit umbraco.tv Default template @@ -1649,9 +1658,10 @@ To manage your website, simply open the Umbraco back office and start adding con Allow editors to create content of this type in different languages. Allow varying by culture Element type - Is an Element type - An Element type is meant to be used for instance in Nested Content, and not in the tree. - This is not applicable for an Element type + Is an element type + An element type is meant to be used for instance in Nested Content, and not in the tree. + A document type cannot be changed to an element type once it has been used to create one or more content items. + This is not applicable for an element type You have made changes to this property. Are you sure you want to discard them? @@ -2218,6 +2228,10 @@ To manage your website, simply open the Umbraco back office and start adding con Search the redirect dashboard Search the user group section Search the users section + Create item + Create + Edit + Name References @@ -2384,4 +2398,8 @@ To manage your website, simply open the Umbraco back office and start adding con Welcome to The Friendly CMS Thank you for choosing Umbraco - we think this could be the beginning of something beautiful. While it may feel overwhelming at first, we've done a lot to make the learning curve as smooth and fast as possible. + + Umbraco Forms + Create forms using an intuitive drag and drop interface. From simple contact forms that sends e-mails to advanced questionaires that integrate with CRM systems. Your clients will love it! + diff --git a/src/Umbraco.Web/Editors/ContentTypeController.cs b/src/Umbraco.Web/Editors/ContentTypeController.cs index 3bf3382ab4..1d37bec593 100644 --- a/src/Umbraco.Web/Editors/ContentTypeController.cs +++ b/src/Umbraco.Web/Editors/ContentTypeController.cs @@ -73,6 +73,13 @@ namespace Umbraco.Web.Editors return Services.ContentTypeService.Count(); } + [HttpGet] + [UmbracoTreeAuthorize(Constants.Trees.DocumentTypes)] + public bool HasContentNodes(int id) + { + return Services.ContentTypeService.HasContentNodes(id); + } + public DocumentTypeDisplay GetById(int id) { var ct = Services.ContentTypeService.Get(id); @@ -428,11 +435,11 @@ namespace Umbraco.Web.Editors } var contentType = Services.ContentTypeBaseServices.GetContentTypeOf(contentItem); - var ids = contentType.AllowedContentTypes.Select(x => x.Id.Value).ToArray(); + var ids = contentType.AllowedContentTypes.OrderBy(c => c.SortOrder).Select(x => x.Id.Value).ToArray(); if (ids.Any() == false) return Enumerable.Empty(); - types = Services.ContentTypeService.GetAll(ids).ToList(); + types = Services.ContentTypeService.GetAll(ids).OrderBy(c => ids.IndexOf(c.Id)).ToList(); } var basics = types.Where(type => type.IsElement == false).Select(Mapper.Map).ToList(); @@ -455,7 +462,7 @@ namespace Umbraco.Web.Editors } } - return basics; + return basics.OrderBy(c => contentId == Constants.System.Root ? c.Name : string.Empty); } /// diff --git a/src/Umbraco.Web/Editors/EntityController.cs b/src/Umbraco.Web/Editors/EntityController.cs index a9da1bd467..9b0c1f064f 100644 --- a/src/Umbraco.Web/Editors/EntityController.cs +++ b/src/Umbraco.Web/Editors/EntityController.cs @@ -219,6 +219,35 @@ namespace Umbraco.Web.Editors throw new HttpResponseException(HttpStatusCode.NotFound); } + /// + /// Gets the url of an entity + /// + /// UDI of the entity to fetch URL for + /// The culture to fetch the URL for + /// The URL or path to the item + public HttpResponseMessage GetUrl(Udi udi, string culture = "*") + { + var intId = Services.EntityService.GetId(udi); + if (!intId.Success) + throw new HttpResponseException(HttpStatusCode.NotFound); + UmbracoEntityTypes entityType; + switch(udi.EntityType) + { + case Constants.UdiEntityType.Document: + entityType = UmbracoEntityTypes.Document; + break; + case Constants.UdiEntityType.Media: + entityType = UmbracoEntityTypes.Media; + break; + case Constants.UdiEntityType.Member: + entityType = UmbracoEntityTypes.Member; + break; + default: + throw new HttpResponseException(HttpStatusCode.NotFound); + } + return GetUrl(intId.Result, entityType, culture); + } + /// /// Gets the url of an entity /// @@ -316,7 +345,9 @@ namespace Umbraco.Web.Editors [HttpGet] public UrlAndAnchors GetUrlAndAnchors(int id, string culture = "*") { - var url = UmbracoContext.UrlProvider.GetUrl(id); + culture = culture ?? ClientCulture(); + + var url = UmbracoContext.UrlProvider.GetUrl(id, culture: culture); var anchorValues = Services.ContentService.GetAnchorValuesFromRTEs(id, culture); return new UrlAndAnchors(url, anchorValues); } diff --git a/src/Umbraco.Web/Editors/ImagesController.cs b/src/Umbraco.Web/Editors/ImagesController.cs index f6452ffba2..d3fc3ee947 100644 --- a/src/Umbraco.Web/Editors/ImagesController.cs +++ b/src/Umbraco.Web/Editors/ImagesController.cs @@ -59,8 +59,27 @@ namespace Umbraco.Web.Editors //redirect to ImageProcessor thumbnail with rnd generated from last modified time of original media file var response = Request.CreateResponse(HttpStatusCode.Found); - var imageLastModified = _mediaFileSystem.GetLastModified(imagePath); - response.Headers.Location = new Uri($"{imagePath}?rnd={imageLastModified:yyyyMMddHHmmss}&upscale=false&width={width}&animationprocessmode=first&mode=max", UriKind.Relative); + + DateTimeOffset? imageLastModified = null; + try + { + imageLastModified = _mediaFileSystem.GetLastModified(imagePath); + + } + catch (Exception) + { + // if we get an exception here it's probably because the image path being requested is an image that doesn't exist + // in the local media file system. This can happen if someone is storing an absolute path to an image online, which + // is perfectly legal but in that case the media file system isn't going to resolve it. + // so ignore and we won't set a last modified date. + } + + // TODO: When we abstract imaging for netcore, we are actually just going to be abstracting a URI builder for images, this + // is one of those places where this can be used. + + var rnd = imageLastModified.HasValue ? $"&rnd={imageLastModified:yyyyMMddHHmmss}" : string.Empty; + + response.Headers.Location = new Uri($"{imagePath}?upscale=false&width={width}&animationprocessmode=first&mode=max{rnd}", UriKind.RelativeOrAbsolute); return response; } diff --git a/src/Umbraco.Web/Editors/MediaTypeController.cs b/src/Umbraco.Web/Editors/MediaTypeController.cs index bbfef7414f..ddf676fcbf 100644 --- a/src/Umbraco.Web/Editors/MediaTypeController.cs +++ b/src/Umbraco.Web/Editors/MediaTypeController.cs @@ -245,11 +245,11 @@ namespace Umbraco.Web.Editors } var contentType = Services.MediaTypeService.Get(contentItem.ContentTypeId); - var ids = contentType.AllowedContentTypes.Select(x => x.Id.Value).ToArray(); + var ids = contentType.AllowedContentTypes.OrderBy(c => c.SortOrder).Select(x => x.Id.Value).ToArray(); if (ids.Any() == false) return Enumerable.Empty(); - types = Services.MediaTypeService.GetAll(ids).ToList(); + types = Services.MediaTypeService.GetAll(ids).OrderBy(c => ids.IndexOf(c.Id)).ToList(); } var basics = types.Select(Mapper.Map).ToList(); @@ -260,7 +260,7 @@ namespace Umbraco.Web.Editors basic.Description = TranslateItem(basic.Description); } - return basics.OrderBy(x => x.Name); + return basics.OrderBy(c => contentId == Constants.System.Root ? c.Name : string.Empty); } /// diff --git a/src/Umbraco.Web/Editors/TourController.cs b/src/Umbraco.Web/Editors/TourController.cs index 044ded82f1..b63a225f66 100644 --- a/src/Umbraco.Web/Editors/TourController.cs +++ b/src/Umbraco.Web/Editors/TourController.cs @@ -111,6 +111,39 @@ namespace Umbraco.Web.Editors return result.Except(toursToBeRemoved).OrderBy(x => x.FileName, StringComparer.InvariantCultureIgnoreCase); } + /// + /// Gets a tours for a specific doctype + /// + /// The documenttype alias + /// A + public IEnumerable GetToursForDoctype(string doctypeAlias) + { + var tourFiles = this.GetTours(); + + var doctypeAliasWithCompositions = new List + { + doctypeAlias + }; + + var contentType = this.Services.ContentTypeService.Get(doctypeAlias); + + if (contentType != null) + { + doctypeAliasWithCompositions.AddRange(contentType.CompositionAliases()); + } + + return tourFiles.SelectMany(x => x.Tours) + .Where(x => + { + if (string.IsNullOrEmpty(x.ContentType)) + { + return false; + } + var contentTypes = x.ContentType.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(ct => ct.Trim()); + return contentTypes.Intersect(doctypeAliasWithCompositions).Any(); + }); + } + private void TryParseTourFile(string tourFile, ICollection result, List filters, diff --git a/src/Umbraco.Web/Models/Mapping/ContentTypeMapDefinition.cs b/src/Umbraco.Web/Models/Mapping/ContentTypeMapDefinition.cs index 07b6801212..de64b15a0f 100644 --- a/src/Umbraco.Web/Models/Mapping/ContentTypeMapDefinition.cs +++ b/src/Umbraco.Web/Models/Mapping/ContentTypeMapDefinition.cs @@ -155,6 +155,7 @@ namespace Umbraco.Web.Models.Mapping //default listview target.ListViewEditorName = Constants.Conventions.DataTypes.ListViewPrefix + "Media"; + target.IsSystemMediaType = source.IsSystemMediaType(); if (string.IsNullOrEmpty(source.Name)) return; @@ -232,7 +233,7 @@ namespace Umbraco.Web.Models.Mapping } // Umbraco.Code.MapAll -CreateDate -DeleteDate -UpdateDate - // Umbraco.Code.MapAll -SupportsPublishing -Key -PropertyEditorAlias -ValueStorageType + // Umbraco.Code.MapAll -SupportsPublishing -Key -PropertyEditorAlias -ValueStorageType -Variations private static void Map(PropertyTypeBasic source, IPropertyType target, MapperContext context) { target.Name = source.Label; @@ -242,9 +243,7 @@ namespace Umbraco.Web.Models.Mapping target.MandatoryMessage = source.Validation.MandatoryMessage; target.ValidationRegExp = source.Validation.Pattern; target.ValidationRegExpMessage = source.Validation.PatternMessage; - target.Variations = source.AllowCultureVariant - ? target.Variations.SetFlag(ContentVariation.Culture) - : target.Variations.UnsetFlag(ContentVariation.Culture); + target.SetVariesBy(ContentVariation.Culture, source.AllowCultureVariant); if (source.Id > 0) target.Id = source.Id; @@ -386,7 +385,7 @@ namespace Umbraco.Web.Models.Mapping target.Validation = source.Validation; } - // Umbraco.Code.MapAll -CreatorId -Level -SortOrder + // Umbraco.Code.MapAll -CreatorId -Level -SortOrder -Variations // Umbraco.Code.MapAll -CreateDate -UpdateDate -DeleteDate // Umbraco.Code.MapAll -ContentTypeComposition (done by AfterMapSaveToType) private static void MapSaveToTypeBase(TSource source, IContentTypeComposition target, MapperContext context) @@ -416,9 +415,7 @@ namespace Umbraco.Web.Models.Mapping if (!(target is IMemberType)) { - target.Variations = source.AllowCultureVariant - ? target.Variations.SetFlag(ContentVariation.Culture) - : target.Variations.UnsetFlag(ContentVariation.Culture); + target.SetVariesBy(ContentVariation.Culture, source.AllowCultureVariant); } // handle property groups and property types @@ -515,7 +512,7 @@ namespace Umbraco.Web.Models.Mapping target.Udi = MapContentTypeUdi(source); target.UpdateDate = source.UpdateDate; - target.AllowedContentTypes = source.AllowedContentTypes.Select(x => x.Id.Value); + target.AllowedContentTypes = source.AllowedContentTypes.OrderBy(c => c.SortOrder).Select(x => x.Id.Value); target.CompositeContentTypes = source.ContentTypeComposition.Select(x => x.Alias); target.LockedCompositeContentTypes = MapLockedCompositions(source); } diff --git a/src/Umbraco.Web/PropertyEditors/NestedContentPropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/NestedContentPropertyEditor.cs index 5ed783a44c..f55e792189 100644 --- a/src/Umbraco.Web/PropertyEditors/NestedContentPropertyEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/NestedContentPropertyEditor.cs @@ -311,9 +311,19 @@ namespace Umbraco.Web.PropertyEditors if (row.PropType.Mandatory) { if (row.JsonRowValue[row.PropKey] == null) - validationResults.Add(new ValidationResult("Item " + (row.RowIndex + 1) + " '" + row.PropType.Name + "' cannot be null", new[] { row.PropKey })); + { + var message = string.IsNullOrWhiteSpace(row.PropType.MandatoryMessage) + ? $"'{row.PropType.Name}' cannot be null" + : row.PropType.MandatoryMessage; + validationResults.Add(new ValidationResult($"Item {(row.RowIndex + 1)}: {message}", new[] { row.PropKey })); + } else if (row.JsonRowValue[row.PropKey].ToString().IsNullOrWhiteSpace() || (row.JsonRowValue[row.PropKey].Type == JTokenType.Array && !row.JsonRowValue[row.PropKey].HasValues)) - validationResults.Add(new ValidationResult("Item " + (row.RowIndex + 1) + " '" + row.PropType.Name + "' cannot be empty", new[] { row.PropKey })); + { + var message = string.IsNullOrWhiteSpace(row.PropType.MandatoryMessage) + ? $"'{row.PropType.Name}' cannot be empty" + : row.PropType.MandatoryMessage; + validationResults.Add(new ValidationResult($"Item {(row.RowIndex + 1)}: {message}", new[] { row.PropKey })); + } } // Check regex @@ -323,7 +333,10 @@ namespace Umbraco.Web.PropertyEditors var regex = new Regex(row.PropType.ValidationRegExp); if (!regex.IsMatch(row.JsonRowValue[row.PropKey].ToString())) { - validationResults.Add(new ValidationResult("Item " + (row.RowIndex + 1) + " '" + row.PropType.Name + "' is invalid, it does not match the correct pattern", new[] { row.PropKey })); + var message = string.IsNullOrWhiteSpace(row.PropType.ValidationRegExpMessage) + ? $"'{row.PropType.Name}' is invalid, it does not match the correct pattern" + : row.PropType.ValidationRegExpMessage; + validationResults.Add(new ValidationResult($"Item {(row.RowIndex + 1)}: {message}", new[] { row.PropKey })); } } } diff --git a/src/Umbraco.Web/PublishedCache/NuCache/ContentNode.cs b/src/Umbraco.Web/PublishedCache/NuCache/ContentNode.cs index de750fb800..43c8e89d2e 100644 --- a/src/Umbraco.Web/PublishedCache/NuCache/ContentNode.cs +++ b/src/Umbraco.Web/PublishedCache/NuCache/ContentNode.cs @@ -112,6 +112,8 @@ namespace Umbraco.Web.PublishedCache.NuCache // everything that is common to both draft and published versions // keep this as small as possible + + public readonly int Id; public readonly Guid Uid; public IPublishedContentType ContentType; @@ -119,10 +121,14 @@ namespace Umbraco.Web.PublishedCache.NuCache public readonly string Path; public readonly int SortOrder; public readonly int ParentContentId; + + // TODO: Can we make everything readonly?? This would make it easier to debug and be less error prone especially for new developers. + // Once a Node is created and exists in the cache it is readonly so we should be able to make that happen at the API level too. public int FirstChildContentId; public int LastChildContentId; public int NextSiblingContentId; public int PreviousSiblingContentId; + public readonly DateTime CreateDate; public readonly int CreatorId; diff --git a/src/Umbraco.Web/PublishedCache/NuCache/ContentStore.cs b/src/Umbraco.Web/PublishedCache/NuCache/ContentStore.cs index 19be13b762..3ee0c3faa8 100644 --- a/src/Umbraco.Web/PublishedCache/NuCache/ContentStore.cs +++ b/src/Umbraco.Web/PublishedCache/NuCache/ContentStore.cs @@ -14,12 +14,24 @@ using Umbraco.Web.PublishedCache.NuCache.Snap; namespace Umbraco.Web.PublishedCache.NuCache { - // stores content + /// + /// Stores content in memory and persists it back to disk + /// + /// + /// + /// Methods in this class suffixed with the term "Locked" means that those methods can only be called within a WriteLock. A WriteLock + /// is acquired by the GetScopedWriteLock method. Locks are not allowed to be recursive. + /// + /// + /// This class's logic is based on the class but has been slightly modified to suit these purposes. + /// + /// internal class ContentStore { // this class is an extended version of SnapDictionary // most of the snapshots management code, etc is an exact copy // SnapDictionary has unit tests to ensure it all works correctly + // For locking information, see SnapDictionary private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor; private readonly IVariationContextAccessor _variationContextAccessor; @@ -39,7 +51,6 @@ namespace Umbraco.Web.PublishedCache.NuCache private long _liveGen, _floorGen; private bool _nextGen, _collectAuto; private Task _collectTask; - private volatile int _wlocked; private List> _wchanges; // TODO: collection trigger (ok for now) @@ -82,15 +93,9 @@ namespace Umbraco.Web.PublishedCache.NuCache private readonly string _instanceId = Guid.NewGuid().ToString("N"); - private class ReadLockInfo - { - public bool Taken; - } - private class WriteLockInfo { public bool Taken; - public bool Count; } // a scope contextual that represents a locked writer to the dictionary @@ -121,22 +126,26 @@ namespace Umbraco.Web.PublishedCache.NuCache return ScopeContextualBase.Get(scopeProvider, _instanceId, scoped => new ScopedWriteLock(this, scoped)); } + private void EnsureLocked() + { + if (!Monitor.IsEntered(_wlocko)) + throw new InvalidOperationException("Write lock must be acquried."); + } + private void Lock(WriteLockInfo lockInfo, bool forceGen = false) { + if (Monitor.IsEntered(_wlocko)) + throw new InvalidOperationException("Recursive locks not allowed"); + Monitor.Enter(_wlocko, ref lockInfo.Taken); - var rtaken = false; - try + lock(_rlocko) { - Monitor.Enter(_rlocko, ref rtaken); - // see SnapDictionary try { } finally { - _wlocked++; - lockInfo.Count = true; - if (_nextGen == false || (forceGen && _wlocked == 1)) + if (_nextGen == false || (forceGen)) { // because we are changing things, a new generation // is created, which will trigger a new snapshot @@ -147,62 +156,48 @@ namespace Umbraco.Web.PublishedCache.NuCache } } } - finally - { - if (rtaken) Monitor.Exit(_rlocko); - } - } - - private void Lock(ReadLockInfo lockInfo) - { - Monitor.Enter(_rlocko, ref lockInfo.Taken); } private void Release(WriteLockInfo lockInfo, bool commit = true) { - if (commit == false) + try { - var rtaken = false; - try + if (commit == false) { - Monitor.Enter(_rlocko, ref rtaken); - try { } - finally + lock (_rlocko) { - _nextGen = false; - _liveGen -= 1; + // see SnapDictionary + try { } + finally + { + _nextGen = false; + _liveGen -= 1; + } } - } - finally - { - if (rtaken) Monitor.Exit(_rlocko); - } - Rollback(_contentNodes); - RollbackRoot(); - Rollback(_contentTypesById); - Rollback(_contentTypesByAlias); + Rollback(_contentNodes); + RollbackRoot(); + Rollback(_contentTypesById); + Rollback(_contentTypesByAlias); + } + else if (_localDb != null && _wchanges != null) + { + foreach (var change in _wchanges) + { + if (change.Value.IsNull) + _localDb.TryRemove(change.Key, out ContentNodeKit unused); + else + _localDb[change.Key] = change.Value; + } + _wchanges = null; + _localDb.Commit(); + } } - else if (_localDb != null && _wchanges != null) + finally { - foreach (var change in _wchanges) - { - if (change.Value.IsNull) - _localDb.TryRemove(change.Key, out ContentNodeKit unused); - else - _localDb[change.Key] = change.Value; - } - _wchanges = null; - _localDb.Commit(); + if (lockInfo.Taken) + Monitor.Exit(_wlocko); } - - if (lockInfo.Count) _wlocked--; - if (lockInfo.Taken) Monitor.Exit(_wlocko); - } - - private void Release(ReadLockInfo lockInfo) - { - if (lockInfo.Taken) Monitor.Exit(_rlocko); } private void RollbackRoot() @@ -249,7 +244,11 @@ namespace Umbraco.Web.PublishedCache.NuCache { _localDb?.Dispose(); } - catch { /* TBD: May already be throwing so don't throw again */} + catch (Exception ex) + { + /* TBD: May already be throwing so don't throw again */ + _logger.Error(ex, "Error trying to release DB"); + } finally { _localDb = null; @@ -257,6 +256,11 @@ namespace Umbraco.Web.PublishedCache.NuCache } } + catch (Exception ex) + { + _logger.Error(ex, "Error trying to lock"); + throw; + } finally { Release(lockInfo); @@ -273,87 +277,111 @@ namespace Umbraco.Web.PublishedCache.NuCache #region Content types - public void NewContentTypes(IEnumerable types) + /// + /// Sets data for new content types + /// + /// + /// + /// This methods MUST be called from within a write lock, normally wrapped within GetScopedWriteLock + /// otherwise an exception will occur. + /// + /// + /// Thrown if this method is not called within a write lock + /// + public void NewContentTypesLocked(IEnumerable types) { - var lockInfo = new WriteLockInfo(); - try - { - Lock(lockInfo); + EnsureLocked(); - foreach (var type in types) - { - SetValueLocked(_contentTypesById, type.Id, type); - SetValueLocked(_contentTypesByAlias, type.Alias, type); - } - } - finally + foreach (var type in types) { - Release(lockInfo); + SetValueLocked(_contentTypesById, type.Id, type); + SetValueLocked(_contentTypesByAlias, type.Alias, type); } } - public void UpdateContentTypes(IEnumerable types) + /// + /// Sets data for updated content types + /// + /// + /// + /// This methods MUST be called from within a write lock, normally wrapped within GetScopedWriteLock + /// otherwise an exception will occur. + /// + /// + /// Thrown if this method is not called within a write lock + /// + public void UpdateContentTypesLocked(IEnumerable types) { //nothing to do if this is empty, no need to lock/allocate/iterate/etc... if (!types.Any()) return; - var lockInfo = new WriteLockInfo(); - try + EnsureLocked(); + + var index = types.ToDictionary(x => x.Id, x => x); + + foreach (var type in index.Values) { - Lock(lockInfo); + SetValueLocked(_contentTypesById, type.Id, type); + SetValueLocked(_contentTypesByAlias, type.Alias, type); + } - var index = types.ToDictionary(x => x.Id, x => x); - - foreach (var type in index.Values) - { - SetValueLocked(_contentTypesById, type.Id, type); - SetValueLocked(_contentTypesByAlias, type.Alias, type); - } - - foreach (var link in _contentNodes.Values) - { - var node = link.Value; - if (node == null) continue; - var contentTypeId = node.ContentType.Id; - if (index.TryGetValue(contentTypeId, out var contentType) == false) continue; + foreach (var link in _contentNodes.Values) + { + var node = link.Value; + if (node == null) continue; + var contentTypeId = node.ContentType.Id; + if (index.TryGetValue(contentTypeId, out var contentType) == false) continue; SetValueLocked(_contentNodes, node.Id, new ContentNode(node, _publishedModelFactory, contentType)); - } - } - finally - { - Release(lockInfo); } } - public void SetAllContentTypes(IEnumerable types) + /// + /// Updates/sets data for all content types + /// + /// + /// + /// This methods MUST be called from within a write lock, normally wrapped within GetScopedWriteLock + /// otherwise an exception will occur. + /// + /// + /// Thrown if this method is not called within a write lock + /// + public void SetAllContentTypesLocked(IEnumerable types) { - var lockInfo = new WriteLockInfo(); - try + EnsureLocked(); + + // clear all existing content types + ClearLocked(_contentTypesById); + ClearLocked(_contentTypesByAlias); + + // set all new content types + foreach (var type in types) { - Lock(lockInfo); - - // clear all existing content types - ClearLocked(_contentTypesById); - ClearLocked(_contentTypesByAlias); - - // set all new content types - foreach (var type in types) - { - SetValueLocked(_contentTypesById, type.Id, type); - SetValueLocked(_contentTypesByAlias, type.Alias, type); - } - - // beware! at that point the cache is inconsistent, - // assuming we are going to SetAll content items! - } - finally - { - Release(lockInfo); + SetValueLocked(_contentTypesById, type.Id, type); + SetValueLocked(_contentTypesByAlias, type.Alias, type); } + + // beware! at that point the cache is inconsistent, + // assuming we are going to SetAll content items! } - public void UpdateContentTypes(IReadOnlyCollection removedIds, IReadOnlyCollection refreshedTypes, IReadOnlyCollection kits) + /// + /// Updates/sets/removes data for content types + /// + /// + /// + /// + /// + /// This methods MUST be called from within a write lock, normally wrapped within GetScopedWriteLock + /// otherwise an exception will occur. + /// + /// + /// Thrown if this method is not called within a write lock + /// + public void UpdateContentTypesLocked(IReadOnlyCollection removedIds, IReadOnlyCollection refreshedTypes, IReadOnlyCollection kits) { + EnsureLocked(); + var removedIdsA = removedIds ?? Array.Empty(); var refreshedTypesA = refreshedTypes ?? Array.Empty(); var refreshedIdsA = refreshedTypesA.Select(x => x.Id).ToList(); @@ -362,85 +390,84 @@ namespace Umbraco.Web.PublishedCache.NuCache if (kits.Count == 0 && refreshedIdsA.Count == 0 && removedIdsA.Count == 0) return; //exit - there is nothing to do here - var lockInfo = new WriteLockInfo(); - try + var removedContentTypeNodes = new List(); + var refreshedContentTypeNodes = new List(); + + // find all the nodes that are either refreshed or removed, + // because of their content type being either refreshed or removed + foreach (var link in _contentNodes.Values) { - Lock(lockInfo); - - var removedContentTypeNodes = new List(); - var refreshedContentTypeNodes = new List(); - - // find all the nodes that are either refreshed or removed, - // because of their content type being either refreshed or removed - foreach (var link in _contentNodes.Values) - { - var node = link.Value; - if (node == null) continue; - var contentTypeId = node.ContentType.Id; - if (removedIdsA.Contains(contentTypeId)) removedContentTypeNodes.Add(node.Id); - if (refreshedIdsA.Contains(contentTypeId)) refreshedContentTypeNodes.Add(node.Id); - } - - // perform deletion of content with removed content type - // removing content types should have removed their content already - // but just to be 100% sure, clear again here - foreach (var node in removedContentTypeNodes) - ClearBranchLocked(node); - - // perform deletion of removed content types - foreach (var id in removedIdsA) - { - if (_contentTypesById.TryGetValue(id, out var link) == false || link.Value == null) - continue; - SetValueLocked(_contentTypesById, id, null); - SetValueLocked(_contentTypesByAlias, link.Value.Alias, null); - } - - // perform update of refreshed content types - foreach (var type in refreshedTypesA) - { - SetValueLocked(_contentTypesById, type.Id, type); - SetValueLocked(_contentTypesByAlias, type.Alias, type); - } - - // perform update of content with refreshed content type - from the kits - // skip missing type, skip missing parents & un-buildable kits - what else could we do? - // kits are ordered by level, so ParentExists is ok here - var visited = new List(); - foreach (var kit in kits.Where(x => - refreshedIdsA.Contains(x.ContentTypeId) && - BuildKit(x, out _))) - { - // replacing the node: must preserve the parents - var node = GetHead(_contentNodes, kit.Node.Id)?.Value; - if (node != null) - kit.Node.FirstChildContentId = node.FirstChildContentId; - - SetValueLocked(_contentNodes, kit.Node.Id, kit.Node); - - visited.Add(kit.Node.Id); - if (_localDb != null) RegisterChange(kit.Node.Id, kit); - } - - // all content should have been refreshed - but... - var orphans = refreshedContentTypeNodes.Except(visited); - foreach (var id in orphans) - ClearBranchLocked(id); + var node = link.Value; + if (node == null) continue; + var contentTypeId = node.ContentType.Id; + if (removedIdsA.Contains(contentTypeId)) removedContentTypeNodes.Add(node.Id); + if (refreshedIdsA.Contains(contentTypeId)) refreshedContentTypeNodes.Add(node.Id); } - finally + + // perform deletion of content with removed content type + // removing content types should have removed their content already + // but just to be 100% sure, clear again here + foreach (var node in removedContentTypeNodes) + ClearBranchLocked(node); + + // perform deletion of removed content types + foreach (var id in removedIdsA) { - Release(lockInfo); + if (_contentTypesById.TryGetValue(id, out var link) == false || link.Value == null) + continue; + SetValueLocked(_contentTypesById, id, null); + SetValueLocked(_contentTypesByAlias, link.Value.Alias, null); } + + // perform update of refreshed content types + foreach (var type in refreshedTypesA) + { + SetValueLocked(_contentTypesById, type.Id, type); + SetValueLocked(_contentTypesByAlias, type.Alias, type); + } + + // perform update of content with refreshed content type - from the kits + // skip missing type, skip missing parents & un-buildable kits - what else could we do? + // kits are ordered by level, so ParentExists is ok here + var visited = new List(); + foreach (var kit in kits.Where(x => + refreshedIdsA.Contains(x.ContentTypeId) && + BuildKit(x, out _))) + { + // replacing the node: must preserve the parents + var node = GetHead(_contentNodes, kit.Node.Id)?.Value; + if (node != null) + kit.Node.FirstChildContentId = node.FirstChildContentId; + + SetValueLocked(_contentNodes, kit.Node.Id, kit.Node); + + visited.Add(kit.Node.Id); + if (_localDb != null) RegisterChange(kit.Node.Id, kit); + } + + // all content should have been refreshed - but... + var orphans = refreshedContentTypeNodes.Except(visited); + foreach (var id in orphans) + ClearBranchLocked(id); } - public void UpdateDataTypes(IEnumerable dataTypeIds, Func getContentType) + /// + /// Updates data types + /// + /// + /// + /// + /// This methods MUST be called from within a write lock, normally wrapped within GetScopedWriteLock + /// otherwise an exception will occur. + /// + /// + /// Thrown if this method is not called within a write lock + /// + public void UpdateDataTypesLocked(IEnumerable dataTypeIds, Func getContentType) { - var lockInfo = new WriteLockInfo(); - try - { - Lock(lockInfo); + EnsureLocked(); - var contentTypes = _contentTypesById + var contentTypes = _contentTypesById .Where(kvp => kvp.Value.Value != null && kvp.Value.Value.PropertyTypes.Any(p => dataTypeIds.Contains(p.DataType.Id))) @@ -449,37 +476,32 @@ namespace Umbraco.Web.PublishedCache.NuCache .Where(x => x != null) // poof, gone, very unlikely and probably an anomaly .ToArray(); - var contentTypeIdsA = contentTypes.Select(x => x.Id).ToArray(); - var contentTypeNodes = new Dictionary>(); - foreach (var id in contentTypeIdsA) - contentTypeNodes[id] = new List(); - foreach (var link in _contentNodes.Values) - { - var node = link.Value; - if (node != null && contentTypeIdsA.Contains(node.ContentType.Id)) - contentTypeNodes[node.ContentType.Id].Add(node.Id); - } - - foreach (var contentType in contentTypes) - { - // again, weird situation - if (contentTypeNodes.ContainsKey(contentType.Id) == false) - continue; - - foreach (var id in contentTypeNodes[contentType.Id]) - { - _contentNodes.TryGetValue(id, out var link); - if (link?.Value == null) - continue; - var node = new ContentNode(link.Value, _publishedModelFactory, contentType); - SetValueLocked(_contentNodes, id, node); - if (_localDb != null) RegisterChange(id, node.ToKit()); - } - } - } - finally + var contentTypeIdsA = contentTypes.Select(x => x.Id).ToArray(); + var contentTypeNodes = new Dictionary>(); + foreach (var id in contentTypeIdsA) + contentTypeNodes[id] = new List(); + foreach (var link in _contentNodes.Values) { - Release(lockInfo); + var node = link.Value; + if (node != null && contentTypeIdsA.Contains(node.ContentType.Id)) + contentTypeNodes[node.ContentType.Id].Add(node.Id); + } + + foreach (var contentType in contentTypes) + { + // again, weird situation + if (contentTypeNodes.ContainsKey(contentType.Id) == false) + continue; + + foreach (var id in contentTypeNodes[contentType.Id]) + { + _contentNodes.TryGetValue(id, out var link); + if (link?.Value == null) + continue; + var node = new ContentNode(link.Value, _publishedModelFactory, contentType); + SetValueLocked(_contentNodes, id, node); + if (_localDb != null) RegisterChange(id, node.ToKit()); + } } } @@ -537,8 +559,22 @@ namespace Umbraco.Web.PublishedCache.NuCache return link; } - public bool Set(ContentNodeKit kit) + /// + /// Sets the data for a + /// + /// + /// + /// + /// This methods MUST be called from within a write lock, normally wrapped within GetScopedWriteLock + /// otherwise an exception will occur. + /// + /// + /// Thrown if this method is not called within a write lock + /// + public bool SetLocked(ContentNodeKit kit) { + EnsureLocked(); + // ReSharper disable LocalizableElement if (kit.IsEmpty) throw new ArgumentException("Kit is empty.", nameof(kit)); @@ -548,20 +584,15 @@ namespace Umbraco.Web.PublishedCache.NuCache _logger.Debug("Set content ID: {KitNodeId}", kit.Node.Id); - var lockInfo = new WriteLockInfo(); - try - { - Lock(lockInfo); + // get existing + _contentNodes.TryGetValue(kit.Node.Id, out var link); + var existing = link?.Value; - // get existing - _contentNodes.TryGetValue(kit.Node.Id, out var link); - var existing = link?.Value; + if (!BuildKit(kit, out var parent)) + return false; - if (!BuildKit(kit, out var parent)) - return false; - - // moving? - var moving = existing != null && existing.ParentContentId != kit.Node.ParentContentId; + // moving? + var moving = existing != null && existing.ParentContentId != kit.Node.ParentContentId; // manage children if (existing != null) @@ -570,42 +601,37 @@ namespace Umbraco.Web.PublishedCache.NuCache kit.Node.LastChildContentId = existing.LastChildContentId; } - // set - SetValueLocked(_contentNodes, kit.Node.Id, kit.Node); - if (_localDb != null) RegisterChange(kit.Node.Id, kit); + // set + SetValueLocked(_contentNodes, kit.Node.Id, kit.Node); + if (_localDb != null) RegisterChange(kit.Node.Id, kit); - // manage the tree - if (existing == null) - { - // new, add to parent - AddTreeNodeLocked(kit.Node, parent); - } - else if (moving || existing.SortOrder != kit.Node.SortOrder) - { - // moved, remove existing from its parent, add content to its parent - RemoveTreeNodeLocked(existing); - AddTreeNodeLocked(kit.Node); - } - else - { - // replacing existing, handle siblings - kit.Node.NextSiblingContentId = existing.NextSiblingContentId; - kit.Node.PreviousSiblingContentId = existing.PreviousSiblingContentId; - } - - _xmap[kit.Node.Uid] = kit.Node.Id; - } - finally + // manage the tree + if (existing == null) { - Release(lockInfo); + // new, add to parent + AddTreeNodeLocked(kit.Node, parent); } + else if (moving || existing.SortOrder != kit.Node.SortOrder) + { + // moved, remove existing from its parent, add content to its parent + RemoveTreeNodeLocked(existing); + AddTreeNodeLocked(kit.Node); + } + else + { + // replacing existing, handle siblings + kit.Node.NextSiblingContentId = existing.NextSiblingContentId; + kit.Node.PreviousSiblingContentId = existing.PreviousSiblingContentId; + } + + _xmap[kit.Node.Uid] = kit.Node.Id; return true; } private void ClearRootLocked() { - if (_root.Gen < _liveGen) + if (_root.Gen != _liveGen) _root = new LinkedNode(new ContentNode(), _liveGen, _root); else _root.Value.FirstChildContentId = -1; @@ -620,195 +646,216 @@ namespace Umbraco.Web.PublishedCache.NuCache /// True if the data is coming from the database (not the local cache db) /// /// + /// /// This requires that the collection is sorted by Level + ParentId + Sort Order. /// This should be used only on a site startup as the first generations. /// This CANNOT be used after startup since it bypasses all checks for Generations. + /// + /// + /// This methods MUST be called from within a write lock, normally wrapped within GetScopedWriteLock + /// otherwise an exception will occur. + /// /// - internal bool SetAllFastSorted(IEnumerable kits, bool fromDb) + /// + /// Thrown if this method is not called within a write lock + /// + public bool SetAllFastSortedLocked(IEnumerable kits, bool fromDb) { - var lockInfo = new WriteLockInfo(); + EnsureLocked(); + var ok = true; - try + + ClearLocked(_contentNodes); + ClearRootLocked(); + + // The name of the game here is to populate each kit's + // FirstChildContentId + // LastChildContentId + // NextSiblingContentId + // PreviousSiblingContentId + + ContentNode previousNode = null; + ContentNode parent = null; + + foreach (var kit in kits) { - Lock(lockInfo); - - ClearLocked(_contentNodes); - ClearRootLocked(); - - // The name of the game here is to populate each kit's - // FirstChildContentId - // LastChildContentId - // NextSiblingContentId - // PreviousSiblingContentId - - ContentNode previousNode = null; - ContentNode parent = null; - - foreach (var kit in kits) + if (!BuildKit(kit, out var parentLink)) { - if (!BuildKit(kit, out var parentLink)) - { - ok = false; - continue; // skip that one - } - - var thisNode = kit.Node; - - if (parent == null) - { - // first parent - parent = parentLink.Value; - parent.FirstChildContentId = thisNode.Id; // this node is the first node - } - else if (parent.Id != parentLink.Value.Id) - { - // new parent - parent = parentLink.Value; - parent.FirstChildContentId = thisNode.Id; // this node is the first node - previousNode = null; // there is no previous sibling - } - - _logger.Debug($"Set {thisNode.Id} with parent {thisNode.ParentContentId}"); - SetValueLocked(_contentNodes, thisNode.Id, thisNode); - - // if we are initializing from the database source ensure the local db is updated - if (fromDb && _localDb != null) RegisterChange(thisNode.Id, kit); - - // this node is always the last child - parent.LastChildContentId = thisNode.Id; - - // wire previous node as previous sibling - if (previousNode != null) - { - previousNode.NextSiblingContentId = thisNode.Id; - thisNode.PreviousSiblingContentId = previousNode.Id; - } - - // this node becomes the previous node - previousNode = thisNode; - - _xmap[kit.Node.Uid] = kit.Node.Id; + ok = false; + continue; // skip that one } - } - finally - { - Release(lockInfo); + + var thisNode = kit.Node; + + if (parent == null) + { + // first parent + parent = parentLink.Value; + parent.FirstChildContentId = thisNode.Id; // this node is the first node + } + else if (parent.Id != parentLink.Value.Id) + { + // new parent + parent = parentLink.Value; + parent.FirstChildContentId = thisNode.Id; // this node is the first node + previousNode = null; // there is no previous sibling + } + + _logger.Debug($"Set {thisNode.Id} with parent {thisNode.ParentContentId}"); + SetValueLocked(_contentNodes, thisNode.Id, thisNode); + + // if we are initializing from the database source ensure the local db is updated + if (fromDb && _localDb != null) RegisterChange(thisNode.Id, kit); + + // this node is always the last child + parent.LastChildContentId = thisNode.Id; + + // wire previous node as previous sibling + if (previousNode != null) + { + previousNode.NextSiblingContentId = thisNode.Id; + thisNode.PreviousSiblingContentId = previousNode.Id; + } + + // this node becomes the previous node + previousNode = thisNode; + + _xmap[kit.Node.Uid] = kit.Node.Id; } return ok; } - public bool SetAll(IEnumerable kits) + /// + /// Set all data for a collection of + /// + /// + /// + /// + /// This methods MUST be called from within a write lock, normally wrapped within GetScopedWriteLock + /// otherwise an exception will occur. + /// + /// + /// Thrown if this method is not called within a write lock + /// + public bool SetAllLocked(IEnumerable kits) { - var lockInfo = new WriteLockInfo(); + EnsureLocked(); + var ok = true; - try + + ClearLocked(_contentNodes); + ClearRootLocked(); + + // do NOT clear types else they are gone! + //ClearLocked(_contentTypesById); + //ClearLocked(_contentTypesByAlias); + + foreach (var kit in kits) { - Lock(lockInfo); - - ClearLocked(_contentNodes); - ClearRootLocked(); - - // do NOT clear types else they are gone! - //ClearLocked(_contentTypesById); - //ClearLocked(_contentTypesByAlias); - - foreach (var kit in kits) + if (!BuildKit(kit, out var parent)) { - if (!BuildKit(kit, out var parent)) - { - ok = false; - continue; // skip that one - } - _logger.Debug($"Set {kit.Node.Id} with parent {kit.Node.ParentContentId}"); - SetValueLocked(_contentNodes, kit.Node.Id, kit.Node); - - if (_localDb != null) RegisterChange(kit.Node.Id, kit); - AddTreeNodeLocked(kit.Node, parent); - - _xmap[kit.Node.Uid] = kit.Node.Id; + ok = false; + continue; // skip that one } - } - finally - { - Release(lockInfo); + _logger.Debug($"Set {kit.Node.Id} with parent {kit.Node.ParentContentId}"); + SetValueLocked(_contentNodes, kit.Node.Id, kit.Node); + + if (_localDb != null) RegisterChange(kit.Node.Id, kit); + AddTreeNodeLocked(kit.Node, parent); + + _xmap[kit.Node.Uid] = kit.Node.Id; } return ok; } - // IMPORTANT kits must be sorted out by LEVEL and by SORT ORDER - public bool SetBranch(int rootContentId, IEnumerable kits) + /// + /// Sets data for a branch of + /// + /// + /// + /// + /// + /// + /// IMPORTANT kits must be sorted out by LEVEL and by SORT ORDER + /// + /// + /// This methods MUST be called from within a write lock, normally wrapped within GetScopedWriteLock + /// otherwise an exception will occur. + /// + /// + /// + /// Thrown if this method is not called within a write lock + /// + public bool SetBranchLocked(int rootContentId, IEnumerable kits) { - var lockInfo = new WriteLockInfo(); + EnsureLocked(); + var ok = true; - try + + // get existing + _contentNodes.TryGetValue(rootContentId, out var link); + var existing = link?.Value; + + // clear + if (existing != null) { - Lock(lockInfo); - - // get existing - _contentNodes.TryGetValue(rootContentId, out var link); - var existing = link?.Value; - - // clear - if (existing != null) - { - //this zero's out the branch (recursively), if we're in a new gen this will add a NULL placeholder for the gen - ClearBranchLocked(existing); - //TODO: This removes the current GEN from the tree - do we really want to do that? - RemoveTreeNodeLocked(existing); - } - - // now add them all back - foreach (var kit in kits) - { - if (!BuildKit(kit, out var parent)) - { - ok = false; - continue; // skip that one - } - SetValueLocked(_contentNodes, kit.Node.Id, kit.Node); - if (_localDb != null) RegisterChange(kit.Node.Id, kit); - AddTreeNodeLocked(kit.Node, parent); - - _xmap[kit.Node.Uid] = kit.Node.Id; - } + //this zero's out the branch (recursively), if we're in a new gen this will add a NULL placeholder for the gen + ClearBranchLocked(existing); + //TODO: This removes the current GEN from the tree - do we really want to do that? + RemoveTreeNodeLocked(existing); } - finally + + // now add them all back + foreach (var kit in kits) { - Release(lockInfo); + if (!BuildKit(kit, out var parent)) + { + ok = false; + continue; // skip that one + } + SetValueLocked(_contentNodes, kit.Node.Id, kit.Node); + if (_localDb != null) RegisterChange(kit.Node.Id, kit); + AddTreeNodeLocked(kit.Node, parent); + + _xmap[kit.Node.Uid] = kit.Node.Id; } return ok; } - public bool Clear(int id) + /// + /// Clears data for a given node id + /// + /// + /// + /// + /// This methods MUST be called from within a write lock, normally wrapped within GetScopedWriteLock + /// otherwise an exception will occur. + /// + /// + /// Thrown if this method is not called within a write lock + /// + public bool ClearLocked(int id) { - var lockInfo = new WriteLockInfo(); - try - { - Lock(lockInfo); + EnsureLocked(); - // try to find the content - // if it is not there, nothing to do - _contentNodes.TryGetValue(id, out var link); // else null - if (link?.Value == null) return false; + // try to find the content + // if it is not there, nothing to do + _contentNodes.TryGetValue(id, out var link); // else null + if (link?.Value == null) return false; - var content = link.Value; - _logger.Debug("Clear content ID: {ContentId}", content.Id); + var content = link.Value; + _logger.Debug("Clear content ID: {ContentId}", content.Id); - // clear the entire branch - ClearBranchLocked(content); + // clear the entire branch + ClearBranchLocked(content); - // manage the tree - RemoveTreeNodeLocked(content); + // manage the tree + RemoveTreeNodeLocked(content); - return true; - } - finally - { - Release(lockInfo); - } + return true; } private void ClearBranchLocked(int id) @@ -902,8 +949,18 @@ namespace Umbraco.Web.PublishedCache.NuCache return link; } + /// + /// This removes this current node from the tree hiearchy by removing it from it's parent's linked list + /// + /// + /// + /// This is called within a lock which means a new Gen is being created therefore this will not modify any existing content in a Gen. + /// private void RemoveTreeNodeLocked(ContentNode content) { + // NOTE: DO NOT modify `content` here, this would modify data for an existing Gen, all modifications are done to clones + // which would be targeting the new Gen. + var parentLink = content.ParentContentId < 0 ? _root : GetRequiredLinkedNode(content.ParentContentId, "parent", null); @@ -940,9 +997,6 @@ namespace Umbraco.Web.PublishedCache.NuCache var prev = GenCloneLocked(prevLink); prev.NextSiblingContentId = content.NextSiblingContentId; } - - content.NextSiblingContentId = -1; - content.PreviousSiblingContentId = -1; } private bool ParentPublishedLocked(ContentNodeKit kit) @@ -958,7 +1012,7 @@ namespace Umbraco.Web.PublishedCache.NuCache { var node = link.Value; - if (node != null && link.Gen < _liveGen) + if (node != null && link.Gen != _liveGen) { node = new ContentNode(link.Value, _publishedModelFactory); if (link == _root) @@ -1112,7 +1166,7 @@ namespace Umbraco.Web.PublishedCache.NuCache // this is safe only because we're write-locked foreach (var kvp in dict.Where(x => x.Value != null)) { - if (kvp.Value.Gen < _liveGen) + if (kvp.Value.Gen != _liveGen) { var link = new LinkedNode(null, _liveGen, kvp.Value); dict.TryUpdate(kvp.Key, link, kvp.Value); @@ -1200,11 +1254,8 @@ namespace Umbraco.Web.PublishedCache.NuCache public Snapshot CreateSnapshot() { - var lockInfo = new ReadLockInfo(); - try + lock(_rlocko) { - Lock(lockInfo); - // if no next generation is required, and we already have one, // use it and create a new snapshot if (_nextGen == false && _genObj != null) @@ -1217,7 +1268,7 @@ namespace Umbraco.Web.PublishedCache.NuCache // else we need to try to create a new gen ref // whether we are wlocked or not, noone can rlock while we do, // so _liveGen and _nextGen are safe - if (_wlocked > 0) // volatile, cannot ++ but could -- + if (Monitor.IsEntered(_wlocko)) { // write-locked, cannot use latest gen (at least 1) so use previous var snapGen = _nextGen ? _liveGen - 1 : _liveGen; @@ -1256,10 +1307,6 @@ namespace Umbraco.Web.PublishedCache.NuCache return snapshot; } - finally - { - Release(lockInfo); - } } public Snapshot LiveSnapshot => new Snapshot(this, _liveGen @@ -1377,16 +1424,17 @@ namespace Umbraco.Web.PublishedCache.NuCache } } - public async Task WaitForPendingCollect() - { - Task task; - lock (_rlocko) - { - task = _collectTask; - } - if (task != null) - await task; - } + // TODO: This is never used? Should it be? Maybe move to TestHelper below? + //public async Task WaitForPendingCollect() + //{ + // Task task; + // lock (_rlocko) + // { + // task = _collectTask; + // } + // if (task != null) + // await task; + //} public long GenCount => _genObjs.Count; diff --git a/src/Umbraco.Web/PublishedCache/NuCache/PublishedSnapshotService.cs b/src/Umbraco.Web/PublishedCache/NuCache/PublishedSnapshotService.cs index 7990433ea2..dabf195284 100755 --- a/src/Umbraco.Web/PublishedCache/NuCache/PublishedSnapshotService.cs +++ b/src/Umbraco.Web/PublishedCache/NuCache/PublishedSnapshotService.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; +using System.Threading; using CSharpTest.Net.Collections; using Newtonsoft.Json; using Umbraco.Core; @@ -206,10 +207,15 @@ namespace Umbraco.Web.PublishedCache.NuCache /// private void MainDomRelease() { + _logger.Debug("Releasing from MainDom..."); + lock (_storesLock) { + _logger.Debug("Releasing content store..."); _contentStore?.ReleaseLocalDb(); //null check because we could shut down before being assigned _localContentDb = null; + + _logger.Debug("Releasing media store..."); _mediaStore?.ReleaseLocalDb(); //null check because we could shut down before being assigned _localMediaDb = null; @@ -393,7 +399,7 @@ namespace Umbraco.Web.PublishedCache.NuCache var contentTypes = _serviceContext.ContentTypeService.GetAll() .Select(x => _publishedContentTypeFactory.CreateContentType(x)); - _contentStore.SetAllContentTypes(contentTypes); + _contentStore.SetAllContentTypesLocked(contentTypes); using (_logger.TraceDuration("Loading content from database")) { @@ -404,7 +410,7 @@ namespace Umbraco.Web.PublishedCache.NuCache // IMPORTANT GetAllContentSources sorts kits by level + parentId + sortOrder var kits = _dataSource.GetAllContentSources(scope); - return onStartup ? _contentStore.SetAllFastSorted(kits, true) : _contentStore.SetAll(kits); + return onStartup ? _contentStore.SetAllFastSortedLocked(kits, true) : _contentStore.SetAllLocked(kits); } } @@ -412,7 +418,7 @@ namespace Umbraco.Web.PublishedCache.NuCache { var contentTypes = _serviceContext.ContentTypeService.GetAll() .Select(x => _publishedContentTypeFactory.CreateContentType(x)); - _contentStore.SetAllContentTypes(contentTypes); + _contentStore.SetAllContentTypesLocked(contentTypes); using (_logger.TraceDuration("Loading content from local cache file")) { @@ -464,7 +470,7 @@ namespace Umbraco.Web.PublishedCache.NuCache var mediaTypes = _serviceContext.MediaTypeService.GetAll() .Select(x => _publishedContentTypeFactory.CreateContentType(x)); - _mediaStore.SetAllContentTypes(mediaTypes); + _mediaStore.SetAllContentTypesLocked(mediaTypes); using (_logger.TraceDuration("Loading media from database")) { @@ -476,7 +482,7 @@ namespace Umbraco.Web.PublishedCache.NuCache _logger.Debug("Loading media from database..."); // IMPORTANT GetAllMediaSources sorts kits by level + parentId + sortOrder var kits = _dataSource.GetAllMediaSources(scope); - return onStartup ? _mediaStore.SetAllFastSorted(kits, true) : _mediaStore.SetAll(kits); + return onStartup ? _mediaStore.SetAllFastSortedLocked(kits, true) : _mediaStore.SetAllLocked(kits); } } @@ -484,7 +490,7 @@ namespace Umbraco.Web.PublishedCache.NuCache { var mediaTypes = _serviceContext.MediaTypeService.GetAll() .Select(x => _publishedContentTypeFactory.CreateContentType(x)); - _mediaStore.SetAllContentTypes(mediaTypes); + _mediaStore.SetAllContentTypesLocked(mediaTypes); using (_logger.TraceDuration("Loading media from local cache file")) { @@ -525,7 +531,7 @@ namespace Umbraco.Web.PublishedCache.NuCache return false; } - return onStartup ? store.SetAllFastSorted(kits, false) : store.SetAll(kits); + return onStartup ? store.SetAllFastSortedLocked(kits, false) : store.SetAllLocked(kits); } // keep these around - might be useful @@ -631,7 +637,7 @@ namespace Umbraco.Web.PublishedCache.NuCache .Where(x => x.RootContentId.HasValue && x.LanguageIsoCode.IsNullOrWhiteSpace() == false) .Select(x => new Domain(x.Id, x.DomainName, x.RootContentId.Value, CultureInfo.GetCultureInfo(x.LanguageIsoCode), x.IsWildcard))) { - _domainStore.Set(domain.Id, domain); + _domainStore.SetLocked(domain.Id, domain); } } @@ -679,10 +685,12 @@ namespace Umbraco.Web.PublishedCache.NuCache publishedChanged = publishedChanged2; } + if (draftChanged || publishedChanged) ((PublishedSnapshot)CurrentPublishedSnapshot)?.Resync(); } + // Calling this method means we have a lock on the contentStore (i.e. GetScopedWriteLock) private void NotifyLocked(IEnumerable payloads, out bool draftChanged, out bool publishedChanged) { publishedChanged = false; @@ -692,7 +700,7 @@ namespace Umbraco.Web.PublishedCache.NuCache // content (and content types) are read-locked while reading content // contentStore is wlocked (so readable, only no new views) // and it can be wlocked by 1 thread only at a time - // contentStore is write-locked during changes + // contentStore is write-locked during changes - see note above, calls to this method are wrapped in contentStore.GetScopedWriteLock foreach (var payload in payloads) { @@ -712,7 +720,7 @@ namespace Umbraco.Web.PublishedCache.NuCache if (payload.ChangeTypes.HasType(TreeChangeTypes.Remove)) { - if (_contentStore.Clear(payload.Id)) + if (_contentStore.ClearLocked(payload.Id)) draftChanged = publishedChanged = true; continue; } @@ -735,7 +743,7 @@ namespace Umbraco.Web.PublishedCache.NuCache // ?? should we do some RV check here? // IMPORTANT GetbranchContentSources sorts kits by level and by sort order var kits = _dataSource.GetBranchContentSources(scope, capture.Id); - _contentStore.SetBranch(capture.Id, kits); + _contentStore.SetBranchLocked(capture.Id, kits); } else { @@ -743,11 +751,11 @@ namespace Umbraco.Web.PublishedCache.NuCache var kit = _dataSource.GetContentSource(scope, capture.Id); if (kit.IsEmpty) { - _contentStore.Clear(capture.Id); + _contentStore.ClearLocked(capture.Id); } else { - _contentStore.Set(kit); + _contentStore.SetLocked(kit); } } @@ -805,7 +813,7 @@ namespace Umbraco.Web.PublishedCache.NuCache if (payload.ChangeTypes.HasType(TreeChangeTypes.Remove)) { - if (_mediaStore.Clear(payload.Id)) + if (_mediaStore.ClearLocked(payload.Id)) anythingChanged = true; continue; } @@ -828,7 +836,7 @@ namespace Umbraco.Web.PublishedCache.NuCache // ?? should we do some RV check here? // IMPORTANT GetbranchContentSources sorts kits by level and by sort order var kits = _dataSource.GetBranchMediaSources(scope, capture.Id); - _mediaStore.SetBranch(capture.Id, kits); + _mediaStore.SetBranchLocked(capture.Id, kits); } else { @@ -836,11 +844,11 @@ namespace Umbraco.Web.PublishedCache.NuCache var kit = _dataSource.GetMediaSource(scope, capture.Id); if (kit.IsEmpty) { - _mediaStore.Clear(capture.Id); + _mediaStore.ClearLocked(capture.Id); } else { - _mediaStore.Set(kit); + _mediaStore.SetLocked(kit); } } @@ -942,14 +950,14 @@ namespace Umbraco.Web.PublishedCache.NuCache using (var scope = _scopeProvider.CreateScope()) { scope.ReadLock(Constants.Locks.ContentTree); - _contentStore.UpdateDataTypes(idsA, id => CreateContentType(PublishedItemType.Content, id)); + _contentStore.UpdateDataTypesLocked(idsA, id => CreateContentType(PublishedItemType.Content, id)); scope.Complete(); } using (var scope = _scopeProvider.CreateScope()) { scope.ReadLock(Constants.Locks.MediaTree); - _mediaStore.UpdateDataTypes(idsA, id => CreateContentType(PublishedItemType.Media, id)); + _mediaStore.UpdateDataTypesLocked(idsA, id => CreateContentType(PublishedItemType.Media, id)); scope.Complete(); } } @@ -979,7 +987,7 @@ namespace Umbraco.Web.PublishedCache.NuCache } break; case DomainChangeTypes.Remove: - _domainStore.Clear(payload.Id); + _domainStore.ClearLocked(payload.Id); break; case DomainChangeTypes.Refresh: var domain = _serviceContext.DomainService.GetById(payload.Id); @@ -987,7 +995,7 @@ namespace Umbraco.Web.PublishedCache.NuCache if (domain.RootContentId.HasValue == false) continue; // anomaly if (domain.LanguageIsoCode.IsNullOrWhiteSpace()) continue; // anomaly var culture = CultureInfo.GetCultureInfo(domain.LanguageIsoCode); - _domainStore.Set(domain.Id, new Domain(domain.Id, domain.DomainName, domain.RootContentId.Value, culture, domain.IsWildcard)); + _domainStore.SetLocked(domain.Id, new Domain(domain.Id, domain.DomainName, domain.RootContentId.Value, culture, domain.IsWildcard)); break; } } @@ -1072,11 +1080,11 @@ namespace Umbraco.Web.PublishedCache.NuCache ? Array.Empty() : _dataSource.GetTypeContentSources(scope, refreshedIds).ToArray(); - _contentStore.UpdateContentTypes(removedIds, typesA, kits); + _contentStore.UpdateContentTypesLocked(removedIds, typesA, kits); if (!otherIds.IsCollectionEmpty()) - _contentStore.UpdateContentTypes(CreateContentTypes(PublishedItemType.Content, otherIds.ToArray())); + _contentStore.UpdateContentTypesLocked(CreateContentTypes(PublishedItemType.Content, otherIds.ToArray())); if (!newIds.IsCollectionEmpty()) - _contentStore.NewContentTypes(CreateContentTypes(PublishedItemType.Content, newIds.ToArray())); + _contentStore.NewContentTypesLocked(CreateContentTypes(PublishedItemType.Content, newIds.ToArray())); scope.Complete(); } } @@ -1103,11 +1111,11 @@ namespace Umbraco.Web.PublishedCache.NuCache ? Array.Empty() : _dataSource.GetTypeMediaSources(scope, refreshedIds).ToArray(); - _mediaStore.UpdateContentTypes(removedIds, typesA, kits); + _mediaStore.UpdateContentTypesLocked(removedIds, typesA, kits); if (!otherIds.IsCollectionEmpty()) - _mediaStore.UpdateContentTypes(CreateContentTypes(PublishedItemType.Media, otherIds.ToArray()).ToArray()); + _mediaStore.UpdateContentTypesLocked(CreateContentTypes(PublishedItemType.Media, otherIds.ToArray()).ToArray()); if (!newIds.IsCollectionEmpty()) - _mediaStore.NewContentTypes(CreateContentTypes(PublishedItemType.Media, newIds.ToArray()).ToArray()); + _mediaStore.NewContentTypesLocked(CreateContentTypes(PublishedItemType.Media, newIds.ToArray()).ToArray()); scope.Complete(); } } @@ -1178,12 +1186,14 @@ namespace Umbraco.Web.PublishedCache.NuCache // elements // just need to make sure nothing gets elements in another enlisted action... so using // a MaxValue to make sure this one runs last, and it should be ok + scopeContext.Enlist("Umbraco.Web.PublishedCache.NuCache.PublishedSnapshotService.Resync", () => this, (completed, svc) => { ((PublishedSnapshot)svc.CurrentPublishedSnapshot)?.Resync(); }, int.MaxValue); } + // create a new snapshot cache if snapshots are different gens if (contentSnap.Gen != _contentGen || mediaSnap.Gen != _mediaGen || domainSnap.Gen != _domainGen || _elementsCache == null) { @@ -1350,7 +1360,7 @@ namespace Umbraco.Web.PublishedCache.NuCache { //culture changed on an existing language var cultureChanged = e.SavedEntities.Any(x => !x.WasPropertyDirty(nameof(ILanguage.Id)) && x.WasPropertyDirty(nameof(ILanguage.IsoCode))); - if(cultureChanged) + if (cultureChanged) { RebuildContentDbCache(); } diff --git a/src/Umbraco.Web/PublishedCache/NuCache/SnapDictionary.cs b/src/Umbraco.Web/PublishedCache/NuCache/SnapDictionary.cs index 9671949ff0..c38940da25 100644 --- a/src/Umbraco.Web/PublishedCache/NuCache/SnapDictionary.cs +++ b/src/Umbraco.Web/PublishedCache/NuCache/SnapDictionary.cs @@ -22,6 +22,11 @@ namespace Umbraco.Web.PublishedCache.NuCache // This class is optimized for many readers, few writers // Readers are lock-free + // NOTE - we used to lock _rlocko the long hand way with Monitor.Enter(_rlocko, ref lockTaken) but this has + // been replaced with a normal c# lock because that's exactly how the normal c# lock works, + // see https://blogs.msdn.microsoft.com/ericlippert/2009/03/06/locks-and-exceptions-do-not-mix/ + // for the readlock, there's no reason here to use the long hand way. + private readonly ConcurrentDictionary> _items; private readonly ConcurrentQueue _genObjs; private GenObj _genObj; @@ -30,7 +35,6 @@ namespace Umbraco.Web.PublishedCache.NuCache private long _liveGen, _floorGen; private bool _nextGen, _collectAuto; private Task _collectTask; - private volatile int _wlocked; // minGenDelta to be adjusted // we may want to throttle collects even if delta is reached @@ -71,20 +75,14 @@ namespace Umbraco.Web.PublishedCache.NuCache // are all ignored - Release is private and meant to be invoked with 'commit' being false only // only on the outermost lock (by SnapDictionaryWriter) - // using (...) {} for locking is prone to nasty leaks in case of weird exceptions - // such as thread-abort or out-of-memory, but let's not worry about it now + // side note - using (...) {} for locking is prone to nasty leaks in case of weird exceptions + // such as thread-abort or out-of-memory, which is why we've moved away from the old using wrapper we had on locking. private readonly string _instanceId = Guid.NewGuid().ToString("N"); - private class ReadLockInfo - { - public bool Taken; - } - private class WriteLockInfo { public bool Taken; - public bool Count; } // a scope contextual that represents a locked writer to the dictionary @@ -92,8 +90,7 @@ namespace Umbraco.Web.PublishedCache.NuCache { private readonly WriteLockInfo _lockinfo = new WriteLockInfo(); private readonly SnapDictionary _dictionary; - private int _released; - + public ScopedWriteLock(SnapDictionary dictionary, bool scoped) { _dictionary = dictionary; @@ -102,8 +99,6 @@ namespace Umbraco.Web.PublishedCache.NuCache public override void Release(bool completed) { - if (Interlocked.CompareExchange(ref _released, 1, 0) != 0) - return; _dictionary.Release(_lockinfo, completed); } } @@ -117,28 +112,31 @@ namespace Umbraco.Web.PublishedCache.NuCache return ScopeContextualBase.Get(scopeProvider, _instanceId, scoped => new ScopedWriteLock(this, scoped)); } + private void EnsureLocked() + { + if (!Monitor.IsEntered(_wlocko)) + throw new InvalidOperationException("Write lock must be acquried."); + } + private void Lock(WriteLockInfo lockInfo, bool forceGen = false) { + if (Monitor.IsEntered(_wlocko)) + throw new InvalidOperationException("Recursive locks not allowed"); + Monitor.Enter(_wlocko, ref lockInfo.Taken); - var rtaken = false; - try + lock(_rlocko) { - Monitor.Enter(_rlocko, ref rtaken); - // assume everything in finally runs atomically // http://stackoverflow.com/questions/18501678/can-this-unexpected-behavior-of-prepareconstrainedregions-and-thread-abort-be-ex // http://joeduffyblog.com/2005/03/18/atomicity-and-asynchronous-exception-failures/ // http://joeduffyblog.com/2007/02/07/introducing-the-new-readerwriterlockslim-in-orcas/ // http://chabster.blogspot.fr/2013/12/readerwriterlockslim-fails-on-dual.html //RuntimeHelpers.PrepareConstrainedRegions(); - try { } finally + try { } + finally { - // increment the lock count, and register that this lock is counting - _wlocked++; - lockInfo.Count = true; - - if (_nextGen == false || (forceGen && _wlocked == 1)) + if (_nextGen == false || (forceGen)) { // because we are changing things, a new generation // is created, which will trigger a new snapshot @@ -149,15 +147,6 @@ namespace Umbraco.Web.PublishedCache.NuCache } } } - finally - { - if (rtaken) Monitor.Exit(_rlocko); - } - } - - private void Lock(ReadLockInfo lockInfo) - { - Monitor.Enter(_rlocko, ref lockInfo.Taken); } private void Release(WriteLockInfo lockInfo, bool commit = true) @@ -168,22 +157,17 @@ namespace Umbraco.Web.PublishedCache.NuCache if (commit == false) { - var rtaken = false; - try + lock(_rlocko) { - Monitor.Enter(_rlocko, ref rtaken); - try { } finally + try { } + finally { // forget about the temp. liveGen _nextGen = false; _liveGen -= 1; } } - finally - { - if (rtaken) Monitor.Exit(_rlocko); - } - + foreach (var item in _items) { var link = item.Value; @@ -197,16 +181,10 @@ namespace Umbraco.Web.PublishedCache.NuCache } } - // decrement the lock count, if counting, then exit the lock - if (lockInfo.Count) _wlocked--; + // TODO: Shouldn't this be in a finally block? Monitor.Exit(_wlocko); } - private void Release(ReadLockInfo lockInfo) - { - if (lockInfo.Taken) Monitor.Exit(_rlocko); - } - #endregion #region Set, Clear, Get, Has @@ -219,75 +197,59 @@ namespace Umbraco.Web.PublishedCache.NuCache return link; } - public void Set(TKey key, TValue value) + public void SetLocked(TKey key, TValue value) { - var lockInfo = new WriteLockInfo(); - try - { - Lock(lockInfo); + EnsureLocked(); - // this is safe only because we're write-locked - var link = GetHead(key); - if (link != null) + // this is safe only because we're write-locked + var link = GetHead(key); + if (link != null) + { + // already in the dict + if (link.Gen != _liveGen) { - // already in the dict - if (link.Gen != _liveGen) - { - // for an older gen - if value is different then insert a new - // link for the new gen, with the new value - if (link.Value != value) - _items.TryUpdate(key, new LinkedNode(value, _liveGen, link), link); - } - else - { - // for the live gen - we can fix the live gen - and remove it - // if value is null and there's no next gen - if (value == null && link.Next == null) - _items.TryRemove(key, out link); - else - link.Value = value; - } + // for an older gen - if value is different then insert a new + // link for the new gen, with the new value + if (link.Value != value) + _items.TryUpdate(key, new LinkedNode(value, _liveGen, link), link); } else { - _items.TryAdd(key, new LinkedNode(value, _liveGen)); - } - } - finally - { - Release(lockInfo); - } - } - - public void Clear(TKey key) - { - Set(key, null); - } - - public void Clear() - { - var lockInfo = new WriteLockInfo(); - try - { - Lock(lockInfo); - - // this is safe only because we're write-locked - foreach (var kvp in _items.Where(x => x.Value != null)) - { - if (kvp.Value.Gen < _liveGen) - { - var link = new LinkedNode(null, _liveGen, kvp.Value); - _items.TryUpdate(kvp.Key, link, kvp.Value); - } + // for the live gen - we can fix the live gen - and remove it + // if value is null and there's no next gen + if (value == null && link.Next == null) + _items.TryRemove(key, out link); else - { - kvp.Value.Value = null; - } + link.Value = value; } } - finally + else { - Release(lockInfo); + _items.TryAdd(key, new LinkedNode(value, _liveGen)); + } + } + + public void ClearLocked(TKey key) + { + SetLocked(key, null); + } + + public void ClearLocked() + { + EnsureLocked(); + + // this is safe only because we're write-locked + foreach (var kvp in _items.Where(x => x.Value != null)) + { + if (kvp.Value.Gen < _liveGen) + { + var link = new LinkedNode(null, _liveGen, kvp.Value); + _items.TryUpdate(kvp.Key, link, kvp.Value); + } + else + { + kvp.Value.Value = null; + } } } @@ -347,11 +309,8 @@ namespace Umbraco.Web.PublishedCache.NuCache public Snapshot CreateSnapshot() { - var lockInfo = new ReadLockInfo(); - try + lock(_rlocko) { - Lock(lockInfo); - // if no next generation is required, and we already have a gen object, // use it to create a new snapshot if (_nextGen == false && _genObj != null) @@ -360,7 +319,7 @@ namespace Umbraco.Web.PublishedCache.NuCache // else we need to try to create a new gen object // whether we are wlocked or not, noone can rlock while we do, // so _liveGen and _nextGen are safe - if (_wlocked > 0) // volatile, cannot ++ but could -- + if (Monitor.IsEntered(_wlocko)) { // write-locked, cannot use latest gen (at least 1) so use previous var snapGen = _nextGen ? _liveGen - 1 : _liveGen; @@ -398,10 +357,6 @@ namespace Umbraco.Web.PublishedCache.NuCache return snapshot; } - finally - { - Release(lockInfo); - } } public Task CollectAsync() @@ -496,17 +451,18 @@ namespace Umbraco.Web.PublishedCache.NuCache } } - public /*async*/ Task PendingCollect() - { - Task task; - lock (_rlocko) - { - task = _collectTask; - } - return task ?? Task.CompletedTask; - //if (task != null) - // await task; - } + // TODO: This is never used? Should it be? Maybe move to TestHelper below? + //public /*async*/ Task PendingCollect() + //{ + // Task task; + // lock (_rlocko) + // { + // task = _collectTask; + // } + // return task ?? Task.CompletedTask; + // //if (task != null) + // // await task; + //} public long GenCount => _genObjs.Count; @@ -531,7 +487,7 @@ namespace Umbraco.Web.PublishedCache.NuCache public long LiveGen => _dict._liveGen; public long FloorGen => _dict._floorGen; public bool NextGen => _dict._nextGen; - public int WLocked => _dict._wlocked; + public bool IsLocked => Monitor.IsEntered(_dict._wlocko); public bool CollectAuto { diff --git a/src/Umbraco.Web/PublishedCache/NuCache/notes.txt b/src/Umbraco.Web/PublishedCache/NuCache/readme.md similarity index 70% rename from src/Umbraco.Web/PublishedCache/NuCache/notes.txt rename to src/Umbraco.Web/PublishedCache/NuCache/readme.md index ff2d8dd48b..c8e8a363e9 100644 --- a/src/Umbraco.Web/PublishedCache/NuCache/notes.txt +++ b/src/Umbraco.Web/PublishedCache/NuCache/readme.md @@ -1,10 +1,6 @@ NuCache Documentation ====================== -NOTE - RENAMING -Facade = PublishedSnapshot -and everything needs to be renamed accordingly - HOW IT WORKS ------------- @@ -22,12 +18,12 @@ When reading the cache, we read views up the chain until we find a value (which null) for the given id, and finally we read the store itself. -The FacadeService manages a ContentStore for content, and another for media. -When a Facade is created, the FacadeService gets ContentView objects from the stores. +The PublishedSnapshotService manages a ContentStore for content, and another for media. +When a PublishedSnapshot is created, the PublishedSnapshotService gets ContentView objects from the stores. Views provide an immutable snapshot of the content and media. -When the FacadeService is notified of changes, it notifies the stores. -Then it resync the current Facade, so that it requires new views, etc. +When the PublishedSnapshotService is notified of changes, it notifies the stores. +Then it resync the current PublishedSnapshot, so that it requires new views, etc. Whenever a content, media or member is modified or removed, the cmsContentNu table is updated with a json dictionary of alias => property value, so that a content, @@ -50,32 +46,32 @@ Each ContentStore has a _freezeLock object used to protect the 'Frozen' state of the store. It's a disposable object that releases the lock when disposed, so usage would be: using (store.Frozen) { ... }. -The FacadeService has a _storesLock object used to guarantee atomic access to the +The PublishedSnapshotService has a _storesLock object used to guarantee atomic access to the set of content, media stores. CACHE ------ -For each set of views, the FacadeService creates a SnapshotCache. So a SnapshotCache +For each set of views, the PublishedSnapshotService creates a SnapshotCache. So a SnapshotCache is valid until anything changes in the content or media trees. In other words, things that go in the SnapshotCache stay until a content or media is modified. -For each Facade, the FacadeService creates a FacadeCache. So a FacadeCache is valid -for the duration of the Facade (usually, the request). In other words, things that go -in the FacadeCache stay (and are visible to) for the duration of the request only. +For each PublishedSnapshot, the PublishedSnapshotService creates a PublishedSnapshotCache. So a PublishedSnapshotCache is valid +for the duration of the PublishedSnapshot (usually, the request). In other words, things that go +in the PublishedSnapshotCache stay (and are visible to) for the duration of the request only. -The FacadeService defines a static constant FullCacheWhenPreviewing, that defines +The PublishedSnapshotService defines a static constant FullCacheWhenPreviewing, that defines how caches operate when previewing: - when true, the caches in preview mode work normally. -- when false, everything that would go to the SnapshotCache goes to the FacadeCache. +- when false, everything that would go to the SnapshotCache goes to the PublishedSnapshotCache. At the moment it is true in the code, which means that eg converted values for previewed content will go in the SnapshotCache. Makes for faster preview, but uses more memory on the long term... would need some benchmarking to figure out what is best. -Members only live for the duration of the Facade. So, for members SnapshotCache is -never used, and everything goes to the FacadeCache. +Members only live for the duration of the PublishedSnapshot. So, for members SnapshotCache is +never used, and everything goes to the PublishedSnapshotCache. All cache keys are computed in the CacheKeys static class. @@ -85,15 +81,15 @@ TESTS For testing purposes the following mechanisms exist: -The Facade type has a static Current property that is used to obtain the 'current' -facade in many places, going through the PublishedCachesServiceResolver to get the -current service, and asking the current service for the current facade, which by +The PublishedSnapshot type has a static Current property that is used to obtain the 'current' +PublishedSnapshot in many places, going through the PublishedCachesServiceResolver to get the +current service, and asking the current service for the current PublishedSnapshot, which by default relies on UmbracoContext. For test purposes, it is possible to override the -entire mechanism by defining Facade.GetCurrentFacadeFunc which should return a facade. +entire mechanism by defining PublishedSnapshot.GetCurrentPublishedSnapshotFunc which should return a PublishedSnapshot. A PublishedContent keeps only id-references to its parent and children, and needs a way to retrieve the actual objects from the cache - which depends on the current -facade. It is possible to override the entire mechanism by defining PublishedContent. +PublishedSnapshot. It is possible to override the entire mechanism by defining PublishedContent. GetContentByIdFunc or .GetMediaByIdFunc. Setting these functions must be done before Resolution is frozen. @@ -110,7 +106,7 @@ possible to support detached contents & properties, even those that do not have int id, but again this should be refactored entirely anyway. Not doing any row-version checks (see XmlStore) when reloading from database, though it -is maintained in the database. Two FIXME in FacadeService. Should we do it? +is maintained in the database. Two FIXME in PublishedSnapshotService. Should we do it? There is no on-disk cache at all so everything is reloaded from the cmsContentNu table when the site restarts. This is pretty fast, but we should experiment with solutions to @@ -121,4 +117,4 @@ PublishedMember exposes properties that IPublishedContent does not, and that are to be lost soon as the member is wrapped (content set, model...) - so we probably need some sort of IPublishedMember. -/ \ No newline at end of file +/ diff --git a/src/Umbraco.Web/PublishedContentExtensions.cs b/src/Umbraco.Web/PublishedContentExtensions.cs index 0b8cc7ce74..d02e5a3197 100644 --- a/src/Umbraco.Web/PublishedContentExtensions.cs +++ b/src/Umbraco.Web/PublishedContentExtensions.cs @@ -1194,7 +1194,7 @@ namespace Umbraco.Web /// if any. In addition, when the content type is multi-lingual, this is the url for the /// specified culture. Otherwise, it is the invariant url. /// - public static string Url(this IPublishedContent content, string culture = null, UrlMode mode = UrlMode.Auto) + public static string Url(this IPublishedContent content, string culture = null, UrlMode mode = UrlMode.Default) { var umbracoContext = Composing.Current.UmbracoContext; diff --git a/src/Umbraco.Web/Runtime/WebInitialComposer.cs b/src/Umbraco.Web/Runtime/WebInitialComposer.cs index b415010503..4cb256e44a 100644 --- a/src/Umbraco.Web/Runtime/WebInitialComposer.cs +++ b/src/Umbraco.Web/Runtime/WebInitialComposer.cs @@ -104,7 +104,7 @@ namespace Umbraco.Web.Runtime // a way to inject the UmbracoContext - DO NOT register this as Lifetime.Request since LI will dispose the context // in it's own way but we don't want that to happen, we manage its lifetime ourselves. composition.Register(factory => factory.GetInstance().UmbracoContext); - + composition.RegisterUnique(); composition.Register(factory => { var umbCtx = factory.GetInstance(); diff --git a/src/Umbraco.Web/Search/IUmbracoTreeSearcherFields.cs b/src/Umbraco.Web/Search/IUmbracoTreeSearcherFields.cs new file mode 100644 index 0000000000..c5a6c53d19 --- /dev/null +++ b/src/Umbraco.Web/Search/IUmbracoTreeSearcherFields.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; + +namespace Umbraco.Web.Search +{ + /// + /// Used to propagate hardcoded internal Field lists + /// + public interface IUmbracoTreeSearcherFields + { + /// + /// Propagate list of searchable fields for all node types + /// + IEnumerable GetBackOfficeFields(); + /// + /// Propagate list of searchable fields for Members + /// + IEnumerable GetBackOfficeMembersFields(); + /// + /// Propagate list of searchable fields for Media + /// + IEnumerable GetBackOfficeMediaFields(); + /// + /// Propagate list of searchable fields for Documents + /// + IEnumerable GetBackOfficeDocumentFields(); + } +} diff --git a/src/Umbraco.Web/Search/UmbracoTreeSearcher.cs b/src/Umbraco.Web/Search/UmbracoTreeSearcher.cs index cf9d390768..f9ef0f0a31 100644 --- a/src/Umbraco.Web/Search/UmbracoTreeSearcher.cs +++ b/src/Umbraco.Web/Search/UmbracoTreeSearcher.cs @@ -28,13 +28,15 @@ namespace Umbraco.Web.Search private readonly IEntityService _entityService; private readonly UmbracoMapper _mapper; private readonly ISqlContext _sqlContext; + private readonly IUmbracoTreeSearcherFields _umbracoTreeSearcherFields; + public UmbracoTreeSearcher(IExamineManager examineManager, UmbracoContext umbracoContext, ILocalizationService languageService, IEntityService entityService, UmbracoMapper mapper, - ISqlContext sqlContext) + ISqlContext sqlContext,IUmbracoTreeSearcherFields umbracoTreeSearcherFields) { _examineManager = examineManager ?? throw new ArgumentNullException(nameof(examineManager)); _umbracoContext = umbracoContext; @@ -42,6 +44,7 @@ namespace Umbraco.Web.Search _entityService = entityService; _mapper = mapper; _sqlContext = sqlContext; + _umbracoTreeSearcherFields = umbracoTreeSearcherFields; } /// @@ -67,7 +70,7 @@ namespace Umbraco.Web.Search string type; var indexName = Constants.UmbracoIndexes.InternalIndexName; - var fields = new List { "id", "__NodeId", "__Key" }; + var fields = _umbracoTreeSearcherFields.GetBackOfficeFields().ToList(); // TODO: WE should try to allow passing in a lucene raw query, however we will still need to do some manual string // manipulation for things like start paths, member types, etc... @@ -87,7 +90,7 @@ namespace Umbraco.Web.Search case UmbracoEntityTypes.Member: indexName = Constants.UmbracoIndexes.MembersIndexName; type = "member"; - fields.AddRange(new[]{ "email", "loginName"}); + fields.AddRange(_umbracoTreeSearcherFields.GetBackOfficeMembersFields()); if (searchFrom != null && searchFrom != Constants.Conventions.MemberTypes.AllMembersListId && searchFrom.Trim() != "-1") { sb.Append("+__NodeTypeAlias:"); @@ -97,12 +100,13 @@ namespace Umbraco.Web.Search break; case UmbracoEntityTypes.Media: type = "media"; - fields.AddRange(new[] { UmbracoExamineIndex.UmbracoFileFieldName }); + fields.AddRange(_umbracoTreeSearcherFields.GetBackOfficeMediaFields()); var allMediaStartNodes = _umbracoContext.Security.CurrentUser.CalculateMediaStartNodeIds(_entityService); AppendPath(sb, UmbracoObjectTypes.Media, allMediaStartNodes, searchFrom, ignoreUserStartNodes, _entityService); break; case UmbracoEntityTypes.Document: type = "content"; + fields.AddRange(_umbracoTreeSearcherFields.GetBackOfficeDocumentFields()); var allContentStartNodes = _umbracoContext.Security.CurrentUser.CalculateContentStartNodeIds(_entityService); AppendPath(sb, UmbracoObjectTypes.Document, allContentStartNodes, searchFrom, ignoreUserStartNodes, _entityService); break; diff --git a/src/Umbraco.Web/Search/UmbracoTreeSearcherFields.cs b/src/Umbraco.Web/Search/UmbracoTreeSearcherFields.cs new file mode 100644 index 0000000000..f90d7bc6b6 --- /dev/null +++ b/src/Umbraco.Web/Search/UmbracoTreeSearcherFields.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.Linq; +using Umbraco.Examine; + +namespace Umbraco.Web.Search +{ + public class UmbracoTreeSearcherFields : IUmbracoTreeSearcherFields + { + private IReadOnlyList _backOfficeFields = new List {"id", "__NodeId", "__Key"}; + public IEnumerable GetBackOfficeFields() + { + return _backOfficeFields; + } + + + private IReadOnlyList _backOfficeMembersFields = new List {"email", "loginName"}; + public IEnumerable GetBackOfficeMembersFields() + { + return _backOfficeMembersFields; + } + private IReadOnlyList _backOfficeMediaFields = new List {UmbracoExamineIndex.UmbracoFileFieldName }; + public IEnumerable GetBackOfficeMediaFields() + { + return _backOfficeMediaFields; + } + public IEnumerable GetBackOfficeDocumentFields() + { + return Enumerable.Empty(); + } + } +} diff --git a/src/Umbraco.Web/Trees/MediaTypeTreeController.cs b/src/Umbraco.Web/Trees/MediaTypeTreeController.cs index db659d7939..a7e65037be 100644 --- a/src/Umbraco.Web/Trees/MediaTypeTreeController.cs +++ b/src/Umbraco.Web/Trees/MediaTypeTreeController.cs @@ -121,7 +121,10 @@ namespace Umbraco.Web.Trees } menu.Items.Add(Services.TextService, opensDialog: true); - menu.Items.Add(Services.TextService, opensDialog: true); + if(ct.IsSystemMediaType() == false) + { + menu.Items.Add(Services.TextService, opensDialog: true); + } menu.Items.Add(new RefreshNode(Services.TextService, true)); } diff --git a/src/Umbraco.Web/Umbraco.Web.csproj b/src/Umbraco.Web/Umbraco.Web.csproj index dea19787f3..0a36e66d8f 100755 --- a/src/Umbraco.Web/Umbraco.Web.csproj +++ b/src/Umbraco.Web/Umbraco.Web.csproj @@ -238,6 +238,8 @@ + + @@ -878,7 +880,7 @@ - + diff --git a/src/Umbraco.Web/UmbracoApplication.cs b/src/Umbraco.Web/UmbracoApplication.cs index fb99ef9a9d..90d575bf73 100644 --- a/src/Umbraco.Web/UmbracoApplication.cs +++ b/src/Umbraco.Web/UmbracoApplication.cs @@ -1,6 +1,9 @@ -using System.Threading; +using System.Configuration; +using System.Threading; using System.Web; using Umbraco.Core; +using Umbraco.Core.Logging.Serilog; +using Umbraco.Core.Runtime; using Umbraco.Core.Configuration; using Umbraco.Core.Hosting; using Umbraco.Core.IO; @@ -21,8 +24,15 @@ namespace Umbraco.Web var connectionStringConfig = configs.ConnectionStrings()[Constants.System.UmbracoConnectionName]; var dbProviderFactoryCreator = new UmbracoDbProviderFactoryCreator(connectionStringConfig?.ProviderName); - - var mainDom = new MainDom(logger, hostingEnvironment); + + // Determine if we should use the sql main dom or the default + var appSettingMainDomLock = ConfigurationManager.AppSettings[Constants.AppSettings.MainDomLock]; + + var mainDomLock = appSettingMainDomLock == "SqlMainDomLock" + ? (IMainDomLock)new SqlMainDomLock(logger, configs, dbProviderFactoryCreator) + : new MainDomSemaphoreLock(logger, hostingEnvironment); + + var mainDom = new MainDom(logger, hostingEnvironment, mainDomLock); return new WebRuntime(this, configs, umbracoVersion, ioHelper, logger, profiler, hostingEnvironment, backOfficeInfo, dbProviderFactoryCreator, mainDom); } diff --git a/src/Umbraco.Web/UmbracoComponentRenderer.cs b/src/Umbraco.Web/UmbracoComponentRenderer.cs index 594b54f1e8..60ee14ff21 100644 --- a/src/Umbraco.Web/UmbracoComponentRenderer.cs +++ b/src/Umbraco.Web/UmbracoComponentRenderer.cs @@ -119,51 +119,7 @@ namespace Umbraco.Web x => x.Key.ToLowerInvariant(), i => (i.Value is string) ? HttpUtility.HtmlDecode(i.Value.ToString()) : i.Value); - var macroControl = _macroRenderer.Render(alias, content, macroProps).GetAsControl(); - - string html; - if (macroControl is LiteralControl control) - { - // no need to execute, we already have text - html = control.Text; - } - else - { - using (var containerPage = new FormlessPage()) - { - containerPage.Controls.Add(macroControl); - - using (var output = new StringWriter()) - { - // .Execute() does a PushTraceContext/PopTraceContext and writes trace output straight into 'output' - // and I do not see how we could wire the trace context to the current context... so it creates dirty - // trace output right in the middle of the page. - // - // The only thing we can do is fully disable trace output while .Execute() runs and restore afterwards - // which means trace output is lost if the macro is a control (.ascx or user control) that is invoked - // from within Razor -- which makes sense anyway because the control can _not_ run correctly from - // within Razor since it will never be inserted into the page pipeline (which may even not exist at all - // if we're running MVC). - // - // I'm sure there's more things that will get lost with this context changing but I guess we'll figure - // those out as we go along. One thing we lose is the content type response output. - // http://issues.umbraco.org/issue/U4-1599 if it is setup during the macro execution. So - // here we'll save the content type response and reset it after execute is called. - - var contentType = _umbracoContextAccessor.UmbracoContext.HttpContext.Response.ContentType; - var traceIsEnabled = containerPage.Trace.IsEnabled; - containerPage.Trace.IsEnabled = false; - _umbracoContextAccessor.UmbracoContext.HttpContext.Server.Execute(containerPage, output, true); - containerPage.Trace.IsEnabled = traceIsEnabled; - //reset the content type - _umbracoContextAccessor.UmbracoContext.HttpContext.Response.ContentType = contentType; - - //Now, we need to ensure that local links are parsed - html = _linkParser.EnsureInternalLinks(output.ToString()); - } - } - - } + var html = _macroRenderer.Render(alias, content, macroProps).GetAsText(); return new HtmlString(html); }