Merge remote-tracking branch 'origin/v10/dev' into v11/dev

# Conflicts:
#	src/Umbraco.Core/Routing/UmbracoRequestPaths.cs
This commit is contained in:
Bjarke Berg
2023-03-29 10:29:44 +02:00
22 changed files with 3872 additions and 49 deletions

View File

@@ -2660,6 +2660,7 @@ Per gestire il tuo sito web, è sufficiente aprire il backoffice di Umbraco e in
<key alias="labelUsedByMemberTypes">Usato nei tipi di membro</key>
<key alias="noMemberTypes">Non ci sono riferimenti a tipi di membro.</key>
<key alias="usedByProperties">Usato da</key>
<key alias="labelUsedByItems">Correlato ai seguenti elementi</key>
<key alias="labelUsedByDocuments">Usato nei documenti</key>
<key alias="labelUsedByMembers">Usato nei membri</key>
<key alias="labelUsedByMedia">Usato nei media</key>

View File

@@ -1040,14 +1040,15 @@ public static class StringExtensions
throw new ArgumentNullException(nameof(text));
}
var pos = text.IndexOf(search, StringComparison.InvariantCulture);
ReadOnlySpan<char> spanText = text.AsSpan();
var pos = spanText.IndexOf(search, StringComparison.InvariantCulture);
if (pos < 0)
{
return text;
}
return text.Substring(0, pos) + replace + text.Substring(pos + search.Length);
return string.Concat(spanText[..pos], replace.AsSpan(), spanText[(pos + search.Length)..]);
}
/// <summary>

View File

@@ -2,6 +2,7 @@ using System.Collections.Specialized;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.Serialization;
using Umbraco.Cms.Core.Models.Entities;
namespace Umbraco.Cms.Core.Models;
@@ -132,7 +133,7 @@ public class PropertyGroup : EntityBase, IEquatable<PropertyGroup>
}
public bool Equals(PropertyGroup? other) =>
base.Equals(other) || (other != null && Type == other.Type && Alias == other.Alias);
base.Equals(other) || (other != null && Type == other.Type && Alias == other.Alias && Id == other.Id);
public override int GetHashCode() => (base.GetHashCode(), Type, Alias).GetHashCode();

View File

@@ -30,7 +30,7 @@ public class PropertyTypeCollection : KeyedCollection<string, IPropertyType>, IN
// This baseclass calling is needed, else compiler will complain about nullability
/// <inheritdoc />
public bool IsReadOnly => ((ICollection<IPropertyType>)this).IsReadOnly;
public bool IsReadOnly => false;
// 'new' keyword is required! we can explicitly implement ICollection<IPropertyType>.Add BUT since normally a concrete PropertyType type
// is passed in, the explicit implementation doesn't get called, this ensures it does get called.

View File

@@ -728,6 +728,8 @@ namespace Umbraco.Cms.Core.Services
media.CreatorId = userId;
}
media.WriterId = userId;
_mediaRepository.Save(media);
scope.Notifications.Publish(new MediaSavedNotification(media, eventMessages).WithStateFrom(savingNotification));
// TODO: See note about suppressing events in content service

View File

