diff --git a/Apps.Contentful/Actions/EntryActions.cs b/Apps.Contentful/Actions/EntryActions.cs index 732750d..99f213b 100644 --- a/Apps.Contentful/Actions/EntryActions.cs +++ b/Apps.Contentful/Actions/EntryActions.cs @@ -10,6 +10,7 @@ using Apps.Contentful.Models.Requests; using Apps.Contentful.Models.Requests.Tags; using Apps.Contentful.Models.Responses; +using Apps.Contentful.Services; using Apps.Contentful.Utils; using Blackbird.Applications.Sdk.Common; using Blackbird.Applications.Sdk.Common.Actions; @@ -43,6 +44,8 @@ namespace Apps.Contentful.Actions; public class EntryActions(InvocationContext invocationContext, IFileManagementClient fileManagementClient) : BaseInvocable(invocationContext) { + private readonly CustomSizeValidationService _customSizeValidationService = new(); + private IEnumerable Creds => InvocationContext.AuthenticationCredentialsProviders; @@ -393,18 +396,8 @@ public async Task SetEntryLocalizableFieldsFromHtmlFile( throw new PluginMisconfigurationException("Only .html, .xliff and .xlf files are supported. Please specify a different file"); } - var client = new ContentfulClient(Creds, input.Environment); var errors = new List(); - var locales = await client.ExecuteWithErrorHandling(async () => await client.GetLocalesCollection()); - if (locales.All(x => x.Code != input.Locale)) - { - var allLocales = string.Join(", ", locales.Select(x => x.Code)); - throw new PluginMisconfigurationException( - $"Locale {input.Locale} not found. Please specify a valid locale. " + - $"Available locales: {allLocales}"); - } - var file = await fileManagementClient.DownloadAsync(input.Content); var content = Encoding.UTF8.GetString(await file.GetByteData()); @@ -417,6 +410,19 @@ public async Task SetEntryLocalizableFieldsFromHtmlFile( throw new PluginMisconfigurationException("XLIFF did not contain any files"); } + errors.AddRange(_customSizeValidationService.Validate(content, input.Locale, input.SkipCustomValidationStep == true)); + + var client = new ContentfulClient(Creds, input.Environment); + + var locales = await client.ExecuteWithErrorHandling(async () => await client.GetLocalesCollection()); + if (locales.All(x => x.Code != input.Locale)) + { + var allLocales = string.Join(", ", locales.Select(x => x.Code)); + throw new PluginMisconfigurationException( + $"Locale {input.Locale} not found. Please specify a valid locale. " + + $"Available locales: {allLocales}"); + } + var mainEntryInfo = EntryToJsonConverter.GetMainEntryInfo(content); var entriesToUpdate = EntryToJsonConverter.GetEntriesInfo(content); @@ -1290,4 +1296,4 @@ private static void ValidateDates(DateTime? after, DateTime? before, string name } #endregion -} \ No newline at end of file +} diff --git a/Apps.Contentful/Apps.Contentful.csproj b/Apps.Contentful/Apps.Contentful.csproj index 5d64138..d26ee3b 100644 --- a/Apps.Contentful/Apps.Contentful.csproj +++ b/Apps.Contentful/Apps.Contentful.csproj @@ -6,7 +6,7 @@ enable Contentful The headless content management system - 1.8.9 + 1.8.10 Apps.Contentful diff --git a/Apps.Contentful/Models/Requests/Tags/UploadEntryRequest.cs b/Apps.Contentful/Models/Requests/Tags/UploadEntryRequest.cs index 655be96..0a7eb15 100644 --- a/Apps.Contentful/Models/Requests/Tags/UploadEntryRequest.cs +++ b/Apps.Contentful/Models/Requests/Tags/UploadEntryRequest.cs @@ -19,4 +19,7 @@ public class UploadEntryRequest : EnvironmentIdentifier, IUploadContentInput [Display("Don't update reference fields")] public bool? DontUpdateReferenceFields { get; set; } + + [Display("Skip custom validation step")] + public bool? SkipCustomValidationStep { get; set; } } diff --git a/Apps.Contentful/Services/CustomSizeValidationService.cs b/Apps.Contentful/Services/CustomSizeValidationService.cs new file mode 100644 index 0000000..49fe01f --- /dev/null +++ b/Apps.Contentful/Services/CustomSizeValidationService.cs @@ -0,0 +1,174 @@ +using Apps.Contentful.HtmlHelpers.Constants; +using Apps.Contentful.Models; +using Blackbird.Applications.Sdk.Common.Exceptions; +using HtmlAgilityPack; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System.Text.RegularExpressions; +using System.Web; + +namespace Apps.Contentful.Services; + +public class CustomSizeValidationService +{ + public IReadOnlyCollection Validate(string content, string locale, bool skipValidation) + { + var violations = GetViolations(content); + if (!violations.Any()) + { + return []; + } + + if (!skipValidation) + { + throw new PluginMisconfigurationException(BuildExceptionMessage(violations, locale)); + } + + return violations.Select(violation => new ContentProcessingError + { + EntryId = violation.EntryId, + ParentEntryId = null, + ErrorMessage = $"Warning: {BuildViolationMessage(violation, locale)} Contentful may reject this field during upload." + }).ToArray(); + } + + private static List GetViolations(string content) + { + var doc = new HtmlDocument(); + doc.LoadHtml(content); + + var nodes = doc.DocumentNode.SelectNodes("//*[@data-blackbird-size and @data-contentful-field-id]"); + if (nodes == null) + { + return []; + } + + var violations = new List(); + foreach (var node in nodes) + { + var rawConstraint = node.GetAttributeValue("data-blackbird-size", string.Empty); + if (!TryGetMaximumSize(rawConstraint, out var maximumLength)) + { + continue; + } + + var fieldValue = HttpUtility.HtmlDecode(node.InnerText ?? string.Empty); + var actualLength = fieldValue.Length; + if (actualLength <= maximumLength) + { + continue; + } + + var fieldId = node.GetAttributeValue(ConvertConstants.FieldIdAttribute, string.Empty); + var entryId = node.AncestorsAndSelf() + .FirstOrDefault(x => x.Attributes[ConvertConstants.EntryIdAttribute] != null)? + .GetAttributeValue(ConvertConstants.EntryIdAttribute, string.Empty) ?? string.Empty; + + violations.Add(new(entryId, fieldId, actualLength, maximumLength)); + } + + return violations; + } + + private static bool TryGetMaximumSize(string rawConstraint, out int maximumLength) + { + maximumLength = 0; + if (string.IsNullOrWhiteSpace(rawConstraint)) + { + return false; + } + + if (int.TryParse(rawConstraint, out maximumLength) && maximumLength > 0) + { + return true; + } + + if (TryGetMaximumSizeFromJson(rawConstraint, out maximumLength)) + { + return true; + } + + var labeledMatch = Regex.Match(rawConstraint, @"(?i)(?:maximumsize|max)\D*(\d+)"); + if (labeledMatch.Success && + int.TryParse(labeledMatch.Groups[1].Value, out maximumLength) && + maximumLength > 0) + { + return true; + } + + var rangeMatch = Regex.Match(rawConstraint, @"^\s*\d+\s*[-:;,]\s*(\d+)\s*$"); + if (rangeMatch.Success && + int.TryParse(rangeMatch.Groups[1].Value, out maximumLength) && + maximumLength > 0) + { + return true; + } + + return false; + } + + private static bool TryGetMaximumSizeFromJson(string rawConstraint, out int maximumLength) + { + maximumLength = 0; + + if (!rawConstraint.TrimStart().StartsWith('{') && !rawConstraint.TrimStart().StartsWith('[')) + { + return false; + } + + try + { + var token = JToken.Parse(rawConstraint); + var maximumToken = FindFirstPropertyValue(token, "MaximumSize", "Max"); + + return maximumToken != null && + int.TryParse(maximumToken.ToString(), out maximumLength) && + maximumLength > 0; + } + catch (JsonException) + { + return false; + } + } + + private static JToken? FindFirstPropertyValue(JToken token, params string[] propertyNames) + { + if (token is JProperty property && + propertyNames.Any(name => property.Name.Equals(name, StringComparison.OrdinalIgnoreCase))) + { + return property.Value; + } + + foreach (var child in token.Children()) + { + var result = FindFirstPropertyValue(child, propertyNames); + if (result != null) + { + return result; + } + } + + return null; + } + + private static string BuildExceptionMessage(IEnumerable violations, string locale) + { + var messages = violations.Select(violation => $"- {BuildViolationMessage(violation, locale)}"); + + return string.Join(Environment.NewLine, + [ + "The translated content violates custom size restrictions from the exported HTML.", + "Fix the values below or set 'Skip custom validation step' to true if you want to continue and receive warnings instead.", + ..messages + ]); + } + + private static string BuildViolationMessage(CustomSizeConstraintViolation violation, string locale) + => $"Field '{violation.FieldId}' of entry '{violation.EntryId}' violates specified constraint for locale '{locale}'. Actual length: {violation.ActualLength}. Maximum allowed length: {violation.MaximumLength}."; + + private sealed record CustomSizeConstraintViolation( + string EntryId, + string FieldId, + int ActualLength, + int MaximumLength); +} diff --git a/Tests.Contentful/CustomSizeValidationServiceTests.cs b/Tests.Contentful/CustomSizeValidationServiceTests.cs new file mode 100644 index 0000000..98f1274 --- /dev/null +++ b/Tests.Contentful/CustomSizeValidationServiceTests.cs @@ -0,0 +1,51 @@ +using Apps.Contentful.Services; +using Blackbird.Applications.Sdk.Common.Exceptions; +using Blackbird.Applications.Sdk.Common.Files; +using System.Text; +using Tests.Contentful.Base; +using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert; + +namespace Tests.Contentful; + +[TestClass] +public class CustomSizeValidationServiceTests : TestBase +{ + [TestMethod] + public async Task Validate_WithExceededCustomSize_ShouldThrowDetailedException() + { + var content = await ReadInputFile("MyRepairs.Online_en-US_fr-FR.html"); + var service = new CustomSizeValidationService(); + + var exception = ThrowsException(() => + service.Validate(content, "fr-FR", false)); + + StringAssert.Contains(exception.Message, "Field 'description'"); + StringAssert.Contains(exception.Message, "entry '3uACdsR62YrTMkBY3T6geU'"); + StringAssert.Contains(exception.Message, "Maximum allowed length: 256"); + } + + [TestMethod] + public async Task Validate_WithExceededCustomSizeAndSkipEnabled_ShouldReturnWarnings() + { + var content = await ReadInputFile("MyRepairs.Online_en-US_fr-FR.html"); + var service = new CustomSizeValidationService(); + + var warnings = service.Validate(content, "fr-FR", true); + + AreEqual(1, warnings.Count); + StringAssert.Contains(warnings.Single().ErrorMessage, "Warning:"); + StringAssert.Contains(warnings.Single().ErrorMessage, "Field 'description'"); + } + + private async Task ReadInputFile(string fileName) + { + using var stream = await FileManager.DownloadAsync(new FileReference + { + Name = fileName, + ContentType = "text/html" + }); + + using var reader = new StreamReader(stream, Encoding.UTF8); + return await reader.ReadToEndAsync(); + } +} diff --git a/Tests.Contentful/EntryActionsTests.cs b/Tests.Contentful/EntryActionsTests.cs index bc2d4e3..0502adc 100644 --- a/Tests.Contentful/EntryActionsTests.cs +++ b/Tests.Contentful/EntryActionsTests.cs @@ -3,6 +3,8 @@ using Apps.Contentful.Models.Requests; using Apps.Contentful.Models.Requests.Tags; using Blackbird.Applications.Sdk.Common.Exceptions; +using Blackbird.Applications.Sdk.Common.Files; +using Blackbird.Applications.Sdk.Common.Invocation; using Blackbird.Filters.Coders; using Newtonsoft.Json; using Tests.Contentful.Base; @@ -252,6 +254,30 @@ public async Task SetEntryLocalizableFieldsFromHtmlFile_MxliffFile_ShouldFailWit Console.WriteLine(Newtonsoft.Json.JsonConvert.SerializeObject(response)); } + [TestMethod] + public async Task SetEntryLocalizableFieldsFromHtmlFile_WithExceededCustomSize_ShouldFailBeforeContentfulCall() + { + var entryActions = new EntryActions(new InvocationContext(), new FileManager()); + var entryIdentifier = new UploadEntryRequest + { + Locale = "fr-FR", + Content = new FileReference + { + Name = "MyRepairs.Online_en-US_fr-FR.html", + ContentType = "text/html" + } + }; + + var response = await ThrowsExceptionAsync( + async () => await entryActions.SetEntryLocalizableFieldsFromHtmlFile(entryIdentifier) + ); + + StringAssert.Contains(response.Message, "Field 'description'"); + StringAssert.Contains(response.Message, "entry '3uACdsR62YrTMkBY3T6geU'"); + StringAssert.Contains(response.Message, "Maximum allowed length: 256"); + Console.WriteLine(JsonConvert.SerializeObject(response, Formatting.Indented)); + } + [TestMethod] public async Task GetEntry_ValidEntryWithoutLocale_ShouldReturnEntryWithTitle() { @@ -682,4 +708,4 @@ public async Task UnpublishEntry_IsSuccess() // Act await actions.UnpublishEntry(entry, input); } -} \ No newline at end of file +}