From fd47eeaf6a58632a04aa9b39dd29df74c28b416e Mon Sep 17 00:00:00 2001 From: RTLCoil Date: Mon, 20 Sep 2021 23:15:22 +0300 Subject: [PATCH] Address review comments --- ...owseResourcesWithStructuredMetadataTest.cs | 104 ++++++++++++++++++ .../IntegrationTestBase.cs | 8 +- .../ProvisioningIntegrationTestBase.cs | 4 +- .../UploadApi/RenameMethodsTest.cs | 97 ++++++++++++++-- CloudinaryDotNet.Tests/ApiShared.ProxyTest.cs | 50 +++++++++ .../Asset/UrlBuilderTest.cs | 30 ++++- CloudinaryDotNet.Tests/MockedCloudinary.cs | 2 +- CloudinaryDotNet.Tests/SearchTest.cs | 58 ++++++++++ .../Transformations/Common/ExpressionTest.cs | 2 + .../UploadApi/CreateSlideshowMethodsTest.cs | 97 ++++++++++++++++ .../AssetsManagement/ListResourcesParams.cs | 6 + .../AssetsUpload/CreateSlideshowParams.cs | 92 ++++++++++++++++ .../AssetsUpload/CreateSlideshowResult.cs | 29 +++++ .../Actions/AssetsUpload/NestedTypes/Slide.cs | 52 +++++++++ .../AssetsUpload/NestedTypes/Slideshow.cs | 42 +++++++ .../NestedTypes/SlideshowManifest.cs | 40 +++++++ .../Actions/AssetsUpload/RenameParams.cs | 21 ++++ CloudinaryDotNet/ApiShared.Internal.cs | 5 +- CloudinaryDotNet/ApiShared.Proxy.cs | 64 +++++++++++ CloudinaryDotNet/ApiShared.cs | 9 +- CloudinaryDotNet/Cloudinary.AdminApi.cs | 37 +++++-- CloudinaryDotNet/Cloudinary.UploadApi.cs | 44 +++++++- CloudinaryDotNet/Search.cs | 7 +- CloudinaryDotNet/Transforms/BaseExpression.cs | 3 +- CloudinaryDotNet/Transforms/CustomFunction.cs | 10 ++ CloudinaryDotNet/Transforms/TextLayer.cs | 35 ++++++ appveyor.yml | 2 +- 27 files changed, 900 insertions(+), 50 deletions(-) create mode 100644 CloudinaryDotNet.IntegrationTests/AdminApi/BrowseResourcesWithStructuredMetadataTest.cs create mode 100644 CloudinaryDotNet.Tests/ApiShared.ProxyTest.cs create mode 100644 CloudinaryDotNet.Tests/SearchTest.cs create mode 100644 CloudinaryDotNet.Tests/UploadApi/CreateSlideshowMethodsTest.cs create mode 100644 CloudinaryDotNet/Actions/AssetsUpload/CreateSlideshowParams.cs create mode 100644 CloudinaryDotNet/Actions/AssetsUpload/CreateSlideshowResult.cs create mode 100644 CloudinaryDotNet/Actions/AssetsUpload/NestedTypes/Slide.cs create mode 100644 CloudinaryDotNet/Actions/AssetsUpload/NestedTypes/Slideshow.cs create mode 100644 CloudinaryDotNet/Actions/AssetsUpload/NestedTypes/SlideshowManifest.cs create mode 100644 CloudinaryDotNet/ApiShared.Proxy.cs diff --git a/CloudinaryDotNet.IntegrationTests/AdminApi/BrowseResourcesWithStructuredMetadataTest.cs b/CloudinaryDotNet.IntegrationTests/AdminApi/BrowseResourcesWithStructuredMetadataTest.cs new file mode 100644 index 00000000..730d6452 --- /dev/null +++ b/CloudinaryDotNet.IntegrationTests/AdminApi/BrowseResourcesWithStructuredMetadataTest.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using CloudinaryDotNet.Actions; +using NUnit.Framework; + +namespace CloudinaryDotNet.IntegrationTests.AdminApi +{ + public class BrowseResourcesWithStructuredMetadataTest : IntegrationTestBase + { + private const string MODERATION_MANUAL = "manual"; + private static string m_contextKey; + private static string m_contextValue; + private static string m_publicId; + + public override void Initialize() + { + base.Initialize(); + + m_contextKey = $"{m_uniqueTestId}_context_key"; + m_contextValue = $"{m_uniqueTestId}_context_value"; + var context = $"{m_contextKey}={m_contextValue}"; + + CreateMetadataField("resource_with_metadata", p => p.DefaultValue = p.Label); + var uploadResult = UploadTestImageResource(p => + { + p.Context = new StringDictionary(context); + p.Moderation = MODERATION_MANUAL; + }); + + m_publicId = uploadResult.PublicId; + } + + [Test, RetryWithDelay] + public async Task TestListResources() + { + var @params = new ListResourcesParams + { + }; + await AssertMetadataFlagBehavior(@params); + } + + [Test, RetryWithDelay] + public async Task TestListResourcesByTag() + { + var @params = new ListResourcesByTagParams + { + Tag = m_apiTag, + }; + await AssertMetadataFlagBehavior(@params); + } + + [Test, RetryWithDelay] + public async Task TestListResourcesByModerationStatus() + { + var @params = new ListResourcesByModerationParams + { + ModerationStatus = ModerationStatus.Pending, + ModerationKind = MODERATION_MANUAL, + }; + await AssertMetadataFlagBehavior(@params); + } + + [Test, RetryWithDelay] + public async Task TestListResourcesByPrefix() + { + var @params = new ListResourcesByPrefixParams + { + Type = "upload", + Prefix = m_test_prefix, + }; + await AssertMetadataFlagBehavior(@params); + } + + private async Task AssertMetadataFlagBehavior(ListResourcesParams @params) + { + @params.Metadata = true; + CommonAssertions(await m_cloudinary.ListResourcesAsync(@params), true); + + @params.Metadata = false; + CommonAssertions(await m_cloudinary.ListResourcesAsync(@params), false); + } + + private void CommonAssertions(ListResourcesResult result, bool shouldHaveMetadata) + { + Assert.IsNotNull(result); + Assert.IsNotNull(result.Resources, result.Error?.Message); + + var resourceWithMetadata = result.Resources.FirstOrDefault(r => r.PublicId == m_publicId); + Assert.IsNotNull(resourceWithMetadata); + + if (shouldHaveMetadata) + { + Assert.IsNotNull(resourceWithMetadata.MetadataFields); + } + else + { + Assert.IsNull(resourceWithMetadata.MetadataFields); + } + } + } +} diff --git a/CloudinaryDotNet.IntegrationTests/IntegrationTestBase.cs b/CloudinaryDotNet.IntegrationTests/IntegrationTestBase.cs index 3592192a..992afd38 100644 --- a/CloudinaryDotNet.IntegrationTests/IntegrationTestBase.cs +++ b/CloudinaryDotNet.IntegrationTests/IntegrationTestBase.cs @@ -200,9 +200,9 @@ private void SaveEmbeddedToDisk(Assembly assembly, string sourcePath, string des stream.CopyTo(fileStream); } } - catch (IOException e) + catch (IOException) { - + } } @@ -308,11 +308,13 @@ protected Task UploadTestRawResourceAsync( /// A convenient method for creating a structured metadata field before testing. /// /// The distinguishable suffix. + /// Action to set custom metadata field parameters. /// The ExternalId of the structured metadata field. - protected string CreateMetadataField(string fieldLabelSuffix) + protected string CreateMetadataField(string fieldLabelSuffix, Action setParamsAction = null) { var metadataLabel = GetUniqueMetadataFieldLabel(fieldLabelSuffix); var metadataParameters = new StringMetadataFieldCreateParams(metadataLabel); + setParamsAction?.Invoke(metadataParameters); var metadataResult = m_cloudinary.AddMetadataField(metadataParameters); Assert.NotNull(metadataResult, metadataResult.Error?.Message); diff --git a/CloudinaryDotNet.IntegrationTests/Provisioning/ProvisioningIntegrationTestBase.cs b/CloudinaryDotNet.IntegrationTests/Provisioning/ProvisioningIntegrationTestBase.cs index e9ec69d6..400e2c6d 100644 --- a/CloudinaryDotNet.IntegrationTests/Provisioning/ProvisioningIntegrationTestBase.cs +++ b/CloudinaryDotNet.IntegrationTests/Provisioning/ProvisioningIntegrationTestBase.cs @@ -76,10 +76,10 @@ private string CreateUser(string userName, string userEmail) { var createUserParams = new CreateUserParams(userName, userEmail, m_userRole) { - SubAccountIds = new List {m_cloudId1} + SubAccountIds = new List { m_cloudId1 } }; var createUserResult = AccountProvisioning.CreateUserAsync(createUserParams).GetAwaiter().GetResult(); - Assert.AreEqual(HttpStatusCode.OK, createUserResult.StatusCode); + Assert.AreEqual(HttpStatusCode.OK, createUserResult.StatusCode, createUserResult?.Error?.Message); Assert.AreEqual(1, createUserResult.SubAccountIds.Length); Assert.AreEqual(m_cloudId1, createUserResult.SubAccountIds[0]); diff --git a/CloudinaryDotNet.IntegrationTests/UploadApi/RenameMethodsTest.cs b/CloudinaryDotNet.IntegrationTests/UploadApi/RenameMethodsTest.cs index fa8aaf5c..8a6f0dc4 100644 --- a/CloudinaryDotNet.IntegrationTests/UploadApi/RenameMethodsTest.cs +++ b/CloudinaryDotNet.IntegrationTests/UploadApi/RenameMethodsTest.cs @@ -1,4 +1,6 @@ -using System.Net; +using System.Linq; +using System.Net; +using System.Threading.Tasks; using CloudinaryDotNet.Actions; using NUnit.Framework; @@ -6,8 +8,19 @@ namespace CloudinaryDotNet.IntegrationTests.UploadApi { public class RenameMethodsTest : IntegrationTestBase { + private static string m_context; + + public override void Initialize() + { + base.Initialize(); + + var contextKey = $"{m_uniqueTestId}_context_key"; + var contextValue = $"{m_uniqueTestId}_context_value"; + m_context = $"{contextKey}={contextValue}"; + } + [Test, RetryWithDelay] - public void TestRename() + public async Task TestRename() { var toPublicId = GetUniquePublicId(); @@ -16,29 +29,29 @@ public void TestRename() File = new FileDescription(m_testImagePath), Tags = m_apiTag }; - var uploadResult1 = m_cloudinary.Upload(uploadParams); + var uploadResult1 = await m_cloudinary.UploadAsync(uploadParams); uploadParams.File = new FileDescription(m_testIconPath); - var uploadResult2 = m_cloudinary.Upload(uploadParams); + var uploadResult2 = await m_cloudinary.UploadAsync(uploadParams); - var renameResult = m_cloudinary.Rename(uploadResult1.PublicId, toPublicId); + var renameResult = await m_cloudinary.RenameAsync(uploadResult1.PublicId, toPublicId); Assert.AreEqual(HttpStatusCode.OK, renameResult.StatusCode, renameResult.Error?.Message); - var getResult = m_cloudinary.GetResource(toPublicId); + var getResult = await m_cloudinary.GetResourceAsync(toPublicId); Assert.NotNull(getResult); - renameResult = m_cloudinary.Rename(uploadResult2.PublicId, toPublicId); + renameResult = await m_cloudinary.RenameAsync(uploadResult2.PublicId, toPublicId); Assert.True(renameResult.StatusCode == HttpStatusCode.BadRequest, renameResult.Error?.Message); - m_cloudinary.Rename(uploadResult2.PublicId, toPublicId, true); + await m_cloudinary.RenameAsync(uploadResult2.PublicId, toPublicId, true); - getResult = m_cloudinary.GetResource(toPublicId); + getResult = await m_cloudinary.GetResourceAsync(toPublicId); Assert.NotNull(getResult); Assert.AreEqual(FILE_FORMAT_ICO, getResult.Format, getResult.Error?.Message); } [Test, RetryWithDelay] - public void TestRenameToType() + public async Task TestRenameToType() { string publicId = GetUniquePublicId(); string newPublicId = GetUniquePublicId(); @@ -51,7 +64,7 @@ public void TestRenameToType() Type = STORAGE_TYPE_UPLOAD }; - var uploadResult = m_cloudinary.Upload(uploadParams); + var uploadResult = await m_cloudinary.UploadAsync(uploadParams); Assert.AreEqual(uploadResult.StatusCode, HttpStatusCode.OK, uploadResult.Error?.Message); RenameParams renameParams = new RenameParams(publicId, newPublicId) @@ -59,10 +72,70 @@ public void TestRenameToType() ToType = STORAGE_TYPE_UPLOAD }; - var renameResult = m_cloudinary.Rename(renameParams); + var renameResult = await m_cloudinary.RenameAsync(renameParams); Assert.AreEqual(renameResult.StatusCode, HttpStatusCode.OK, renameResult.Error?.Message); Assert.AreEqual(renameResult.Type, STORAGE_TYPE_UPLOAD); Assert.AreEqual(renameResult.PublicId, newPublicId); } + + [Test] + public async Task TestRenameReturnsContext() + { + string publicId = GetUniquePublicId(); + string newPublicId = GetUniquePublicId(); + + await UploadImage(publicId); + + var @params = new RenameParams(publicId, newPublicId) + { + Context = true + }; + var renameResult = await m_cloudinary.RenameAsync(@params); + Assert.IsTrue(renameResult.Context.HasValues); + + @params.Context = false; + renameResult = await m_cloudinary.RenameAsync(@params); + Assert.IsNull(renameResult.Context); + } + + [Test] + public async Task TestRenameReturnsMetadata() + { + string publicId = GetUniquePublicId(); + string newPublicId = GetUniquePublicId(); + + await UploadImage(publicId, true); + + var @params = new RenameParams(publicId, newPublicId) + { + Metadata = true + }; + var renameResult = await m_cloudinary.RenameAsync(@params); + Assert.IsTrue(renameResult.MetadataFields.HasValues); + + @params.Metadata = false; + renameResult = await m_cloudinary.RenameAsync(@params); + Assert.IsNull(renameResult.MetadataFields); + } + + private async Task UploadImage(string publicId, bool withMetadata = false) + { + if (withMetadata) + { + CreateMetadataField("rename_with_metadata", p => p.DefaultValue = p.Label); + } + + var uploadParams = new ImageUploadParams() + { + PublicId = publicId, + File = new FileDescription(m_testImagePath), + Tags = m_apiTag, + Type = STORAGE_TYPE_UPLOAD, + Context = new StringDictionary(m_context), + }; + + var uploadResult = await m_cloudinary.UploadAsync(uploadParams); + Assert.AreEqual(uploadResult.StatusCode, HttpStatusCode.OK, uploadResult.Error?.Message); + } } } diff --git a/CloudinaryDotNet.Tests/ApiShared.ProxyTest.cs b/CloudinaryDotNet.Tests/ApiShared.ProxyTest.cs new file mode 100644 index 00000000..9fa90c65 --- /dev/null +++ b/CloudinaryDotNet.Tests/ApiShared.ProxyTest.cs @@ -0,0 +1,50 @@ +#if NETSTANDARD2_0 +using NUnit.Framework; + +namespace CloudinaryDotNet.Tests +{ + public class ApiSharedProxyTest + { + private ApiShared _apiShared; + + [SetUp] + public void SetUp() + { + _apiShared = new ApiShared(); + } + + [Test] + public void TestDoesNotRecreateClientOnEmptyProxy([Values(null, "")] string proxy) + { + var originalClient = _apiShared.Client; + _apiShared.ApiProxy = proxy; + + Assert.AreEqual(originalClient, _apiShared.Client); + } + + [Test] + public void TestDoesNotRecreateClientOnTheSameProxy() + { + var proxy = "http://proxy.com"; + + _apiShared.ApiProxy = proxy; + var originalClient = _apiShared.Client; + + _apiShared.ApiProxy = proxy; + + Assert.AreEqual(originalClient, _apiShared.Client); + } + + [Test] + public void TestRecreatesClientWhenNewProxyIsSet() + { + var proxy = "http://proxy.com"; + var originalClient = _apiShared.Client; + + _apiShared.ApiProxy = proxy; + + Assert.AreNotEqual(originalClient, _apiShared.Client); + } + } +} +#endif diff --git a/CloudinaryDotNet.Tests/Asset/UrlBuilderTest.cs b/CloudinaryDotNet.Tests/Asset/UrlBuilderTest.cs index 1723f9f1..c83e619f 100644 --- a/CloudinaryDotNet.Tests/Asset/UrlBuilderTest.cs +++ b/CloudinaryDotNet.Tests/Asset/UrlBuilderTest.cs @@ -494,7 +494,7 @@ public void TestAgentPlatformHeaders() var httpRequestMessage = CreateRequest("UserPlatform/2.3"); //Can't test the result, so we just verify the UserAgent parameter is sent to the server - StringAssert.IsMatch(@"CloudinaryDotNet\/(\d+)\.(\d+)\.(\d+) \(" + ApiShared.USER_AGENT.Replace("(", "").Replace(")", "") + @"\) UserPlatform/2\.3", + StringAssert.IsMatch(@"CloudinaryDotNet\/(\d+)\.(\d+)\.(\d+) \(" + ApiShared.RUNTIME_INFORMATION.Replace("(", "").Replace(")", "") + @"\) UserPlatform/2\.3", httpRequestMessage.Headers.UserAgent.ToString()); } @@ -514,17 +514,17 @@ public void UnexpectedUserPlatformShouldNotThrow(string userPlatorm) [TestCase("Mono 5.11.0 ((HEAD/768f1b247c6)")] [TestCase("(")] [TestCase(")")] - public void MalformedUserAgentShouldNotThrow(string userAgent) + public void MalformedRuntimeInformationShouldNotThrow(string runtimeInformation) { - var prevAgent = ApiShared.USER_AGENT; - ApiShared.USER_AGENT = userAgent; + var prevAgent = ApiShared.RUNTIME_INFORMATION; + ApiShared.RUNTIME_INFORMATION = runtimeInformation; try { Assert.DoesNotThrow(() => CreateRequest("UserPlatform")); } finally { - ApiShared.USER_AGENT = prevAgent; + ApiShared.RUNTIME_INFORMATION = prevAgent; } } @@ -610,5 +610,25 @@ public void TestApiUrlWithPrivateCdn() StringAssert.StartsWith("https://api.cloudinary.com", urlZipImage); } + + [Test] + public void TestTextLayerStyleIdentifierVariables() + { + string buildUrl(Func setTextStyleAction) => + m_api.UrlImgUp.Transform( + new Transformation() + .Variable("$style", "!Arial_12!") + .Chain() + .Overlay( + setTextStyleAction(new TextLayer().Text("hello-world")) + ) + ).BuildUrl("sample"); + + var expected = + "http://res.cloudinary.com/testcloud/image/upload/$style_!Arial_12!/l_text:$style:hello-world/sample"; + + Assert.AreEqual(expected, buildUrl(l => l.TextStyle("$style"))); + Assert.AreEqual(expected, buildUrl(l => l.TextStyle(new Expression("$style")))); + } } } diff --git a/CloudinaryDotNet.Tests/MockedCloudinary.cs b/CloudinaryDotNet.Tests/MockedCloudinary.cs index f3f04355..e4179b58 100644 --- a/CloudinaryDotNet.Tests/MockedCloudinary.cs +++ b/CloudinaryDotNet.Tests/MockedCloudinary.cs @@ -34,7 +34,7 @@ public MockedCloudinary(string responseStr = "{}") : base("cloudinary://a:b@test StatusCode = HttpStatusCode.OK, Content = new StringContent(responseStr) }); - ApiShared.Client = new HttpClient(HandlerMock.Object); + Api.Client = new HttpClient(HandlerMock.Object); } /// diff --git a/CloudinaryDotNet.Tests/SearchTest.cs b/CloudinaryDotNet.Tests/SearchTest.cs new file mode 100644 index 00000000..f50fac56 --- /dev/null +++ b/CloudinaryDotNet.Tests/SearchTest.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +namespace CloudinaryDotNet.Tests +{ + public class SearchTest + { + private MockedCloudinary m_cloudinary = new MockedCloudinary(); + + [Test] + public void TestShouldNotDuplicateValues() + { + m_cloudinary + .Search() + .SortBy("created_at", "asc") + .SortBy("created_at", "desc") + .SortBy("public_id", "asc") + .Aggregate("format") + .Aggregate("format") + .Aggregate("resource_type") + .WithField("context") + .WithField("context") + .WithField("tags") + .Execute(); + + AssertCorrectRequest(m_cloudinary.HttpRequestContent); + } + + private void AssertCorrectRequest(string request) + { + var requestJson = JToken.Parse(request); + + Assert.IsNotNull(requestJson["sort_by"]); + Assert.AreEqual( + new List> + { + new Dictionary { ["created_at"] = "desc" }, + new Dictionary { ["public_id"] = "asc" } + }, + requestJson["sort_by"] + .Children() + .Select(item => + new Dictionary + { + [item.Properties().First().Name] = item.Properties().First().Value.ToString() + }) + ); + + Assert.IsNotNull(requestJson["aggregate"]); + Assert.AreEqual(new[] { "format", "resource_type" }, requestJson["aggregate"].Values()); + + Assert.IsNotNull(requestJson["with_field"]); + Assert.AreEqual(new[] { "context", "tags" }, requestJson["with_field"].Values()); + } + } +} diff --git a/CloudinaryDotNet.Tests/Transformations/Common/ExpressionTest.cs b/CloudinaryDotNet.Tests/Transformations/Common/ExpressionTest.cs index c47cdb73..2a0f33e9 100644 --- a/CloudinaryDotNet.Tests/Transformations/Common/ExpressionTest.cs +++ b/CloudinaryDotNet.Tests/Transformations/Common/ExpressionTest.cs @@ -322,6 +322,8 @@ public void TestShouldSupportPowOperator() [TestCase("foo&&bar", "foo&&bar")] [TestCase("width", "w")] [TestCase("initial_aspect_ratio", "iar")] + [TestCase("duration", "du")] + [TestCase("preview:duration", "preview:duration")] [TestCase("$width", "$width")] [TestCase("$initial_aspect_ratio", "$initial_ar")] [TestCase("$mywidth", "$mywidth")] diff --git a/CloudinaryDotNet.Tests/UploadApi/CreateSlideshowMethodsTest.cs b/CloudinaryDotNet.Tests/UploadApi/CreateSlideshowMethodsTest.cs new file mode 100644 index 00000000..9fb40c38 --- /dev/null +++ b/CloudinaryDotNet.Tests/UploadApi/CreateSlideshowMethodsTest.cs @@ -0,0 +1,97 @@ +using System.Collections.Generic; +using CloudinaryDotNet.Actions; +using NUnit.Framework; +using SystemHttp = System.Net.Http; + +namespace CloudinaryDotNet.Tests.UploadApi +{ + public class CreateSlideshowMethodsTest + { + [Test] + public void TestCreateSlideshowFromManifestTransformation() + { + var cloudinary = new MockedCloudinary(); + + const string slideshowManifest = "w_352;h_240;du_5;fps_30;vars_(slides_((media_s64:aHR0cHM6Ly9y" + + "ZXMuY2xvdWRpbmFyeS5jb20vZGVtby9pbWFnZS91cGxvYWQvY291cGxl);(media_s64:aH" + + "R0cHM6Ly9yZXMuY2xvdWRpbmFyeS5jb20vZGVtby9pbWFnZS91cGxvYWQvc2FtcGxl)))"; + + var csParams = new CreateSlideshowParams + { + ManifestTransformation = new Transformation().CustomFunction(CustomFunction.Render(slideshowManifest)), + Tags = new List {"tag1", "tag2", "tag3"}, + Transformation = new Transformation().FetchFormat("auto").Quality("auto") + }; + + cloudinary.CreateSlideshow(csParams); + + cloudinary.AssertHttpCall(SystemHttp.HttpMethod.Post, "video/create_slideshow"); + + foreach (var expected in new List + { + $"fn_render:{slideshowManifest}", + "tag1", + "tag2", + "tag3", + "f_auto,q_auto" + }) + { + StringAssert.Contains(expected, cloudinary.HttpRequestContent); + } + } + [Test] + public void TestCreateSlideshowFromManifestJson() + { + var cloudinary = new MockedCloudinary(); + + const string expectedManifestJson = + @"{""w"":848,""h"":480,""du"":6,""fps"":30,""vars"":{""sdur"":500,""tdur"":500,""slides"":"+ + @"[{""media"":""i:protests9""},{""media"":""i:protests8""},{""media"":""i:protests7""},"+ + @"{""media"":""i:protests6""},{""media"":""i:protests2""},{""media"":""i:protests1""}]}}"; + + const string notificationUrl = "https://example.com"; + const string uploadPreset = "test_preset"; + const string testId = "test_id"; + + + var csParams = new CreateSlideshowParams + { + ManifestJson = new SlideshowManifest + { + Width = 848, + Height = 480, + Duration = 6, + Fps = 30, + Variables = new Slideshow + { + SlideDuration = 500, + TransitionDuration = 500, + Slides = new List + { + new Slide("i:protests9"), new Slide("i:protests8"), new Slide("i:protests7"), + new Slide("i:protests6"), new Slide("i:protests2"), new Slide("i:protests1") + } + } + }, + PublicId = testId, + NotificationUrl = notificationUrl, + UploadPreset = uploadPreset, + Overwrite = true + }; + + cloudinary.CreateSlideshow(csParams); + + foreach (var expected in new List + { + expectedManifestJson, + testId, + notificationUrl, + uploadPreset, + "1" // Overwrite + }) + { + StringAssert.Contains(expected, cloudinary.HttpRequestContent); + } + } + } +} diff --git a/CloudinaryDotNet/Actions/AssetsManagement/ListResourcesParams.cs b/CloudinaryDotNet/Actions/AssetsManagement/ListResourcesParams.cs index 53913055..e26fd6ae 100644 --- a/CloudinaryDotNet/Actions/AssetsManagement/ListResourcesParams.cs +++ b/CloudinaryDotNet/Actions/AssetsManagement/ListResourcesParams.cs @@ -39,6 +39,11 @@ public class ListResourcesParams : BaseParams /// public bool Context { get; set; } + /// + /// Gets or sets a value indicating whether if true, include metadata assigned to each resource. + /// + public bool Metadata { get; set; } + /// /// Gets or sets when a listing request has more results to return than , /// the value is returned as part of the response. You can then specify this value as @@ -84,6 +89,7 @@ public override SortedDictionary ToParamsDictionary() AddParam(dict, "context", Context); AddParam(dict, "direction", Direction); AddParam(dict, "type", Type); + AddParam(dict, "metadata", Metadata); return dict; } diff --git a/CloudinaryDotNet/Actions/AssetsUpload/CreateSlideshowParams.cs b/CloudinaryDotNet/Actions/AssetsUpload/CreateSlideshowParams.cs new file mode 100644 index 00000000..17d45ea0 --- /dev/null +++ b/CloudinaryDotNet/Actions/AssetsUpload/CreateSlideshowParams.cs @@ -0,0 +1,92 @@ +namespace CloudinaryDotNet.Actions +{ + using System; + using System.Collections.Generic; + using Newtonsoft.Json; + + /// + /// Parameters of generating a slideshow. + /// + public class CreateSlideshowParams : BaseParams + { + /// + /// Gets or sets the manifest transformation for slideshow creation. + /// + public Transformation ManifestTransformation { get; set; } + + /// + /// Gets or sets the manifest json for slideshow creation. + /// + public SlideshowManifest ManifestJson { get; set; } + + /// + /// Gets or sets the identifier that is used for accessing the generated video. + /// + public string PublicId { get; set; } + + /// + /// Gets or sets an additional transformation to run on the created slideshow before saving it in the cloud. + /// For example: limit the dimensions of the uploaded image to 512x512 pixels. + /// + public Transformation Transformation { get; set; } + + /// + /// Gets or sets a list of tag names to assign to the generated slideshow. + /// + /// A list of strings where each element represents a tag name. + public List Tags { get; set; } + + /// + /// Gets or sets whether to overwrite existing resources with the same public ID. + /// + public bool? Overwrite { get; set; } + + /// + /// Gets or sets an HTTP URL to send notification to (a webhook) when the operation or any additional + /// requested asynchronous action is completed. If not specified, + /// the response is sent to the global Notification URL (if defined) + /// in the Upload settings of your account console. + /// + public string NotificationUrl { get; set; } + + /// + /// Gets or sets whether it is allowed to use an upload preset for setting parameters of this upload (optional). + /// + public string UploadPreset { get; set; } + + /// + /// Validate object model. + /// + public override void Check() + { + if (ManifestTransformation == null && ManifestJson == null) + { + throw new ArgumentException("Please specify ManifestTransformation or ManifestJson"); + } + } + + /// + /// Maps object model to dictionary of parameters in cloudinary notation. + /// + /// Sorted dictionary of parameters. + public override SortedDictionary ToParamsDictionary() + { + SortedDictionary dict = base.ToParamsDictionary(); + + AddParam(dict, "manifest_json", JsonConvert.SerializeObject(ManifestJson, new JsonSerializerSettings + { + NullValueHandling = NullValueHandling.Ignore, + DefaultValueHandling = DefaultValueHandling.Ignore, + })); + AddParam(dict, "manifest_transformation", ManifestTransformation?.Generate()); + AddParam(dict, "public_id", PublicId); + AddParam(dict, "transformation", Transformation?.Generate()); + AddParam(dict, "tags", Tags); + AddParam(dict, "overwrite", Overwrite); + AddParam(dict, "notification_url", NotificationUrl); + AddParam(dict, "upload_preset", UploadPreset); + + return dict; + } + } +} diff --git a/CloudinaryDotNet/Actions/AssetsUpload/CreateSlideshowResult.cs b/CloudinaryDotNet/Actions/AssetsUpload/CreateSlideshowResult.cs new file mode 100644 index 00000000..3bc2b264 --- /dev/null +++ b/CloudinaryDotNet/Actions/AssetsUpload/CreateSlideshowResult.cs @@ -0,0 +1,29 @@ +namespace CloudinaryDotNet.Actions +{ + using System.Runtime.Serialization; + + /// + /// Result of the create slideshow operation. + /// + [DataContract] + public class CreateSlideshowResult : BaseResult + { + /// + /// Gets or sets the status of the create slideshow operation. + /// + [DataMember(Name = "status")] + public string Status { get; set; } + + /// + /// Gets or sets the public ID assigned to the asset. + /// + [DataMember(Name = "public_id")] + public string PublicId { get; set; } + + /// + /// Gets or sets the ID of the batch. + /// + [DataMember(Name = "batch_id")] + public string BatchId { get; set; } + } +} diff --git a/CloudinaryDotNet/Actions/AssetsUpload/NestedTypes/Slide.cs b/CloudinaryDotNet/Actions/AssetsUpload/NestedTypes/Slide.cs new file mode 100644 index 00000000..33a228a1 --- /dev/null +++ b/CloudinaryDotNet/Actions/AssetsUpload/NestedTypes/Slide.cs @@ -0,0 +1,52 @@ +namespace CloudinaryDotNet.Actions +{ + using Newtonsoft.Json; + + /// + /// Represents settings of a single slide. + /// Is a part of Slideshow. + /// + public class Slide + { + /// + /// Initializes a new instance of the class. + /// + /// The media. + public Slide(string media) + { + Media = media; + } + + /// + /// Gets or sets the media. + /// Specify images as i:[public_id]. Specify videos as v:[public_id]. + /// + [JsonProperty(PropertyName = "media")] + public string Media { get; set; } + + /// + /// Gets or sets the slide type. Set to video when using a video for a slide. + /// For example: media_v:my-public-id;type_s:video. Server Default: image. + /// + [JsonProperty(PropertyName = "type")] + public int Type { get; set; } + + /// + /// Gets or sets the transition to use from the individual slide to the next. Server Default: CrossZoom. + /// + [JsonProperty(PropertyName = "transition_s")] + public string Transition { get; set; } + + /// + /// Gets or sets the slide duration in milliseconds. Server Default: 3000. + /// + [JsonProperty(PropertyName = "sdur")] + public int SlideDuration { get; set; } + + /// + /// Gets or sets the transition duration in milliseconds. Server Default: 1000. + /// + [JsonProperty(PropertyName = "tdur")] + public int TransitionDuration { get; set; } + } +} diff --git a/CloudinaryDotNet/Actions/AssetsUpload/NestedTypes/Slideshow.cs b/CloudinaryDotNet/Actions/AssetsUpload/NestedTypes/Slideshow.cs new file mode 100644 index 00000000..9f74b3ed --- /dev/null +++ b/CloudinaryDotNet/Actions/AssetsUpload/NestedTypes/Slideshow.cs @@ -0,0 +1,42 @@ +namespace CloudinaryDotNet.Actions +{ + using System.Collections.Generic; + using Newtonsoft.Json; + + /// + /// Represents settings of the slideshow. + /// Is a part of SlideshowManifest. + /// + public class Slideshow + { + /// + /// Gets or sets the transition to use for all slides. Server Default: CrossZoom. + /// + [JsonProperty(PropertyName = "transition_s")] + public string Transition { get; set; } + + /// + /// Gets or sets a single transformation to apply to all slides. Server Default: null. + /// + [JsonProperty(PropertyName = "transformation_s")] + public string Transformation { get; set; } + + /// + /// Gets or sets the duration for all slides in milliseconds. Server Default: 3000. + /// + [JsonProperty(PropertyName = "sdur")] + public int SlideDuration { get; set; } + + /// + /// Gets or sets the duration for all transitions in milliseconds. Server Default: 1000. + /// + [JsonProperty(PropertyName = "tdur")] + public int TransitionDuration { get; set; } + + /// + /// Gets or sets the duration for all transitions in milliseconds. Server Default: 1000. + /// + [JsonProperty(PropertyName = "slides")] + public List Slides { get; set; } + } +} diff --git a/CloudinaryDotNet/Actions/AssetsUpload/NestedTypes/SlideshowManifest.cs b/CloudinaryDotNet/Actions/AssetsUpload/NestedTypes/SlideshowManifest.cs new file mode 100644 index 00000000..9660a7cf --- /dev/null +++ b/CloudinaryDotNet/Actions/AssetsUpload/NestedTypes/SlideshowManifest.cs @@ -0,0 +1,40 @@ +namespace CloudinaryDotNet.Actions +{ + using Newtonsoft.Json; + + /// + /// Represents a manifest for slideshow creation. + /// + public class SlideshowManifest + { + /// + /// Gets or sets the width of the slideshow in pixels. + /// + [JsonProperty(PropertyName = "w")] + public int Width { get; set; } + + /// + /// Gets or sets the height of the slideshow in pixels. + /// + [JsonProperty(PropertyName = "h")] + public int Height { get; set; } + + /// + /// Gets or sets the duration of the slideshow in seconds. Server Default: 10. + /// + [JsonProperty(PropertyName = "du", NullValueHandling=NullValueHandling.Ignore)] + public int Duration { get; set; } + + /// + /// Gets or sets the frames per second of the slideshow. Server Default: 20. + /// + [JsonProperty(PropertyName = "fps", NullValueHandling=NullValueHandling.Ignore)] + public int Fps { get; set; } + + /// + /// Gets or sets the slideshow settings. + /// + [JsonProperty(PropertyName = "vars")] + public Slideshow Variables { get; set; } + } +} diff --git a/CloudinaryDotNet/Actions/AssetsUpload/RenameParams.cs b/CloudinaryDotNet/Actions/AssetsUpload/RenameParams.cs index e8e0ede4..f10c3c55 100644 --- a/CloudinaryDotNet/Actions/AssetsUpload/RenameParams.cs +++ b/CloudinaryDotNet/Actions/AssetsUpload/RenameParams.cs @@ -70,6 +70,16 @@ public RenameParams(string fromPublicId, string toPublicId) /// public bool Invalidate { get; set; } + /// + /// Gets or sets a value indicating whether if true, include context assigned to the resource. + /// + public bool Context { get; set; } + + /// + /// Gets or sets a value indicating whether if true, include metadata assigned to the resource. + /// + public bool Metadata { get; set; } + /// /// Maps object model to dictionary of parameters in cloudinary notation. /// @@ -84,6 +94,17 @@ public override SortedDictionary ToParamsDictionary() AddParam(dict, "type", Type); AddParam(dict, "to_type", ToType); AddParam(dict, "invalidate", Invalidate); + + if (Context) + { + AddParam(dict, "context", Context); + } + + if (Metadata) + { + AddParam(dict, "metadata", Metadata); + } + return dict; } diff --git a/CloudinaryDotNet/ApiShared.Internal.cs b/CloudinaryDotNet/ApiShared.Internal.cs index 82c420d7..25e0f5ae 100644 --- a/CloudinaryDotNet/ApiShared.Internal.cs +++ b/CloudinaryDotNet/ApiShared.Internal.cs @@ -257,7 +257,9 @@ private static void AddCommentToUserAgent( return; } + // User-Agent's comment section is sensitive to brackets var normalizedComment = RemoveBracketsFrom(comment); + userAgentHeader.Add(new ProductInfoHeaderValue($"({normalizedComment})")); } @@ -478,7 +480,7 @@ private void PrePrepareRequestBody( var userAgentHeader = request.Headers.UserAgent; userAgentHeader.Add(new ProductInfoHeaderValue("CloudinaryDotNet", CloudinaryVersion.Full)); - AddCommentToUserAgent(userAgentHeader, USER_AGENT); + AddCommentToUserAgent(userAgentHeader, RUNTIME_INFORMATION); SetUserPlatform(userAgentHeader); byte[] authBytes = Encoding.ASCII.GetBytes(GetApiCredentials()); @@ -501,7 +503,6 @@ private void PrePrepareRequestBody( private void SetUserPlatform(HttpHeaderValueCollection userAgentHeader) { - Console.WriteLine($"UserPlatform: [{UserPlatform}] ======"); var up = UserPlatform?.Trim(); if (string.IsNullOrEmpty(up)) { diff --git a/CloudinaryDotNet/ApiShared.Proxy.cs b/CloudinaryDotNet/ApiShared.Proxy.cs new file mode 100644 index 00000000..da8f4b7d --- /dev/null +++ b/CloudinaryDotNet/ApiShared.Proxy.cs @@ -0,0 +1,64 @@ +namespace CloudinaryDotNet +{ +#if NETSTANDARD2_0 + using System; + using System.Net; +#endif + using System.Net.Http; + + /// + /// Provider for the API calls. + /// + public partial class ApiShared + { + /// + /// Sends HTTP requests and receives HTTP responses. + /// + public HttpClient Client = new HttpClient(); + +#if NETSTANDARD2_0 + private string m_apiProxy = string.Empty; + + /// + /// Gets or sets address of the proxy server that will be used for API calls. + /// + public string ApiProxy + { + get => m_apiProxy; + set + { + string newValue = value ?? string.Empty; + + if (newValue != m_apiProxy) + { + m_apiProxy = newValue; + RecreateClient(); + } + } + } + + private void RecreateClient() + { + Client.Dispose(); + Client = null; + + if (string.IsNullOrEmpty(ApiProxy)) + { + Client = new HttpClient(); + } + else + { + var proxyAddress = new Uri(ApiProxy); + var webProxy = new WebProxy(proxyAddress, false); + var httpClientHandler = new HttpClientHandler + { + Proxy = webProxy, + UseProxy = true, + }; + + Client = new HttpClient(httpClientHandler); + } + } +#endif + } +} diff --git a/CloudinaryDotNet/ApiShared.cs b/CloudinaryDotNet/ApiShared.cs index d57cc73a..c8fe8e60 100644 --- a/CloudinaryDotNet/ApiShared.cs +++ b/CloudinaryDotNet/ApiShared.cs @@ -69,14 +69,9 @@ public partial class ApiShared : ISignProvider public const string HTTP_BOUNDARY = "notrandomsequencetouseasboundary"; /// - /// User agent for cloudinary API requests. + /// Runtime information for cloudinary API requests. /// - public static string USER_AGENT = RuntimeInformation.FrameworkDescription; - - /// - /// Sends HTTP requests and receives HTTP responses. - /// - public static HttpClient Client = new HttpClient(); + public static string RUNTIME_INFORMATION = RuntimeInformation.FrameworkDescription; /// /// Whether to use a sub domain. diff --git a/CloudinaryDotNet/Cloudinary.AdminApi.cs b/CloudinaryDotNet/Cloudinary.AdminApi.cs index 840735ea..a1e876a9 100644 --- a/CloudinaryDotNet/Cloudinary.AdminApi.cs +++ b/CloudinaryDotNet/Cloudinary.AdminApi.cs @@ -93,9 +93,18 @@ public ListResourcesResult ListResources( /// Starting position. /// (Optional) Cancellation token. /// Parsed result of the resources listing. - public Task ListResourcesByTypeAsync(string type, string nextCursor = null, CancellationToken? cancellationToken = null) + public Task ListResourcesByTypeAsync( + string type, + string nextCursor = null, + CancellationToken? cancellationToken = null) { - return ListResourcesAsync(new ListResourcesParams() { Type = type, NextCursor = nextCursor }, cancellationToken); + var listResourcesParams = new ListResourcesParams() + { + Type = type, + NextCursor = nextCursor, + }; + + return ListResourcesAsync(listResourcesParams, cancellationToken); } /// @@ -139,7 +148,10 @@ public Task ListResourcesByPrefixAsync( /// Resource type. /// Starting position. /// Parsed result of the resources listing. - public ListResourcesResult ListResourcesByPrefix(string prefix, string type = "upload", string nextCursor = null) + public ListResourcesResult ListResourcesByPrefix( + string prefix, + string type = "upload", + string nextCursor = null) { return ListResourcesByPrefixAsync(prefix, type, nextCursor) .GetAwaiter().GetResult(); @@ -200,7 +212,10 @@ public ListResourcesResult ListResourcesByPrefix(string prefix, bool tags, bool /// Starting position. /// (Optional) Cancellation token. /// Parsed result of the resources listing. - public Task ListResourcesByTagAsync(string tag, string nextCursor = null, CancellationToken? cancellationToken = null) + public Task ListResourcesByTagAsync( + string tag, + string nextCursor = null, + CancellationToken? cancellationToken = null) { var listResourcesByTagParams = new ListResourcesByTagParams() { @@ -227,7 +242,9 @@ public ListResourcesResult ListResourcesByTag(string tag, string nextCursor = nu /// Public identifiers. /// (Optional) Cancellation token. /// Parsed result of the resources listing. - public Task ListResourcesByPublicIdsAsync(IEnumerable publicIds, CancellationToken? cancellationToken = null) + public Task ListResourcesByPublicIdsAsync( + IEnumerable publicIds, + CancellationToken? cancellationToken = null) { var listSpecificResourcesParams = new ListSpecificResourcesParams() { @@ -381,9 +398,15 @@ public Task ListResourcesByContextAsync( /// If true, include context assigned to each resource. /// The next cursor. /// Parsed result of the resources listing. - public ListResourcesResult ListResourcesByContext(string key, string value = "", bool tags = false, bool context = false, string nextCursor = null) + public ListResourcesResult ListResourcesByContext( + string key, + string value = "", + bool tags = false, + bool context = false, + string nextCursor = null) { - return ListResourcesByContextAsync(key, value, tags, context, nextCursor).GetAwaiter().GetResult(); + return ListResourcesByContextAsync(key, value, tags, context, nextCursor) + .GetAwaiter().GetResult(); } /// diff --git a/CloudinaryDotNet/Cloudinary.UploadApi.cs b/CloudinaryDotNet/Cloudinary.UploadApi.cs index 0f9937ec..36e13b05 100644 --- a/CloudinaryDotNet/Cloudinary.UploadApi.cs +++ b/CloudinaryDotNet/Cloudinary.UploadApi.cs @@ -336,7 +336,11 @@ public T UploadLarge(BasicRawUploadParams parameters, int bufferSize = DEFAUL /// Overwrite a file with the same identifier as new if such file exists. /// (Optional) Cancellation token. /// Result of resource renaming. - public Task RenameAsync(string fromPublicId, string toPublicId, bool overwrite = false, CancellationToken? cancellationToken = null) + public Task RenameAsync( + string fromPublicId, + string toPublicId, + bool overwrite = false, + CancellationToken? cancellationToken = null) { return RenameAsync( new RenameParams(fromPublicId, toPublicId) @@ -355,11 +359,12 @@ public Task RenameAsync(string fromPublicId, string toPublicId, bo /// Result of resource renaming. public RenameResult Rename(string fromPublicId, string toPublicId, bool overwrite = false) { - return Rename( - new RenameParams(fromPublicId, toPublicId) - { - Overwrite = overwrite, - }); + var renameParams = new RenameParams(fromPublicId, toPublicId) + { + Overwrite = overwrite, + }; + + return RenameAsync(renameParams).GetAwaiter().GetResult(); } /// @@ -862,6 +867,33 @@ public Task TextAsync(TextParams parameters, CancellationToken? canc cancellationToken); } + /// + /// Creates auto-generated video slideshow. + /// + /// Parameters for generating the slideshow. + /// The public id of the generated slideshow. + public CreateSlideshowResult CreateSlideshow(CreateSlideshowParams parameters) + { + return CreateSlideshowAsync(parameters).GetAwaiter().GetResult(); + } + + /// + /// Creates auto-generated video slideshow asynchronously. + /// + /// Parameters for generating the slideshow. + /// (Optional) Cancellation token. + /// The public id of the generated slideshow. + public Task CreateSlideshowAsync(CreateSlideshowParams parameters, CancellationToken? cancellationToken = null) + { + string uri = m_api.ApiUrlVideoUpV.Action("create_slideshow").BuildUrl(); + + return CallUploadApiAsync( + HttpMethod.POST, + uri, + parameters, + cancellationToken); + } + /// /// Generates an image of a given textual string. /// diff --git a/CloudinaryDotNet/Search.cs b/CloudinaryDotNet/Search.cs index d8804083..67dbe121 100644 --- a/CloudinaryDotNet/Search.cs +++ b/CloudinaryDotNet/Search.cs @@ -1,6 +1,7 @@ namespace CloudinaryDotNet { using System.Collections.Generic; + using System.Linq; using System.Threading; using System.Threading.Tasks; using CloudinaryDotNet.Actions; @@ -129,17 +130,17 @@ public Dictionary ToQuery() if (withFieldParam.Count > 0) { - queryParams.Add("with_field", withFieldParam); + queryParams.Add("with_field", withFieldParam.Distinct()); } if (sortByParam.Count > 0) { - queryParams.Add("sort_by", sortByParam); + queryParams.Add("sort_by", sortByParam.GroupBy(d => d.Keys.First()).Select(l => l.Last())); } if (aggregateParam.Count > 0) { - queryParams.Add("aggregate", aggregateParam); + queryParams.Add("aggregate", aggregateParam.Distinct()); } return queryParams; diff --git a/CloudinaryDotNet/Transforms/BaseExpression.cs b/CloudinaryDotNet/Transforms/BaseExpression.cs index 8bb6632e..efb7dc49 100644 --- a/CloudinaryDotNet/Transforms/BaseExpression.cs +++ b/CloudinaryDotNet/Transforms/BaseExpression.cs @@ -69,6 +69,7 @@ public abstract class BaseExpression : BaseExpression { "tags", "tags" }, { "pageX", "px" }, { "pageY", "py" }, + { "duration", "du" }, }; /// @@ -498,7 +499,7 @@ private static string GetPattern() } sb.Remove(sb.Length - 1, 1); - sb.Append(")(?=[ _])|(? + /// Generates a render custom function param to send to CustomFunction(customFunction) transformation. + /// + /// The manifest transformation. + /// A new instance of custom function param. + public static CustomFunction Render(string manifest) + { + return new CustomFunction("render", manifest); + } + /// /// Generate a remote lambda custom action function to send to CustomFunction(customFunction) transformation. /// diff --git a/CloudinaryDotNet/Transforms/TextLayer.cs b/CloudinaryDotNet/Transforms/TextLayer.cs index e082dc49..6bbbed15 100644 --- a/CloudinaryDotNet/Transforms/TextLayer.cs +++ b/CloudinaryDotNet/Transforms/TextLayer.cs @@ -92,6 +92,11 @@ public class TextLayer : BaseLayer /// protected string m_text; + /// + /// The text style to generate an image for. + /// + protected object m_textStyle; + /// /// Required name of the font family. e.g. "arial". /// @@ -209,6 +214,30 @@ public TextLayer Text(string text) return this; } + /// + /// Sets a text style identifier. + /// Note: If this is used, all other style attributes are ignored in favor of this identifier. + /// + /// A variable string or an explicit style (e.g. "Arial_17_bold_antialias_best"). + /// The layer with text style applied. + public TextLayer TextStyle(string textStyleIdentifier) + { + this.m_textStyle = textStyleIdentifier; + return this; + } + + /// + /// Sets a text style identifier. + /// Note: If this is used, all other style attributes are ignored in favor of this identifier. + /// + /// An expression instance referencing the style.. + /// The layer with text style applied. + public TextLayer TextStyle(Expression textStyleIdentifier) + { + this.m_textStyle = textStyleIdentifier; + return this; + } + /// /// Type of font antialiasing to use. /// @@ -396,6 +425,12 @@ private string OverlayTextEncode(string text) private string TextStyleIdentifier() { + var ts = m_textStyle?.ToString(); + if (!string.IsNullOrEmpty(ts)) + { + return ts; + } + List components = new List(); if (!string.IsNullOrEmpty(m_fontWeight) && !m_fontWeight.Equals("normal", StringComparison.Ordinal)) diff --git a/appveyor.yml b/appveyor.yml index ee928677..6099c23d 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -12,7 +12,7 @@ skip_tags: true skip_non_tags: false # Do not build feature branch with open Pull Requests -skip_branch_with_pr: true +skip_branch_with_pr: false # Maximum number of concurrent jobs for the project max_jobs: 1