@@ -1,4 +1,4 @@
using System.Diagnostics;
using System.Diagnostics;
using System.Globalization;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models;
@@ -305,10 +305,10 @@ namespace Umbraco.Cms.Core.Strings
return text;
}
private static string RemoveSurrogatePairs(string text)
private string RemoveSurrogatePairs(string text)
{
var input = text.ToCharArray();
var output = new char[input.Length];
var input = text.AsSpan();
Span<char> output = input.Length <= 1024 ? stackalloc char[input.Length] : new char[text.Length];
var opos = 0;
for (var ipos = 0; ipos < input.Length; ipos++)
@@ -325,7 +325,7 @@ namespace Umbraco.Cms.Core.Strings
}
}
return new string(output, 0, opos);
return new string(output);
}
// here was a subtle, ascii-optimized version of the cleaning code, and I was
@@ -347,7 +347,8 @@ namespace Umbraco.Cms.Core.Strings
// it's faster to use an array than a StringBuilder
var ilen = input.Length;
var output = new char[ilen * 2]; // twice the length should be OK in all cases
var totalSize = ilen * 2;
Span<char> output = totalSize <= 1024 ? stackalloc char[totalSize] : new char[totalSize]; // twice the length should be OK in all cases
for (var i = 0; i < ilen; i++)
{
@@ -479,11 +480,11 @@ namespace Umbraco.Cms.Core.Strings
throw new Exception("Invalid state.");
}
return new string(output, 0, opos);
return new string(output.Slice(0, opos));
}
// note: supports surrogate pairs in input string
internal void CopyTerm(string input, int ipos, char[] output, ref int opos, int len, CleanStringType caseType, string culture, bool isAcronym)
internal void CopyTerm(string input, int ipos, Span<char> output, ref int opos, int len, CleanStringType caseType, string culture, bool isAcronym)
{
var term = input.Substring(ipos, len);
CultureInfo cultureInfo = string.IsNullOrEmpty(culture) ? CultureInfo.InvariantCulture : CultureInfo.GetCultureInfo(culture);
@@ -509,19 +510,19 @@ namespace Umbraco.Cms.Core.Strings
//case CleanStringType.LowerCase:
//case CleanStringType.UpperCase:
case CleanStringType.Unchanged:
term.CopyTo(0, output, opos, len);
term.CopyTo(output.Slice(opos, len));
opos += len;
break;
case CleanStringType.LowerCase:
term = term.ToLower(cultureInfo);
term.CopyTo(0, output, opos, term.Length);
term.CopyTo(output.Slice(opos, term.Length));
opos += term.Length;
break;
case CleanStringType.UpperCase:
term = term.ToUpper(cultureInfo);
term.CopyTo(0, output, opos, term.Length);
term.CopyTo(output.Slice(opos, term.Length));
opos += term.Length;
break;
@@ -532,7 +533,7 @@ namespace Umbraco.Cms.Core.Strings
{
s = term.Substring(ipos, 2);
s = opos == 0 ? s.ToLower(cultureInfo) : s.ToUpper(cultureInfo);
s.CopyTo(0, output, opos, s.Length);
s.CopyTo(output.Slice(opos, s.Length));
opos += s.Length;
i++; // surrogate pair len is 2
}
@@ -543,7 +544,7 @@ namespace Umbraco.Cms.Core.Strings
if (len > i)
{
term = term.Substring(i).ToLower(cultureInfo);
term.CopyTo(0, output, opos, term.Length);
term.CopyTo(output.Slice(opos, term.Length));
opos += term.Length;
}
break;
@@ -555,7 +556,7 @@ namespace Umbraco.Cms.Core.Strings
{
s = term.Substring(ipos, 2);
s = s.ToUpper(cultureInfo);
s.CopyTo(0, output, opos, s.Length);
s.CopyTo(output.Slice(opos, s.Length));
opos += s.Length;
i++; // surrogate pair len is 2
}
@@ -566,7 +567,7 @@ namespace Umbraco.Cms.Core.Strings
if (len > i)
{
term = term.Substring(i).ToLower(cultureInfo);
term.CopyTo(0, output, opos, term.Length);
term.CopyTo(output.Slice(opos, term.Length));
opos += term.Length;
}
break;
@@ -578,7 +579,7 @@ namespace Umbraco.Cms.Core.Strings
{
s = term.Substring(ipos, 2);
s = opos == 0 ? s : s.ToUpper(cultureInfo);
s.CopyTo(0, output, opos, s.Length);
s.CopyTo(output.Slice(opos, s.Length));
opos += s.Length;
i++; // surrogate pair len is 2
}
@@ -589,7 +590,7 @@ namespace Umbraco.Cms.Core.Strings
if (len > i)
{
term = term.Substring(i);
term.CopyTo(0, output, opos, term.Length);
term.CopyTo(output.Slice(opos, term.Length));
opos += term.Length;
}
break;

View File

@@ -11,21 +11,27 @@ namespace Umbraco.Cms.Core.Strings;
/// </remarks>
public static class Utf8ToAsciiConverter
{
[Obsolete("Use ToAsciiString(ReadOnlySpan<char>..) instead")]
public static string ToAsciiString(string text, char fail = '?')
{
return ToAsciiString(text.AsSpan(), fail);
}
/// <summary>
/// Converts an Utf8 string into an Ascii string.
/// </summary>
/// <param name="text">The text to convert.</param>
/// <param name="fail">The character to use to replace characters that cannot properly be converted.</param>
/// <returns>The converted text.</returns>
public static string ToAsciiString(string text, char fail = '?')
public static string ToAsciiString(ReadOnlySpan<char> text, char fail = '?')
{
var input = text.ToCharArray();
// this is faster although it uses more memory
// but... we should be filtering short strings only...
var output = new char[input.Length * 3]; // *3 because of things such as OE
var len = ToAscii(input, output, fail);
return new string(output, 0, len);
var totalSize = text.Length * 3;
Span<char> output = totalSize <= 1024 ? stackalloc char[totalSize] : new char[totalSize]; // *3 because of things such as OE
var len = ToAscii(text, output, fail);
return new string(output[..len]);
// var output = new StringBuilder(input.Length + 16); // default is 16, start with at least input length + little extra
// ToAscii(input, output);
@@ -66,7 +72,7 @@ public static class Utf8ToAsciiConverter
/// <returns>The number of characters in the output array.</returns>
/// <remarks>The caller must ensure that the output array is big enough.</remarks>
/// <exception cref="OverflowException">The output array is not big enough.</exception>
private static int ToAscii(char[] input, char[] output, char fail = '?')
private static int ToAscii(ReadOnlySpan<char> input, Span<char> output, char fail = '?')
{
var opos = 0;
@@ -121,7 +127,7 @@ public static class Utf8ToAsciiConverter
/// <para>Input should contain Utf8 characters exclusively and NOT Unicode.</para>
/// <para>Removes controls, normalizes whitespaces, replaces symbols by '?'.</para>
/// </remarks>
private static void ToAscii(char[] input, int ipos, char[] output, ref int opos, char fail = '?')
private static void ToAscii(ReadOnlySpan<char> input, int ipos, Span<char> output, ref int opos, char fail = '?')
{
var c = input[ipos];

View File

@@ -142,6 +142,9 @@ function contentEditingHelper(fileManager, $q, $location, $routeParams, editorSt
//update editor state to what is current
editorState.set(args.content);
//needs to be manually set for infinite editing mode
args.scope.isNew = args.content.id === 0 && args.scope.isNew;
return $q.reject(err);
});
}

View File

@@ -796,6 +796,7 @@ input.umb-group-builder__group-sort-value {
transform: translate(0, -50%);
}
input.editor-label,
textarea.editor-label {
border-color: transparent;
box-shadow: none;

View File

@@ -7,7 +7,8 @@
}
.umb-editor-wrapper .umb-node-preview {
.umb-property-editor--limit-width();
word-break: break-word;
.umb-property-editor--limit-width();
}
.umb-node-preview:last-of-type {
@@ -38,7 +39,6 @@
.umb-node-preview__content {
flex: 1 1 auto;
margin-right: 25px;
overflow: hidden;
}

View File

@@ -1,3 +1,5 @@
.umb-readonlyvalue {
position:relative;
.umb-readonlyvalue {
position: relative;
word-break: break-word;
.umb-property-editor--limit-width();
}

View File

@@ -15,7 +15,7 @@
var vm = this;
const dataTypesCanBeChangedConfig = window.Umbraco.Sys.ServerVariables.umbracoSettings.dataTypesCanBeChanged;
vm.allowChangeDataType = false;
vm.changeDataTypeHelpTextIsVisible = false;
vm.propertyTypeHasValues = false;

View File

@@ -20,7 +20,8 @@
<div class="umb-control-group -no-border" ng-if="!model.property.locked">
<div class="control-group -no-margin">
<textarea class="editor-label"
<input type="text"
class="editor-label"
data-element="property-name"
name="propertyLabel"
ng-model="model.property.label"
@@ -28,10 +29,8 @@
placeholder="@placeholders_entername"
umb-auto-focus
focus-on-filled="true"
umb-auto-resize
required
ng-keypress="vm.submitOnEnter($event)">
</textarea>
ng-keypress="vm.submitOnEnter($event)" />
<div ng-messages="propertySettingsForm.propertyLabel.$error" show-validation-on-submit>
<span class="umb-validation-label" ng-message="required">Required label</span>
</div>
@@ -47,11 +46,10 @@
ng-model="model.property.description"
localize="placeholder"
placeholder="@placeholders_enterDescription"
ng-keypress="vm.submitOnEnter($event)"
umb-auto-resize>
</textarea>
</div>
<ng-form name="dataTypeForm">
<div class="umb-control-group control-group" ng-model="model.property.editor" name="selectedEditor" val-require-component ng-if="!model.property.locked">
<umb-button
@@ -82,7 +80,7 @@
allow-change="vm.allowChangeDataType"
on-change="vm.openDataTypePicker(model.property)">
</umb-node-preview>
<div ng-if="vm.changeDataTypeHelpTextIsVisible" class="mt2 umb-alert umb-alert--info">
<localize key="contentTypeEditor_changeDataTypeHelpText">
</div>

View File

@@ -1,6 +1,6 @@
<ul role="tablist" class="umb-tabs-nav">
<li class="umb-tab" ng-repeat="tab in vm.tabs | limitTo: vm.maxTabs" data-element="tab-{{tab.alias}}" ng-class="{'umb-tab--active': tab.active, 'umb-tab--error': valTab_tabHasError}" val-tab>
<button class="btn-reset umb-tab-button" ng-click="vm.clickTab($event, tab)" role="tab" ng-disabled="tab.disabled" aria-selected="{{tab.active}}" type="button">
<ul role="tablist" class="umb-tabs-nav">
<li role="tab" aria-selected="{{tab.active}}" class="umb-tab" ng-repeat="tab in vm.tabs | limitTo: vm.maxTabs" data-element="tab-{{tab.alias}}" ng-class="{'umb-tab--active': tab.active, 'umb-tab--error': valTab_tabHasError}" val-tab>
<button class="btn-reset umb-tab-button" ng-click="vm.clickTab($event, tab)" ng-disabled="tab.disabled" type="button">
{{ tab.label }}
<div ng-show="valTab_tabHasError && !tab.active" class="badge">!</div>
</button>

View File

@@ -32,7 +32,7 @@
<localize key="blockEditor_areaAliasHelp">When using GetBlockGridHTML() to render the Block Grid, the alias will be rendered in the markup as a 'data-area-alias' attribute. Use the alias attribute to target the element for the area. Example. .umb-block-grid__area[data-area-alias="MyAreaAlias"] { ... }</localize>
</umb-property-info-button>
<div class="controls">
<input type="text" name="alias" ng-model="vm.area.alias" val-server="alias" style="width:100%" required umb-auto-focus/>
<input type="text" name="alias" ng-model="vm.area.alias" val-server="alias" class="w-100" required umb-auto-focus/>
</div>
<div ng-messages="blockGridBlockConfigurationAreaForm.alias.$error" class="red">
<div ng-message="alias">
@@ -51,7 +51,7 @@
<localize key="blockEditor_areaCreateLabelHelp">Override the label text for adding a new Block to this Area, Example: 'Add Widget'</localize>
</umb-property-info-button>
<div class="controls">
<input type="text" name="createLabel" ng-model="vm.area.createLabel" style="width:100%" umb-auto-focus/>
<input type="text" name="createLabel" ng-model="vm.area.createLabel" class="w-100" umb-auto-focus/>
</div>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,108 @@
using System;
using BenchmarkDotNet.Attributes;
using Umbraco.Cms.Core.Strings;
using Umbraco.Tests.Benchmarks.Config;
namespace Umbraco.Tests.Benchmarks;
[QuickRunWithMemoryDiagnoserConfig]
public class ShortStringHelperBenchmarks
{
private DefaultShortStringHelper _shortStringHelper;
private string _input;
[GlobalSetup]
public void Setup()
{
_shortStringHelper = new DefaultShortStringHelper(new DefaultShortStringHelperConfig());
_input = "This is a 🎈 balloon";
}
[Benchmark(Baseline = true)]
public void ToUrlSegment()
{
_shortStringHelper.CleanStringForUrlSegment(_input);
}
/*[Benchmark(Baseline = true)]
public string OldAsciString()
{
return OldUtf8ToAsciiConverter.ToAsciiString(_input);
}
[Benchmark]
public string NewAsciString()
{
return Utf8ToAsciiConverter.ToAsciiString(_input);
}*/
#region SurrogatePairs
/*[Benchmark(Baseline = true)]
public string RemoveSurrogatePairs()
{
var input = _input.ToCharArray();
var output = new char[input.Length];
var opos = 0;
for (var ipos = 0; ipos < input.Length; ipos++)
{
var c = input[ipos];
if (char.IsSurrogate(c)) // ignore high surrogate
{
ipos++; // and skip low surrogate
output[opos++] = '?';
}
else
{
output[opos++] = c;
}
}
return new string(output, 0, opos);
}
[Benchmark]
public string RemoveNewSurrogatePairs()
{
var input = _input.AsSpan();
Span<char> output = input.Length <= 1024 ? stackalloc char[input.Length] : new char[input.Length];
var opos = 0;
for (var ipos = 0; ipos < input.Length; ipos++)
{
var c = input[ipos];
if (char.IsSurrogate(c)) // ignore high surrogate
{
ipos++; // and skip low surrogate
output[opos++] = '?';
}
else
{
output[opos++] = c;
}
}
return new string(output);
}*/
#endregion
//| Method | Mean | Error | StdDev | Ratio | Gen 0 | Allocated |
//|-----------------------------------:|---------:|---------:|--------:|------:|-------:|----------:|
//| ToUrlSegment | 464.2 ns | 34.88 ns | 1.91 ns | 1.00 | 0.1627 | 512 B |
//| ToUrlSegment (With below changes) | 455.7 ns | 26.83 ns | 1.47 ns | 1.00 | 0.1182 | 384 B |
//| ToUrlSegment(CleanCodeString change| 420.6 ns | 64.06 ns | 3.51 ns | 1.00 | 0.0856 | 280 B |
//| Method | Mean | Error | StdDev | Ratio | Gen 0 | Allocated |
//|------------------------ |---------:|----------:|---------:|------:|-------:|----------:|
//| RemoveSurrogatePairs | 70.75 ns | 15.307 ns | 0.839 ns | 1.00 | 0.0610 | 192 B |
//| RemoveNewSurrogatePairs | 58.44 ns | 7.297 ns | 0.400 ns | 0.83 | 0.0198 | 64 B |
//| Method | Mean | Error | StdDev | Ratio | Gen 0 | Allocated |
//|-------------- |---------:|---------:|--------:|------:|-------:|----------:|
//| OldAsciString | 181.4 ns | 11.50 ns | 0.63 ns | 1.00 | 0.0851 | 272 B |
//| NewAsciString | 180.7 ns | 5.35 ns | 0.29 ns | 1.00 | 0.0450 | 64 B |
}

View File

@@ -0,0 +1,66 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using BenchmarkDotNet.Attributes;
using Umbraco.Tests.Benchmarks.Config;
namespace Umbraco.Tests.Benchmarks;
[QuickRunWithMemoryDiagnoserConfig]
public class StringReplaceFirstBenchmarks
{
[Params("Test string",
"This is a test string that contains multiple test entries",
"This is a string where the searched value is very far back. The system needs to go through all of this code before it reaches the test")]
public string Text { get; set; }
public string Search { get; set; }
public string Replace { get; set; }
[GlobalSetup]
public void Setup()
{
Search = "test";
Replace = "release";
}
[Benchmark(Baseline = true, Description = "Replace first w/ substring")]
public string SubstringReplaceFirst()
{
var pos = Text.IndexOf(Search, StringComparison.InvariantCulture);
if (pos < 0)
{
return Text;
}
return Text.Substring(0, pos) + Replace + Text.Substring(pos + Search.Length);
}
[Benchmark(Description = "Replace first w/ span")]
public string SpanReplaceFirst()
{
var spanText = Text.AsSpan();
var pos = spanText.IndexOf(Search, StringComparison.InvariantCulture);
if (pos < 0)
{
return Text;
}
return string.Concat(spanText[..pos], Replace.AsSpan(), spanText[(pos + Search.Length)..]);
}
//| Method | Text | Mean | Error | StdDev | Ratio | RatioSD | Gen 0 | Allocated |
//|----------------------------- |--------------------- |----------:|---------:|---------:|------:|--------:|-------:|----------:|
//| 'Replace first w/ substring' | Test string | 46.08 ns | 25.83 ns | 1.416 ns | 1.00 | 0.00 | - | - |
//| 'Replace first w/ span' | Test string | 38.59 ns | 19.46 ns | 1.067 ns | 0.84 | 0.05 | - | - |
//| | | | | | | | | |
//| 'Replace first w/ substring' | This(...)test[134] | 407.89 ns | 52.08 ns | 2.855 ns | 1.00 | 0.00 | 0.1833 | 584 B |
//| 'Replace first w/ span' | This(...)test[134] | 372.99 ns | 58.38 ns | 3.200 ns | 0.91 | 0.01 | 0.0941 | 296 B |
//| | | | | | | | | |
//| 'Replace first w/ substring' | This(...)tries[57] | 113.16 ns | 27.95 ns | 1.532 ns | 1.00 | 0.00 | 0.0961 | 304 B |
//| 'Replace first w/ span' | This(...)tries[57] | 76.57 ns | 17.86 ns | 0.979 ns | 0.68 | 0.01 | 0.0455 | 144 B |
}

View File

@@ -74,6 +74,7 @@ public class StringReplaceManyBenchmarks
return result;
}
/*
short text, short replacement:

View File

@@ -255,7 +255,7 @@ public class CreatedPackagesRepositoryTests : UmbracoIntegrationTest
Assert.AreEqual(test, mediaEntry.Name);
Assert.IsNotNull(zipArchive.GetEntry("package.xml"));
Assert.AreEqual(
$"<MediaItems><MediaSet><testImage id=\"{m1.Id}\" key=\"{m1.Key}\" parentID=\"-1\" level=\"1\" creatorID=\"-1\" sortOrder=\"0\" createDate=\"{m1.CreateDate:s}\" updateDate=\"{m1.UpdateDate:s}\" nodeName=\"Test File\" urlName=\"test-file\" path=\"{m1.Path}\" isDoc=\"\" nodeType=\"{mt.Id}\" nodeTypeAlias=\"testImage\" writerName=\"\" writerID=\"0\" udi=\"{m1.GetUdi()}\" mediaFilePath=\"/media/test-file.txt\"><umbracoFile><![CDATA[/media/test-file.txt]]></umbracoFile><umbracoBytes><![CDATA[100]]></umbracoBytes><umbracoExtension><![CDATA[png]]></umbracoExtension></testImage></MediaSet></MediaItems>",
$"<MediaItems><MediaSet><testImage id=\"{m1.Id}\" key=\"{m1.Key}\" parentID=\"-1\" level=\"1\" creatorID=\"-1\" sortOrder=\"0\" createDate=\"{m1.CreateDate:s}\" updateDate=\"{m1.UpdateDate:s}\" nodeName=\"Test File\" urlName=\"test-file\" path=\"{m1.Path}\" isDoc=\"\" nodeType=\"{mt.Id}\" nodeTypeAlias=\"testImage\" writerName=\"Administrator\" writerID=\"-1\" udi=\"{m1.GetUdi()}\" mediaFilePath=\"/media/test-file.txt\"><umbracoFile><![CDATA[/media/test-file.txt]]></umbracoFile><umbracoBytes><![CDATA[100]]></umbracoBytes><umbracoExtension><![CDATA[png]]></umbracoExtension></testImage></MediaSet></MediaItems>",
packageXml.Element("umbPackage").Element("MediaItems").ToString(SaveOptions.DisableFormatting));
Assert.AreEqual(2, zipArchive.Entries.Count());
Assert.AreEqual(ZipArchiveMode.Read, zipArchive.Mode);

View File

@@ -326,6 +326,14 @@ public class StringExtensionsTests
Assert.AreEqual(expected, output);
}
[TestCase("test to test", "test", "release", "release to test")]
[TestCase("nothing to do", "test", "release", "nothing to do")]
public void ReplaceFirst(string input, string search, string replacement, string expected)
{
var output = input.ReplaceFirst(search, replacement);
Assert.AreEqual(expected, output);
}
[Test]
public void IsFullPath()
{

View File

@@ -12,7 +12,5 @@
<ItemGroup>
<ProjectReference Include="..\..\src\Umbraco.Core\Umbraco.Core.csproj" />
<PackageReference Include="Umbraco.Deploy.Core" Version="10.1.3" />
<PackageReference Include="Umbraco.Forms.Core" Version="10.2.3" />
</ItemGroup>
</Project>